diff --git a/.agents/hooks/core/agentmemory-client.ts b/.agents/hooks/core/agentmemory-client.ts index f81ecb0..50962bd 100644 --- a/.agents/hooks/core/agentmemory-client.ts +++ b/.agents/hooks/core/agentmemory-client.ts @@ -6,10 +6,6 @@ import https from "node:https"; import { homedir } from "node:os"; import { basename, join } from "node:path"; -// AgentMemory's published version line moved from 0.11/0.12 (original design -// target) to 0.9.x service builds; accept 0.9.x and the 0.1x.x range. -const SUPPORTED = /^0\.(9|1\d)\./; - function endpointUrl(): string | null { if (process.env.OMA_NO_AGENTMEMORY === "1") return null; if (process.env.AGENTMEMORY_URL) return process.env.AGENTMEMORY_URL; @@ -106,36 +102,12 @@ export async function isAgentMemoryReachable(): Promise { try { const response = await requestAgentMemory(url, "/agentmemory/health"); - if (response.statusCode < 200 || response.statusCode >= 300) { - reachable = false; - return reachable; - } - const headerVersion = response.headers["x-agentmemory-version"]; - const version = Array.isArray(headerVersion) - ? headerVersion[0] - : headerVersion; - // Recent AgentMemory releases expose the version only in the health body, - // not the `x-agentmemory-version` header. - let isAgentMemory = false; - let bodyVersion: string | undefined; - try { - const parsed = JSON.parse(response.body) as { - service?: unknown; - status?: unknown; - version?: unknown; - }; - isAgentMemory = - parsed.service === "agentmemory" || - parsed.status === "healthy" || - parsed.status === "ok"; - if (typeof parsed.version === "string") bodyVersion = parsed.version; - } catch { - // Non-JSON body — fall back to the header check below. - } - const resolvedVersion = version ?? bodyVersion; - reachable = - isAgentMemory || - (resolvedVersion !== undefined && SUPPORTED.test(resolvedVersion)); + // Capability-based acceptance: any 2xx health response from the + // explicitly configured endpoint counts as reachable. Version pinning + // proved brittle (the published line already jumped from 0.11/0.12 + // design targets to 0.9.x service builds), so payload shape and version + // are no longer gating. + reachable = response.statusCode >= 200 && response.statusCode < 300; return reachable; } catch { reachable = false; diff --git a/.agents/hooks/core/hook-output.ts b/.agents/hooks/core/hook-output.ts index 5fd76dc..306fecd 100644 --- a/.agents/hooks/core/hook-output.ts +++ b/.agents/hooks/core/hook-output.ts @@ -8,6 +8,11 @@ import type { Vendor } from "./types.ts"; export function makePromptOutput( vendor: Vendor, additionalContext: string, + // Native hook event the context is injected for. Defaults to the prompt-submit + // event; the dispatch layer passes "SessionStart" for session-start injection + // (commandcode / cursor sessionStart) so the emitted hookSpecificOutput names + // the correct event. Standalone core-script callers use the default. + hookEventName: string = "UserPromptSubmit", ): string { switch (vendor) { case "antigravity": @@ -19,33 +24,43 @@ export function makePromptOutput( injectSteps: [{ ephemeralMessage: additionalContext }], }); case "claude": - case "commandcode": + case "commandcode": { // Official Claude Code docs (code.claude.com/docs/en/hooks) specify // `hookSpecificOutput.additionalContext` — the top-level field is kept // for back-compat with older builds that read it. // commandcode (Command Code, commandcode.ai) mirrors the Claude hook - // dialect, but has NO prompt event (only PreToolUse/PostToolUse/Stop), - // so this branch never fires for it — kept for Vendor exhaustiveness. - return JSON.stringify({ + // dialect. It has no prompt-submit event, but DOES inject context on + // SessionStart (additionalContext) — dispatch passes hookEventName + // "SessionStart" for that path. + const hookSpecificOutput: Record = { + hookEventName, additionalContext, - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext, - }, - }); + }; + // Claude Code re-scans skill/command directories after SessionStart hooks + // complete when the output sets `reloadSkills` (docs: SessionStart + // hookSpecificOutput.reloadSkills). It is Claude-only and only meaningful + // for SessionStart; this builder is called solely when context was + // actually injected, so the "only when injecting" condition is inherent. + if (vendor === "claude" && hookEventName === "SessionStart") { + hookSpecificOutput.reloadSkills = true; + } + return JSON.stringify({ additionalContext, hookSpecificOutput }); + } case "codex": return JSON.stringify({ hookSpecificOutput: { - hookEventName: "UserPromptSubmit", + hookEventName, additionalContext, }, }); case "cursor": + // Cursor reads the top-level `additional_context` (sessionStart) / + // `additionalContext`; the hookSpecificOutput block is informational. return JSON.stringify({ additionalContext, additional_context: additionalContext, hookSpecificOutput: { - hookEventName: "UserPromptSubmit", + hookEventName, additionalContext, }, }); @@ -70,7 +85,7 @@ export function makePromptOutput( // Qwen Code fork uses hookSpecificOutput (same as Codex) return JSON.stringify({ hookSpecificOutput: { - hookEventName: "UserPromptSubmit", + hookEventName, additionalContext, }, }); @@ -82,18 +97,27 @@ export function makeBlockOutput(vendor: Vendor, reason: string): string { case "claude": case "codex": case "commandcode": - case "cursor": case "kiro": case "qwen": return JSON.stringify({ decision: "block", reason }); + case "cursor": + // Cursor's `stop` hook ignores Claude-style `{decision:"block"}`. It + // re-enters the loop via `{followup_message}`, which is auto-submitted as + // the next turn (capped by the entry's loop_limit). Cursor's only + // block-producing chain is `stop` — its sole preToolUse handler + // (test-filter) never blocks, only mutates — so followup_message is + // always the correct dialect here. + return JSON.stringify({ followup_message: reason }); case "antigravity": // agy Stop: `decision:"continue"` re-enters the loop (= block the stop); // `reason` is injected as a system message. (Any other value allows stop.) return JSON.stringify({ decision: "continue", reason }); case "pi": - // pi has no stop-blocking event (agent_end is notification-only), so - // persistent-mode never runs under pi. This shape mirrors pi's native - // tool_call block return for completeness/forward-compat. + // pi's bridge implements persistent-mode via agent_settled + + // pi.sendUserMessage: it runs the persistent-mode subprocess (which + // resolves to the Claude dialect `{decision:"block", reason}`) and also + // accepts this `{block:true, reason}` shape, re-submitting `reason` as the + // next turn so the workflow continues. return JSON.stringify({ block: true, reason }); case "grok": // Grok Stop hooks are generally advisory. Emit block decision + rich diff --git a/.agents/hooks/core/keyword-detector.ts b/.agents/hooks/core/keyword-detector.ts index 223fcb1..e336814 100644 --- a/.agents/hooks/core/keyword-detector.ts +++ b/.agents/hooks/core/keyword-detector.ts @@ -25,6 +25,7 @@ import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; import { UNKNOWN_SESSION_ID, VENDORS } from "./constants.ts"; import { clearGrokContext } from "./grok-context.ts"; import { makePromptOutput } from "./hook-output.ts"; +import { isRelayedAgentMessage, normalizePromptInput } from "./prompt-input.ts"; // triggers.json is imported statically: the bundler inlines it into the oma // binary (bundled `oma hook` path needs no file on disk), while a standalone // bun run resolves the sibling file next to this module (pi / direct run). @@ -154,6 +155,11 @@ export function isGenuineUserPrompt(input: Record): boolean { return true; } +// ── Guard: relayed inter-agent messages ────────────────────── +// Shared with skill-injector — see prompt-input.ts. Re-exported here so +// existing imports/tests keep resolving from this module. +export { isRelayedAgentMessage }; + // ── Guard 3: Reinforcement suppression ─────────────────────── const REINFORCEMENT_WINDOW_MS = 60_000; // 60 seconds @@ -349,24 +355,84 @@ export function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** + * Merge a language-keyed keyword/pattern bank into a single flat list: + * universal ("*") + English (the universal default) + the configured + * language's own entries (skipped when lang === "en" to avoid duplicates). + * Shared by buildPatterns and buildRawPatterns — both keyword banks and + * pattern banks use this exact `Record` shape. + */ +export function collectLangEntries( + bank: Record, + lang: string, +): string[] { + return [ + ...(bank["*"] ?? []), + ...(bank.en ?? []), + ...(lang !== "en" ? (bank[lang] ?? []) : []), + ]; +} + +/** + * Keyword-plus-compiled-regex pair. Kept 1:1 with the literal keyword string + * (as authored in triggers.json) so callers that need the ACTUAL matched + * keyword text — e.g. specificity ranking — don't have to re-derive it from + * match[0], which would require re-deriving buildPatterns' word-boundary + * peeling logic (fragile, and wrong for CJK where no boundary is added). + */ +export interface KeywordPatternEntry { + regex: RegExp; + keyword: string; +} + +export function buildPatternEntries( + keywords: Record, + lang: string, + cjkScripts: string[], +): KeywordPatternEntry[] { + return collectLangEntries(keywords, lang).map((kw) => { + const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); + const regex = + cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw) + ? new RegExp(escaped, "i") + : new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); + return { regex, keyword: kw }; + }); +} + export function buildPatterns( keywords: Record, lang: string, cjkScripts: string[], ): RegExp[] { - const allKeywords = [ - ...(keywords["*"] ?? []), - ...(keywords.en ?? []), - ...(lang !== "en" ? (keywords[lang] ?? []) : []), - ]; + return buildPatternEntries(keywords, lang, cjkScripts).map((e) => e.regex); +} - return allKeywords.map((kw) => { - const escaped = escapeRegex(kw).replace(/\s+/g, "\\s+"); - if (cjkScripts.includes(lang) || /[^\p{ASCII}]/u.test(kw)) { - return new RegExp(escaped, "i"); +/** + * Raw-pattern-plus-source pair — mirrors KeywordPatternEntry for the + * `patterns` (intent regex) field. `source` is the raw regex string itself: + * unlike keyword entries, a raw pattern has no fixed "keyword" — its + * specificity is however much text it actually matched (match[0]). + */ +export interface RawPatternEntry { + regex: RegExp; + source: string; +} + +export function buildRawPatternEntries( + patterns: Record | undefined, + lang: string, +): RawPatternEntry[] { + if (!patterns) return []; + const compiled: RawPatternEntry[] = []; + for (const raw of collectLangEntries(patterns, lang)) { + try { + compiled.push({ regex: new RegExp(raw, "iu"), source: raw }); + } catch { + // Skip invalid regex — surfaces during config edit, not at runtime } - return new RegExp(`(?:^|[^\\w-])${escaped}(?:$|[^\\w-])`, "i"); - }); + } + return compiled; } /** @@ -379,21 +445,7 @@ export function buildRawPatterns( patterns: Record | undefined, lang: string, ): RegExp[] { - if (!patterns) return []; - const all = [ - ...(patterns["*"] ?? []), - ...(patterns.en ?? []), - ...(lang !== "en" ? (patterns[lang] ?? []) : []), - ]; - const compiled: RegExp[] = []; - for (const raw of all) { - try { - compiled.push(new RegExp(raw, "iu")); - } catch { - // Skip invalid regex — surfaces during config edit, not at runtime - } - } - return compiled; + return buildRawPatternEntries(patterns, lang).map((e) => e.regex); } export function buildInformationalPatterns(config: TriggerConfig): RegExp[] { @@ -752,6 +804,93 @@ export function deactivateAllPersistentModes( } } +// ── Specificity ranking ─────────────────────────────────────── + +/** + * One surviving pattern match for one workflow, carrying everything the + * ranking rules in `pickWinningCandidate` need. Built once per successful + * `pattern.exec()` across ALL workflows in `config.workflows` — the matching + * loop no longer returns on the first hit; it collects every candidate + * first and lets specificity decide the winner. + */ +export interface WorkflowCandidate { + workflow: string; + persistent: boolean; + /** Index of the match within the cleaned (stripped/normalized) text. */ + matchIndex: number; + matchText: string; + /** Position re-located in the ORIGINAL prompt — used for tie-break #3. */ + origIndex: number; + /** Length of the specific text that matched (trimmed keyword or, for a + * `patterns` intent-regex hit, the trimmed match[0] span) — rule #1. */ + keywordLength: number; + /** Whether the specificity text contains whitespace — rule #2. */ + isMultiWord: boolean; + /** Index of this workflow in triggers.json `workflows` — rule #4 (final + * tiebreak, preserves the pre-ranking first-declared-wins behavior). */ + declarationIndex: number; + /** True if any suppression filter (RC3 technical-reference is a hard drop + * and never reaches this point; informational-context / pasted-content / + * reinforcement) applies to this specific match. */ + suppressed: boolean; +} + +/** + * Pick the winning candidate among all workflow matches collected for a + * prompt, or `null` if none survive. + * + * Ranking order (only consulted on a tie with the previous rule): + * 1. Longest matched keyword/phrase wins — "deepsec pr review" (18 chars) + * beats "review" (6 chars) even though "review" also literally matches + * as a substring of the same sentence. + * 2. A multi-word/compound match beats a single-word match of equal + * length (defensive tiebreak; rule 1 already separates most real + * cases since compound phrases are almost always longer). + * 3. Earliest match position in the ORIGINAL prompt wins — mirrors the + * existing "keyword near the front = command position" heuristic used + * elsewhere in this file (RC2 pasted-content guard). + * 4. Final tiebreak: declaration order in triggers.json `workflows` — + * i.e. the original pre-ranking first-match-wins behavior, kept only + * as a last resort when two workflows are otherwise indistinguishable. + * + * DESIGN DECISION (round-1 trigger demotion, part A): suppression is + * evaluated PER CANDIDATE, not globally. A candidate flagged `suppressed` + * (by the informational-context window, the persistent-mode pasted-content + * limit, or reinforcement) is simply removed from the ranking pool — it + * does NOT veto a *different*, unsuppressed candidate from a more generic + * workflow. Reasoning: every existing suppression filter in this file + * already operates locally, on one match at a time (a suppressed hit in one + * workflow's pattern loop has always fallen through to the next + * pattern/workflow, never blocking unrelated matches elsewhere in the same + * prompt) — ranking preserves that locality instead of upgrading it into a + * prompt-wide veto. The alternative (any suppressed specific match blocks + * the whole prompt from firing anything) would silence genuine, independent + * requests that merely share a sentence with a meta-mention of a more + * specific workflow. See triggers-corpus.json for a case that locks this in: + * a prompt that asks an informational "what is X" question about a specific + * workflow AND, separately, makes a genuine generic request — the generic + * request still fires. + */ +export function pickWinningCandidate( + candidates: WorkflowCandidate[], +): WorkflowCandidate | null { + const eligible = candidates.filter((c) => !c.suppressed); + if (eligible.length === 0) return null; + eligible.sort((a, b) => { + if (b.keywordLength !== a.keywordLength) { + return b.keywordLength - a.keywordLength; + } + if (a.isMultiWord !== b.isMultiWord) { + return a.isMultiWord ? -1 : 1; + } + if (a.origIndex !== b.origIndex) { + return a.origIndex - b.origIndex; + } + return a.declarationIndex - b.declarationIndex; + }); + return eligible[0] ?? null; +} + // ── Pure handler (canonical ABI) ───────────────────────────── /** @@ -778,6 +917,9 @@ export async function run( if (!prompt.trim()) return null; if (startsWithSlashCommand(prompt)) return null; + // Relayed inter-agent messages carry another agent's text, not a user + // request — their content must not drive workflow keyword detection. + if (isRelayedAgentMessage(prompt)) return null; const config = loadConfig(); const lang = detectLanguage(projectDir); @@ -804,73 +946,125 @@ export async function run( // Skip persistent workflows entirely if the prompt is an analytical question const analytical = isAnalyticalQuestion(cleaned); - for (const [workflow, def] of Object.entries(config.workflows)) { + // shouldSkipAllWorkflows does not depend on the workflow being evaluated — + // hoisted out of the loop (was re-checked on every iteration pre-ranking). + if (shouldSkipAllWorkflows(cleaned)) return null; + + // Position guard must reflect the user's ACTUAL prompt, not the + // content-stripped text. stripCodeBlocks/stripSystemEchoes remove quoted + // and code spans, which shrinks the text and pulls keywords toward the + // front — defeating the "deep in a long prompt = not an instruction" + // heuristic (a keyword genuinely at char 245 of a discussion can appear + // at char 179 after stripping, slipping under PERSISTENT_MATCH_LIMIT). + const origPrompt = normalizeForMatching(prompt); + + // Collect every surviving match across every workflow first — specificity + // ranking (see pickWinningCandidate) decides the winner, replacing the old + // declaration-order first-match-wins loop. + const candidates: WorkflowCandidate[] = []; + const workflowEntries = Object.entries(config.workflows); + + for ( + let declarationIndex = 0; + declarationIndex < workflowEntries.length; + declarationIndex++ + ) { + const entry = workflowEntries[declarationIndex]; + if (!entry) continue; + const [workflow, def] = entry; if (excluded.has(workflow)) continue; - if (shouldSkipAllWorkflows(cleaned)) continue; const workflowPredicate = KEYWORD_SKIP_PREDICATES[workflow]; if (workflowPredicate?.(cleaned)) continue; if (analytical && def.persistent) continue; - const patterns = [ - ...buildPatterns(def.keywords, lang, config.cjkScripts), - ...buildRawPatterns(def.patterns, lang), - ]; + const reinforced = isReinforcementSuppressed(kwState, workflow); - for (const pattern of patterns) { - const match = pattern.exec(cleaned); - if (!match) continue; + const considerMatch = (regex: RegExp, specificityText: string) => { + const match = regex.exec(cleaned); + if (!match) return; // RC3: compound technical tokens (ralph:verify, ralph.md, // workflows/ralph) reference the workflow as an artifact, not a run - // request. - if (isTechnicalReference(cleaned, match.index, match[0])) continue; - if (isInformationalContext(cleaned, match.index, infoPatterns)) continue; - // Position guard must reflect the user's ACTUAL prompt, not the - // content-stripped text. stripCodeBlocks/stripSystemEchoes remove quoted - // and code spans, which shrinks the text and pulls keywords toward the - // front — defeating the "deep in a long prompt = not an instruction" - // heuristic (a keyword genuinely at char 245 of a discussion can appear - // at char 179 after stripping, slipping under PERSISTENT_MATCH_LIMIT). - // Re-locate the matched keyword in the original prompt for the check. - const origPrompt = normalizeForMatching(prompt); + // request — dropped entirely, never becomes a candidate. + if (isTechnicalReference(cleaned, match.index, match[0])) return; + + // Re-locate the matched keyword in the original prompt for the + // pasted-content position guard. const origIndex = origPrompt.indexOf(match[0]); const posIndex = origIndex >= 0 ? origIndex : match.index; - if (isPastedContent(posIndex, def.persistent, origPrompt.length)) - continue; - if (isReinforcementSuppressed(kwState, workflow)) continue; + const informational = isInformationalContext( + cleaned, + match.index, + infoPatterns, + ); + const pasted = isPastedContent( + posIndex, + def.persistent, + origPrompt.length, + ); + const text = specificityText.trim(); + + candidates.push({ + workflow, + persistent: def.persistent, + matchIndex: match.index, + matchText: match[0], + origIndex: posIndex, + keywordLength: text.length, + isMultiWord: /\s/.test(text), + declarationIndex, + suppressed: informational || pasted || reinforced, + }); + }; + + for (const { regex, keyword } of buildPatternEntries( + def.keywords, + lang, + config.cjkScripts, + )) { + considerMatch(regex, keyword); + } + for (const { regex, source } of buildRawPatternEntries( + def.patterns, + lang, + )) { + considerMatch(regex, source); + } + } - if (def.persistent) { - activateMode(projectDir, workflow, sessionId); - } - await activateL1WorkflowSession(projectDir, workflow, vendor, sessionId); - const updatedState = recordKwTrigger(kwState, workflow); - saveKwState(projectDir, updatedState); - - const contextLines = [ - `[OMA WORKFLOW: ${workflow.toUpperCase()}]`, - `User intent matches the /${workflow} workflow.`, - `Read and follow \`.agents/workflows/${workflow}.md\` step by step.`, - `User request: ${prompt}`, - `IMPORTANT: Start the workflow IMMEDIATELY. Do not ask for confirmation.`, - ]; - - if (config.extensionRouting) { - const extensions = detectExtensions(prompt); - const agent = resolveAgentFromExtensions( - extensions, - config.extensionRouting, - ); - if (agent) { - contextLines.push(`[OMA AGENT HINT: ${agent}]`); - } - } + const winner = pickWinningCandidate(candidates); + if (!winner) return null; + + const { workflow } = winner; + + if (winner.persistent) { + activateMode(projectDir, workflow, sessionId); + } + await activateL1WorkflowSession(projectDir, workflow, vendor, sessionId); + const updatedState = recordKwTrigger(kwState, workflow); + saveKwState(projectDir, updatedState); + + const contextLines = [ + `[OMA WORKFLOW: ${workflow.toUpperCase()}]`, + `User intent matches the /${workflow} workflow.`, + `Read and follow \`.agents/workflows/${workflow}.md\` step by step.`, + `User request: ${prompt}`, + `IMPORTANT: Start the workflow IMMEDIATELY. Do not ask for confirmation.`, + ]; - return { type: "context", additionalContext: contextLines.join("\n") }; + if (config.extensionRouting) { + const extensions = detectExtensions(prompt); + const agent = resolveAgentFromExtensions( + extensions, + config.extensionRouting, + ); + if (agent) { + contextLines.push(`[OMA AGENT HINT: ${agent}]`); } } - return null; + return { type: "context", additionalContext: contextLines.join("\n") }; } // ── Standalone entry (pi subprocess / direct bun invocation) ── @@ -890,7 +1084,7 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; + let prompt = normalizePromptInput(input.prompt); // agy's PreInvocation stdin carries no `prompt` — recover the user request // from the transcript. PreInvocation fires before every model call, so only diff --git a/.agents/hooks/core/prompt-input.ts b/.agents/hooks/core/prompt-input.ts new file mode 100644 index 0000000..7f18580 --- /dev/null +++ b/.agents/hooks/core/prompt-input.ts @@ -0,0 +1,45 @@ +// Shared normalizer for the raw `prompt` field delivered on hook stdin. +// +// Most host CLIs deliver `prompt` as a plain string, but some (e.g. Kimi Code +// CLI's UserPromptSubmit payload) deliver it as a ContentPart[] array such as +// `[{ "type": "text", "text": "hello" }]`. The standalone hook scripts cast it +// with `(input.prompt as string) ?? ""`, so on those vendors the downstream +// string methods throw and the top-level catch swallows the error — the entire +// prompt chain silently no-ops. Route every raw read through this helper so the +// array shape collapses to the equivalent string. + +// Relayed inter-agent messages (teammate reports, task notifications, idle +// notifications) are transported through the prompt channel but are not user +// intent: their content routinely carries workflow keywords and skill trigger +// words, producing false activations. Detect the transport envelopes +// conservatively so prompt-driven handlers can skip them. +const RELAY_ENVELOPE_PREFIXES = [ + " trimmed.startsWith(p))) return true; + return trimmed.slice(0, 200).includes('"type":"idle_notification"'); +} + +/** Coerce a raw stdin `prompt` field to a string across vendor payload shapes. */ +export function normalizePromptInput(prompt: unknown): string { + if (typeof prompt === "string") return prompt; + if (Array.isArray(prompt)) { + return prompt + .filter( + (p): p is { type: string; text: string } => + !!p && + typeof p === "object" && + (p as { type?: unknown }).type === "text" && + typeof (p as { text?: unknown }).text === "string", + ) + .map((p) => p.text) + .join(" "); + } + return ""; +} diff --git a/.agents/hooks/core/serena-primer.ts b/.agents/hooks/core/serena-primer.ts index 6605ac2..df2aabc 100644 --- a/.agents/hooks/core/serena-primer.ts +++ b/.agents/hooks/core/serena-primer.ts @@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; import { makePromptOutput } from "./hook-output.ts"; +import { normalizePromptInput } from "./prompt-input.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; import { getProjectDir, inferVendorFromScriptPath } from "./vendor-detect.ts"; @@ -136,7 +137,10 @@ export async function run( const { cwd: projectDir, sid: sessionId = "unknown" } = ctx; if (!isSerenaProject(projectDir)) return null; - if (!claimSession(projectDir, sessionId)) return null; + // Compaction keeps the session id, so the session-once claim would skip + // exactly the turn that just lost the primer from context — force re-inject. + const forced = input.source === "compact"; + if (!claimSession(projectDir, sessionId) && !forced) return null; return { type: "context", additionalContext: primerContext() }; } @@ -190,7 +194,7 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; + let prompt = normalizePromptInput(input.prompt); // agy's PreInvocation stdin carries no `prompt`; recover it and only act on // the first invocation of a turn. diff --git a/.agents/hooks/core/skill-injector.ts b/.agents/hooks/core/skill-injector.ts index 550fbd8..66c8139 100644 --- a/.agents/hooks/core/skill-injector.ts +++ b/.agents/hooks/core/skill-injector.ts @@ -24,6 +24,7 @@ import { basename, dirname, join } from "node:path"; import { agyConversationId, isAgyInput, readAgyPrompt } from "./agy-input.ts"; import { toPosixPath } from "./fs-utils.ts"; import { makePromptOutput } from "./hook-output.ts"; +import { isRelayedAgentMessage, normalizePromptInput } from "./prompt-input.ts"; // triggers.json is imported statically: bundler inlines it into the oma binary; // standalone bun runs resolve the sibling file (pi / direct run). import embeddedTriggers from "./triggers.json" with { type: "json" }; @@ -463,6 +464,11 @@ export async function run( if (!prompt.trim()) return null; + // Relayed inter-agent messages (teammate reports, idle notifications) are + // not user intent — their content routinely contains skill trigger words + // and produced false skill suggestions. Same guard as keyword-detector. + if (isRelayedAgentMessage(prompt)) return null; + // Claude-specific: slash-skill resolution must run BEFORE the slash early-exit // and persistent-workflow guard (same order as the original standalone path). if (vendor === "claude") { @@ -514,7 +520,7 @@ async function main() { const vendor = detectVendor(input); const projectDir = getProjectDir(vendor, input); const sessionId = getSessionId(input); - let prompt = (input.prompt as string) ?? ""; + let prompt = normalizePromptInput(input.prompt); // agy's PreInvocation stdin carries no `prompt`; recover it from the // transcript, and only act on the first invocation of a turn. diff --git a/.agents/hooks/core/state-boundary.ts b/.agents/hooks/core/state-boundary.ts index 8700b10..ddc1bd0 100644 --- a/.agents/hooks/core/state-boundary.ts +++ b/.agents/hooks/core/state-boundary.ts @@ -6,6 +6,7 @@ import { agyConversationId, isAgyInput } from "./agy-input.ts"; import { syncGrokContext } from "./grok-context.ts"; import { makePromptOutput } from "./hook-output.ts"; import { writeInjectLog } from "./inject-log.ts"; +import { normalizePromptInput } from "./prompt-input.ts"; import { emitEvent, type OmaEvent, readEvents } from "./state-emit.ts"; import { getActiveSid, readIndex, setLastSession } from "./state-marker.ts"; import type { HandlerCtx, HandlerResult, HookInput, Vendor } from "./types.ts"; @@ -98,6 +99,7 @@ export async function onBoundary( vendor: Vendor, vendorSid: string, promptText?: string, + forced = false, ): Promise { const idx = readIndex(projectDir); const previous = idx.lastSession; @@ -105,7 +107,9 @@ export async function onBoundary( !previous || previous.vendor !== vendor || previous.vendorSid !== vendorSid; const statelessTurnFlush = vendor === "kiro" && vendorSid === "unknown"; - if (!boundary && !statelessTurnFlush) { + // `forced` = post-compaction SessionStart: the session id is unchanged (no + // boundary), but the snapshot was just compacted out of context — re-emit. + if (!boundary && !statelessTurnFlush && !forced) { setLastSession(projectDir, vendor, vendorSid); return null; } @@ -122,7 +126,9 @@ export async function onBoundary( vendorSid, payload: { reason: !boundary - ? "stateless-vendor-turn" + ? statelessTurnFlush + ? "stateless-vendor-turn" + : "post-compact-rehydration" : previous ? "vendor-session-transition" : "session-created", @@ -189,12 +195,15 @@ export async function run( const { vendor, cwd: projectDir, sid: vendorSid = "unknown" } = ctx; // input.kind === "prompt" is guaranteed by the guard above; the user prompt is - // the primary recall signal for boundary rehydration. + // the primary recall signal for boundary rehydration. A post-compaction + // SessionStart (source === "compact") forces re-emission: the session id is + // unchanged, but the snapshot was just compacted out of the context window. const rendered = await onBoundary( projectDir, vendor, vendorSid, input.prompt, + input.source === "compact", ); if (!rendered) return null; return { type: "context", additionalContext: rendered }; @@ -218,7 +227,7 @@ async function main() { // Delegate to run() — single logic source. const hookInput: HookInput = { kind: "prompt", - prompt: (input.prompt as string) ?? "", + prompt: normalizePromptInput(input.prompt), cwd: projectDir, }; const ctxVal: HandlerCtx = { vendor, cwd: projectDir, sid: vendorSid }; diff --git a/.agents/hooks/core/test-filter.ts b/.agents/hooks/core/test-filter.ts index f6ee917..3034ec1 100644 --- a/.agents/hooks/core/test-filter.ts +++ b/.agents/hooks/core/test-filter.ts @@ -103,8 +103,16 @@ export async function run( const { toolName, toolInput, cwd: projectDir } = input; const { vendor } = ctx; - // Claude-family uses Bash; some CLIs use run_shell_command. - if (toolName !== "Bash" && toolName !== "run_shell_command") return null; + // Claude-family uses Bash; some CLIs use run_shell_command; Cursor names its + // terminal tool "Shell" (matches cursor.json's preToolUse matcher); Kiro's + // canonical shell tool is execute_bash (the agent-JSON matcher name). + if ( + toolName !== "Bash" && + toolName !== "run_shell_command" && + toolName !== "Shell" && + toolName !== "execute_bash" + ) + return null; const command = toolInput.command as string | undefined; if (!command) return null; @@ -115,12 +123,18 @@ export async function run( const isExcluded = EXCLUDE_PATTERNS.some((p) => p.test(command)); if (isExcluded) return null; - const filterScript = join( - projectDir, + // Resolve the filter script: vendor hook dir first, then the opencode + // bridge dir (opencode has no core Vendor identity — its subprocess payload + // detects as claude, whose hook dir is absent in opencode-only installs), + // then the SSOT core dir as the last resort. + const filterScript = [ getHookDir(vendor), - "filter-test-output.sh", - ); - if (!existsSync(filterScript)) return null; + join(".opencode", "plugins", "oma"), + join(".agents", "hooks", "core"), + ] + .map((dir) => join(projectDir, dir, "filter-test-output.sh")) + .find((p) => existsSync(p)); + if (!filterScript) return null; const filteredCmd = `set -o pipefail; (${command}) 2>&1 | bash "${filterScript}"`; const updatedInput: Record = { diff --git a/.agents/hooks/core/triggers.json b/.agents/hooks/core/triggers.json index 73c6a59..6e0874d 100644 --- a/.agents/hooks/core/triggers.json +++ b/.agents/hooks/core/triggers.json @@ -3116,7 +3116,6 @@ "should you", "could we", "would you", - "what if", "what about", "why build", "why create", @@ -3127,7 +3126,10 @@ "뭐야", "뭐임", "무엇", - "어떻게", + "어떻게 동작", + "어떻게 작동", + "어떻게 되는지", + "어떻게 하는지", "설명해", "알려줘", "키워드", @@ -3143,13 +3145,12 @@ "왜 만들", "어떻게 만들", "어떨까", - "하면 좋을", + "하면 좋을지", "한다면", "할까요", "보강할", "에 대해", "에 대한", - "한번 봐", "깊게 봐", "코드를 한번", "그 워크플로우", diff --git a/.agents/hooks/core/types.ts b/.agents/hooks/core/types.ts index 5ae6a74..a58ffda 100644 --- a/.agents/hooks/core/types.ts +++ b/.agents/hooks/core/types.ts @@ -54,7 +54,18 @@ export interface ModeState { * Discriminated on `kind`; produced by adapters.ts normalizeInput(). */ export type HookInput = - | { kind: "prompt"; prompt: string; cwd: string } + | { + kind: "prompt"; + prompt: string; + cwd: string; + /** + * SessionStart trigger source (claude: startup|resume|clear|compact). + * `compact` lets session-once handlers (serena-primer, state-boundary) + * force re-injection: compaction keeps the session id, so their normal + * dedup would otherwise skip exactly the turn that lost the context. + */ + source?: string; + } | { kind: "pre_tool"; toolName: string; diff --git a/.agents/hooks/variants/antigravity.json b/.agents/hooks/variants/antigravity.json index 0c9f5d6..f233148 100644 --- a/.agents/hooks/variants/antigravity.json +++ b/.agents/hooks/variants/antigravity.json @@ -25,11 +25,6 @@ "timeout": 3 } ], - "PreToolUse": { - "hook": "test-filter.ts", - "matcher": "run_command", - "timeout": 5 - }, "Stop": { "hook": "persistent-mode.ts", "timeout": 5 diff --git a/.agents/hooks/variants/claude.json b/.agents/hooks/variants/claude.json index b9a8a41..e319b13 100644 --- a/.agents/hooks/variants/claude.json +++ b/.agents/hooks/variants/claude.json @@ -6,6 +6,16 @@ "projectDirEnv": "CLAUDE_PROJECT_DIR", "runtime": "bun", "events": { + "SessionStart": [ + { + "hook": "serena-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } + ], "UserPromptSubmit": [ { "hook": "keyword-detector.ts", diff --git a/.agents/hooks/variants/codex.json b/.agents/hooks/variants/codex.json index 0609cab..4000cb5 100644 --- a/.agents/hooks/variants/codex.json +++ b/.agents/hooks/variants/codex.json @@ -33,12 +33,5 @@ "hook": "persistent-mode.ts", "timeout": 5 } - }, - "featureFlags": { - "file": ".codex/config.toml", - "section": "features", - "flags": { - "hooks": true - } } } diff --git a/.agents/hooks/variants/commandcode.json b/.agents/hooks/variants/commandcode.json index 150f1ef..67b1819 100644 --- a/.agents/hooks/variants/commandcode.json +++ b/.agents/hooks/variants/commandcode.json @@ -6,6 +6,16 @@ "projectDirEnv": null, "runtime": "bun", "events": { + "SessionStart": [ + { + "hook": "serena-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } + ], "Stop": { "hook": "persistent-mode.ts", "timeout": 5 diff --git a/.agents/hooks/variants/cursor.json b/.agents/hooks/variants/cursor.json index 8feb024..390ff7b 100644 --- a/.agents/hooks/variants/cursor.json +++ b/.agents/hooks/variants/cursor.json @@ -24,6 +24,26 @@ "hook": "serena-primer.ts", "timeout": 3 } + ], + "preToolUse": { + "hook": "test-filter.ts", + "matcher": "Shell", + "timeout": 5 + }, + "stop": { + "hook": "persistent-mode.ts", + "timeout": 5, + "loopLimit": null + }, + "sessionStart": [ + { + "hook": "serena-primer.ts", + "timeout": 3 + }, + { + "hook": "state-boundary.ts", + "timeout": 5 + } ] }, "extra": { diff --git a/.agents/hooks/variants/grok.json b/.agents/hooks/variants/grok.json index a0ee2bf..8b19400 100644 --- a/.agents/hooks/variants/grok.json +++ b/.agents/hooks/variants/grok.json @@ -26,7 +26,7 @@ ], "PreToolUse": { "hook": "test-filter.ts", - "matcher": "run_terminal_cmd", + "matcher": "Bash", "timeout": 5 }, "Stop": { diff --git a/.agents/hooks/variants/hook-variant.schema.json b/.agents/hooks/variants/hook-variant.schema.json index 4387c98..4ac2056 100644 --- a/.agents/hooks/variants/hook-variant.schema.json +++ b/.agents/hooks/variants/hook-variant.schema.json @@ -86,6 +86,11 @@ "flatHookEntries": { "type": "boolean", "description": "When true, settings hook entries are written as flat {command, timeout[, matcher]} objects under each event key (Cursor hooks.json format) instead of nested {matcher, hooks:[...]} groups (Claude Code format)." + }, + "skipSettingsMerge": { + "type": "boolean", + "description": "When true, the generic installer copies hook scripts and writes the oma-hook.sh wrapper but does NOT merge hook entries into settingsFile. Used by Kiro, whose CLI reads hooks from a dedicated agent config (.kiro/agents/oma-hooks.json via applyKiroOmaHooksAgent) — the generic .kiro/settings/cli.json merge would be dead config Kiro never reads. `extra` (statusLine/permissions) is still applied when present.", + "default": false } }, "$defs": { @@ -109,6 +114,10 @@ "minimum": 1, "maximum": 30, "default": 5 + }, + "loopLimit": { + "type": ["integer", "null"], + "description": "Cursor `stop` hook only: caps how many times the hook may auto-resubmit a followup_message (Cursor's loop_limit). null = uncapped; omit for Cursor's default (5). Ignored by other vendors." } } }, diff --git a/.agents/hooks/variants/kiro.json b/.agents/hooks/variants/kiro.json index 8a01e24..4b659d1 100644 --- a/.agents/hooks/variants/kiro.json +++ b/.agents/hooks/variants/kiro.json @@ -5,6 +5,7 @@ "settingsFile": ".kiro/settings/cli.json", "projectDirEnv": null, "runtime": "bun", + "skipSettingsMerge": true, "events": { "userPromptSubmit": [ { diff --git a/.agents/hooks/variants/opencode/oma.ts b/.agents/hooks/variants/opencode/oma.ts index 2e0e5c6..51060a5 100644 --- a/.agents/hooks/variants/opencode/oma.ts +++ b/.agents/hooks/variants/opencode/oma.ts @@ -2,34 +2,55 @@ * oh-my-agent — opencode (Sst opencode) plugin bridge. * * SSOT source. At install time `installOpencodePlugin` copies this file to - * `.opencode/plugins/oma/oma.ts` alongside the core hook scripts. opencode - * auto-discovers plugins under each `.opencode/plugins` subdirectory. + * `.opencode/plugins/oma/oma.ts` alongside the core hook scripts, and + * `registerOpencodePlugin` adds it to `.opencode/opencode.jsonc` (the nested + * subdir is invisible to opencode's flat auto-discovery). * * Why a bridge instead of a per-vendor variants JSON entry: opencode does NOT * register settings-file hooks like the other vendors. It loads in-process - * TypeScript plugins and dispatches plugin event handlers. So rather than the - * generic `installHooksFromVariant` path (events → settings file → `bun - * `. @@ -104,7 +109,7 @@ For `viewer.html`, the CLI injects: |---|---| | Slide root | `position: absolute; inset: 0; width: 1920px; height: 1080px;` | | Safe zones | Left/right margin ≥ 80px; top/bottom margin ≥ 60px | -| Body text | 28–36 px minimum; heading 64–120 px | +| Body text | 28–36 px doctrine floor; heading 64–120 px. (The validator hard-fails only below 18 px — 18–27 px passes the gate but is reserved for captions/labels.) | | Icon / decorative image | explicit `width`/`height` in px | | Background gradients | allowed (CSS); rasterized to PNG at PPTX export | | Clipping / overflow | `overflow: hidden` on `.slide` prevents bleed-out | @@ -223,10 +228,17 @@ sees the true 1920×1080 px layout. `viewport-base.css` `@media print` rules: Result: one clean 1920×1080 slide per printed page. -## 10. Presenter View (postMessage API) +## 10. Presenter View (embedded notes panel + postMessage API) -When `viewer.html` opens the deck inside an `