diff --git a/.agents/agents/backend-engineer.md b/.agents/agents/backend-engineer.md index 3204865..47776fd 100644 --- a/.agents/agents/backend-engineer.md +++ b/.agents/agents/backend-engineer.md @@ -40,7 +40,7 @@ Router (HTTP) → Service (Business Logic) → Repository (Data Access) → Mode ## Rules 1. Stay in scope — only work on assigned backend tasks -2. Write tests for all new code +2. Write tests for all new code; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 3. Follow Repository → Service → Router pattern (no business logic in routes) 4. Validate all inputs with the project's validation library 5. Parameterized queries only (no string interpolation in SQL) diff --git a/.agents/agents/debug-investigator.md b/.agents/agents/debug-investigator.md index 7bcd41e..e3cde15 100644 --- a/.agents/agents/debug-investigator.md +++ b/.agents/agents/debug-investigator.md @@ -46,7 +46,7 @@ CHARTER_CHECK: 1. Stay in scope — only work on assigned debug tasks 2. Fix root cause, not symptoms 3. Minimal changes only — no refactoring during bugfix; route refactoring needs to refactor-engineer -4. Every fix gets a regression test +4. Every fix gets a regression test; run it before the fix where feasible and record RED (failing output) → GREEN (post-fix pass) in the bug report 5. Search for similar patterns after fixing 6. Document out-of-scope findings for other agents 7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.agents/agents/frontend-engineer.md b/.agents/agents/frontend-engineer.md index 134bdf7..2489dac 100644 --- a/.agents/agents/frontend-engineer.md +++ b/.agents/agents/frontend-engineer.md @@ -47,6 +47,6 @@ FSD-lite: root `src/` + feature `src/features/*/` 5. TailwindCSS v4 for styling, design tokens 1:1 mapping 6. Library defaults (greenfield; existing project choices win): luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) 7. Absolute imports with `@/` -8. Write tests for custom logic (>90% coverage target) +8. Write tests for custom logic (>90% coverage target); honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. Document out-of-scope dependencies for other agents 10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.agents/agents/mobile-engineer.md b/.agents/agents/mobile-engineer.md index 5419fd7..31bba4d 100644 --- a/.agents/agents/mobile-engineer.md +++ b/.agents/agents/mobile-engineer.md @@ -46,7 +46,7 @@ Clean Architecture: domain → data → presentation (Swift native: App/Core/Fea 5. Transport client with interceptors (Dio / axios / generated Client) + repository-layer response cache, offline-first architecture 6. Secrets in secure storage only — never plain prefs or MMKV 7. 60fps target performance -8. Write widget/component tests and integration tests +8. Write widget/component tests and integration tests; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. ARB-based localization: edit ARB source files only, never generated localization code 10. Document out-of-scope dependencies for other agents 11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.agents/agents/pm-planner.md b/.agents/agents/pm-planner.md index 87674c8..609d651 100644 --- a/.agents/agents/pm-planner.md +++ b/.agents/agents/pm-planner.md @@ -50,12 +50,13 @@ Each task must include: - `priority`: execution tier — 1 = independent (runs first), 2 = depends on tier 1, etc. (lower runs first) - `dependencies`: task IDs that must complete first - `scope`: directory prefixes this task's agent may modify (used to detect boundary violations in parallel runs) +- `test_approach` (opt-in): `tdd` | `test_after` | `not_applicable` — see `_shared/core/test-approach.md`. `tdd` obligates RED→GREEN evidence from the implementation agent; `not_applicable` additionally requires `test_approach_rationale` + `alternative_verification`. Never assign `tdd` to refactor tasks (characterization tests instead) ## Rules 1. Stay in scope — planning only, no code implementation 2. API-first design 3. Minimize dependencies for maximum parallelism -4. Security and testing are part of every task (not separate) +4. Security and testing are part of every task (not separate); assign per-task `test_approach` (`tdd|test_after|not_applicable`) where a test strategy matters — `not_applicable` requires rationale + alternative verification, and no approach waives the >= 80% coverage gate 5. Each task completable by a single agent 6. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.agents/hooks/core/keyword-detector.ts b/.agents/hooks/core/keyword-detector.ts index e336814..08094f6 100644 --- a/.agents/hooks/core/keyword-detector.ts +++ b/.agents/hooks/core/keyword-detector.ts @@ -357,20 +357,35 @@ export function escapeRegex(s: string): string { /** * Merge a language-keyed keyword/pattern bank into a single flat list: - * universal ("*") + English (the universal default) + the configured - * language's own entries (skipped when lang === "en" to avoid duplicates). - * Shared by buildPatterns and buildRawPatterns — both keyword banks and - * pattern banks use this exact `Record` shape. + * universal ("*") + English + EVERY other language's entries, deduped + * case-insensitively. Same rationale as RC4 (buildInformationalPatterns): + * users prompt in whichever language they think in — `language` in + * oma-config.yaml controls the RESPONSE language, not the prompt language — + * so gating by config language silently disabled e.g. every Korean trigger + * for `language: en` projects. A keyword written in language X can only + * match a prompt that contains X-script text (current banks are en/ko/ja/zh; + * if a Latin-script bank like es/fr is ever added, phrase distinctiveness is + * the gate instead), so merging all languages cannot fire on unrelated + * prompts. Shared by buildPatterns and buildRawPatterns — both keyword banks + * and pattern banks use this exact `Record` shape. */ -export function collectLangEntries( - bank: Record, - lang: string, -): string[] { - return [ +export function collectLangEntries(bank: Record): string[] { + const ordered = [ ...(bank["*"] ?? []), ...(bank.en ?? []), - ...(lang !== "en" ? (bank[lang] ?? []) : []), + ...Object.entries(bank) + .filter(([key]) => key !== "*" && key !== "en") + .flatMap(([, entries]) => entries), ]; + const seen = new Set(); + const out: string[] = []; + for (const entry of ordered) { + const key = entry.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(entry); + } + return out; } /** @@ -390,7 +405,7 @@ export function buildPatternEntries( lang: string, cjkScripts: string[], ): KeywordPatternEntry[] { - return collectLangEntries(keywords, lang).map((kw) => { + return collectLangEntries(keywords).map((kw) => { const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); const regex = cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw) @@ -421,11 +436,10 @@ export interface RawPatternEntry { export function buildRawPatternEntries( patterns: Record | undefined, - lang: string, ): RawPatternEntry[] { if (!patterns) return []; const compiled: RawPatternEntry[] = []; - for (const raw of collectLangEntries(patterns, lang)) { + for (const raw of collectLangEntries(patterns)) { try { compiled.push({ regex: new RegExp(raw, "iu"), source: raw }); } catch { @@ -443,9 +457,8 @@ export function buildRawPatternEntries( */ export function buildRawPatterns( patterns: Record | undefined, - lang: string, ): RegExp[] { - return buildRawPatternEntries(patterns, lang).map((e) => e.regex); + return buildRawPatternEntries(patterns).map((e) => e.regex); } export function buildInformationalPatterns(config: TriggerConfig): RegExp[] { @@ -707,6 +720,7 @@ function activateMode( projectDir: string, workflow: string, sessionId: string, + omaSid?: string | null, ): void { // Never persist a workflow under the unresolved-session fallback id: such a // file cannot be isolated per session and would cross-contaminate any later @@ -718,6 +732,7 @@ function activateMode( sessionId, activatedAt: new Date().toISOString(), reinforcementCount: 0, + ...(omaSid ? { omaSid } : {}), }; writeFileSync( join(getStateDir(projectDir), `${workflow}-state-${sessionId}.json`), @@ -770,11 +785,12 @@ export const DEACTIVATION_PHRASES: Record = { pl: ["workflow zakończony", "workflow ukończony"], }; -export function isDeactivationRequest(prompt: string, lang: string): boolean { - const phrases = [ - ...(DEACTIVATION_PHRASES.en ?? []), - ...(lang !== "en" ? (DEACTIVATION_PHRASES[lang] ?? []) : []), - ]; +export function isDeactivationRequest(prompt: string): boolean { + // All languages merged, never gated by config language (same rationale as + // collectLangEntries): a user prompting in Korean must be able to say + // "워크플로우 완료" even when `language: en`. A phrase only matches a prompt + // actually written in that language, so merging cannot misfire. + const phrases = Object.values(DEACTIVATION_PHRASES).flat(); const normalized = normalizeForMatching(prompt); return phrases.some((phrase) => normalized.includes(normalizeForMatching(phrase)), @@ -925,7 +941,7 @@ export async function run( const lang = detectLanguage(projectDir); // Check for deactivation request before workflow detection - if (isDeactivationRequest(prompt, lang)) { + if (isDeactivationRequest(prompt)) { deactivateAllPersistentModes(projectDir, sessionId); // Grok's resume context lives in a session-start file, not L1 stdout — clear it. if (vendor === "grok") clearGrokContext(projectDir); @@ -1025,10 +1041,7 @@ export async function run( )) { considerMatch(regex, keyword); } - for (const { regex, source } of buildRawPatternEntries( - def.patterns, - lang, - )) { + for (const { regex, source } of buildRawPatternEntries(def.patterns)) { considerMatch(regex, source); } } @@ -1038,10 +1051,17 @@ export async function run( const { workflow } = winner; + // Activate the L1 session first so its sid can be recorded in the + // persistent-mode state file (the Stop hook emits gate events under it). + const omaSid = await activateL1WorkflowSession( + projectDir, + workflow, + vendor, + sessionId, + ); if (winner.persistent) { - activateMode(projectDir, workflow, sessionId); + activateMode(projectDir, workflow, sessionId, omaSid); } - await activateL1WorkflowSession(projectDir, workflow, vendor, sessionId); const updatedState = recordKwTrigger(kwState, workflow); saveKwState(projectDir, updatedState); diff --git a/.agents/hooks/core/persistent-mode.ts b/.agents/hooks/core/persistent-mode.ts index e20dda6..698a0c3 100644 --- a/.agents/hooks/core/persistent-mode.ts +++ b/.agents/hooks/core/persistent-mode.ts @@ -13,6 +13,7 @@ * exit 2 = block stop */ +import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, @@ -40,15 +41,123 @@ import { getProjectDir } from "./vendor-detect.ts"; const MAX_REINFORCEMENTS = 5; const STALE_HOURS = 2; -function detectLanguage(projectDir: string): string { - const prefsPath = join(projectDir, ".agents", "oma-config.yaml"); - if (!existsSync(prefsPath)) return "en"; +// ── Goal contract: deterministic stop gate + wall-clock budget ─ +// (design-prime-agent-adoption Track B — no-exec-of-agent-writable-strings) + +/** + * The only gate values the Stop hook will ever execute. Each maps to a + * package.json script of the same name, run as an argv array WITHOUT a shell. + * The gate value lives in an agent-writable state file; executing anything + * outside this allowlist would be an arbitrary-command path that bypasses the + * PreToolUse permission layer. Never widen this to free-form strings. + */ +const GATE_KEYWORDS = new Set(["typecheck", "test", "lint"]); + +/** Hard cap on a gate run; SIGKILL after this. Keeps Stop-hook latency bounded. */ +const GATE_TIMEOUT_MS = 60_000; + +/** Tail of gate output carried back into the block reason. */ +const GATE_OUTPUT_TAIL_CHARS = 2_000; + +/** + * Resolve an allowlisted gate keyword to a package-runner argv, or null when + * the keyword is not allowlisted, package.json is absent, or it defines no + * script of that name. Pure node:fs — no shell, no third-party imports. + */ +export function resolveGateArgv( + gateKeyword: string, + projectDir: string, +): string[] | null { + if (!GATE_KEYWORDS.has(gateKeyword)) return null; + const pkgPath = join(projectDir, "package.json"); + if (!existsSync(pkgPath)) return null; try { - const content = readFileSync(prefsPath, "utf-8"); - const match = content.match(/^language:\s*(\S+)/m); - return match?.[1] ?? "en"; + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + scripts?: Record; + }; + if (typeof pkg.scripts?.[gateKeyword] !== "string") return null; } catch { - return "en"; + return null; + } + if ( + existsSync(join(projectDir, "bun.lock")) || + existsSync(join(projectDir, "bun.lockb")) + ) { + return ["bun", "run", gateKeyword]; + } + if (existsSync(join(projectDir, "pnpm-lock.yaml"))) { + return ["pnpm", "run", gateKeyword]; + } + if (existsSync(join(projectDir, "yarn.lock"))) { + return ["yarn", gateKeyword]; + } + return ["npm", "run", gateKeyword]; +} + +export interface GateRunResult { + passed: boolean; + timedOut: boolean; + outputTail: string; +} + +/** Run a resolved gate argv with a hard timeout. No shell involved. */ +export function runGateCommand( + argv: string[], + projectDir: string, +): GateRunResult { + 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, + }); + 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. */ +export function isBudgetExhausted(state: ModeState): boolean { + const minutes = state.goal?.budget?.wallClockMinutes; + if ( + typeof minutes !== "number" || + !Number.isFinite(minutes) || + minutes <= 0 + ) { + return false; + } + const elapsedMs = Date.now() - new Date(state.activatedAt).getTime(); + return elapsedMs >= minutes * 60_000; +} + +/** + * Emit a gate event onto the L1 trail recorded at activation. Best-effort: + * older state files carry no omaSid, and event emission must never break the + * Stop decision itself. + */ +async function emitGateEvent( + projectDir: string, + state: ModeState, + kind: "gate.passed" | "gate.failed", + payload: Record, +): Promise { + if (!state.omaSid) return; + try { + const { emitEvent } = await import("./state-emit.ts"); + await emitEvent(projectDir, state.omaSid, { + kind, + payload: { workflow: state.workflow, ...payload }, + }); + } catch { + // best-effort — never let event I/O change the stop decision } } @@ -218,8 +327,7 @@ export async function run( // text (parity with the standalone main() path). Without this, persistent // mode could not be deactivated via the central `oma hook` dispatch. if (input.responseText) { - const lang = detectLanguage(projectDir); - if (isDeactivationRequest(input.responseText, lang)) { + if (isDeactivationRequest(input.responseText)) { deactivateAllForSession(projectDir, sessionId); return null; } @@ -236,16 +344,75 @@ export async function run( continue; } - incrementReinforcement(projectDir, workflow, sessionId, state); + // (1) Wall-clock budget: exhausted → honest partial 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`, + }); + continue; + } const stateFile = `.agents/state/${workflow}-state-${sessionId}.json`; + + // (2) Deterministic completion gate. Only allowlisted keywords resolve to + // a runnable argv; anything else (including free-form shell strings an + // agent may have written into the state file) is NEVER executed and falls + // through to the plain reinforcement block below. + const gateKeyword = state.goal?.completion?.gate; + let ignoredGateNote = ""; + if (gateKeyword) { + const argv = resolveGateArgv(gateKeyword, projectDir); + if (argv) { + const gate = runGateCommand(argv, projectDir); + if (gate.passed) { + // The gate is the mechanical proof of completion: allow the stop. + deactivate(projectDir, workflow, sessionId); + await emitGateEvent(projectDir, state, "gate.passed", { + gate: gateKeyword, + summary: `stop gate '${gateKeyword}' passed for /${workflow}`, + }); + continue; + } + // Failure and timeout both count toward MAX_REINFORCEMENTS so a + // permanently red gate cannot block stops forever. + incrementReinforcement(projectDir, workflow, sessionId, state); + await emitGateEvent(projectDir, state, "gate.failed", { + gate: gateKeyword, + timedOut: gate.timedOut, + summary: `stop gate '${gateKeyword}' ${gate.timedOut ? "timed out" : "failed"} for /${workflow}`, + }); + 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.outputTail + ? `--- gate output (tail) ---\n${gate.outputTail}` + : "", + `To abandon instead: delete ${stateFile} or say "workflow done".`, + ] + .filter(Boolean) + .join("\n"); + return { type: "block", reason }; + } + ignoredGateNote = `Note: configured stop gate ${JSON.stringify(gateKeyword)} is not an allowed keyword (typecheck|test|lint) or has no matching package.json script — it was NOT executed.`; + } + + incrementReinforcement(projectDir, workflow, sessionId, state); + const reason = [ `[OMA PERSISTENT MODE: ${workflow.toUpperCase()}]`, `The /${workflow} workflow is still active (reinforcement ${state.reinforcementCount}/${MAX_REINFORCEMENTS}).`, `Continue executing the workflow. If all tasks are genuinely complete:`, ` 1. Delete the state file: Bash \`rm ${stateFile}\``, ` 2. Or ask the user to say "워크플로우 완료" / "workflow done"`, - ].join("\n"); + ignoredGateNote, + ] + .filter(Boolean) + .join("\n"); return { type: "block", reason }; } @@ -267,7 +434,6 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - const lang = detectLanguage(projectDir); // Check all text fields in stdin for deactivation phrases. // The assistant may have included "workflow done" in its response, @@ -284,7 +450,7 @@ async function main() { .filter((v): v is string => typeof v === "string") .join(" "); - if (textToCheck && isDeactivationRequest(textToCheck, lang)) { + if (textToCheck && isDeactivationRequest(textToCheck)) { // Deactivate all persistent workflows for this session (shared helper). deactivateAllForSession(projectDir, sessionId); process.exit(0); diff --git a/.agents/hooks/core/serena-primer.ts b/.agents/hooks/core/serena-primer.ts index df2aabc..07eaca2 100644 --- a/.agents/hooks/core/serena-primer.ts +++ b/.agents/hooks/core/serena-primer.ts @@ -117,6 +117,7 @@ export function primerContext(): string { "- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.", "- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.", "- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.", + '- Result size: omit `max_answer_chars` on Serena tools (uses the configured default, typically 150000). Never pass small caps like `3000` on broad searches. If a call returns "The answer is too long (N characters)", retry with `max_answer_chars` > N or narrow path/glob — do not keep the low cap.', "- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.", ].join("\n"); } diff --git a/.agents/hooks/core/skill-injector.ts b/.agents/hooks/core/skill-injector.ts index 66c8139..06fe400 100644 --- a/.agents/hooks/core/skill-injector.ts +++ b/.agents/hooks/core/skill-injector.ts @@ -188,10 +188,16 @@ export function matchSkills( const jsonEntry = config.skills?.[skill.name]; if (!jsonEntry) continue; + // All languages merged, never gated by config language: users prompt in + // whichever language they think in (`language` controls the RESPONSE + // language). A keyword written in language X can only match a prompt + // containing X-script text, so merging cannot fire on unrelated prompts. const jsonTriggers = [ ...(jsonEntry.keywords["*"] ?? []), ...(jsonEntry.keywords.en ?? []), - ...(lang !== "en" ? (jsonEntry.keywords[lang] ?? []) : []), + ...Object.entries(jsonEntry.keywords) + .filter(([key]) => key !== "*" && key !== "en") + .flatMap(([, entries]) => entries), ]; const seen = new Set(); diff --git a/.agents/hooks/core/triggers.json b/.agents/hooks/core/triggers.json index 8e5bff8..9aee86c 100644 --- a/.agents/hooks/core/triggers.json +++ b/.agents/hooks/core/triggers.json @@ -1211,12 +1211,10 @@ "랄프", "멈추지마", "멈추지 말고", - "끝까지", "완료될때까지", "될때까지 해", "끝날때까지", "다 끝내", - "다 해", "전부 완료", "끝까지 해", "중단하지마", diff --git a/.agents/hooks/core/types.ts b/.agents/hooks/core/types.ts index a58ffda..219a0e1 100644 --- a/.agents/hooks/core/types.ts +++ b/.agents/hooks/core/types.ts @@ -35,11 +35,45 @@ export interface RawHookInput { stopReason?: string; } +/** + * Optional goal contract for a persistent workflow (design-prime-agent-adoption + * Track B). Written by `oma goal:set`; read by the persistent-mode Stop hook. + */ +export interface ModeGoal { + /** Human description of the objective. Informational only. */ + description?: string; + budget?: { + /** + * Wall-clock budget in minutes, measured from `activatedAt`. When + * exceeded the Stop hook deactivates the workflow and allows an honest + * partial stop (machine verdict, not model discretion). + */ + wallClockMinutes?: number; + }; + completion?: { + /** + * Deterministic stop gate. MUST be an allowlist keyword ("typecheck" | + * "test" | "lint") that maps to an existing package.json script; the hook + * runs it as an argv array with no shell. Free-form strings are NEVER + * executed — this value lives in an agent-writable state file, so + * executing it verbatim would be an arbitrary-command-execution path + * that bypasses the PreToolUse permission layer. + */ + gate?: string; + }; +} + export interface ModeState { workflow: string; sessionId: string; activatedAt: string; reinforcementCount: number; + /** + * L1 session id (`oma-…`) recorded at activation so the Stop hook can emit + * gate.passed / gate.failed events onto the same events.jsonl trail. + */ + omaSid?: string; + goal?: ModeGoal; } // --------------------------------------------------------------------------- diff --git a/.agents/oma-config.yaml b/.agents/oma-config.yaml index 68564bb..c9f28bd 100644 --- a/.agents/oma-config.yaml +++ b/.agents/oma-config.yaml @@ -46,3 +46,8 @@ scm: - "*.example" - "*.sample" - "*.template" + +# Added by oma update — new config keys (template defaults; edit freely) +agents: + eval: + model: anthropic/claude-sonnet-4-6 diff --git a/.agents/skills/_shared/core/context-budget.md b/.agents/skills/_shared/core/context-budget.md index 206f2ba..b1aadb6 100644 --- a/.agents/skills/_shared/core/context-budget.md +++ b/.agents/skills/_shared/core/context-budget.md @@ -11,6 +11,13 @@ Follow this guide to use context efficiently. 2. **No duplicate reads**: Do not re-read files already read 3. **Lazy resource loading**: Load resources only when needed 4. **Maintain records**: Note read files and symbols in progress +5. **Run functions over data, don't read data into context**: when a scene + processes bulk data (harvest results, logs, transcripts, large JSON), do the + processing through a deterministic tool/CLI stage (`CALL_TOOL`) and bring + back only a summary plus the artifact path. Streaming raw data through the + context spends tokens reading what a program could have computed — the + `oma market` pipe stages (harvest → score → fuse → cluster stay in JSON; + only the rendered brief path returns) are the reference pattern. --- diff --git a/.agents/skills/_shared/core/test-approach.md b/.agents/skills/_shared/core/test-approach.md new file mode 100644 index 0000000..41ca3f7 --- /dev/null +++ b/.agents/skills/_shared/core/test-approach.md @@ -0,0 +1,53 @@ +# Per-Task Test Approach & TDD Evidence + +Opt-in, per-task test strategy carried in the PM plan (`plan-{sessionId}.json`). +PM assigns it (see `oma-pm/resources/execution-protocol.md` Step 3); implementation +agents honor it; QA and Ultrawork gates verify evidence **only** for tasks marked `tdd`. + +## Approaches + +| `test_approach` | Meaning | Agent obligation | +|---|---|---| +| `tdd` | Deterministic, high-risk behavior (validation, authorization, state transitions, calculations, error handling) | Write and run the focused test **before** the production change (RED), make the minimal change (GREEN), refactor only if needed. Record a `TDD_EVIDENCE` block in the result file. | +| `test_after` | Automated tests required, but a useful isolated RED state is impractical | Write tests with/after the implementation. No evidence block required. | +| `not_applicable` | Automated tests inappropriate | Perform the plan's `alternative_verification` and report its outcome. Plan must carry `test_approach_rationale`. | + +Tasks without a `test_approach` field behave as today (tests per the agent's +normal protocol). Refactor tasks never use `tdd` — they keep the +characterization-test safety net (`oma-refactor`). + +## Coverage non-waiver rule + +No `test_approach` value relaxes the global unit-test coverage gate +(**>= 80%**, QA checklist / SHIP_GATE). `not_applicable` code still counts +toward the aggregate; excluding it requires a declarative entry in the +project's coverage config with justification — never a silent drop. + +## TDD_EVIDENCE block format + +Append to the agent's result file (`result-{agent}.md`), one entry per `tdd` task: + +``` +TDD_EVIDENCE: +- task: task-2 + test_command: bun test src/services/discount.test.ts + red: "expected 400, received 200" (before implementation) + green: 12 pass, 0 fail (after implementation) +``` + +Requirements (enforced by `oma verify ` → "TDD Evidence" check): + +1. Block starts with the literal marker `TDD_EVIDENCE:` +2. Every `tdd` task id from the plan appears in the block +3. At least one `red:` entry (the observed failure before the change) and one + `green:` entry (the passing result after the change) + +A task may opt out with `tdd_evidence_required: false` in the plan (e.g., the +RED state is demonstrated in a linked CI run instead); the rationale belongs in +the task description. + +## Debug parity + +Debug regression tests follow the same discipline where feasible: run the +regression test before applying the fix, record the failing output (RED) and +the post-fix pass (GREEN) in the bug report / result file. diff --git a/.agents/skills/_version.json b/.agents/skills/_version.json index 0070ef0..a33d2fa 100644 --- a/.agents/skills/_version.json +++ b/.agents/skills/_version.json @@ -1,6 +1,6 @@ { - "version": "11.1.1", + "version": "11.10.3", "schemaVersion": 2, "mode": "project", - "installedAt": "2026-07-27T12:13:46.129Z" + "installedAt": "2026-08-10T10:10:15.956Z" } diff --git a/.agents/skills/oma-backend/resources/execution-protocol.md b/.agents/skills/oma-backend/resources/execution-protocol.md index c338b64..a477993 100644 --- a/.agents/skills/oma-backend/resources/execution-protocol.md +++ b/.agents/skills/oma-backend/resources/execution-protocol.md @@ -28,6 +28,7 @@ Follow these steps in order (adjust depth by difficulty). - Identify security requirements (auth, validation, rate limiting) ## Step 3: Implement +- **Honor the task's `test_approach`** (see `../../_shared/core/test-approach.md`): for `tdd` tasks, write and run the focused test first (record the RED failure), make the minimal change (GREEN), then continue; for `tdd` the test comes before item 3 below - Create/modify files in this order: 1. Database models + migrations 2. Validation schemas (request/response) @@ -41,6 +42,7 @@ Follow these steps in order (adjust depth by difficulty). - Run `resources/checklist.md` items - Run `../../_shared/core/common-checklist.md` items - Ensure all tests pass +- For `tdd` tasks, append the `TDD_EVIDENCE` block (test command, RED, GREEN) to the result file per `../../_shared/core/test-approach.md` - Confirm OpenAPI docs are complete ## On Error diff --git a/.agents/skills/oma-debug/resources/execution-protocol.md b/.agents/skills/oma-debug/resources/execution-protocol.md index 258391e..a4dc6ee 100644 --- a/.agents/skills/oma-debug/resources/execution-protocol.md +++ b/.agents/skills/oma-debug/resources/execution-protocol.md @@ -33,11 +33,12 @@ Follow these steps in order (adjust depth by difficulty). - Check `resources/common-patterns.md` for known patterns ## Step 3: Fix & Test -- Apply minimal fix that addresses the root cause - Write a regression test that: - Fails without the fix - Passes with the fix - Covers the specific edge case +- **Run the regression test before applying the fix where feasible**: record the failing output (RED), apply the minimal fix, record the pass (GREEN) — include both in the bug report / result file (see `../../_shared/core/test-approach.md` §Debug parity) +- Apply minimal fix that addresses the root cause - Check for similar patterns elsewhere: `search_for_pattern("same_bug_pattern")` - If found, fix proactively or report them diff --git a/.agents/skills/oma-frontend/SKILL.md b/.agents/skills/oma-frontend/SKILL.md index 22d5095..05d905e 100644 --- a/.agents/skills/oma-frontend/SKILL.md +++ b/.agents/skills/oma-frontend/SKILL.md @@ -129,7 +129,8 @@ Then run the project's frontend verification commands, typically lint, typecheck 5. Run the execution checklist before handoff and include relevant verification results. 6. **Self-describing file names**: every new file follows the File Naming convention in `../../rules/frontend.md` §Naming Conventions — domain + role readable from the basename alone (`order-summary-card.tsx`, `use-order-polling.ts`, `cart.atoms.ts`). Grab-bag names (`utils.ts`, `helpers.ts`, `misc.ts`) and version suffixes (`*-v2`, `*-final`) are banned. 7. **Next.js 16 `proxy.ts` is mandatory; `middleware.ts` is BANNED**: this project is Next.js 16+. `middleware.ts` is NOT "deprecated"; it is forbidden, touch it and you die. The canonical request-proxy / auth-gate file is `proxy.ts` (root or `src/`) exporting a `proxy` function. NEVER create, recommend, suggest, or "restore" `middleware.ts`. NEVER flag `proxy.ts` as dead code, unused, or not-wired. Any such finding is a fatal self-error: retract it immediately and write `proxy.ts`. -8. **Angular projects follow `resources/angular-rules.md`**: standalone components + `OnPush` + signals-first, `inject()` DI, lazy routes, new control flow. **Any non-trivial RxJS pipeline MUST ship with a marble test (`TestScheduler` from `rxjs/testing`)** — a stream without a marble test fails review. React/Next.js-specific rules (shadcn workflow, `proxy.ts`, Libraries table below) do not apply in Angular projects. +8. **`next/link` defaults to `prefetch={false}`**: every `` MUST pass `prefetch={false}` unless there is a stated reason not to. Next.js's default prefetching fires a request per link entering the viewport, which hammers container CPU/memory and origin bandwidth on list-heavy or nav-heavy pages. Opt back in (`prefetch` omitted, or `prefetch` / `prefetch="unstable_forceStale"`) ONLY for a small, deliberate set of high-intent targets (primary CTA, next step in a funnel), and note the reason inline. A `` without an explicit prefetch decision fails review. +9. **Angular projects follow `resources/angular-rules.md`**: standalone components + `OnPush` + signals-first, `inject()` DI, lazy routes, new control flow. **Any non-trivial RxJS pipeline MUST ship with a marble test (`TestScheduler` from `rxjs/testing`)** — a stream without a marble test fails review. React/Next.js-specific rules (shadcn workflow, `proxy.ts`, Libraries table below) do not apply in Angular projects. ### Libraries diff --git a/.agents/skills/oma-frontend/resources/checklist.md b/.agents/skills/oma-frontend/resources/checklist.md index abfb82c..dbe518e 100644 --- a/.agents/skills/oma-frontend/resources/checklist.md +++ b/.agents/skills/oma-frontend/resources/checklist.md @@ -8,6 +8,7 @@ Run through every item before submitting your work. - [ ] Do NOT flag `src/proxy.ts` as dead code or recommend renaming to `middleware.ts`; `proxy.ts` is the canonical Next.js 16+ convention - [ ] Config flags use the `Proxy` form (e.g. `skipProxyUrlNormalize`), not the legacy `Middleware` form +- [ ] Every `` passes `prefetch={false}`; any prefetching link is a deliberate high-intent target with an inline reason ## TypeScript - [ ] Strict mode, no `any` types diff --git a/.agents/skills/oma-frontend/resources/execution-protocol.md b/.agents/skills/oma-frontend/resources/execution-protocol.md index c118190..4f39a3e 100644 --- a/.agents/skills/oma-frontend/resources/execution-protocol.md +++ b/.agents/skills/oma-frontend/resources/execution-protocol.md @@ -29,6 +29,7 @@ Follow these steps in order (adjust depth by difficulty). - Plan responsive breakpoints and accessibility requirements ## Step 3: Implement +- **Honor the task's `test_approach`** (see `../../_shared/core/test-approach.md`): for `tdd` tasks, write and run the focused test first (record the RED failure), make the minimal change (GREEN), then continue - Create/modify files in this order: 1. TypeScript types/interfaces 2. API client hooks (orval-generated from OpenAPI when available; hand-written TanStack Query otherwise) @@ -42,6 +43,7 @@ Follow these steps in order (adjust depth by difficulty). - Run `resources/checklist.md` items - Run `../../_shared/core/common-checklist.md` items - Check TypeScript strict mode: no errors +- For `tdd` tasks, append the `TDD_EVIDENCE` block (test command, RED, GREEN) to the result file per `../../_shared/core/test-approach.md` - Verify responsive design at 320px, 768px, 1024px, 1440px - Test keyboard navigation and screen reader compatibility diff --git a/.agents/skills/oma-frontend/resources/snippets.md b/.agents/skills/oma-frontend/resources/snippets.md index 4930d41..7e0402d 100644 --- a/.agents/skills/oma-frontend/resources/snippets.md +++ b/.agents/skills/oma-frontend/resources/snippets.md @@ -8,8 +8,13 @@ Copy-paste ready patterns. Use these as starting points, adapt to the specific t ```tsx // Internal nav: , never +// prefetch={false} is the DEFAULT — viewport prefetching eats container CPU/RAM. import Link from "next/link"; -View gallery +View gallery + +// Opt back in only for a few high-intent targets, and say why: +// primary funnel CTA — prefetch is intentional +Checkout // Custom font: next/font, never import { Inter } from "next/font/google"; diff --git a/.agents/skills/oma-mobile/resources/execution-protocol.md b/.agents/skills/oma-mobile/resources/execution-protocol.md index 0b18cfb..3834a21 100644 --- a/.agents/skills/oma-mobile/resources/execution-protocol.md +++ b/.agents/skills/oma-mobile/resources/execution-protocol.md @@ -33,6 +33,7 @@ Follow these steps in order (adjust depth by difficulty). - Note platform differences (iOS HIG vs Material Design 3) ## Step 3: Implement +- **Honor the task's `test_approach`** (see `../../_shared/core/test-approach.md`): for `tdd` tasks, write and run the focused test first (record the RED failure), make the minimal change (GREEN), then continue - Create/modify files in this order (Flutter shown; Swift maps to Core → Features → Tests, RN to api → queries/mutations → store → ui → navigation → tests): 1. Domain: entities and repository interfaces 2. Data: models, API clients (Dio / axios / generated Client), repository implementations @@ -45,6 +46,7 @@ Follow these steps in order (adjust depth by difficulty). ## Step 4: Verify - Run `resources/checklist.md` items - Run `../../_shared/core/common-checklist.md` items +- For `tdd` tasks, append the `TDD_EVIDENCE` block (test command, RED, GREEN) to the result file per `../../_shared/core/test-approach.md` - Test on both iOS and Android (or emulators) - Verify 60fps performance (no jank) - Check dark mode support diff --git a/.agents/skills/oma-pm/resources/examples.md b/.agents/skills/oma-pm/resources/examples.md index 5380c09..f3671b8 100644 --- a/.agents/skills/oma-pm/resources/examples.md +++ b/.agents/skills/oma-pm/resources/examples.md @@ -24,6 +24,9 @@ "dependencies": [], "estimated_complexity": "high", "scope": ["src/api/auth/"], + "test_approach": "tdd", + "test_scope": ["unit", "integration"], + "test_approach_rationale": "Deterministic authorization rules with clear inputs and outputs.", "acceptance_criteria": [ "POST /api/auth/register with email + password", "POST /api/auth/login returns access + refresh tokens", diff --git a/.agents/skills/oma-pm/resources/execution-protocol.md b/.agents/skills/oma-pm/resources/execution-protocol.md index 29341a7..7ffa575 100644 --- a/.agents/skills/oma-pm/resources/execution-protocol.md +++ b/.agents/skills/oma-pm/resources/execution-protocol.md @@ -41,6 +41,13 @@ Follow these steps in order (adjust depth by difficulty). - Each task has: agent, title, description, acceptance criteria, priority, dependencies, **scope** - `agent`: one of the orchestrator-dispatchable domains — `backend`, `frontend`, `mobile`, `db`, `qa`, `debug`, `pm`, `architecture`, `refactor`, `tf-infra`, `docs` (see the agent mapping table in `.agents/workflows/orchestrate.md`) - `scope`: array of directory prefixes this agent is allowed to modify (e.g., `["src/api/", "migrations/"]`). Used by `verify` to detect cross-agent boundary violations in parallel execution. +- **Test approach (opt-in, per task)**: set `test_approach` where a test strategy matters + - `tdd`: deterministic, high-risk behavior (validation, authorization, state transitions, calculations, error handling). Implementation agent must record RED→GREEN evidence (see `TDD_EVIDENCE` block below). + - `test_after`: automated tests required, but a useful isolated RED state is impractical + - `not_applicable`: automated tests inappropriate — **must** fill `test_approach_rationale` and `alternative_verification` (documented manual/alternative check) + - Do **not** mark `tdd` for: documentation, pure styling, generated code, IaC plans, behavior-preserving refactors (refactor tasks keep their characterization-test safety net), or inherently nondeterministic integrations + - `test_scope`: which layers the tests cover (e.g., `["unit", "integration"]`) + - No `test_approach` value ever waives the global unit-test coverage gate (>= 80%); `not_applicable` code still counts toward the aggregate unless declaratively excluded in coverage config with justification - Minimize dependencies for maximum parallel execution - Priority tiers: 1 = independent (run first), 2 = depends on tier 1, etc. - The numeric tier is the **canonical** `priority` value in plan JSON (what the orchestrator fans out on). @@ -52,6 +59,7 @@ Follow these steps in order (adjust depth by difficulty). ## Step 4: Validate Plan - Check: Can each task be done independently given its dependencies? - Check: Are acceptance criteria measurable and testable? +- Check: Is `test_approach` valid where set (`tdd|test_after|not_applicable`), with rationale + alternative verification for every `not_applicable`? (`oma verify pm` enforces this contract) - Check: Is security considered from the start (not deferred)? - Check: Are API contracts defined before frontend/mobile tasks? - Check: Are major risks, owners, and approval points explicit when needed? diff --git a/.agents/skills/oma-pm/resources/task-template.json b/.agents/skills/oma-pm/resources/task-template.json index f85f3d2..d20d4bb 100644 --- a/.agents/skills/oma-pm/resources/task-template.json +++ b/.agents/skills/oma-pm/resources/task-template.json @@ -28,7 +28,12 @@ "estimated_complexity": "low|medium|high|very-high", "acceptance_criteria": [], "artifacts_expected": [], - "scope": [] + "scope": [], + "test_approach": "tdd|test_after|not_applicable", + "test_scope": [], + "tdd_evidence_required": true, + "test_approach_rationale": "", + "alternative_verification": "" } ], "api_contracts": [ diff --git a/.agents/skills/oma-qa/resources/checklist.md b/.agents/skills/oma-qa/resources/checklist.md index 475477b..fc28254 100644 --- a/.agents/skills/oma-qa/resources/checklist.md +++ b/.agents/skills/oma-qa/resources/checklist.md @@ -127,7 +127,8 @@ ## Testing Checklist ### Unit Tests -- [ ] Test coverage > 80% +- [ ] Test coverage >= 80% (never waived by any task's `test_approach`; `not_applicable` code counts toward the aggregate unless declaratively excluded in coverage config with justification) +- [ ] Tasks marked `test_approach: tdd` have a `TDD_EVIDENCE` block in the implementation result (focused test command, RED failure, GREEN pass) — see `../../_shared/core/test-approach.md`; do not require this evidence for `test_after` / `not_applicable` tasks - [ ] All business logic functions tested - [ ] Edge cases covered - [ ] Error handling tested @@ -270,7 +271,7 @@ - [ ] No data loss scenarios ### Important (Should Pass) -- [ ] Test coverage > 80% +- [ ] Test coverage >= 80% - [ ] Accessibility WCAG 2.2 AA - [ ] Code quality metrics met - [ ] Documentation complete diff --git a/.agents/skills/oma-skill-creator/SKILL.md b/.agents/skills/oma-skill-creator/SKILL.md index 010ad43..c12d556 100644 --- a/.agents/skills/oma-skill-creator/SKILL.md +++ b/.agents/skills/oma-skill-creator/SKILL.md @@ -159,6 +159,7 @@ Create, revise, and validate OMA skills using the SSL-lite Markdown structure de 11. Do not create extra README, changelog, or installation docs inside a skill. 12. Do not overwrite unrelated user edits. 13. Enforce the three utility-predictive content dimensions — failure mechanism encoding, actionable specificity, high-risk action blacklist — per the Utility Content Checks in `resources/validation-checklist.md` (SkillLens, arXiv:2605.23899). +14. Bulk-data scenes must run functions over data, not read data into context: route the processing through a deterministic tool/CLI stage (`CALL_TOOL`) and return only a summary plus the artifact path. Do not design scenes that stream raw harvested/parsed data through the model's context (see `../_shared/core/context-budget.md`, Core Principle 5). ## References - SSL-lite template: `resources/ssl-lite-template.md` diff --git a/.agents/skills/oma-video/SKILL.md b/.agents/skills/oma-video/SKILL.md index 7f016c4..7bd9b6f 100644 --- a/.agents/skills/oma-video/SKILL.md +++ b/.agents/skills/oma-video/SKILL.md @@ -203,7 +203,7 @@ Before invoking `oma video generate`, the calling agent runs this checklist. **I - [ ] **Locale**: narration + caption language (default from config; translated via oma-translator when non-source). - [ ] **Captions**: `tiktok` (centered, static windowed cues), `lower-third`, or `none`. - [ ] **Duration**: target seconds (<= 180) or `auto` (derived from the script). -- [ ] **Voice / music**: voice profile or `none`; music `upbeat` / `calm` / `none`. **The default voice is `none` → a silent video with estimated caption timing.** Pass `--voice ` (a Voicebox profile) whenever narration is expected. Music mixing is currently deferred (recorded + warned, not mixed). +- [ ] **Voice / music**: voice profile or `none`; music `upbeat` / `calm` / `none`. **The default voice is `none` → a silent video with estimated caption timing.** Pass `--voice ` (a Voicebox profile) whenever narration is expected. Music is rendered offline by Strudel and mixed at −18 dB; it needs a one-time `oma video doctor --install-strudel` and degrades to no music without it. **Amplification shortcut.** For a one-line brief (e.g. "shorts about Jeju coffee"), do not pop a questionnaire if the request is genuinely simple. Instead **amplify inline and show the user** the inferred plan before invoking: @@ -247,7 +247,7 @@ oma video generate "" [--mode shorts|explainer|demo] \ [--aspect 9:16|16:9|1:1|auto] [--locale ] \ [--captions tiktok|lower-third|none] \ [--visual auto|generate|stock|aigc|slide] \ - [--voice |none] [--music upbeat|calm|none] \ + [--voice |none] [--music upbeat|calm|cinematic|lofi|piano|none] \ [--duration |auto] [--compositor remotion|mpt] \ [--capture ] \ [--source file|web] [--url ] [--device ] \ @@ -298,7 +298,13 @@ Other skills call `oma video generate --format json` and parse the JSON envelope - **Narration is one wav**: oma-voice joins every scene line into a single `audio/narration-01.wav`, referenced by `render-spec.audio.narration`. There are no per-scene `narration-NN.wav` files. - **Timing**: per-line offsets live in `timing.json` (voicebox-stt -> estimated; the `tts-native` and `whisper-cpp` source values are reserved but not yet wired — `TODO(oma-deferred): whisper-cpp`); scene boundaries and caption cues are derived from it. - **Captions**: key-free `.srt` (+ `.vtt`) built from `timing.json`; `render-spec.captions.file` points at the `.srt`. The compositor renders **static windowed cues** — the cue active at the current frame, CSS-wrapped (no per-word animation). -- **Music**: deferred (`TODO(oma-deferred): music`) — no music asset source is wired yet, so a requested `--music` mode is recorded in `script.json` and surfaced as a warning, but no music is mixed. When implemented, it mixes under narration at `render-spec.audio.musicGainDb` (default −18 dB). +- **Music**: `--music ` renders a BGM bed with **Strudel** and mixes it under narration at `render-spec.audio.musicGainDb` (default −18 dB). The bed is generated offline (headless Chrome + `OfflineAudioContext`), so it needs no key, no network, and no audio device — a 30s bed renders in well under a second. + - **Presets**: `calm` (sustained pad + arpeggio), `upbeat` (bright plucks), `cinematic` (drone build to a lead), `lofi` (warm chords, swung ticks), `piano` (neoclassical arpeggio). Each preset picks its key and mode from the run `seed`, so the same preset sounds different run to run without a second pattern. + - **Artifacts** in the run dir: `music/bgm.wav` (mixed by the compositor), `music/bgm.mp3` (preview), `music/bgm-raw.wav` (pre-master), and `music/pattern.strudel` — the source that produced them, editable and re-renderable by hand. + - **Level**: every bed is normalised to −14 LUFS with a static gain before a peak limiter, so `musicGainDb` means the same thing for every preset. Normalisation is deliberately *not* `loudnorm`'s one-pass mode, which flattens the arrangement arc. + - **Opt-in install**: `@strudel/*` is AGPL-3.0-or-later while the oma CLI is MIT, so the deps are never bundled and never installed implicitly. Run `oma video doctor --install-strudel` once. The CLI never imports Strudel — it spawns `resources/strudel/render.mjs` as a subprocess, the same boundary the Remotion / Playwright projects use. + - **Fallback**: a missing install, a missing Chrome, or a failed render degrades to *no music* with a warning. The run still succeeds and `audio.music` stays unset (never a dangling `staticFile()` ref). + - **Determinism**: the built-in beds are oscillator-only (sine / triangle / square / sawtooth), which render byte-identically on replay. Noise sounds (`white` / `pink` / `brown`) draw from `Math.random()` and would break that, so the templates avoid them. ## References diff --git a/.agents/skills/oma-video/resources/execution-protocol.md b/.agents/skills/oma-video/resources/execution-protocol.md index bafb9c1..e739574 100644 --- a/.agents/skills/oma-video/resources/execution-protocol.md +++ b/.agents/skills/oma-video/resources/execution-protocol.md @@ -15,7 +15,7 @@ plan when the brief is a one-liner. - `mode` ∈ {`shorts`, `explainer`, `demo`}. - `aspect` ∈ {`9:16`, `16:9`, `1:1`, `auto`} (`auto` snaps to the mode default: shorts -> 9:16, explainer/demo -> 16:9). - `captions` ∈ {`tiktok`, `lower-third`, `none`}; `visual` ∈ {`auto`, `generate`, `stock`, `aigc`, `slide`}. - - `music` ∈ {`upbeat`, `calm`, `none`}; `compositor` ∈ {`remotion`, `mpt`}. + - `music` ∈ {`upbeat`, `calm`, `cinematic`, `lofi`, `piano`, `none`}; `compositor` ∈ {`remotion`, `mpt`}. - `duration` ≤ `limits.max_duration_sec` (180); resulting `scenes` ≤ `limits.max_scenes` (40). - `out` is inside `$PWD` unless `--allow-external-out`. - For `demo`: `--capture` (if given) exists, is absolute + `$PWD`-guarded, and is a valid video format. diff --git a/.agents/skills/oma-video/resources/prompt-tips.md b/.agents/skills/oma-video/resources/prompt-tips.md index 69b53b1..6bc6c33 100644 --- a/.agents/skills/oma-video/resources/prompt-tips.md +++ b/.agents/skills/oma-video/resources/prompt-tips.md @@ -53,7 +53,7 @@ Scene/backdrop → Subject → Details → Constraints - Anchor the **arc**: hook → body → payoff. A short without a hook gets swiped past. - Match **aspect to mode** (9:16 shorts, 16:9 explainer/demo) or use `auto`. - Keep narration **per-scene and short** so caption pages and scene boundaries align. -- Pick **music** that matches pacing (`upbeat` for shorts, `calm` for explainer) — note music mixing is currently deferred: the choice is recorded in `script.json` and warned, but not yet audible. +- Pick **music** that matches pacing (`upbeat` for shorts, `calm` for explainer) — Strudel renders the bed offline and mixes it at −18 dB under narration. Needs a one-time `oma video doctor --install-strudel`; without it the run still succeeds, just silent. ## Don'ts diff --git a/.agents/skills/oma-video/resources/script-schema.md b/.agents/skills/oma-video/resources/script-schema.md index 86c515d..b927f03 100644 --- a/.agents/skills/oma-video/resources/script-schema.md +++ b/.agents/skills/oma-video/resources/script-schema.md @@ -15,7 +15,7 @@ required** — omitting it is the most common authoring failure. | `locale` | string (min 1) | ✅ | narration/caption language tag, e.g. `en`, `ko` | | `title` | string (min 1) | ✅ | also drives the output filename slug: `-.mp4` | | `scenes` | array (min 1, ≤ 40) | ✅ | see per-scene fields below | -| `music` | `upbeat` \| `calm` \| `none` | ✅ | recorded only — music mixing is deferred (`TODO(oma-deferred): music`) | +| `music` | `upbeat` \| `calm` \| `cinematic` \| `lofi` \| `piano` \| `none` | ✅ | drives the Strudel BGM bed; mixed at −18 dB under narration | | `brand` | object | — | free-form; defaults to `{}` | ### Per-scene fields (`scenes[]`) @@ -77,5 +77,5 @@ required** — omitting it is the most common authoring failure. - [ ] `aspect` is concrete (`9:16` / `16:9` / `1:1`) — never `auto`. - [ ] Scene count ≤ 40, total `durationSec` ≤ 180. - [ ] Every scene has a `visual.kind` from the enum; `still` scenes carry an English `visual.prompt`. -- [ ] `music` is set (use `"none"` unless the user asked — mixing is deferred either way). +- [ ] `music` is set (use `"none"` unless the user asked for a bed). - [ ] Validate cheaply before rendering: pass the file via `--script` with `--dry-run` first. diff --git a/.agents/skills/oma-video/resources/strudel/package.json b/.agents/skills/oma-video/resources/strudel/package.json new file mode 100644 index 0000000..000fce4 --- /dev/null +++ b/.agents/skills/oma-video/resources/strudel/package.json @@ -0,0 +1,11 @@ +{ + "name": "oma-video-strudel", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Vendored Strudel BGM renderer for oma-video. Deps are installed on demand by `oma video doctor --install-strudel`. @strudel/* is AGPL-3.0-or-later and is NEVER imported by the MIT-licensed oma CLI — render.mjs runs as a subprocess (same boundary as the Remotion / Playwright projects).", + "license": "MIT", + "dependencies": { + "@strudel/web": "^1.3.0" + } +} diff --git a/.agents/skills/oma-video/resources/strudel/page.html b/.agents/skills/oma-video/resources/strudel/page.html new file mode 100644 index 0000000..afe58bf --- /dev/null +++ b/.agents/skills/oma-video/resources/strudel/page.html @@ -0,0 +1,87 @@ + + + + + oma-video strudel offline render + + + + + + + diff --git a/.agents/skills/oma-video/resources/strudel/render.mjs b/.agents/skills/oma-video/resources/strudel/render.mjs new file mode 100644 index 0000000..e9a67c6 --- /dev/null +++ b/.agents/skills/oma-video/resources/strudel/render.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node +// Strudel BGM renderer — boundary-safe subprocess entrypoint. +// +// oma-video NEVER imports Strudel. @strudel/* is AGPL-3.0-or-later while the +// oma CLI is MIT, so the TypeScript provider (`providers/music-strudel.ts` + +// `internal/strudel-project.ts`) only ever LOCATES this project on disk and +// spawns *this* script. Same boundary the Remotion / Playwright projects use. +// +// MECHANISM: `@strudel/webaudio.renderPatternAudio()` renders a pattern through +// an OfflineAudioContext — faster than realtime, no audio device, no autoplay +// gesture. A 30s bed renders in well under a second. The bundle only runs in a +// browser, so we drive a headless Chrome over raw CDP (Node's global WebSocket; +// zero npm deps here beyond @strudel/web itself) and serve the bundle from a +// loopback-only HTTP server. +// +// DETERMINISM: oscillator sounds (sine/triangle/square/sawtooth) render +// byte-identically across runs. Noise-based sounds (white/pink/brown) do NOT — +// superdough fills those buffers from Math.random(). Patterns that must be +// reproducible should stay oscillator-only. +// +// Contract — flags in, ONE JSON result line out (stdout's LAST line): +// IN --pattern-file file holding the strudel pattern code (required) +// --out output wav, inside a run dir (required) +// --chrome Chrome/Chromium executable (required) +// --seconds bed length in seconds (required) +// --cps cycles per second (default 0.5) +// --sample-rate default 44100 +// --timeout hard ceiling for the whole render (default 120000) +// OUT {"ok":true,"output":"","seconds":,"bytes":,"sha256":""} +// | {"ok":false,"error":"","code":""} +// +// Exit code is 0 on success, 1 on failure; the JSON result line is authoritative. +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DIST = path.join(HERE, "node_modules", "@strudel", "web", "dist"); + +const MIME = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".html": "text/html", + ".json": "application/json", + ".wasm": "application/wasm", +}; + +let server; +let chrome; +let userDataDir; +let hardTimer; + +function fail(message, code = "render_failed") { + process.stdout.write(`${JSON.stringify({ ok: false, error: message, code })}\n`); + cleanup().finally(() => process.exit(1)); +} + +async function cleanup() { + if (hardTimer) clearTimeout(hardTimer); + try { + chrome?.kill("SIGTERM"); + } catch {} + try { + server?.close(); + } catch {} + if (userDataDir) { + await rm(userDataDir, { recursive: true, force: true }).catch(() => {}); + } +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith("--")) continue; + const eq = token.indexOf("="); + if (eq !== -1) { + out[token.slice(2, eq)] = token.slice(eq + 1); + } else { + out[token.slice(2)] = argv[i + 1]?.startsWith("--") ? "" : argv[++i]; + } + } + return out; +} + +const args = parseArgs(process.argv.slice(2)); +const patternFile = args["pattern-file"]; +const outPath = args.out; +const chromePath = args.chrome; +const seconds = Number(args.seconds); +const cps = Number(args.cps ?? 0.5); +const sampleRate = Number(args["sample-rate"] ?? 44100); +const timeoutMs = Number(args.timeout ?? 120000); + +if (!patternFile || !outPath || !chromePath) { + fail("missing required flag: --pattern-file / --out / --chrome", "bad_args"); +} else if (!Number.isFinite(seconds) || seconds <= 0) { + fail(`--seconds must be a positive number (got ${args.seconds})`, "bad_args"); +} else if (!existsSync(DIST)) { + fail( + `@strudel/web is not installed in ${HERE} — run \`oma video doctor --install-strudel\``, + "not_installed", + ); +} else if (!existsSync(chromePath)) { + fail(`chrome executable not found: ${chromePath}`, "no_chrome"); +} else { + main().catch((err) => fail(err?.message ?? String(err))); +} + +async function main() { + hardTimer = setTimeout( + () => fail(`render exceeded ${timeoutMs}ms`, "timeout"), + timeoutMs, + ); + hardTimer.unref?.(); + + const code = await readFile(patternFile, "utf8"); + + // 1. Loopback-only static server for the page + the strudel bundle. Paths are + // confined to this dir and the resolved dist (no traversal). + server = createServer(async (req, res) => { + const pathname = new URL(req.url, "http://127.0.0.1").pathname; + let file; + if (pathname === "/" || pathname === "/page.html") { + file = path.join(HERE, "page.html"); + } else if (pathname === "/strudel.js") { + file = path.join(DIST, "index.js"); + } else { + const resolved = path.resolve(DIST, `.${pathname}`); + file = resolved.startsWith(DIST) ? resolved : null; + } + if (!file) { + res.writeHead(403).end("forbidden"); + return; + } + try { + const body = await readFile(file); + res.writeHead(200, { + "content-type": MIME[path.extname(file)] ?? "application/octet-stream", + "cross-origin-opener-policy": "same-origin", + "cross-origin-embedder-policy": "require-corp", + "cross-origin-resource-policy": "cross-origin", + }); + res.end(body); + } catch { + res.writeHead(404).end("not found"); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const httpPort = server.address().port; + + // 2. Headless Chrome. No audio device is needed — OfflineAudioContext renders + // into a buffer, so this works on a bare CI box. + userDataDir = await mkdtemp(path.join(tmpdir(), "oma-strudel-")); + chrome = spawn( + chromePath, + [ + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + "--disable-dev-shm-usage", + `--user-data-dir=${userDataDir}`, + "--remote-debugging-port=0", + "about:blank", + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + chrome.on("error", (err) => fail(`chrome failed to start: ${err.message}`, "no_chrome")); + + const devtoolsPort = await waitForDevtoolsPort( + path.join(userDataDir, "DevToolsActivePort"), + ); + + // 3. Drive it over raw CDP. + const targets = await ( + await fetch(`http://127.0.0.1:${devtoolsPort}/json/list`) + ).json(); + const page = targets.find((t) => t.type === "page"); + if (!page) throw new Error("chrome exposed no page target"); + + const cdp = await connect(page.webSocketDebuggerUrl); + await cdp.send("Runtime.enable"); + await cdp.send("Page.enable"); + await cdp.send("Page.navigate", { + url: `http://127.0.0.1:${httpPort}/page.html`, + }); + await cdp.waitForLoad(); + + const base64Length = await cdp.evaluate( + `window.__omaRender(${JSON.stringify(code)}, ${seconds}, ${cps}, ${sampleRate})`, + ); + if (!base64Length || base64Length <= 0) { + throw new Error("strudel produced an empty buffer"); + } + + // 4. Pull the wav back in slices so no single CDP message gets huge. + const CHUNK = 4 * 1024 * 1024; + let b64 = ""; + for (let offset = 0; offset < base64Length; offset += CHUNK) { + b64 += await cdp.evaluate( + `window.__omaWav.slice(${offset}, ${offset + CHUNK})`, + ); + } + const wav = Buffer.from(b64, "base64"); + await writeFile(outPath, wav); + + process.stdout.write( + `${JSON.stringify({ + ok: true, + output: outPath, + seconds, + bytes: wav.length, + sha256: createHash("sha256").update(wav).digest("hex"), + })}\n`, + ); + await cleanup(); + process.exit(0); +} + +/** Chrome writes its chosen debugger port here once it is listening. */ +async function waitForDevtoolsPort(portFile) { + for (let i = 0; i < 200; i++) { + if (existsSync(portFile)) { + const [line] = readFileSync(portFile, "utf8").split("\n"); + const port = Number(line); + if (Number.isFinite(port) && port > 0) return port; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("chrome never reported a devtools port"); +} + +/** Minimal CDP client over Node's global WebSocket. */ +async function connect(wsUrl) { + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = () => reject(new Error("cdp websocket failed to open")); + }); + + let nextId = 0; + const pending = new Map(); + let loaded = false; + const loadWaiters = []; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(JSON.stringify(msg.error))); + else resolve(msg.result); + return; + } + if (msg.method === "Page.loadEventFired") { + loaded = true; + while (loadWaiters.length) loadWaiters.shift()(); + } + }; + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + + return { + send, + waitForLoad: () => + loaded ? Promise.resolve() : new Promise((resolve) => loadWaiters.push(resolve)), + async evaluate(expression) { + const res = await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (res.exceptionDetails) { + const detail = + res.exceptionDetails.exception?.description ?? + res.exceptionDetails.text ?? + "unknown page error"; + throw new Error(detail.split("\n")[0]); + } + return res.result.value; + }, + }; +} diff --git a/.agents/workflows/orchestrate.md b/.agents/workflows/orchestrate.md index 313ca4a..9755045 100644 --- a/.agents/workflows/orchestrate.md +++ b/.agents/workflows/orchestrate.md @@ -37,12 +37,26 @@ The detected runtime vendor and each agent's target vendor determine how agents ## Step 1: Load or Create Plan +### 1a. Load + Look for a plan file: 1. Check `.agents/results/plan-{sessionId}.json` (current session's plan). 2. If not found: find the most recent `.agents/results/plan-*.json` file. -3. If none exist: ask the user to run `/plan` first, or ask them to describe the tasks to execute. -- **Do NOT proceed without a plan.** +3. A plan is **usable** only when every task carries an agent assignment, a priority tier, its dependencies, and acceptance criteria. A plan missing any of these is not execution-ready — fall through to 1b rather than fanning out against it. + +### 1b. Create (no usable plan) + +A missing plan is not a stop condition. `/orchestrate` creates the plan itself instead of handing the request back to the user: + +1. Generate the session ID now (format: `session-YYYYMMDD-HHMMSS`). Step 2 reuses this id verbatim — do not generate a second one. +2. Read and follow `.agents/workflows/plan.md` step by step, passing this session ID as its `{sessionId}` so the artifact lands at `.agents/results/plan-{sessionId}.json`. +3. **Do NOT skip `plan.md` Step 6 (Review Plan with User).** It is this run's approval gate — the Step 3 fan-out is authorized by it. Delegation never removes a user gate. +4. Once the plan is saved and approved, load it and continue to Step 2 with the same session ID. + +Stop and report only when the plan cannot be produced: the user declines to plan, or `plan.md` blocks because the request is too underspecified to decompose. + +- **Do NOT spawn agents without a usable plan.** --- @@ -66,7 +80,7 @@ Look for a plan file: └──────────┴───────────────────┘ ``` -3. Generate session ID (format: `session-YYYYMMDD-HHMMSS`). +3. Session ID: reuse the id generated in Step 1b when the plan was created in this run; otherwise generate one now (format: `session-YYYYMMDD-HHMMSS`). 4. **Domain gate**: for each planned task, classify it into `domain_tags` by matching against the `Intent signature` block of each installed `.agents/skills/oma-*/SKILL.md`, and derive `exposed_skill_set` (skills whose name is in `domain_tags`). If fewer than 2 skills match confidently, fall back to the full installed set and mark `exposure_fallback: true`. See `.agents/skills/oma-orchestrator/SKILL.md` (PHASE 1.5) for the full rules. 5. Use memory write tool to create `orchestrator-session.md` and `task-board.md` in the memory base path. Record `Exposed Skills` and `Exposure Fallback` per task in `task-board.md`. 6. Set session status to RUNNING. @@ -148,6 +162,7 @@ Also use memory read tool to poll `progress-{agent}[-{sessionId}].md` for logic - Use memory edit tool to update `task-board.md` with turn counts and status changes. - Watch for: completion, failures, crashes. +- A `no-artifact` status (or `oma agent:spawn` exit code 3) means the vendor exited 0 but wrote no result artifact under the workspace — a silent misdirected write. Treat it as a failed spawn: do NOT collect it as completed; re-dispatch (natively if the external vendor is unreliable) and check the session trail for the `blocker.raised` event. ### Context Anxiety Check (per polling cycle) diff --git a/.agents/workflows/ultrawork.md b/.agents/workflows/ultrawork.md index 35a35a2..64f89d4 100644 --- a/.agents/workflows/ultrawork.md +++ b/.agents/workflows/ultrawork.md @@ -59,6 +59,9 @@ Reviewers are read-only evaluators. Implementation and refactor **actions** (Pha 10. Record session start using memory write tool: - Create `session-ultrawork.md` in the memory base path - Include: session start time, session ID, user request summary, workflow version (ultrawork) +11. (Recommended) Attach a mechanical stop gate when the project has a cheap deterministic check: + - `oma goal:set --gate typecheck` (allowlist: `typecheck` | `test` | `lint`; maps to the package.json script) + - While set, the Stop hook allows the session to end only when the gate passes; failures return the output tail. Add `--budget-minutes ` to bound unattended runs with an honest partial stop. --- diff --git a/.agents/workflows/ultrawork/resources/phase-gates.md b/.agents/workflows/ultrawork/resources/phase-gates.md index feb9467..816ea4c 100644 --- a/.agents/workflows/ultrawork/resources/phase-gates.md +++ b/.agents/workflows/ultrawork/resources/phase-gates.md @@ -13,6 +13,7 @@ The "Owner" of each gate coordinates the phase and records the verdict; it does ### Criteria - [ ] Plan documented with acceptance criteria +- [ ] Where set, `test_approach` is valid (`tdd|test_after|not_applicable`); every `not_applicable` carries `test_approach_rationale` + `alternative_verification`; refactor tasks are never `tdd` (see `_shared/core/test-approach.md`) - [ ] Assumptions explicitly listed - [ ] Alternatives considered for architecture decisions (min 2) - [ ] Over-engineering review completed @@ -36,6 +37,7 @@ Revise plan, do not proceed to IMPL ### Criteria - [ ] Code compiles/builds successfully - [ ] Tests pass +- [ ] Tasks marked `test_approach: tdd` have a `TDD_EVIDENCE` block (focused test command, RED failure, GREEN pass) in the result — checked **only** for `tdd` tasks; `oma verify ` automates this - [ ] Only planned files modified - [ ] No unrequested features added - [ ] Diff reviewed for scope creep @@ -98,7 +100,7 @@ Address issues, re-verify ### Criteria - [ ] Lint passes - [ ] Type check passes -- [ ] Test coverage >= 80% +- [ ] Test coverage >= 80% (hard floor — no task's `test_approach`, including `not_applicable`, waives or lowers it) - [ ] UX flows verified - [ ] No hardcoded secrets - [ ] Migrations safe diff --git a/.agents/workflows/video.md b/.agents/workflows/video.md index b1754a9..fbc78e7 100644 --- a/.agents/workflows/video.md +++ b/.agents/workflows/video.md @@ -57,7 +57,7 @@ For `demo`, also resolve the **source**: a recorded file or Cap → `--source fi | stock video | Pexels (`PEXELS_API_KEY`) | oma-image stills + Ken Burns | `TODO(oma-deferred): pexels` | | AIGC video | Pixelle-MCP + RunningHub (`RUNNINGHUB_API_KEY`) | oma-image stills | `TODO(oma-deferred): pixelle` | | caption timing | voicebox-stt (MCP `voicebox_transcribe` → REST) | estimate | `TODO(oma-deferred): whisper-cpp` | - | music mixing | (not wired — recorded + warned only) | render without music | `TODO(oma-deferred): music` | + | music mixing | Strudel offline render (`oma video doctor --install-strudel`) | render without music | — | | premium TTS | (not needed — oma-voice is local) | — | — | - **Pixelle AIGC is a community MCP**: off by default, requires one-time explicit user consent plus a source review before connecting, and is always cost-gated on RunningHub credits. @@ -113,7 +113,7 @@ The agent writes the script — this is the start of the determinism boundary. D ```bash oma video generate "" --mode --aspect --locale \ --captions --visual \ - --voice --music --duration \ + --voice --music --duration \ --compositor --seed \ --script --dry-run --format json ``` @@ -193,7 +193,7 @@ Review the finished video against the brief and the quality bars. Iterate by re- - Narration audio is present (or intentionally silent) and aligns to scenes. - Captions are synced to `timing.json`, within the safe area, and legible (static windowed cues, CSS-wrapped, Pretendard, design rule 2). - Visuals match each scene's intent; no placeholder leakage unless the run intentionally used the fallback. - - Aspect / dimensions are correct for the mode; branding applied as requested. (Music mixing is deferred — a requested music mode only produces a warning, never audio.) + - Aspect / dimensions are correct for the mode; branding applied as requested. (A requested music mode yields `music/bgm.wav` mixed at −18 dB, or a fallback warning and a silent render when Strudel is not installed.) 2. **Route each defect to its stage:** - script/narration/scene-count → **Step 3** (re-author script). - audio/timing → **Step 4** voice track (check oma-voice, re-synthesize). diff --git a/.claude/agents/backend-engineer.md b/.claude/agents/backend-engineer.md index 7a4221d..cad333b 100644 --- a/.claude/agents/backend-engineer.md +++ b/.claude/agents/backend-engineer.md @@ -46,7 +46,7 @@ Router (HTTP) → Service (Business Logic) → Repository (Data Access) → Mode ## Rules 1. Stay in scope — only work on assigned backend tasks -2. Write tests for all new code +2. Write tests for all new code; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 3. Follow Repository → Service → Router pattern (no business logic in routes) 4. Validate all inputs with the project's validation library 5. Parameterized queries only (no string interpolation in SQL) diff --git a/.claude/agents/debug-investigator.md b/.claude/agents/debug-investigator.md index 0e0bc5d..a18e1a4 100644 --- a/.claude/agents/debug-investigator.md +++ b/.claude/agents/debug-investigator.md @@ -53,7 +53,7 @@ CHARTER_CHECK: 1. Stay in scope — only work on assigned debug tasks 2. Fix root cause, not symptoms 3. Minimal changes only — no refactoring during bugfix; route refactoring needs to refactor-engineer -4. Every fix gets a regression test +4. Every fix gets a regression test; run it before the fix where feasible and record RED (failing output) → GREEN (post-fix pass) in the bug report 5. Search for similar patterns after fixing 6. Document out-of-scope findings for other agents 7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.claude/agents/frontend-engineer.md b/.claude/agents/frontend-engineer.md index cc83c56..9a29107 100644 --- a/.claude/agents/frontend-engineer.md +++ b/.claude/agents/frontend-engineer.md @@ -54,6 +54,6 @@ FSD-lite: root `src/` + feature `src/features/*/` 5. TailwindCSS v4 for styling, design tokens 1:1 mapping 6. Library defaults (greenfield; existing project choices win): luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) 7. Absolute imports with `@/` -8. Write tests for custom logic (>90% coverage target) +8. Write tests for custom logic (>90% coverage target); honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. Document out-of-scope dependencies for other agents 10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.claude/agents/mobile-engineer.md b/.claude/agents/mobile-engineer.md index ac734b1..dc85dba 100644 --- a/.claude/agents/mobile-engineer.md +++ b/.claude/agents/mobile-engineer.md @@ -53,7 +53,7 @@ Clean Architecture: domain → data → presentation (Swift native: App/Core/Fea 5. Transport client with interceptors (Dio / axios / generated Client) + repository-layer response cache, offline-first architecture 6. Secrets in secure storage only — never plain prefs or MMKV 7. 60fps target performance -8. Write widget/component tests and integration tests +8. Write widget/component tests and integration tests; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. ARB-based localization: edit ARB source files only, never generated localization code 10. Document out-of-scope dependencies for other agents 11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.claude/agents/pm-planner.md b/.claude/agents/pm-planner.md index 5dd3096..ba66e5f 100644 --- a/.claude/agents/pm-planner.md +++ b/.claude/agents/pm-planner.md @@ -56,12 +56,13 @@ Each task must include: - `priority`: execution tier — 1 = independent (runs first), 2 = depends on tier 1, etc. (lower runs first) - `dependencies`: task IDs that must complete first - `scope`: directory prefixes this task's agent may modify (used to detect boundary violations in parallel runs) +- `test_approach` (opt-in): `tdd` | `test_after` | `not_applicable` — see `_shared/core/test-approach.md`. `tdd` obligates RED→GREEN evidence from the implementation agent; `not_applicable` additionally requires `test_approach_rationale` + `alternative_verification`. Never assign `tdd` to refactor tasks (characterization tests instead) ## Rules 1. Stay in scope — planning only, no code implementation 2. API-first design 3. Minimize dependencies for maximum parallelism -4. Security and testing are part of every task (not separate) +4. Security and testing are part of every task (not separate); assign per-task `test_approach` (`tdd|test_after|not_applicable`) where a test strategy matters — `not_applicable` requires rationale + alternative verification, and no approach waives the >= 80% coverage gate 5. Each task completable by a single agent 6. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.codex/agents/backend-engineer.toml b/.codex/agents/backend-engineer.toml index d0f944b..dcd3f4d 100644 --- a/.codex/agents/backend-engineer.toml +++ b/.codex/agents/backend-engineer.toml @@ -40,7 +40,7 @@ Router (HTTP) → Service (Business Logic) → Repository (Data Access) → Mode ## Rules 1. Stay in scope — only work on assigned backend tasks -2. Write tests for all new code +2. Write tests for all new code; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 3. Follow Repository → Service → Router pattern (no business logic in routes) 4. Validate all inputs with the project's validation library 5. Parameterized queries only (no string interpolation in SQL) diff --git a/.codex/agents/debug-investigator.toml b/.codex/agents/debug-investigator.toml index cad7527..4b56814 100644 --- a/.codex/agents/debug-investigator.toml +++ b/.codex/agents/debug-investigator.toml @@ -46,7 +46,7 @@ CHARTER_CHECK: 1. Stay in scope — only work on assigned debug tasks 2. Fix root cause, not symptoms 3. Minimal changes only — no refactoring during bugfix; route refactoring needs to refactor-engineer -4. Every fix gets a regression test +4. Every fix gets a regression test; run it before the fix where feasible and record RED (failing output) → GREEN (post-fix pass) in the bug report 5. Search for similar patterns after fixing 6. Document out-of-scope findings for other agents 7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.codex/agents/frontend-engineer.toml b/.codex/agents/frontend-engineer.toml index e5816ad..d277d57 100644 --- a/.codex/agents/frontend-engineer.toml +++ b/.codex/agents/frontend-engineer.toml @@ -46,7 +46,7 @@ FSD-lite: root `src/` + feature `src/features/*/` 5. TailwindCSS v4 for styling, design tokens 1:1 mapping 6. Library defaults (greenfield; existing project choices win): luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) 7. Absolute imports with `@/` -8. Write tests for custom logic (>90% coverage target) +8. Write tests for custom logic (>90% coverage target); honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. Document out-of-scope dependencies for other agents 10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions """ diff --git a/.codex/agents/mobile-engineer.toml b/.codex/agents/mobile-engineer.toml index 5cb6cc8..82f66f1 100644 --- a/.codex/agents/mobile-engineer.toml +++ b/.codex/agents/mobile-engineer.toml @@ -46,7 +46,7 @@ Clean Architecture: domain → data → presentation (Swift native: App/Core/Fea 5. Transport client with interceptors (Dio / axios / generated Client) + repository-layer response cache, offline-first architecture 6. Secrets in secure storage only — never plain prefs or MMKV 7. 60fps target performance -8. Write widget/component tests and integration tests +8. Write widget/component tests and integration tests; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. ARB-based localization: edit ARB source files only, never generated localization code 10. Document out-of-scope dependencies for other agents 11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.codex/agents/pm-planner.toml b/.codex/agents/pm-planner.toml index 9a64b73..a58d2a4 100644 --- a/.codex/agents/pm-planner.toml +++ b/.codex/agents/pm-planner.toml @@ -50,13 +50,14 @@ Each task must include: - `priority`: execution tier — 1 = independent (runs first), 2 = depends on tier 1, etc. (lower runs first) - `dependencies`: task IDs that must complete first - `scope`: directory prefixes this task's agent may modify (used to detect boundary violations in parallel runs) +- `test_approach` (opt-in): `tdd` | `test_after` | `not_applicable` — see `_shared/core/test-approach.md`. `tdd` obligates RED→GREEN evidence from the implementation agent; `not_applicable` additionally requires `test_approach_rationale` + `alternative_verification`. Never assign `tdd` to refactor tasks (characterization tests instead) ## Rules 1. Stay in scope — planning only, no code implementation 2. API-first design 3. Minimize dependencies for maximum parallelism -4. Security and testing are part of every task (not separate) +4. Security and testing are part of every task (not separate); assign per-task `test_approach` (`tdd|test_after|not_applicable`) where a test strategy matters — `not_applicable` requires rationale + alternative verification, and no approach waives the >= 80% coverage gate 5. Each task completable by a single agent 6. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions """ diff --git a/.cursor/agents/backend-engineer.md b/.cursor/agents/backend-engineer.md index 2fefd28..8596eb0 100644 --- a/.cursor/agents/backend-engineer.md +++ b/.cursor/agents/backend-engineer.md @@ -45,7 +45,7 @@ Router (HTTP) → Service (Business Logic) → Repository (Data Access) → Mode ## Rules 1. Stay in scope — only work on assigned backend tasks -2. Write tests for all new code +2. Write tests for all new code; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 3. Follow Repository → Service → Router pattern (no business logic in routes) 4. Validate all inputs with the project's validation library 5. Parameterized queries only (no string interpolation in SQL) diff --git a/.cursor/agents/debug-investigator.md b/.cursor/agents/debug-investigator.md index 3b96a2a..4d27b0b 100644 --- a/.cursor/agents/debug-investigator.md +++ b/.cursor/agents/debug-investigator.md @@ -51,7 +51,7 @@ CHARTER_CHECK: 1. Stay in scope — only work on assigned debug tasks 2. Fix root cause, not symptoms 3. Minimal changes only — no refactoring during bugfix; route refactoring needs to refactor-engineer -4. Every fix gets a regression test +4. Every fix gets a regression test; run it before the fix where feasible and record RED (failing output) → GREEN (post-fix pass) in the bug report 5. Search for similar patterns after fixing 6. Document out-of-scope findings for other agents 7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.cursor/agents/frontend-engineer.md b/.cursor/agents/frontend-engineer.md index f3413cd..07a8874 100644 --- a/.cursor/agents/frontend-engineer.md +++ b/.cursor/agents/frontend-engineer.md @@ -53,6 +53,6 @@ FSD-lite: root `src/` + feature `src/features/*/` 5. TailwindCSS v4 for styling, design tokens 1:1 mapping 6. Library defaults (greenfield; existing project choices win): luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) 7. Absolute imports with `@/` -8. Write tests for custom logic (>90% coverage target) +8. Write tests for custom logic (>90% coverage target); honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. Document out-of-scope dependencies for other agents 10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.cursor/agents/mobile-engineer.md b/.cursor/agents/mobile-engineer.md index c439583..4cfbd95 100644 --- a/.cursor/agents/mobile-engineer.md +++ b/.cursor/agents/mobile-engineer.md @@ -52,7 +52,7 @@ Clean Architecture: domain → data → presentation (Swift native: App/Core/Fea 5. Transport client with interceptors (Dio / axios / generated Client) + repository-layer response cache, offline-first architecture 6. Secrets in secure storage only — never plain prefs or MMKV 7. 60fps target performance -8. Write widget/component tests and integration tests +8. Write widget/component tests and integration tests; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. ARB-based localization: edit ARB source files only, never generated localization code 10. Document out-of-scope dependencies for other agents 11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.cursor/agents/pm-planner.md b/.cursor/agents/pm-planner.md index c145402..1c6cfc7 100644 --- a/.cursor/agents/pm-planner.md +++ b/.cursor/agents/pm-planner.md @@ -55,12 +55,13 @@ Each task must include: - `priority`: execution tier — 1 = independent (runs first), 2 = depends on tier 1, etc. (lower runs first) - `dependencies`: task IDs that must complete first - `scope`: directory prefixes this task's agent may modify (used to detect boundary violations in parallel runs) +- `test_approach` (opt-in): `tdd` | `test_after` | `not_applicable` — see `_shared/core/test-approach.md`. `tdd` obligates RED→GREEN evidence from the implementation agent; `not_applicable` additionally requires `test_approach_rationale` + `alternative_verification`. Never assign `tdd` to refactor tasks (characterization tests instead) ## Rules 1. Stay in scope — planning only, no code implementation 2. API-first design 3. Minimize dependencies for maximum parallelism -4. Security and testing are part of every task (not separate) +4. Security and testing are part of every task (not separate); assign per-task `test_approach` (`tdd|test_after|not_applicable`) where a test strategy matters — `not_applicable` requires rationale + alternative verification, and no approach waives the >= 80% coverage gate 5. Each task completable by a single agent 6. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.gitignore b/.gitignore index 3b645f6..f4db8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ docs/generated/ .agents/backup/ docs/plans/ + +.qwen/tmp/ diff --git a/.opencode/agents/backend-engineer.md b/.opencode/agents/backend-engineer.md index 5100d78..7c71bba 100644 --- a/.opencode/agents/backend-engineer.md +++ b/.opencode/agents/backend-engineer.md @@ -42,7 +42,7 @@ Router (HTTP) → Service (Business Logic) → Repository (Data Access) → Mode ## Rules 1. Stay in scope — only work on assigned backend tasks -2. Write tests for all new code +2. Write tests for all new code; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 3. Follow Repository → Service → Router pattern (no business logic in routes) 4. Validate all inputs with the project's validation library 5. Parameterized queries only (no string interpolation in SQL) diff --git a/.opencode/agents/debug-investigator.md b/.opencode/agents/debug-investigator.md index fe2f2e7..e777357 100644 --- a/.opencode/agents/debug-investigator.md +++ b/.opencode/agents/debug-investigator.md @@ -49,7 +49,7 @@ CHARTER_CHECK: 1. Stay in scope — only work on assigned debug tasks 2. Fix root cause, not symptoms 3. Minimal changes only — no refactoring during bugfix; route refactoring needs to refactor-engineer -4. Every fix gets a regression test +4. Every fix gets a regression test; run it before the fix where feasible and record RED (failing output) → GREEN (post-fix pass) in the bug report 5. Search for similar patterns after fixing 6. Document out-of-scope findings for other agents 7. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.opencode/agents/frontend-engineer.md b/.opencode/agents/frontend-engineer.md index d3adc79..395b8bf 100644 --- a/.opencode/agents/frontend-engineer.md +++ b/.opencode/agents/frontend-engineer.md @@ -49,6 +49,6 @@ FSD-lite: root `src/` + feature `src/features/*/` 5. TailwindCSS v4 for styling, design tokens 1:1 mapping 6. Library defaults (greenfield; existing project choices win): luxon (dates), ahooks (hooks), es-toolkit (utils), jotai (client state), TanStack Query (server state) 7. Absolute imports with `@/` -8. Write tests for custom logic (>90% coverage target) +8. Write tests for custom logic (>90% coverage target); honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. Document out-of-scope dependencies for other agents 10. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.opencode/agents/mobile-engineer.md b/.opencode/agents/mobile-engineer.md index c8d8ac9..bb4360d 100644 --- a/.opencode/agents/mobile-engineer.md +++ b/.opencode/agents/mobile-engineer.md @@ -49,7 +49,7 @@ Clean Architecture: domain → data → presentation (Swift native: App/Core/Fea 5. Transport client with interceptors (Dio / axios / generated Client) + repository-layer response cache, offline-first architecture 6. Secrets in secure storage only — never plain prefs or MMKV 7. 60fps target performance -8. Write widget/component tests and integration tests +8. Write widget/component tests and integration tests; honor the plan task's `test_approach` — for `tdd`, demonstrate RED before the change and record a `TDD_EVIDENCE` block (test command, RED, GREEN) in the result file 9. ARB-based localization: edit ARB source files only, never generated localization code 10. Document out-of-scope dependencies for other agents 11. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.opencode/agents/pm-planner.md b/.opencode/agents/pm-planner.md index 4284400..871de02 100644 --- a/.opencode/agents/pm-planner.md +++ b/.opencode/agents/pm-planner.md @@ -52,12 +52,13 @@ Each task must include: - `priority`: execution tier — 1 = independent (runs first), 2 = depends on tier 1, etc. (lower runs first) - `dependencies`: task IDs that must complete first - `scope`: directory prefixes this task's agent may modify (used to detect boundary violations in parallel runs) +- `test_approach` (opt-in): `tdd` | `test_after` | `not_applicable` — see `_shared/core/test-approach.md`. `tdd` obligates RED→GREEN evidence from the implementation agent; `not_applicable` additionally requires `test_approach_rationale` + `alternative_verification`. Never assign `tdd` to refactor tasks (characterization tests instead) ## Rules 1. Stay in scope — planning only, no code implementation 2. API-first design 3. Minimize dependencies for maximum parallelism -4. Security and testing are part of every task (not separate) +4. Security and testing are part of every task (not separate); assign per-task `test_approach` (`tdd|test_after|not_applicable`) where a test strategy matters — `not_applicable` requires rationale + alternative verification, and no approach waives the >= 80% coverage gate 5. Each task completable by a single agent 6. Never modify `.agents/` files (SSOT) — run outputs under `.agents/results/` and `.agents/state/memories/` are the only exceptions diff --git a/.opencode/plugins/oma/keyword-detector.ts b/.opencode/plugins/oma/keyword-detector.ts index e336814..08094f6 100644 --- a/.opencode/plugins/oma/keyword-detector.ts +++ b/.opencode/plugins/oma/keyword-detector.ts @@ -357,20 +357,35 @@ export function escapeRegex(s: string): string { /** * Merge a language-keyed keyword/pattern bank into a single flat list: - * universal ("*") + English (the universal default) + the configured - * language's own entries (skipped when lang === "en" to avoid duplicates). - * Shared by buildPatterns and buildRawPatterns — both keyword banks and - * pattern banks use this exact `Record` shape. + * universal ("*") + English + EVERY other language's entries, deduped + * case-insensitively. Same rationale as RC4 (buildInformationalPatterns): + * users prompt in whichever language they think in — `language` in + * oma-config.yaml controls the RESPONSE language, not the prompt language — + * so gating by config language silently disabled e.g. every Korean trigger + * for `language: en` projects. A keyword written in language X can only + * match a prompt that contains X-script text (current banks are en/ko/ja/zh; + * if a Latin-script bank like es/fr is ever added, phrase distinctiveness is + * the gate instead), so merging all languages cannot fire on unrelated + * prompts. Shared by buildPatterns and buildRawPatterns — both keyword banks + * and pattern banks use this exact `Record` shape. */ -export function collectLangEntries( - bank: Record, - lang: string, -): string[] { - return [ +export function collectLangEntries(bank: Record): string[] { + const ordered = [ ...(bank["*"] ?? []), ...(bank.en ?? []), - ...(lang !== "en" ? (bank[lang] ?? []) : []), + ...Object.entries(bank) + .filter(([key]) => key !== "*" && key !== "en") + .flatMap(([, entries]) => entries), ]; + const seen = new Set(); + const out: string[] = []; + for (const entry of ordered) { + const key = entry.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(entry); + } + return out; } /** @@ -390,7 +405,7 @@ export function buildPatternEntries( lang: string, cjkScripts: string[], ): KeywordPatternEntry[] { - return collectLangEntries(keywords, lang).map((kw) => { + return collectLangEntries(keywords).map((kw) => { const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); const regex = cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw) @@ -421,11 +436,10 @@ export interface RawPatternEntry { export function buildRawPatternEntries( patterns: Record | undefined, - lang: string, ): RawPatternEntry[] { if (!patterns) return []; const compiled: RawPatternEntry[] = []; - for (const raw of collectLangEntries(patterns, lang)) { + for (const raw of collectLangEntries(patterns)) { try { compiled.push({ regex: new RegExp(raw, "iu"), source: raw }); } catch { @@ -443,9 +457,8 @@ export function buildRawPatternEntries( */ export function buildRawPatterns( patterns: Record | undefined, - lang: string, ): RegExp[] { - return buildRawPatternEntries(patterns, lang).map((e) => e.regex); + return buildRawPatternEntries(patterns).map((e) => e.regex); } export function buildInformationalPatterns(config: TriggerConfig): RegExp[] { @@ -707,6 +720,7 @@ function activateMode( projectDir: string, workflow: string, sessionId: string, + omaSid?: string | null, ): void { // Never persist a workflow under the unresolved-session fallback id: such a // file cannot be isolated per session and would cross-contaminate any later @@ -718,6 +732,7 @@ function activateMode( sessionId, activatedAt: new Date().toISOString(), reinforcementCount: 0, + ...(omaSid ? { omaSid } : {}), }; writeFileSync( join(getStateDir(projectDir), `${workflow}-state-${sessionId}.json`), @@ -770,11 +785,12 @@ export const DEACTIVATION_PHRASES: Record = { pl: ["workflow zakończony", "workflow ukończony"], }; -export function isDeactivationRequest(prompt: string, lang: string): boolean { - const phrases = [ - ...(DEACTIVATION_PHRASES.en ?? []), - ...(lang !== "en" ? (DEACTIVATION_PHRASES[lang] ?? []) : []), - ]; +export function isDeactivationRequest(prompt: string): boolean { + // All languages merged, never gated by config language (same rationale as + // collectLangEntries): a user prompting in Korean must be able to say + // "워크플로우 완료" even when `language: en`. A phrase only matches a prompt + // actually written in that language, so merging cannot misfire. + const phrases = Object.values(DEACTIVATION_PHRASES).flat(); const normalized = normalizeForMatching(prompt); return phrases.some((phrase) => normalized.includes(normalizeForMatching(phrase)), @@ -925,7 +941,7 @@ export async function run( const lang = detectLanguage(projectDir); // Check for deactivation request before workflow detection - if (isDeactivationRequest(prompt, lang)) { + if (isDeactivationRequest(prompt)) { deactivateAllPersistentModes(projectDir, sessionId); // Grok's resume context lives in a session-start file, not L1 stdout — clear it. if (vendor === "grok") clearGrokContext(projectDir); @@ -1025,10 +1041,7 @@ export async function run( )) { considerMatch(regex, keyword); } - for (const { regex, source } of buildRawPatternEntries( - def.patterns, - lang, - )) { + for (const { regex, source } of buildRawPatternEntries(def.patterns)) { considerMatch(regex, source); } } @@ -1038,10 +1051,17 @@ export async function run( const { workflow } = winner; + // Activate the L1 session first so its sid can be recorded in the + // persistent-mode state file (the Stop hook emits gate events under it). + const omaSid = await activateL1WorkflowSession( + projectDir, + workflow, + vendor, + sessionId, + ); if (winner.persistent) { - activateMode(projectDir, workflow, sessionId); + activateMode(projectDir, workflow, sessionId, omaSid); } - await activateL1WorkflowSession(projectDir, workflow, vendor, sessionId); const updatedState = recordKwTrigger(kwState, workflow); saveKwState(projectDir, updatedState); diff --git a/.opencode/plugins/oma/persistent-mode.ts b/.opencode/plugins/oma/persistent-mode.ts index e20dda6..698a0c3 100644 --- a/.opencode/plugins/oma/persistent-mode.ts +++ b/.opencode/plugins/oma/persistent-mode.ts @@ -13,6 +13,7 @@ * exit 2 = block stop */ +import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, @@ -40,15 +41,123 @@ import { getProjectDir } from "./vendor-detect.ts"; const MAX_REINFORCEMENTS = 5; const STALE_HOURS = 2; -function detectLanguage(projectDir: string): string { - const prefsPath = join(projectDir, ".agents", "oma-config.yaml"); - if (!existsSync(prefsPath)) return "en"; +// ── Goal contract: deterministic stop gate + wall-clock budget ─ +// (design-prime-agent-adoption Track B — no-exec-of-agent-writable-strings) + +/** + * The only gate values the Stop hook will ever execute. Each maps to a + * package.json script of the same name, run as an argv array WITHOUT a shell. + * The gate value lives in an agent-writable state file; executing anything + * outside this allowlist would be an arbitrary-command path that bypasses the + * PreToolUse permission layer. Never widen this to free-form strings. + */ +const GATE_KEYWORDS = new Set(["typecheck", "test", "lint"]); + +/** Hard cap on a gate run; SIGKILL after this. Keeps Stop-hook latency bounded. */ +const GATE_TIMEOUT_MS = 60_000; + +/** Tail of gate output carried back into the block reason. */ +const GATE_OUTPUT_TAIL_CHARS = 2_000; + +/** + * Resolve an allowlisted gate keyword to a package-runner argv, or null when + * the keyword is not allowlisted, package.json is absent, or it defines no + * script of that name. Pure node:fs — no shell, no third-party imports. + */ +export function resolveGateArgv( + gateKeyword: string, + projectDir: string, +): string[] | null { + if (!GATE_KEYWORDS.has(gateKeyword)) return null; + const pkgPath = join(projectDir, "package.json"); + if (!existsSync(pkgPath)) return null; try { - const content = readFileSync(prefsPath, "utf-8"); - const match = content.match(/^language:\s*(\S+)/m); - return match?.[1] ?? "en"; + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + scripts?: Record; + }; + if (typeof pkg.scripts?.[gateKeyword] !== "string") return null; } catch { - return "en"; + return null; + } + if ( + existsSync(join(projectDir, "bun.lock")) || + existsSync(join(projectDir, "bun.lockb")) + ) { + return ["bun", "run", gateKeyword]; + } + if (existsSync(join(projectDir, "pnpm-lock.yaml"))) { + return ["pnpm", "run", gateKeyword]; + } + if (existsSync(join(projectDir, "yarn.lock"))) { + return ["yarn", gateKeyword]; + } + return ["npm", "run", gateKeyword]; +} + +export interface GateRunResult { + passed: boolean; + timedOut: boolean; + outputTail: string; +} + +/** Run a resolved gate argv with a hard timeout. No shell involved. */ +export function runGateCommand( + argv: string[], + projectDir: string, +): GateRunResult { + 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, + }); + 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. */ +export function isBudgetExhausted(state: ModeState): boolean { + const minutes = state.goal?.budget?.wallClockMinutes; + if ( + typeof minutes !== "number" || + !Number.isFinite(minutes) || + minutes <= 0 + ) { + return false; + } + const elapsedMs = Date.now() - new Date(state.activatedAt).getTime(); + return elapsedMs >= minutes * 60_000; +} + +/** + * Emit a gate event onto the L1 trail recorded at activation. Best-effort: + * older state files carry no omaSid, and event emission must never break the + * Stop decision itself. + */ +async function emitGateEvent( + projectDir: string, + state: ModeState, + kind: "gate.passed" | "gate.failed", + payload: Record, +): Promise { + if (!state.omaSid) return; + try { + const { emitEvent } = await import("./state-emit.ts"); + await emitEvent(projectDir, state.omaSid, { + kind, + payload: { workflow: state.workflow, ...payload }, + }); + } catch { + // best-effort — never let event I/O change the stop decision } } @@ -218,8 +327,7 @@ export async function run( // text (parity with the standalone main() path). Without this, persistent // mode could not be deactivated via the central `oma hook` dispatch. if (input.responseText) { - const lang = detectLanguage(projectDir); - if (isDeactivationRequest(input.responseText, lang)) { + if (isDeactivationRequest(input.responseText)) { deactivateAllForSession(projectDir, sessionId); return null; } @@ -236,16 +344,75 @@ export async function run( continue; } - incrementReinforcement(projectDir, workflow, sessionId, state); + // (1) Wall-clock budget: exhausted → honest partial 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`, + }); + continue; + } const stateFile = `.agents/state/${workflow}-state-${sessionId}.json`; + + // (2) Deterministic completion gate. Only allowlisted keywords resolve to + // a runnable argv; anything else (including free-form shell strings an + // agent may have written into the state file) is NEVER executed and falls + // through to the plain reinforcement block below. + const gateKeyword = state.goal?.completion?.gate; + let ignoredGateNote = ""; + if (gateKeyword) { + const argv = resolveGateArgv(gateKeyword, projectDir); + if (argv) { + const gate = runGateCommand(argv, projectDir); + if (gate.passed) { + // The gate is the mechanical proof of completion: allow the stop. + deactivate(projectDir, workflow, sessionId); + await emitGateEvent(projectDir, state, "gate.passed", { + gate: gateKeyword, + summary: `stop gate '${gateKeyword}' passed for /${workflow}`, + }); + continue; + } + // Failure and timeout both count toward MAX_REINFORCEMENTS so a + // permanently red gate cannot block stops forever. + incrementReinforcement(projectDir, workflow, sessionId, state); + await emitGateEvent(projectDir, state, "gate.failed", { + gate: gateKeyword, + timedOut: gate.timedOut, + summary: `stop gate '${gateKeyword}' ${gate.timedOut ? "timed out" : "failed"} for /${workflow}`, + }); + 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.outputTail + ? `--- gate output (tail) ---\n${gate.outputTail}` + : "", + `To abandon instead: delete ${stateFile} or say "workflow done".`, + ] + .filter(Boolean) + .join("\n"); + return { type: "block", reason }; + } + ignoredGateNote = `Note: configured stop gate ${JSON.stringify(gateKeyword)} is not an allowed keyword (typecheck|test|lint) or has no matching package.json script — it was NOT executed.`; + } + + incrementReinforcement(projectDir, workflow, sessionId, state); + const reason = [ `[OMA PERSISTENT MODE: ${workflow.toUpperCase()}]`, `The /${workflow} workflow is still active (reinforcement ${state.reinforcementCount}/${MAX_REINFORCEMENTS}).`, `Continue executing the workflow. If all tasks are genuinely complete:`, ` 1. Delete the state file: Bash \`rm ${stateFile}\``, ` 2. Or ask the user to say "워크플로우 완료" / "workflow done"`, - ].join("\n"); + ignoredGateNote, + ] + .filter(Boolean) + .join("\n"); return { type: "block", reason }; } @@ -267,7 +434,6 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - const lang = detectLanguage(projectDir); // Check all text fields in stdin for deactivation phrases. // The assistant may have included "workflow done" in its response, @@ -284,7 +450,7 @@ async function main() { .filter((v): v is string => typeof v === "string") .join(" "); - if (textToCheck && isDeactivationRequest(textToCheck, lang)) { + if (textToCheck && isDeactivationRequest(textToCheck)) { // Deactivate all persistent workflows for this session (shared helper). deactivateAllForSession(projectDir, sessionId); process.exit(0); diff --git a/.opencode/plugins/oma/serena-primer.ts b/.opencode/plugins/oma/serena-primer.ts index df2aabc..07eaca2 100644 --- a/.opencode/plugins/oma/serena-primer.ts +++ b/.opencode/plugins/oma/serena-primer.ts @@ -117,6 +117,7 @@ export function primerContext(): string { "- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.", "- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.", "- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.", + '- Result size: omit `max_answer_chars` on Serena tools (uses the configured default, typically 150000). Never pass small caps like `3000` on broad searches. If a call returns "The answer is too long (N characters)", retry with `max_answer_chars` > N or narrow path/glob — do not keep the low cap.', "- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.", ].join("\n"); } diff --git a/.opencode/plugins/oma/skill-injector.ts b/.opencode/plugins/oma/skill-injector.ts index 66c8139..06fe400 100644 --- a/.opencode/plugins/oma/skill-injector.ts +++ b/.opencode/plugins/oma/skill-injector.ts @@ -188,10 +188,16 @@ export function matchSkills( const jsonEntry = config.skills?.[skill.name]; if (!jsonEntry) continue; + // All languages merged, never gated by config language: users prompt in + // whichever language they think in (`language` controls the RESPONSE + // language). A keyword written in language X can only match a prompt + // containing X-script text, so merging cannot fire on unrelated prompts. const jsonTriggers = [ ...(jsonEntry.keywords["*"] ?? []), ...(jsonEntry.keywords.en ?? []), - ...(lang !== "en" ? (jsonEntry.keywords[lang] ?? []) : []), + ...Object.entries(jsonEntry.keywords) + .filter(([key]) => key !== "*" && key !== "en") + .flatMap(([, entries]) => entries), ]; const seen = new Set(); diff --git a/.opencode/plugins/oma/triggers.json b/.opencode/plugins/oma/triggers.json index 8e5bff8..9aee86c 100644 --- a/.opencode/plugins/oma/triggers.json +++ b/.opencode/plugins/oma/triggers.json @@ -1211,12 +1211,10 @@ "랄프", "멈추지마", "멈추지 말고", - "끝까지", "완료될때까지", "될때까지 해", "끝날때까지", "다 끝내", - "다 해", "전부 완료", "끝까지 해", "중단하지마", diff --git a/.opencode/plugins/oma/types.ts b/.opencode/plugins/oma/types.ts index a58ffda..219a0e1 100644 --- a/.opencode/plugins/oma/types.ts +++ b/.opencode/plugins/oma/types.ts @@ -35,11 +35,45 @@ export interface RawHookInput { stopReason?: string; } +/** + * Optional goal contract for a persistent workflow (design-prime-agent-adoption + * Track B). Written by `oma goal:set`; read by the persistent-mode Stop hook. + */ +export interface ModeGoal { + /** Human description of the objective. Informational only. */ + description?: string; + budget?: { + /** + * Wall-clock budget in minutes, measured from `activatedAt`. When + * exceeded the Stop hook deactivates the workflow and allows an honest + * partial stop (machine verdict, not model discretion). + */ + wallClockMinutes?: number; + }; + completion?: { + /** + * Deterministic stop gate. MUST be an allowlist keyword ("typecheck" | + * "test" | "lint") that maps to an existing package.json script; the hook + * runs it as an argv array with no shell. Free-form strings are NEVER + * executed — this value lives in an agent-writable state file, so + * executing it verbatim would be an arbitrary-command-execution path + * that bypasses the PreToolUse permission layer. + */ + gate?: string; + }; +} + export interface ModeState { workflow: string; sessionId: string; activatedAt: string; reinforcementCount: number; + /** + * L1 session id (`oma-…`) recorded at activation so the Stop hook can emit + * gate.passed / gate.failed events onto the same events.jsonl trail. + */ + omaSid?: string; + goal?: ModeGoal; } // --------------------------------------------------------------------------- diff --git a/.qwen/settings.json b/.qwen/settings.json index b332f70..ba6a426 100644 --- a/.qwen/settings.json +++ b/.qwen/settings.json @@ -72,5 +72,10 @@ "$version": 4, "hooksConfig": { "enabled": true + }, + "model": { + "generationConfig": { + "timeout": 300000 + } } }