feat: first-class agent-native runtime hooks for integrations - #3704
feat: first-class agent-native runtime hooks for integrations#3704kanfil wants to merge 26 commits into
Conversation
|
Thanks for this — the agent-runtime wiring is a real gap and the canonical→native event mapping, marker-based surgical teardown, and layered resolution are the right instincts. Sharing some architectural feedback before this goes further; happy to pair on any of it. 1. Call the feature "events", and use snake_case names consistent with the existing event vocabulary.
Adapters translate those canonical snake_case names to each agent's native casing (Claude 2. Drive adapters from the integration registry, not a parallel 3. Split sourcing from writing so
4. The dispatcher is agent-agnostic → make it core. One 5. Two capabilities the PR description advertises aren't actually wired yet — make sure the reworked versions are real and covered by tests. As filed: the disable flag reads 6. Missing Copilot CLI adapter. Copilot CLI has first-class hooks (docs): native events ( Smaller correctness notes: the Codex adapter embeds Net: the writing mechanism belongs in the integration layer as you have it — the main structural change is splitting the one module in two: native-config writing stays per-integration (registry-driven), while event sourcing moves to an injected core resolver, with the dispatcher lifted to core. Add the terminology change and the Copilot adapter and I think this is in great shape. Glad to help implement or review. Posted on behalf of @mnriem by GitHub Copilot (model: Claude Opus 4.8). |
mnriem
left a comment
There was a problem hiding this comment.
Please address my feedback above. Great proposal!
- Rename hooks terminology to 'events' (events:, --events flag, events.py). - Use snake_case names for canonical events consistent with spec-kit vocabulary. - Fold event config adapters into integration classes via class attributes (CANONICAL_TO_NATIVE, events_config_file, events_format). - Lift event command-script resolution to core 'specify event run' command. - Split events sourcing from integration config writing. - Support first-class Copilot CLI events JSON generation under '.github/hooks/speckit.json'. - Rewrite and expand full test suite under 'tests/integrations/test_events.py'. Assisted-by: opencode (model: litellm/gemini-3.5-flash, autonomous)
There was a problem hiding this comment.
Pull request overview
Adds agent-native runtime event handling, connecting extension commands to integration lifecycle hooks.
Changes:
- Adds event resolution, dispatch, validation, configuration merging, and cleanup.
- Adds native event mappings for nine integrations and wires events into setup workflows.
- Adds the
specify event runcommand and event-focused tests.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/events.py |
Implements event resolution, dispatch, adapters, and cleanup. |
src/specify_cli/commands/event.py |
Adds event execution CLI. |
src/specify_cli/commands/init.py |
Resolves events during initialization. |
src/specify_cli/__init__.py |
Registers event commands. |
src/specify_cli/extensions/__init__.py |
Validates extension event declarations. |
src/specify_cli/integrations/base.py |
Adds shared event setup and teardown behavior. |
src/specify_cli/integrations/_install_commands.py |
Installs resolved integration events. |
src/specify_cli/integrations/_migrate_commands.py |
Handles events during switch and upgrade. |
src/specify_cli/integrations/claude/__init__.py |
Defines Claude event mappings. |
src/specify_cli/integrations/codex/__init__.py |
Defines Codex event mappings and options. |
src/specify_cli/integrations/copilot/__init__.py |
Adds Copilot hook generation. |
src/specify_cli/integrations/cursor_agent/__init__.py |
Defines Cursor event mappings and options. |
src/specify_cli/integrations/devin/__init__.py |
Defines Devin event mappings. |
src/specify_cli/integrations/gemini/__init__.py |
Defines Gemini event mappings. |
src/specify_cli/integrations/opencode/__init__.py |
Configures OpenCode plugin events. |
src/specify_cli/integrations/qwen/__init__.py |
Defines Qwen event mappings. |
src/specify_cli/integrations/tabnine/__init__.py |
Defines Tabnine event mappings. |
tests/integrations/test_events.py |
Tests event resolution and adapter output. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (3)
tests/integrations/test_events.py:18
events_stale_exclusionsis imported but never referenced, causing Ruff F401 in CI.
collect_extension_events,
events_stale_exclusions,
install_integration_events,
remove_integration_events,
tests/integrations/test_events.py:27
IntegrationBaseandCodexIntegrationare unused imports, so the required Ruff job fails with F401.
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.manifest import IntegrationManifest
from specify_cli.integrations.claude import ClaudeIntegration
from specify_cli.integrations.cursor_agent import CursorAgentIntegration
from specify_cli.integrations.codex import CodexIntegration
tests/integrations/test_events.py:32
- None of these four integration classes is referenced by the tests, causing four Ruff F401 errors in CI.
from specify_cli.integrations.qwen import QwenIntegration
from specify_cli.integrations.gemini import GeminiIntegration
from specify_cli.integrations.devin import DevinIntegration
from specify_cli.integrations.tabnine import TabnineIntegration
- Files reviewed: 18/18 changed files
- Comments generated: 23
- Review effort level: Medium
|
Please address Copilot feedback and fix test & lint errors |
Address Copilot review finding #18 (src/specify_cli/__init__.py event-command import missing # noqa: E402), #19 (unused console import in commands/event.py), and #20 (unused patch/yaml/Path/integration imports in test_events.py). Also fix two stray F541 f-string prefixes in _build_opencode_plugin that ruff flagged in the same job. Bump dev version 0.14.2.dev0 -> 0.14.2.dev1 and add a CHANGELOG entry per the AGENTS.md convention for Specify CLI __init__.py changes. Refs: PR github#3704 Copilot inline review (findings #18, #19, #20) Assisted-by: opencode (model: glm-5.2, autonomous)
Address Copilot review findings that left generated event hooks inert or schema-invalid after the rework: - #2: the resolved events map now carries an ordered list of handlers per event (dict[str, list[dict]]) so two extensions declaring the same event both run instead of the last one silently winning. collect_extension_events accumulates; every adapter emits one native entry per handler. - #6: Claude/Gemini/Qwen/Devin/Tabnine native schema accepts a single 'command' string, not command+args. Each adapter now renders one complete shell invocation of the dispatcher via _dispatcher_command(). - #7: Gemini measures hook timeouts in milliseconds; add events_timeout_unit attr and _native_timeout() so the 60s default becomes 60000ms instead of terminating the dispatcher after 60ms. - #4: _resolve_event_command_argv() replaces _extract_script_path() — scripts: values are command strings (e.g. 'scripts/bash/setup-plan.sh --json'), not bare paths. Resolves the project's sh/ps/py variant, splits safely into argv, and prepends the interpreter for .py. - #5: bundled-template fallback now uses _locate_core_pack()/_repo_root() (core_pack/commands, not the non-existent core_pack/templates/commands). - #16: all formatters use IntegrationBase.resolve_python_interpreter() so generated commands honor the project venv and never hard-code python3 (absent on Windows). The opencode TS plugin bakes in the same resolved interpreter. - #13: opencode TS plugin runEvent() now throws on failure instead of process.exit(2), which killed the OpenCode host process; only the failing hook is rejected. - #21: user YAML override is validated (event names, non-empty command strings) before returning; a malformed override is warned about and ignored rather than crashing installation on cfg.get(). Bump dev version 0.14.2.dev1 -> 0.14.2.dev2 (gemini/__init__.py change) and add a CHANGELOG entry. Refs: PR github#3704 Copilot inline review (findings #2, #4, #5, #6, #7, #13, #16, #21) Assisted-by: opencode (model: glm-5.2, autonomous)
Address Copilot review findings on native-config merge and teardown: - #9: _has_marker now recurses into nested 'hooks' arrays so a matcher-group containing Specify-owned inner hooks is recognized and replaced on upgrade instead of accumulating duplicates. - #11: _merge_json_fragment strips ALL Specify-marked entries from every event before adding the new set, so an override that drops an event (pre_tool_use -> stop) removes the stale marked entry instead of leaving it active. - #3: an empty resolved map (--events false / disabled override) now runs the native-config removal path instead of early-returning, so prior Specify hooks are stripped. The shared dispatcher is left untouched (#10). - #14: teardown deletes a Spec-Kit-created config that is now empty of user content (rather than leaving '{}' that confused manifest.uninstall()), while preserving pre-existing configs with user hooks/settings. - #10: the shared .specify/events.py dispatcher is deleted only when no other installed event-capable integration's manifest still references it, so uninstalling one multi-install integration doesn't break the others. - #8: Copilot's .github/hooks/speckit.json now merges owned entries (with markers) into a pre-existing file instead of overwriting, and teardown removes only owned entries (deleting the file when no user hooks remain). - #22/#23: JSON/JSONC parse failures in native configs (Claude/Cursor/etc. and opencode.json) abort the merge with a warning instead of resetting user content to '{}'. - #12: write destinations are validated (symlinked-ancestor rejection + containment) before any bytes are written, so a symlinked .specify or native config directory can't redirect writes outside the repository. Refs: PR github#3704 Copilot inline review (findings #3, #8, #9, #10, #11, #12, #14, #22, #23) Assisted-by: opencode (model: glm-5.2, autonomous)
…ct command validation Address Copilot review findings on sourcing, validation, and lifecycle: - #1: collect_extension_events now honors the extension registry's 'enabled' flag — a disabled extension's events are skipped so disabling an extension actually deactivates its runtime hooks. Adds refresh_integration_events(), wired into extension add/remove/enable/disable, so installing, removing, enabling, or disabling an extension regenerates each installed event-capable integration's native event config (the documented install-after-init flow is no longer inert, and disabled/removed extension events are stripped). - #17: validate_events now requires 'command' to be a non-empty string, not merely truthy, so a value like 'command: [foo]' is rejected at manifest load instead of rendering into invalid native configuration. - #15: updated PR github#3704 description to the implemented events terminology (.specify/events.py, events:, --events, integration-events.yml) replacing the stale bridge.py / runtime_hooks: / --hooks false / integration-hooks.yml references that no longer match the shipped API. (#21 — user YAML override validation — was addressed in the prior tier.) Refs: PR github#3704 Copilot inline review (findings #1, #15, #17) Assisted-by: opencode (model: glm-5.2, autonomous)
|
Posted on behalf of @kanfil by opencode (model: glm-5.2, autonomous). Addressed all 23 Copilot inline review findings plus the ruff lint errors. Pushed as four commits on top of
Tests: One implementation note for #2: I moved it into the native-config tier (rather than the lifecycle tier in the original plan) because it is inseparable from the adapter command-string rendering — both rewrite the same formatters against the events-map shape, so doing them together avoided a throwaway intermediate rewrite. |
|
Please do not update CHANGELOG.md nor pyproject.toml as those are done by release process |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (7)
src/specify_cli/events.py:853
- The reference-count branch still leaves
events.pyin this manifest.IntegrationBase.teardown()immediately callsmanifest.uninstall(), so the matching dispatcher file is then deleted even though another integration references it. Drop the shared path from the manifest when retaining it; the current unit test only callsremove_integration_events()and misses the subsequent uninstall.
if not _other_event_integrations_reference_dispatcher(project_root, integration.key):
dispatcher_path = project_root / dispatcher_rel
if dispatcher_path.exists():
dispatcher_path.unlink(missing_ok=True)
manifest.remove(dispatcher_rel)
src/specify_cli/events.py:264
- The Python branch never anchors
scripts/...beneath.specify(or.specify/extensions/<id>), unlike the sh/ps branch below. Thus every event command in a project configured with--script pylaunches a nonexistent project-rootscripts/...path. Resolve the first token against the same base before building argv.
if variant == "py":
# .py files aren't directly executable on Windows; prefix the resolved
# interpreter. build_python_invocation returns a shell-quoted command
# string, so split it back into argv for subprocess.
invocation = IntegrationBase.build_python_invocation(script_cmd, project_root)
try:
return shlex.split(invocation, posix=(os.name != "nt"))
except ValueError:
return None
src/specify_cli/events.py:750
- All handlers are placed under the first handler's matcher. If two extensions register the same tool event with different matchers, both commands run for the first matcher and neither is registered for the later matcher. Emit a matcher group per handler (or group handlers by equal matcher) instead of selecting
handlers[0].
for ev, handlers in filtered.items():
native = canonical_to_native[ev]
matcher = handlers[0].get("matcher", "*") if handlers else "*"
inner_hooks: list[dict[str, Any]] = []
for cfg in handlers:
src/specify_cli/events.py:679
- Both fields receive one host-resolved command. For example, setup on Linux can write
.venv/bin/pythoninto the PowerShell hook, while setup on Windows can write.venv/Scripts/python.exeinto the Bash hook, so the checked-in repository hook fails on the other OS. Generate independent Bash and PowerShell invocations rather than duplicating the current host's command.
dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev)
entries.append(
{
"type": "command",
"bash": dispatcher_cmd,
"powershell": dispatcher_cmd,
"timeoutSec": _native_timeout(integration, cfg.get("timeout", 60)),
src/specify_cli/events.py:1273
- A parse failure returns
None, but every installer branch still unconditionally callsmanifest.record_existing()for that untouched config. On uninstall, cleanup also aborts parsing, after whichmanifest.uninstall()sees the original hash and deletes the user's JSONC/malformed file. The merge helpers need to report whether they actually wrote, and callers must not track a file when preservation caused the merge to be skipped.
except (json.JSONDecodeError, ValueError) as exc:
logger.warning(
"Could not parse %s (may contain JSONC comments or be malformed); "
"skipping event-config merge to preserve user content.",
path,
src/specify_cli/events.py:270
- For the
psvariant, the later return uses the.ps1path as the executable. Windowssubprocess.run(..., shell=False)cannot execute a PowerShell script directly, so event dispatch fails on the default Windows script type. Prefix the resolved script withpwsh -File(or the supported Windows PowerShell fallback) before returning argv.
# sh / ps: the command string is "<relpath> [args...]". Resolve the
# leading path component against the project, then rejoin with the
# remaining tokens.
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
if not tokens:
src/specify_cli/events.py:912
- Refresh discards the integration's persisted
parsed_optionsby passingNone. After an install/upgrade with--events false, any later extension add/enable/disable re-resolves events as enabled and recreates hooks, violating the CLI gate. Read this integration's storedparsed_optionsfromstateand pass them here.
_remove_native_event_hooks(integration, project_root, manifest)
events_map = resolve_events(key, integration.config, project_root, None)
if events_map:
install_integration_events(integration, project_root, manifest, events_map)
- Files reviewed: 21/21 changed files
- Comments generated: 4
- Review effort level: Medium
|
Please address Copilot feedback |
Per maintainer request, the events PR no longer carries CHANGELOG entries or pyproject version revs. This restores both files to their pre-PR (da6c20d) state: pyproject.toml back to 0.14.2.dev0 and the [Unreleased] block removed from CHANGELOG.md. The AGENTS.md version-rev convention for __init__.py changes is intentionally waived for this PR by maintainer decision. This also clears the pending merge conflicts with upstream/main on these two files (upstream's 0.14.2 release commit c0fe0e4): our side now makes no net change to them relative to the merge-base, so a future upstream merge takes theirs on both without conflict. Assisted-by: opencode (model: glm-5.2, autonomous)
Copilot and Devin are event-capable, but their options() overrides returned only --skills without calling super(), so the base class never declared --events. The documented --integration-options "--events false" opt-out was therefore rejected as unknown for both adapters. Both now compose with super().options() (mirroring Codex and Cursor) so --events is declared alongside --skills. Added a TestEventCapableOptionsCompo sition test class asserting --events appears in Copilot, Devin, Cursor, and Codex options() output. Refs: PR github#3704 Copilot review 4790195897 (findings #8, #9) Assisted-by: opencode (model: glm-5.2, autonomous)
Address three Copilot review findings on native-config generation: - #7: Cursor's .cursor/hooks.json schema requires top-level "version": 1, but json-flat used _merge_json_fragment() which only writes hooks, so a freshly generated file was missing the required schema version. Added a version kwarg to _merge_json_fragment (preserving a user's value if present) and the Cursor json-flat branch now passes version=1. - S3: json-nested placed all handlers under the first handler's matcher, so two extensions registering the same event with different matchers both ran for the first matcher and neither for the later. Handlers are now grouped by distinct matcher, emitting one matcher-group per matcher (handlers sharing a matcher stay in one group). - S4: Copilot's bash and powershell fields both received the same host-resolved command, so a config generated on Linux wrote a POSIX venv path into the PowerShell hook (and vice-versa). _dispatcher_command gains a target_os kwarg; Copilot now emits an independent POSIX interpreter (python3) for bash and a Windows interpreter (python) for powershell, so the checked-in config works on either OS. Tests: added TestCursorJsonWriting (version present + preserved) and matcher-grouping regressions (per-distinct-matcher, shared-matcher); updated the Copilot generation test to assert bash != powershell with OS-appropriate interpreters. Refs: PR github#3704 Copilot review 4790195897 (findings #7, S3, S4) Assisted-by: opencode (model: glm-5.2, autonomous)
Address two Copilot review findings on the core command runner: - S2: the py variant called build_python_invocation() on the raw scripts: command string, which left 'scripts/...' anchored at the project root instead of under .specify/ (or .specify/extensions/<id>/). Every event command in a project configured with --script py launched a nonexistent project-root path. The py branch now shares the same base-anchoring as sh/ps and prepends the resolved interpreter as argv (no shell quoting needed for subprocess.run(shell=False)). - S6: the ps variant returned the .ps1 path as the executable, but Windows subprocess.run(shell=False) cannot execute a PowerShell script directly, so event dispatch failed on the default Windows script type. The ps branch now prefixes argv with 'pwsh -File' (PowerShell 7+), falling back to 'powershell -File' (Windows PowerShell) when pwsh is absent. Tests: added test_py_variant_anchored_under_specify and test_ps_variant_prefixed_with_powershell_launcher covering the new argv shapes (interpreter + .specify-anchored path; launcher -File + path). Refs: PR github#3704 Copilot review 4790195897 (findings S2, S6) Assisted-by: opencode (model: glm-5.2, autonomous)
…tain, honor --events false in refresh, preserve layers on invalid override Address four Copilot review findings on merge/teardown/refresh safety: - S5: _merge_json_fragment/_merge_opencode_plugin_ref/_merge_copilot_json now return bool (wrote). Install branches skip manifest.record_existing() and created.append() when a merge was skipped on parse failure, so a user's JSONC/malformed native config is not tracked and manifest.uninstall() can't later delete the untouched file. - S1: remove_integration_events now drops this integration's manifest claim on the shared dispatcher (manifest.remove) even when the file is retained because another integration references it. Previously the retained file stayed tracked, so the subsequent manifest.uninstall() in teardown() saw the matching hash and deleted the file another integration still depended on. The unit test now exercises full teardown() (not just remove_integration_events) to cover the gap. - S7: refresh_integration_events reads each integration's stored parsed_options via _resolve_integration_options and passes them to resolve_events, so a persisted --events false is honored across extension add/enable/disable instead of being discarded (which re-enabled events the user had disabled). - #10: an invalid override entry now abandons the entire override and keeps the accumulated built-in + extension layers, instead of resetting resolved_override to {} and assigning that empty map to events (which silently disabled all hooks on a single typo). Only a fully-valid override (including an explicit events: {}) replaces the prior layers. Tests: added TestOverridePreserveLayers (invalid entry keeps layers; explicit empty disables), TestSkippedMergeNotTracked (JSONC not recorded), and TestDispatcherManifestClaimDroppedOnRetain (full teardown keeps dispatcher when another integration references it). Added S7 refresh-honors-events-false regression. Refs: PR github#3704 Copilot review 4790195897 (findings S5, S1, S7, #10) Assisted-by: opencode (model: glm-5.2, autonomous)
|
Posted on behalf of @kanfil by opencode (model: glm-5.2, autonomous). Addressed all 11 actionable findings from Copilot review 4790195897 (the 4 high-confidence inline + 7 low-confidence suppressed). Pushed as five commits on top of
Tests: Note on stale inline comments: GitHub re-anchored the six comments from the original review ( |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/specify_cli/events.py:171
- This lookup does not match the actual extension registry schema:
.registrystores entries underextensions, and each registry entry storesregistered_commands, not manifest command declarations. The filename scan only works when the file stem equals the command name, although valid manifests can map a command to another filename (for example,speckit.selftest.extensionmaps tocommands/selftest.md). Such event commands always resolve as missing; load the installed extension manifest and use itsprovides.commands[].filemapping.
for ext_id, meta in data.items():
if not isinstance(meta, dict):
continue
for cmd in meta.get("commands", []):
if cmd.get("name") == command_name:
ext_dir = project_root / ".specify" / "extensions" / ext_id
return ext_dir / cmd["file"], ext_id
src/specify_cli/events.py:869
- When cleanup successfully removes Specify entries but preserves user content, the helpers return
False, so the native config remains claimed by the manifest.teardown(..., force=True)then callsmanifest.uninstall()and deletes the entire user-owned settings file. Distinguish “retained after cleanup” from “cleanup skipped/failed” and drop the manifest claim for the retained file before forced uninstall.
if deleted:
manifest.remove(config_file)
- Files reviewed: 19/19 changed files
- Comments generated: 5
- Review effort level: Medium
|
Please address Copilot feedback and fix test & lint errors |
The 'no commands/hooks/events' validation message changed to 'Extension must provide at least one command, hook, or event' when the events feature added a third provider kind, but test_no_commands_no_hooks still matched the old 'must provide at least one command or hook' text and failed on every CI job. Update the regex to the current message. Refs: PR github#3704 CI failure (test_extensions.py:579) Assisted-by: opencode (model: glm-5.2, autonomous)
…olution, toml teardown safe-dest
Address three findings from Copilot review 4791088500:
- S9: _remove_native_event_hooks now unconditionally drops this integration's
manifest claim on the native config, not only when the file was deleted.
Previously a config whose owned entries were cleaned but user content
retained stayed tracked, so teardown(force=True) -> manifest.uninstall(
force=True) deleted the entire user-owned settings file. This is the
config-file mirror of the earlier shared-dispatcher fix.
- S8: _find_command_template resolved extension event commands via a broken
registry lookup (the registry stores per-agent registered_commands
name-lists, not a {name, file} map) and a file-stem scan that only matched
when the .md stem equaled the command name. A manifest mapping
speckit.selftest.extension -> commands/selftest.md resolved as missing. It
now enumerates installed extensions via ExtensionManager.get_extension()
and matches provides.commands[].name -> file, with the directory scan and
core-template lookups kept as fallbacks.
- R3: _remove_toml_entries now validates the destination with
_ensure_safe_destination before read/write, matching the merge path, so a
symlink swap of .codex/config.toml after install can't make teardown
overwrite a file outside the project.
Tests: forced full teardown preserves a user settings file; an extension
command whose file stem differs from its name resolves via the manifest;
TOML teardown rejects a symlinked config destination.
Refs: PR github#3704 Copilot review 4791088500 (findings S8, S9, R3)
Assisted-by: opencode (model: glm-5.2, autonomous)
…bnine ms Address four findings from Copilot review 4791088500: - R1: the generated dispatcher and resolve_and_run_event_command now run their subprocesses with cwd set to the dispatcher-derived project root. Previously 'specify event run' (and the resolved script) inherited the agent's working directory, but event_run resolves the project via Path.cwd(), so a hook fired from a subdirectory targeted the wrong project and reported the command missing. - R2: _dispatcher_command now shell-quotes each component (interpreter, command, event) for the target shell (POSIX via shlex.quote; PowerShell via single-quoted literals with doubled quotes). An interpreter path containing spaces or an extension/override command containing shell metacharacters is passed as a single argument instead of being reinterpreted by the native hook shell. Claude's prefix is left unquoted so the shell still expands it (prefix + relative path are fixed, safe strings). - R4: the Codex TOML matcher is now rendered through the shared TOML escaper like command, so a matcher containing a quote/backslash/newline/control character no longer produces malformed config.toml. - R5: Tabnine declares events_timeout_unit='ms' (its hook schema mirrors Gemini's BeforeTool/AfterTool), so the 60s default becomes 60000ms instead of timeout: 60 (60 ms), which would terminate the dispatcher immediately. Tests: cwd-forced execution from a subdirectory; POSIX/PowerShell quoting of metacharacter and space-bearing components; TOML matcher with a quote parses cleanly; Tabnine timeout converts to 60000. Updated the Copilot generation test for the new quoted args. Refs: PR github#3704 Copilot review 4791088500 (findings R1, R2, R4, R5) Assisted-by: opencode (model: glm-5.2, autonomous)
|
Posted on behalf of @kanfil by opencode (model: glm-5.2, autonomous). Fixed the CI-blocking stale test and addressed all 7 findings from Copilot review 4791088500 (5 inline + 2 suppressed). Pushed as three commits on top of
Tests: Note on R5: Tabnine's ms timeout unit is inferred from its Gemini-parity native mapping ( |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (5)
src/specify_cli/events.py:666
- On Windows,
_shell_quote()returns a single-quoted PowerShell string, but a quoted string is not invoked as a command without PowerShell's call operator. Generated hooks such as'python' '.specify/events.py' ...therefore fail instead of launching the dispatcher. Prefix the quoted interpreter with&for Windows targets.
q_interp = _shell_quote(interpreter, target_os)
q_command = _shell_quote(command_name, target_os)
q_event = _shell_quote(event_name, target_os)
return f"{q_interp} {dispatcher} {q_command} {q_event}"
src/specify_cli/events.py:660
- Leaving the
${CLAUDE_PROJECT_DIR}expansion unquoted causes shell word splitting when the project path contains spaces, so Claude cannot launch the dispatcher in those projects. Quote the complete variable-based path; double quotes still allow the variable to expand.
if integration.key == "claude":
# Leave ${CLAUDE_PROJECT_DIR} unquoted so the shell expands it; the
# prefix and the relative path are both fixed and metacharacter-free.
dispatcher = "${CLAUDE_PROJECT_DIR}/" + EVENTS_DISPATCHER_REL
src/specify_cli/events.py:995
- The dispatcher is shared across integration manifests, but it is not excluded from upgrade stale cleanup. If Claude and Codex are installed and Claude is upgraded with
--events false, Claude's new manifest omitsevents.py; Phase 2 then deletes it from the old manifest without the teardown refcount check, breaking Codex. Protect the shared dispatcher from the generic stale pass and leave its deletion toremove_integration_events().
exclusions = set()
config_file = getattr(integration, "events_config_file", None)
if config_file:
exclusions.add(config_file)
if integration_key == "opencode":
exclusions.add(".opencode/plugin/speckit-events.ts")
src/specify_cli/events.py:489
- A malformed override handler such as
stop: []orstop: bad-valuenormalizes to no handlers, but this branch merely skips it and still adoptsresolved_override. If it is the only entry, a malformed override silently disables every built-in and extension hook, contrary to the stated invalid-override fallback. Mark the whole override invalid here, while retainingevents: {}as the explicit disable case.
for ev, raw in key_events.items():
handlers = _normalize_handlers(raw)
if not handlers:
logger.warning(
"Override %s: event '%s' has no valid handler; skipping entry",
override_file, ev,
)
continue
src/specify_cli/events.py:1489
- Cursor configs created by Spec Kit retain
{"version": 1}after all owned hooks are removed: unlike the Copilot remover, this generic path only deletes a literally empty object. The manifest claim is then dropped, so uninstall permanently leaves the generated stub behind, contradicting clean teardown. Track whether the file pre-existed or otherwise handle the owned Cursor version field without deleting a user-owned version-only file.
existing.pop("hooks", None)
# #14: if the config is now empty (no user content), delete the file
# rather than leaving a ``{}`` that confuses manifest.uninstall().
if not existing:
dst.unlink(missing_ok=True)
return True
_safe_write_json(dst, existing)
- Files reviewed: 20/20 changed files
- Comments generated: 10
- Review effort level: Medium
Three Windows test failures, one a real cross-OS bug:
- W1 (bug): EVENTS_DISPATCHER_REL was str(Path('.specify')/'events.py'),
which yields '.specify\events.py' on Windows. Manifest keys are stored in
POSIX form (.as_posix()), so 'dispatcher_rel in manifest.files' was always
False on Windows: the shared-dispatcher manifest-claim drop was skipped and
manifest.uninstall(force=True) deleted the dispatcher another integration
still depended on. Make it a POSIX constant (.as_posix()) so it matches
manifest keys on every platform.
- W2/W3 (tests): the py/ps argv assertions used endswith() and an exact
launcher-name set that broke on Windows backslash paths and the
pwsh.EXE/full-path launcher returned by shutil.which. Compare in POSIX form
and match the launcher by case-insensitive stem.
Refs: PR github#3704 Windows CI failures
Assisted-by: opencode (model: glm-5.2, autonomous)
…ommand-ref canonicalization
Address four Copilot review findings:
- C4: a malformed override handler (e.g. "stop: []" or "stop: bad-value")
normalizes to no handlers. Previously the entry was skipped and the override
still adopted, so an override whose only entry was malformed silently
disabled every built-in and extension hook. The empty-handler case now
abandons the whole override (keeps prior layers); an explicit "events: {}"
(no entries) remains a valid disable.
- C6: a non-mapping integration entry (e.g. "claude: bad") was coerced to
"events: {}" and treated as a valid explicit disable. It now warns and
abandons the override, keeping the accumulated layers. Only an explicitly
present, mapping-valued "events" field replaces the prior layers.
- C10: matcher is now validated as a string (or absent) in both
validate_events (manifest) and _validate_resolved_event (override). A
non-string matcher such as "matcher: []" previously passed validation but
crashed by_matcher.setdefault(matcher, ...) with TypeError: unhashable
type, aborting init or refresh.
- C11: ExtensionManifest._validate now applies the same rename + alias-lift
canonicalization to event command references that it already applies to
hook references. An event referencing an auto-corrected command (e.g.
my-ext.boot -> speckit.my-ext.boot) previously kept the obsolete name,
so dispatch reported no command and the event silently no-oped.
Tests: empty-handler/non-mapping override preserves layers; non-string
matcher rejected in manifest and abandoned in override; event command ref
lifted to canonical form with a warning.
Refs: PR github#3704 Copilot review (findings C4, C6, C10, C11)
Assisted-by: opencode (model: glm-5.2, autonomous)
…sor version stub, non-destructive refresh
Address three Copilot review findings:
- C3: the shared .specify/events.py dispatcher is now in
events_stale_exclusions(). It is written into every event-capable
integration's manifest but reference-counted across them; an upgrade with
--events false omits events.py from the new manifest, so the generic stale
pass would delete it without the refcount check, breaking any other
installed event-capable integration. Its deletion is left to
remove_integration_events(), which checks the refcount.
- C5: _remove_json_entries now deletes a Spec-Kit-created Cursor file that
retains only {"version": 1} after all owned hooks are removed (we added the
version field), mirroring _remove_copilot_entries. Previously the generic
remover only deleted a literally-empty object, so clean teardown left a
generated stub behind.
- C12: refresh_integration_events now resolves first and calls
install_integration_events once, instead of running the destructive
_remove_native_event_hooks pre-step before resolution. A later failure
(invalid destination, write error, formatter error) no longer destroys the
working native config before the new one is written.
install_integration_events already removes stale Specify-marked entries and
handles an empty map (stripping prior hooks), so the pre-step was both
unsafe and redundant.
Tests: dispatcher in stale exclusions; Cursor version-only stub deleted on
teardown; refresh failure preserves the pre-existing config (no pre-strip).
Refs: PR github#3704 Copilot review (findings C3, C5, C12)
Assisted-by: opencode (model: glm-5.2, autonomous)
…-quoted, & for windows
Address two Copilot review findings on the shell-quoting added in the prior
round (R2):
- C1: _shell_quote("host") now always uses POSIX shlex.quote, not PowerShell
single-quoting on Windows. The single-command-string formats
(Claude/Gemini/Qwen/Devin/Tabnine) are run via the agent's POSIX-ish shell
(Git Bash on Windows), and a single-quoted 'python' is not invoked as a
command by PowerShell without the call operator — so generated hooks failed
to launch the dispatcher on Windows. Safe tokens pass through bare
(python3, speckit.ext.cmd) on every platform. PowerShell single-quoting is
now used only for the explicit target_os="windows" (Copilot's powershell
field), where the quoted interpreter is prefixed with "& " so it is
actually invoked.
- C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is now double-quoted
("${CLAUDE_PROJECT_DIR}/.specify/events.py") so the variable still expands
(double quotes allow expansion in POSIX shells) but a project path
containing spaces no longer word-splits and breaks dispatcher launch.
Tests: host target never emits PowerShell quotes; windows target carries the
& call operator; Claude dispatcher is double-quoted; updated Copilot
generation assertions for the &-prefixed powershell command.
Refs: PR github#3704 Copilot review (findings C1, C2)
Assisted-by: opencode (model: glm-5.2, autonomous)
…xecFileSync argv, forwards input+output Address three Copilot review findings on the opencode TS plugin: - C8: the dispatcher and interpreter are now resolved per-project at plugin load from the `directory` OpenCode passes to the plugin factory, not process.cwd(). OpenCode may be launched from a parent directory or host another workspace, in which case process.cwd() pointed at the wrong project and every event failed. The resolver prefers a project-local venv interpreter, then falls back to python3. - C9: the dispatcher is launched with execFileSync and an argv array [interpreter, dispatcher, command, event] instead of a shell command string built by interpolating the interpreter/command/event into a template literal. Command/event strings are only validated as non-empty, so quotes or backticks could previously break the generated TypeScript and shell metacharacters could execute outside the dispatcher; an interpreter path with spaces also failed. No shell is involved now. - C7: tool callbacks now forward both `input` and `output` to runEvent (combined into one JSON payload), so pre_tool_use can inspect the tool arguments and post_tool_use can inspect the result — the primary payload for those events. Previously only `input` was forwarded. Tests: plugin resolves dispatcher/interpreter from `directory` (no process.cwd() path.join), uses execFileSync (no shell string), and forwards output to runEvent for both pre/post_tool_use. Refs: PR github#3704 Copilot review (findings C7, C8, C9) Assisted-by: opencode (model: glm-5.2, autonomous)
…Stop
Address three Copilot review findings on adapter mappings (verified against
each agent's published hook documentation):
- U1: Qwen Code command hooks measure timeout in milliseconds (default
60000), per the Qwen Code hooks docs. The adapter previously inherited the
seconds default, so every generated handler got timeout: 60 (60 ms) and was
killed before the dispatcher could start. Declare events_timeout_unit="ms".
- U2: Devin's .devin/hooks.v1.json is a root event map ({"PreToolUse": [...]})
with no top-level "hooks" wrapper (the docs state "the hooks object is the
entire file"). The adapter reused json-nested, which writes events under a
"hooks" key Devin never reads. Add a json-root-nested format with a matching
writer (_merge_json_root) and remover (_remove_json_root_entries) that
operate on the root event keys, sharing the matcher-grouping, marker, and
JSONC-abort behavior of the nested variants.
- U3: Copilot CLI supports the canonical per-turn stop lifecycle as native
agentStop; add "stop": "agentStop" to the mapping so an extension's stop
handler fires for Copilot.
Tests: Qwen timeout converts to 60000; Devin events written at the root (no
"hooks" wrapper) and teardown preserves user root entries; Copilot stop maps
to agentStop.
Refs: PR github#3704 Copilot review (findings U1, U2, U3)
Assisted-by: opencode (model: glm-5.2, autonomous)
|
Posted on behalf of @kanfil by opencode (model: glm-5.2, autonomous). Fixed the three Windows CI failures and addressed all 15 findings from the latest Copilot review (5 inline + 2 suppressed high-confidence, plus 8 lower-confidence generated comments). Pushed as six commits on top of Windows CI (3 failures):
Copilot review — 15 findings:
Verification: Note on U1/U2/U3: these were verified against each agent's published hook documentation (Qwen Code command-hook timeout = ms; Devin |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (7)
src/specify_cli/events.py:1243
commandand matcher values come from user/extension YAML but are interpolated into single-quoted TypeScript literals without escaping. A quote or backslash produces invalid generated code (and can inject code into the plugin). Serialize these values as JSON string literals when rendering the TypeScript.
checks = " || ".join(f"input.tool === '{t.lower()}'" for t in tools)
body_lines.append(
f" if ({checks}) {{ runEvent('{command}', '{ev}', input, output); }}"
src/specify_cli/events.py:155
- OpenCode is supported on Windows, but this fallback always invokes
python3, which is commonly unavailable there. Projects without.venvtherefore fail every generated OpenCode event. Selectpythonon Windows (andpython3on POSIX), or implement the same portable interpreter fallback as the other adapters.
INTERPRETER = (
(require('fs').existsSync(venvPy) && venvPy) ||
(require('fs').existsSync(venvWin) && venvWin) ||
'python3'
) as string;
src/specify_cli/events.py:775
- When events resolve to empty, prior native hooks are removed but the previously tracked shared dispatcher is deliberately left behind. During an
--events falseupgrade the new manifest no longer claims it and stale cleanup excludes it, so if this is the last event integration.specify/events.pybecomes permanently orphaned and uninstall cannot remove it. Apply the same ownership/refcount cleanup used by teardown when no other manifest references the dispatcher.
# #3: an empty resolved map (--events false, or override disabling events)
# must still strip prior Specify hooks from this integration's native
# config rather than leaving them active. The shared dispatcher is left
# untouched — another integration may still reference it (#10).
if not filtered:
_remove_native_event_hooks(integration, project_root, manifest)
return []
src/specify_cli/events.py:113
- The native configuration accepts a per-handler timeout, but the generated dispatcher always terminates the CLI after 120 seconds; the inner runner has the same fixed cap. Therefore a valid handler configured with a timeout above 120 seconds can never run for the requested duration. Pass the resolved timeout through to both subprocess layers or remove the conflicting internal cap.
result = subprocess.run(
specify_args,
input=payload,
capture_output=True,
text=True,
timeout=120,
cwd=str(project_root),
src/specify_cli/events.py:332
- For a project configured with the supported
shscript type on Windows,subprocess.run(shell=False)cannot execute a.shfile directly;chmoddoes not change that. Prefix the script with an available Bash/sh launcher on Windows, as the PowerShell branch already does for.ps1files.
# sh: the script is chmod'd executable during install.
return [str(script_abs), *rest_args]
src/specify_cli/integrations/gemini/init.py:29
- Gemini exposes
BeforeAgentfor the lifecycle point represented by canonicaluser_prompt_submit(its own Claude-hook migration mapsUserPromptSubmittoBeforeAgent). Omitting it here causes valid extension handlers to be skipped even though Gemini supports the event.
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
"stop": "AfterAgent",
}
src/specify_cli/integrations/tabnine/init.py:28
- Tabnine's Gemini-compatible hook schema also provides
BeforeAgentandAfterAgent, but these mappings omit canonicaluser_prompt_submitandstop. Those extension events are therefore warned about and skipped for Tabnine despite native support.
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
}
- Files reviewed: 20/20 changed files
- Comments generated: 3
- Review effort level: Medium
|
Please address Copilot feedback |
…ailures Address two Copilot review findings: - R1: collect_extension_events now reads events from a validated ExtensionManifest (whose command refs were canonicalized at install validation, C11) instead of the raw extension.yml YAML. Previously an event command ref like my-ext.boot was normalized to speckit.my-ext.boot during install validation, but the on-disk YAML kept the obsolete name; refresh then emitted it and _find_command_template could not match it, leaving the hook silently inert. Registry-tracked extensions use the validated manifest; on-disk extensions not yet in the registry fall back to the raw YAML (preserving the partial-staged-install scan behavior). - R3: refresh_integration_events now accumulates per-integration failures and raises EventRefreshError at the end (after refreshing the others) so the extension lifecycle commands (add/remove/enable/disable) can't claim an extension was fully deactivated while a stale native hook may still be active. A new _refresh_events_and_warn helper surfaces the aggregated failures as a warning at each call site without aborting the overall command (the extension was already added/removed/enabled/disabled). Tests: event command ref canonicalized via the validated manifest; refresh failure raises EventRefreshError (aggregated) while still preserving the pre-existing config. Refs: PR github#3704 Copilot review (findings R1, R3) Assisted-by: opencode (model: glm-5.2, autonomous)
…n Windows Address two Copilot review findings on interpreter resolution: - R2: the dispatcher's _find_specify and the opencode TS resolver both selected a project-local venv python and ran `-m specify_cli` without checking that specify_cli is importable there. In a typical project where Spec Kit is installed globally (or via uv tool) but the project has its own unrelated virtualenv, every event invoked that interpreter and failed instead of reaching the PATH `specify` fallback. Both now probe the candidate interpreter (subprocess `import specify_cli` / execFileSync probe) before selecting it, falling through to the fallback when the venv lacks Spec Kit. - S2: the opencode TS PATH fallback was always `python3`, which is commonly unavailable on Windows. It is now `python` on Windows (process.platform === 'win32') and `python3` on POSIX. Tests: the generated dispatcher contains the _has_specify_cli probe and the PATH fallback; the opencode TS plugin probes for specify_cli and uses a platform-appropriate PATH interpreter. Refs: PR github#3704 Copilot review (findings R2, S2) Assisted-by: opencode (model: glm-5.2, autonomous)
Address Copilot review finding S1: command and matcher values come from user/extension YAML but were interpolated into single-quoted TypeScript literals without escaping. A quote, backslash, or backtick in a command or matcher produced invalid generated TypeScript and could inject code into the plugin. _build_opencode_plugin now serializes every interpolated value (command, event name, native hook key, matcher tool names) as a JSON string literal via json.dumps, which produces a valid double-quoted, fully-escaped TS/JS string. Tests: a command and matcher containing quotes/backticks render inside JSON double-quoted literals; the dangerous single-quoted form is absent. Updated the forwards-output test for the new double-quoted literals. Refs: PR github#3704 Copilot review (finding S1) Assisted-by: opencode (model: glm-5.2, autonomous)
…cher for sh on Windows Address two Copilot review findings: - S4: the dispatcher and inner runner both hardcoded timeout=120, so a valid handler configured with a timeout above 120 seconds could never run for its full duration. The resolved per-handler timeout now flows through the chain: _dispatcher_command appends it (in the integration's native unit, plus a small buffer) as a 4th argument; the generated dispatcher reads sys.argv[3] and uses it for its inner subprocess and the `event run` invocation; `event run` accepts a timeout argument and passes it to resolve_and_run_event_command, which uses it for the script subprocess. Defaults to 120s when absent (backward compat with already-deployed dispatchers that don't pass the arg). - S5: for a project configured with the sh script type on Windows, subprocess.run(shell=False) cannot execute a .sh file directly (chmod doesn't change that). The sh variant now prefixes a bash/sh launcher (resolved via shutil.which) on Windows, mirroring the ps branch's pwsh -File handling. Tests: dispatcher reads the timeout arg and uses it; the native command appends the resolved timeout; the sh variant uses a launcher on Windows. Refs: PR github#3704 Copilot review (findings S4, S5) Assisted-by: opencode (model: glm-5.2, autonomous)
…ables events Address Copilot review finding S3: the empty-resolved-map install path (--events false upgrade, or override disabling events) stripped prior native hooks but left the shared dispatcher behind. Because the new manifest no longer claims it and stale cleanup excludes it (C3), .specify/events.py became permanently orphaned when this was the last event-capable integration — uninstall could not remove it. Extracted the dispatcher refcount cleanup into _cleanup_shared_dispatcher (shared by remove_integration_events and the empty-map install path) and called it from the empty-map path so the dispatcher is deleted when no other installed event-capable integration's manifest references it, while still being retained when another integration does. Tests: an --events false upgrade of the last event integration deletes the dispatcher; with another integration still referencing it, the dispatcher is retained. Refs: PR github#3704 Copilot review (finding S3) Assisted-by: opencode (model: glm-5.2, autonomous)
Address two Copilot review findings on adapter mappings: - S6: Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle point (verified against Gemini CLI's hooks docs — BeforeAgent fires after the user submits a prompt, before planning). The mapping omitted user_prompt_submit, so valid extension handlers were skipped. Added user_prompt_submit -> BeforeAgent. - S7: Tabnine's Gemini-compatible schema also provides BeforeAgent and AfterAgent, but the mapping omitted user_prompt_submit and stop. Added user_prompt_submit -> BeforeAgent and stop -> AfterAgent so those extension events fire instead of being warned about and skipped. Tests: Gemini and Tabnine mappings include BeforeAgent/AfterAgent. Refs: PR github#3704 Copilot review (findings S6, S7) Assisted-by: opencode (model: glm-5.2, autonomous)
|
Posted on behalf of @kanfil by opencode (model: glm-5.2, autonomous). Addressed all 10 findings from the latest Copilot review (3 inline + 7 suppressed). Pushed as six commits on top of
Verification: Note on S6: Gemini's |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (4)
src/specify_cli/events.py:260
- Disabled extensions are still searched here because
registry.keys()andget_extension()do not filterenabled: false; the raw directory fallback below can also rediscover them. If native cleanup is skipped (for example, an existing JSONC config cannot be parsed), a stale hook will therefore continue executing a disabled extension. Enforce the enabled flag again during dispatch and prevent the disk fallback from re-enabling explicitly disabled IDs.
for ext_id in sorted(manager.registry.keys()):
manifest = manager.get_extension(ext_id)
if manifest is None:
continue
for cmd in manifest.commands:
if not isinstance(cmd, dict):
continue
if cmd.get("name") == command_name and cmd.get("file"):
src/specify_cli/events.py:1193
- During
integration upgrade --integration-options "--events false", the caller supplies a fresh manifest (_migrate_commands.py:575-592), so this condition is false even though the old on-disk manifest owned the dispatcher. Stale cleanup also explicitly excludes this path, leaving.specify/events.pyorphaned after the last integration disables events. Run the no-other-references deletion independently of whether the new manifest currently contains the key.
if dispatcher_rel in manifest.files:
manifest.remove(dispatcher_rel)
if not _other_event_integrations_reference_dispatcher(project_root, integration.key):
dispatcher_path = project_root / dispatcher_rel
if dispatcher_path.exists():
dispatcher_path.unlink(missing_ok=True)
src/specify_cli/events.py:219
- OpenCode ignores each handler's configured timeout: the generated callback always uses 60000 ms and invokes the dispatcher without its timeout argument, so handlers configured above 60 seconds are always terminated early while the inner runner defaults to 120 seconds. Thread
cfg["timeout"]through the generatedrunEventcalls, using milliseconds only forexecFileSyncand seconds for the dispatcher argument.
execFileSync(INTERPRETER, [DISPATCHER, command, event], {{
input: JSON.stringify({{ input, output }}),
stdio: ['pipe', 'inherit', 'inherit'],
timeout: 60000,
src/specify_cli/extensions/_commands.py:63
- This refresh helper is wired to add/remove/enable/disable, but not to
extension_update, which replaces the installedextension.ymlat lines 1296-1300. If an update adds, removes, or changes event declarations, native configs remain stale until a manual integration upgrade. Refresh once after the update command has finalized its successful updates and rollbacks.
def _refresh_events_and_warn(project_root: Path) -> None:
- Files reviewed: 20/20 changed files
- Comments generated: 2
- Review effort level: Medium
| # 3. Fallback to system path specify | ||
| return ["specify"] |
| if timeout_seconds is not None: | ||
| resolved = _native_timeout(integration, timeout_seconds) | ||
| # Add a small buffer so the inner subprocess isn't killed at the same | ||
| # instant the native cap fires (the agent kills the dispatcher process, | ||
| # not the grandchild; the inner cap is a backstop, not the bound). | ||
| inner_cap = resolved + 5 | ||
| base += f" {_shell_quote(str(inner_cap), target_os)}" |
|
Please address Copilot feedback |
🎯 Problem & Motivation
While Spec Kit excels at scaffolding commands and prompt templates during
specify init, there was previously no first-class mechanism for event-driven execution natively tied to AI coding agents in upstream.This PR introduces an agent-native runtime events layer that bridges Spec Kit directly to agent lifecycle events (canonical snake_case names:
session_start,pre_tool_use,post_tool_use,session_end,user_prompt_submit,stop).✨ Key Features Added
The Event Dispatcher (
.specify/events.py):A lightweight, zero-dependency Python script scaffolded in projects during
specify init. It maps registered Spec Kit commands to their underlying scripts and executes them on demand, passing any payload throughstdin.Agent Event Adapters (registry-driven, one per integration):
Target-aware adapters that generate/merge native event configurations seamlessly during
setup()andteardown():.claude/settings.json(nested JSON, singlecommandstring).cursor/hooks.json(flat, camelCase structure).codex/config.toml(TOML format).github/hooks/speckit.json(dedicated file, bash/powershell variants).opencode/plugin/speckit-events.ts+opencode.json(TypeScript plugin)Layered Event Resolution:
Events are resolved cleanly using a 4-tier priority stack:
--events falseduring init/upgrade disables events entirely (and strips prior hooks)..specify/integration-events.ymlto override event behaviors.events:in theirextension.yml(completely decoupling core code from extension details). Multiple extensions declaring the same event all run.Adapters translate canonical snake_case event names to each agent's native casing (Claude
SessionStart, Copilot/CursorsessionStart, GeminiBeforeTool, …), so extension authors never learn agent-specific names.🛠️ Example Usage
An extension can declare events in
extension.yml:When the user runs
specify init --integration claude, the Claude adapter automatically detects this, generates the Event Dispatcher, and injects the hook into.claude/settings.json. When Claude Code launches, it natively triggers the dispatcher, booting the team context seamlessly. Installing/enabling/disabling an extension refreshes each installed integration's native event config automatically.🧪 Verification & Dependencies
pyyaml(already a spec-kit dependency) to parse extension-declared events. No new external dependencies.specify integration uninstallto cleanly strip all native event config entries/plugins, leaving the user's files exactly as they were. The shared.specify/events.pydispatcher is reference-counted so it is only deleted when no installed event-capable integration still references it.enabledhonoring), multi-handler-per-event accumulation, adapter configurations, idempotent merge/teardown, JSONC preservation, and safe-write containment.