mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
* feat(events): context injection for opencode and JSON-envelope agent hooks Adds first-class context injection to agent runtime events: 1. opencode: maps session_start to experimental.chat.system.transform (injects into system prompt) and user_prompt_submit to chat.message (injects synthetic TextPart). TS plugin captures runEvent stdout (stdio pipe, encoding utf-8) and pushes into output objects. Part IDs derive from output.parts[last].id to preserve OpenCode's prt_ brand and prevent session schema crashes. 2. JSON-envelope hook wrapping: adds events_context_envelope to IntegrationBase so agents that require JSON on stdout receive their target envelope via the dispatcher's 5th argument: - gemini, tabnine, qwen, devin: hookSpecificOutput.additionalContext on session_start/user_prompt_submit; suppress on non-injectable events (prevents systemMessage user-facing noise) - copilot: top-level additionalContext on session_start - cursor: top-level additional_context on session_start; suppress elsewhere - claude, codex: plain stdout passthrough (already injected) 3. Dispatcher template and resolve_and_run_event_command parse the 5th envelope arg and wrap stdout accordingly. Tests added for opencode TextPart schema, part ID derivation, envelope command generation, and dispatcher output wrapping. All 162 events/integration tests pass. * fix(events): address code review on #3934 - Qwen/Gemini/Tabnine/Devin: include native hookEventName inside hookSpecificOutput envelope (required by Qwen's hooks spec). Thread the native event name from the integration's CANONICAL_TO_NATIVE through _dispatcher_command as a 6th dispatcher argument, through the dispatcher template's main()/_run_inline()/_emit(), and through resolve_and_run_event_command()/_emit_event_stdout(). - Copilot: map user_prompt_submit to additionalContext (previously unmapped, breaking per-prompt context injection despite Copilot CLI supporting it via userPromptSubmitted). - OpenCode: guard experimental.chat.system.transform so canonical session_start handlers only run when input.sessionID is present — OpenCode fires this hook for non-session operations (e.g. agent generation) with no sessionID. Assisted-by: opencode (model: glm-5.2, supervised) * fix(events): address second Copilot review round on #3934 - Positional arg alignment: always emit default timeout (60s) as the 4th dispatcher argument even when timeout_seconds is omitted, so the envelope (5th) and native_event (6th) land in the correct argv slots. Previously, omitting timeout_seconds caused the envelope to be parsed as an invalid timeout, silently falling back to plain stdout. - OpenCode session_start caching: cache handler output per sessionID in the generated TS plugin so non-idempotent handlers (setup, telemetry, file-mutating scripts) run once per session instead of on every LLM request. Cache is evicted on session.deleted. - Updated PR description to reflect Copilot user_prompt_submit now maps to additionalContext (was documented as plain/unprocessed). Assisted-by: opencode (model: glm-5.2, supervised)
This commit is contained in:
+230
-21
@@ -251,7 +251,7 @@ def _resolve_argv(template_path, project_root, ext_id):
|
||||
return [str(script_abs), *rest]
|
||||
|
||||
|
||||
def _run_inline(command_name, payload, project_root, timeout):
|
||||
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
|
||||
"""Resolve and run the event command with stdlib only (no specify_cli)."""
|
||||
template_path, ext_id = _find_command_template(command_name, project_root)
|
||||
if not template_path:
|
||||
@@ -269,7 +269,7 @@ def _run_inline(command_name, payload, project_root, timeout):
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.stdout:
|
||||
sys.stdout.write(result.stdout)
|
||||
_emit(result.stdout, envelope, native_event)
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
sys.stderr.write(result.stderr)
|
||||
@@ -283,6 +283,46 @@ def _run_inline(command_name, payload, project_root, timeout):
|
||||
return 2
|
||||
|
||||
|
||||
def _emit(output, envelope, native_event=""):
|
||||
"""Write handler output to stdout in the agent's context-injection shape.
|
||||
|
||||
Not every agent injects a hook's plain-text stdout as model context:
|
||||
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
|
||||
user-facing noise, never context), Copilot discards non-JSON stdout, and
|
||||
Cursor parses stdout as JSON. The native hook command passes the envelope
|
||||
as the dispatcher's 5th argument (see events_context_envelope on the
|
||||
integration classes), and the native event name as the 6th argument so
|
||||
hookSpecificOutput can include hookEventName:
|
||||
|
||||
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
|
||||
additionalContext → {"additionalContext": ...} (top-level, Copilot)
|
||||
additional_context → {"additional_context": ...} (top-level, Cursor)
|
||||
suppress → emit nothing (strict-JSON agents on events whose
|
||||
output can't be used)
|
||||
plain (default) → passthrough (Claude/Codex inject plain stdout)
|
||||
|
||||
Empty output emits nothing under any envelope (an empty additionalContext
|
||||
is useless noise).
|
||||
"""
|
||||
if not output:
|
||||
return
|
||||
if envelope == "suppress":
|
||||
return
|
||||
if envelope == "hookSpecificOutput":
|
||||
payload = {"additionalContext": output}
|
||||
if native_event:
|
||||
payload["hookEventName"] = native_event
|
||||
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
|
||||
return
|
||||
if envelope == "additionalContext":
|
||||
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
|
||||
return
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(0)
|
||||
@@ -297,6 +337,16 @@ def main():
|
||||
timeout = int(sys.argv[3])
|
||||
except (TypeError, ValueError):
|
||||
timeout = 120
|
||||
# Optional 5th arg: context-injection envelope for stdout (C13): plain
|
||||
# (default), hookSpecificOutput, additionalContext, additional_context,
|
||||
# or suppress. Unknown values fall back to plain passthrough.
|
||||
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
|
||||
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
|
||||
envelope = "plain"
|
||||
# Optional 6th arg: native event name for hookSpecificOutput's
|
||||
# hookEventName field (required by Qwen's hooks spec; included by
|
||||
# Gemini/Tabnine/Devin which derive from the same protocol).
|
||||
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
|
||||
@@ -307,14 +357,14 @@ def main():
|
||||
from specify_cli.events import resolve_and_run_event_command
|
||||
sys.exit(
|
||||
resolve_and_run_event_command(
|
||||
command_name, _event_name, payload, project_root, timeout=timeout
|
||||
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
|
||||
)
|
||||
)
|
||||
except ImportError:
|
||||
except (ImportError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
|
||||
sys.exit(_run_inline(command_name, payload, project_root, timeout))
|
||||
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -362,17 +412,21 @@ function resolveDispatcher(directory: string): void {{
|
||||
) as string;
|
||||
}}
|
||||
|
||||
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
|
||||
if (!DISPATCHER) return;
|
||||
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
|
||||
if (!DISPATCHER) return '';
|
||||
try {{
|
||||
// execFileSync with an argv array invokes the interpreter directly — no
|
||||
// shell — so command/event strings with metacharacters can't break out
|
||||
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
|
||||
// execFileSync timeout is ms with a buffer so the outer cap fires after
|
||||
// the dispatcher's inner subprocess (S3).
|
||||
execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
||||
// the dispatcher's inner subprocess (S3). stdout is captured and
|
||||
// returned so context-injection hooks (experimental.chat.system.transform,
|
||||
// chat.message) can push it into their outputs; stderr stays inherited so
|
||||
// dispatcher errors remain visible (C11).
|
||||
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
||||
input: JSON.stringify({{ input, output }}),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
encoding: 'utf-8',
|
||||
timeout: (timeoutSec + {buffer}) * 1000,
|
||||
}});
|
||||
}} catch (e) {{
|
||||
@@ -382,6 +436,12 @@ function runEvent(command: string, event: string, input: any, output: any, timeo
|
||||
}}
|
||||
}}
|
||||
|
||||
// Cache session_start handler output per sessionID so non-idempotent
|
||||
// handlers (setup, telemetry, file-mutating scripts) run once per session
|
||||
// instead of on every LLM request (experimental.chat.system.transform
|
||||
// fires per LLM turn). Evicted on session.deleted.
|
||||
const sessionStartCache = new Map<string, string>();
|
||||
|
||||
{event_entries}
|
||||
|
||||
export default (async ({{ client, project, directory, $ }}) => {{
|
||||
@@ -585,12 +645,26 @@ def resolve_and_run_event_command(
|
||||
project_root: Path,
|
||||
*,
|
||||
timeout: int = 120,
|
||||
envelope: str = "plain",
|
||||
native_event: str = "",
|
||||
) -> int:
|
||||
"""Core entry point to resolve and execute an event-driven command.
|
||||
|
||||
*timeout* is the per-handler timeout in seconds, passed through from the
|
||||
native hook config via the dispatcher (S4) so a handler configured above
|
||||
the previous fixed 120s cap can run for its full duration.
|
||||
|
||||
*envelope* selects how the handler's stdout is emitted for the agent's
|
||||
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
|
||||
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
|
||||
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
|
||||
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
|
||||
output can't be used).
|
||||
|
||||
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
|
||||
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
|
||||
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
|
||||
used when *envelope* is ``hookSpecificOutput``.
|
||||
"""
|
||||
template_path, ext_id = _find_command_template(command_name, project_root)
|
||||
if not template_path:
|
||||
@@ -610,7 +684,7 @@ def resolve_and_run_event_command(
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.stdout:
|
||||
sys.stdout.write(result.stdout)
|
||||
_emit_event_stdout(result.stdout, envelope, native_event)
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
sys.stderr.write(result.stderr)
|
||||
@@ -624,6 +698,35 @@ def resolve_and_run_event_command(
|
||||
return 2
|
||||
|
||||
|
||||
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
|
||||
"""Write handler stdout in the agent's context-injection shape (C13).
|
||||
|
||||
Mirrors the ``_emit`` helper inside the generated dispatcher template;
|
||||
keep both in sync. Empty output emits nothing under any envelope.
|
||||
|
||||
*native_event* is the agent's native hookEventName, required inside
|
||||
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
|
||||
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
|
||||
"""
|
||||
if not output:
|
||||
return
|
||||
if envelope == "suppress":
|
||||
return
|
||||
if envelope == "hookSpecificOutput":
|
||||
payload = {"additionalContext": output}
|
||||
if native_event:
|
||||
payload["hookEventName"] = native_event
|
||||
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
|
||||
return
|
||||
if envelope == "additionalContext":
|
||||
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
|
||||
return
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
|
||||
|
||||
# Resolved events map: each canonical event name maps to an *ordered list* of
|
||||
@@ -1018,7 +1121,17 @@ def _dispatcher_command(
|
||||
When *timeout_seconds* is given, the resolved timeout (in the
|
||||
integration's native unit) is appended as a 4th argument so the dispatcher
|
||||
and inner runner honor the per-handler timeout instead of a fixed 120s cap
|
||||
that would kill a handler configured for longer (S4).
|
||||
that would kill a handler configured for longer (S4). When omitted, a
|
||||
default of 60s is emitted so the positional argument order
|
||||
(command event timeout envelope native_event) stays aligned — otherwise
|
||||
the envelope would land in the timeout slot and the dispatcher would
|
||||
silently fall back to plain stdout.
|
||||
|
||||
When the integration declares a context-injection envelope for this
|
||||
canonical event (``events_context_envelope``, C13), the envelope token is
|
||||
appended as a 5th argument so the dispatcher wraps stdout in the JSON
|
||||
shape the agent's hook protocol requires. Plain-passthrough agents
|
||||
(Claude/Codex) declare no envelope and get no extra argument.
|
||||
"""
|
||||
if target_os == "host":
|
||||
interpreter = _resolve_interpreter(project_root)
|
||||
@@ -1038,16 +1151,44 @@ def _dispatcher_command(
|
||||
# operator. Prefix & for the explicit windows target only.
|
||||
prefix = "& " if target_os == "windows" else ""
|
||||
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
|
||||
if timeout_seconds is not None:
|
||||
# R2: the dispatcher interprets this argument as seconds, so pass the
|
||||
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
||||
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
||||
# applied to the native hook timeout field (in the adapter formatters)
|
||||
# so the agent's outer cap fires after the inner subprocess timeout.
|
||||
base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
|
||||
# Always emit the timeout (4th positional arg) so the dispatcher's argv
|
||||
# parsing stays aligned when an envelope (5th) or native_event (6th)
|
||||
# follows. Without it the envelope would land in the timeout slot and
|
||||
# the dispatcher would fall back to plain stdout (R3).
|
||||
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
|
||||
# R2: the dispatcher interprets this argument as seconds, so pass the
|
||||
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
||||
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
||||
# applied to the native hook timeout field (in the adapter formatters)
|
||||
# so the agent's outer cap fires after the inner subprocess timeout.
|
||||
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
|
||||
envelope = _context_envelope_for(integration, event_name)
|
||||
if envelope:
|
||||
base += f" {_shell_quote(envelope, target_os)}"
|
||||
# hookSpecificOutput requires the native hookEventName inside the
|
||||
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
|
||||
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
|
||||
# Append the native event name as a 6th dispatcher argument so the
|
||||
# dispatcher can populate hookEventName in the JSON output.
|
||||
if envelope == "hookSpecificOutput":
|
||||
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
|
||||
if native_event:
|
||||
base += f" {_shell_quote(native_event, target_os)}"
|
||||
return base
|
||||
|
||||
|
||||
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
|
||||
"""Resolve the context-injection envelope for an integration + event (C13).
|
||||
|
||||
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
|
||||
integration declares no envelope for the event (plain stdout passthrough).
|
||||
"""
|
||||
mapping = getattr(integration, "events_context_envelope", None) or {}
|
||||
if canonical_event in mapping:
|
||||
return mapping[canonical_event]
|
||||
return mapping.get("*")
|
||||
|
||||
|
||||
def install_integration_events(
|
||||
integration: IntegrationBase,
|
||||
project_root: Path,
|
||||
@@ -1590,6 +1731,14 @@ def _build_opencode_plugin(
|
||||
an argv array (C9). Both the ``input`` and ``output`` callback arguments
|
||||
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
|
||||
arguments and post_tool_use can inspect the result.
|
||||
|
||||
Context-injection natives get dedicated hook bodies: for
|
||||
``experimental.chat.system.transform`` the handlers' concatenated stdout
|
||||
is pushed into ``output.system`` (system-prompt injection, re-applied per
|
||||
LLM request so the context survives compaction); for ``chat.message`` it
|
||||
is pushed as a synthetic text part on the user message (C11). Other
|
||||
natives keep their side-effect behavior (tool.execute.* args mutation;
|
||||
session.* lifecycle events via the generic ``event`` hook).
|
||||
"""
|
||||
event_entries: list[str] = []
|
||||
plugin_returns: list[str] = []
|
||||
@@ -1604,11 +1753,16 @@ def _build_opencode_plugin(
|
||||
ev_lit = json.dumps(ev)
|
||||
native_lit = json.dumps(native)
|
||||
|
||||
is_injection = native in ("experimental.chat.system.transform", "chat.message")
|
||||
|
||||
# Build the body: one runEvent() call per handler wrapped in try/catch,
|
||||
# forwarding both input and output (C7). An optional tool-name matcher
|
||||
# guard applies to tool.execute.* hooks. All handlers execute before
|
||||
# any aggregate error is thrown.
|
||||
# any aggregate error is thrown. Injection hooks additionally collect
|
||||
# each handler's stdout and return the concatenation.
|
||||
body_lines: list[str] = [" const errors: string[] = [];"]
|
||||
if is_injection:
|
||||
body_lines.append(" const contexts: string[] = [];")
|
||||
for cfg in handlers:
|
||||
command = str(cfg.get("command", ""))
|
||||
command_lit = json.dumps(command)
|
||||
@@ -1630,6 +1784,10 @@ def _build_opencode_plugin(
|
||||
body_lines.append(
|
||||
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
)
|
||||
elif is_injection:
|
||||
body_lines.append(
|
||||
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
)
|
||||
else:
|
||||
body_lines.append(
|
||||
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
@@ -1648,14 +1806,65 @@ def _build_opencode_plugin(
|
||||
f" _{ev}(input, output);\n"
|
||||
f" }},"
|
||||
)
|
||||
elif native == "experimental.chat.system.transform":
|
||||
body_lines.append(' return contexts.join("\\n\\n");')
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any): string {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# OpenCode fires experimental.chat.system.transform for non-session
|
||||
# operations (e.g. agent generation) with no sessionID. Guard so
|
||||
# canonical session_start handlers only run when a session is
|
||||
# present, preventing their output from being injected into
|
||||
# internal prompts. Cache the handler output per sessionID so
|
||||
# non-idempotent handlers (setup, telemetry, file-mutating
|
||||
# scripts) execute once per session instead of on every LLM
|
||||
# request; the cache is evicted on session.deleted.
|
||||
plugin_returns.append(
|
||||
f" {native_lit}: async (input: any, output: any) => {{\n"
|
||||
f" if (!input.sessionID) return;\n"
|
||||
f" let ctx = sessionStartCache.get(input.sessionID);\n"
|
||||
f" if (ctx === undefined) {{\n"
|
||||
f" ctx = _{ev}(input, output);\n"
|
||||
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
|
||||
f" }}\n"
|
||||
f" if (ctx) output.system.push(ctx);\n"
|
||||
f" }},"
|
||||
)
|
||||
elif native == "chat.message":
|
||||
body_lines.append(' return contexts.join("\\n\\n");')
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any): string {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# Part id must start with "prt" (opencode's Identifier brand): an
|
||||
# invalid id fails the user-part schema validation and crashes the
|
||||
# whole session (C12). Derive it from the last existing part so the
|
||||
# brand survives an opencode prefix change, falling back to "prt_"
|
||||
# when output.parts is empty.
|
||||
plugin_returns.append(
|
||||
f" {native_lit}: async (input: any, output: any) => {{\n"
|
||||
f" const ctx = _{ev}(input, output);\n"
|
||||
f" if (!ctx) return;\n"
|
||||
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
|
||||
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
|
||||
f" }},"
|
||||
)
|
||||
else:
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any) {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# Evict the sessionStartCache when the session is deleted so the
|
||||
# cache doesn't grow unbounded across sessions.
|
||||
eviction = ""
|
||||
if native == "session.deleted":
|
||||
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
|
||||
event_handlers.append(
|
||||
f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
|
||||
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
|
||||
)
|
||||
|
||||
if event_handlers:
|
||||
|
||||
@@ -957,6 +957,20 @@ class IntegrationBase(ABC):
|
||||
"""Return True if this integration supports agent-native events."""
|
||||
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
|
||||
|
||||
# Context-injection envelope for hook stdout, keyed by canonical event
|
||||
# (with "*" as the fallback). Not every agent injects a hook's plain-text
|
||||
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
|
||||
# protocols (plain text becomes user-facing noise), Copilot discards
|
||||
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
|
||||
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
|
||||
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
|
||||
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
|
||||
# "suppress" → emit nothing (strict-JSON agents on events whose
|
||||
# output can't be used)
|
||||
# Absent (no matching key and no "*") → plain stdout passthrough
|
||||
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
|
||||
events_context_envelope: dict[str, str] = {}
|
||||
|
||||
# -- Convenience helpers for subclasses -------------------------------
|
||||
|
||||
def install(
|
||||
|
||||
@@ -152,6 +152,14 @@ class CopilotIntegration(IntegrationBase):
|
||||
}
|
||||
events_config_file = ".github/hooks/speckit.json"
|
||||
events_format = "copilot-json"
|
||||
# Copilot sessionStart and userPromptSubmitted inject a top-level
|
||||
# additionalContext field into the model-facing prompt (C13). Non-JSON
|
||||
# stdout is discarded harmlessly by Copilot on other events, so no other
|
||||
# event needs an envelope.
|
||||
events_context_envelope = {
|
||||
"session_start": "additionalContext",
|
||||
"user_prompt_submit": "additionalContext",
|
||||
}
|
||||
|
||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||
_skills_mode: bool = True
|
||||
|
||||
@@ -48,6 +48,14 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
}
|
||||
events_config_file = ".cursor/hooks.json"
|
||||
events_format = "json-flat"
|
||||
# Cursor sessionStart injects a top-level additional_context (snake_case)
|
||||
# field (C13). beforeSubmitPrompt has no context output field (block/allow
|
||||
# only), and plain text on any hook fails Cursor's JSON parse — suppress
|
||||
# everything else.
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "additional_context",
|
||||
}
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -44,6 +44,13 @@ class DevinIntegration(SkillsIntegration):
|
||||
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
|
||||
# json-root-nested writer/remover operate directly on the root event keys.
|
||||
events_format = "json-root-nested"
|
||||
# Devin's hooks protocol is JSON-stdout; additionalContext is the
|
||||
# documented injection field for SessionStart/UserPromptSubmit (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -33,6 +33,15 @@ class GeminiIntegration(TomlIntegration):
|
||||
}
|
||||
events_config_file = ".gemini/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Gemini mandates JSON-only hook stdout ("silence is mandatory"): plain
|
||||
# text becomes a user-facing systemMessage, never context. Inject via
|
||||
# hookSpecificOutput.additionalContext on the two context events and
|
||||
# suppress stdout everywhere else (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
|
||||
# which use seconds. The shared formatter converts via _native_timeout (#7)
|
||||
# so the default 60s becomes 60000ms instead of terminating the dispatcher
|
||||
|
||||
@@ -23,7 +23,15 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"pre_tool_use": "tool.execute.before",
|
||||
"post_tool_use": "tool.execute.after",
|
||||
"session_start": "session.created",
|
||||
# session_start maps to the system-prompt transform hook (not the
|
||||
# session.created event) so the handler's stdout is injected into the
|
||||
# system prompt — session.created has no output channel. The hook
|
||||
# fires per LLM request, which keeps the context present across
|
||||
# compaction at the cost of running the handler per turn.
|
||||
"session_start": "experimental.chat.system.transform",
|
||||
# user_prompt_submit maps to chat.message so handler stdout is
|
||||
# injected as a synthetic text part on the user's message.
|
||||
"user_prompt_submit": "chat.message",
|
||||
"session_end": "session.deleted",
|
||||
}
|
||||
events_config_file = "opencode.json"
|
||||
|
||||
@@ -30,6 +30,12 @@ class QwenIntegration(MarkdownIntegration):
|
||||
}
|
||||
events_config_file = ".qwen/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Qwen hooks are a JSON stdin/stdout protocol (Gemini-derived) (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
# Qwen Code's command hooks measure timeout in milliseconds (default
|
||||
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
|
||||
@@ -33,6 +33,12 @@ class TabnineIntegration(TomlIntegration):
|
||||
}
|
||||
events_config_file = ".tabnine/agent/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Tabnine is Gemini-hooks-compatible (JSON-only stdout) (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
|
||||
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
|
||||
Reference in New Issue
Block a user