Skip to content

feat: first-class agent-native runtime hooks for integrations - #3704

Open
kanfil wants to merge 26 commits into
github:mainfrom
tikalk:feat/agent-runtime-hooks
Open

feat: first-class agent-native runtime hooks for integrations#3704
kanfil wants to merge 26 commits into
github:mainfrom
tikalk:feat/agent-runtime-hooks

Conversation

@kanfil

@kanfil kanfil commented Jul 23, 2026

Copy link
Copy Markdown

🎯 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

  1. 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 through stdin.

  2. Agent Event Adapters (registry-driven, one per integration):
    Target-aware adapters that generate/merge native event configurations seamlessly during setup() and teardown():

    • Claude Code: .claude/settings.json (nested JSON, single command string)
    • Cursor: .cursor/hooks.json (flat, camelCase structure)
    • Codex: .codex/config.toml (TOML format)
    • Copilot CLI: .github/hooks/speckit.json (dedicated file, bash/powershell variants)
    • opencode: .opencode/plugin/speckit-events.ts + opencode.json (TypeScript plugin)
    • Gemini, Qwen, Devin, Tabnine: Native settings merge
  3. Layered Event Resolution:
    Events are resolved cleanly using a 4-tier priority stack:

    • Layer 1 (CLI): Passing --events false during init/upgrade disables events entirely (and strips prior hooks).
    • Layer 2 (User Override): Users can create .specify/integration-events.yml to override event behaviors.
    • Layer 3 (Extension-Declared): Extensions declare events: in their extension.yml (completely decoupling core code from extension details). Multiple extensions declaring the same event all run.
    • Layer 4 (Built-in Defaults): Native integration class defaults.

    Adapters translate canonical snake_case event names to each agent's native casing (Claude SessionStart, Copilot/Cursor sessionStart, Gemini BeforeTool, …), so extension authors never learn agent-specific names.


🛠️ Example Usage

An extension can declare events in extension.yml:

extension:
  id: "team-ai-directives"
  name: "Team AI Directives"
  version: "1.0.0"

events:
  session_start:
    command: "speckit.team-directives.boot"
    description: "Load team rules and constitution on session startup"

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

  • Dependencies: Uses pyyaml (already a spec-kit dependency) to parse extension-declared events. No new external dependencies.
  • Teardown: Run specify integration uninstall to cleanly strip all native event config entries/plugins, leaving the user's files exactly as they were. The shared .specify/events.py dispatcher is reference-counted so it is only deleted when no installed event-capable integration still references it.
  • Unit Tests: Includes a suite of unit tests verifying layered event resolution, override parsing/validation, extension collection (with registry enabled honoring), multi-handler-per-event accumulation, adapter configurations, idempotent merge/teardown, JSONC preservation, and safe-write containment.

@kanfil
kanfil requested a review from mnriem as a code owner July 23, 2026 22:15
@mnriem

mnriem commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

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. hooks is already load-bearing in Spec Kit: extensions declare hooks: in extension.yml, there's a documented Hook System + HookExecutor, and those fire on SDD-workflow points (before_specify, after_tasks) as soft/in-prompt prompts. This PR fires on agent-runtime points (SessionStart, PreToolUse) wired into native agent config — genuinely different, and reusing "hooks" will confuse users and docs. Suggest:

  • extension field events: (not runtime_hooks),
  • CLI flag --events,
  • canonical event names in the same xx_yyy style as the existing workflow events: session_start, pre_tool_use, post_tool_use, session_end, user_prompt_submit, stop,
  • dispatcher events.py, marker __speckit_event__.

Adapters translate those canonical snake_case names to each agent's native casing (Claude SessionStart, Copilot/Cursor sessionStart, Gemini BeforeTool, …), so extension authors never learn agent-specific names.

2. Drive adapters from the integration registry, not a parallel _ADAPTERS dict. AGENTS.md makes the integration registry the single source of truth; the hardcoded {"claude": ..., "cursor-agent": ...} map means new agents must be registered twice and keys can drift. Fold each adapter into its integration class as CANONICAL_TO_NATIVE + events_config_file + emit_events()/remove_events() the registry drives — the same pattern Copilot already uses to own .vscode/settings.json. This also collapses the 4 duplicated insertions in base.py into one path.

3. Split sourcing from writing so integrations/ never imports extensions/. Right now _hooks.py both writes native config and sources declarations by scanning .specify/extensions/*/extension.yml (from ..extensions import ValidationError). Those are two responsibilities:

  • Writing native config is legitimately integration-domain (like Copilot's settings.json) → keep it in the integration class.
  • Sourcing the event→command map (built-in defaults + extension events: + user override) should move out: have the init orchestration resolve it and inject it via setup(..., events=map). The integration owns the write but doesn't reach up into the extension system.

4. The dispatcher is agent-agnostic → make it core. One bridge.py serves every agent and re-implements command→script resolution that already lives in _commands.py/the registrar. Lift it to a core .specify/events.py that reuses the existing resolver instead of duplicating it.

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 parsed_options.get("hooks"), but no integration declares that option via options() and there's no top-level flag, so it can't be set the way the description implies; and validate_runtime_hooks() documents itself as "called from ExtensionManifest._validate()", but the manifest isn't touched in this PR (only 3 files changed), so malformed declarations are never validated at load. After the rework that means: --events should be a real, declared gate, and event validation should actually be invoked from the manifest validator — both with end-to-end tests.

6. Missing Copilot CLI adapter. Copilot CLI has first-class hooks (docs): native events (sessionStart, preToolUse, postToolUse, sessionEnd, userPromptSubmitted), per-entry bash/powershell variants, and a dedicated .github/hooks/*.json location. Writing our own .github/hooks/speckit.json (rather than merging a shared settings file) makes teardown a plain file-delete with no marker scanning, and the bash/powershell split maps cleanly onto Spec Kit's existing scripts: {sh, ps} frontmatter. Note Copilot also reads cross-tool .claude/settings.json, so a project with both integrations needs a de-dupe guard in the resolver.

Smaller correctness notes: the Codex adapter embeds $(git rev-parse --show-toplevel) inside a TOML command string (relies on the agent shell-expanding it); several native event names (Cursor beforeSubmitPrompt, Gemini BeforeTool/AfterAgent, Tabnine) read as best-guesses worth verifying per-agent or marking experimental; and silent writes into .claude/settings.json etc. during init should be surfaced in output.

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 mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 run command 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_exclusions is 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

  • IntegrationBase and CodexIntegration are 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

Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/commands/event.py Outdated
Comment thread tests/integrations/test_events.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py Outdated
@mnriem

mnriem commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and fix test & lint errors

kanfil added 4 commits July 27, 2026 16:15
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)
@kanfil

kanfil commented Jul 27, 2026

Copy link
Copy Markdown
Author

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 da6c20d9 (head now 2623ddbd):

Tests: tests/integrations/test_events.py grew from 23 to 46 tests (added regressions for each finding). 189 adapter tests and 14 extension tests still pass. Version bumped 0.14.2.dev00.14.2.dev2 with CHANGELOG entries for the __init__.py touches (AGENTS.md convention).

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.

@mnriem

mnriem commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Please do not update CHANGELOG.md nor pyproject.toml as those are done by release process

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (7)

src/specify_cli/events.py:853

  • The reference-count branch still leaves events.py in this manifest. IntegrationBase.teardown() immediately calls manifest.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 calls remove_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 py launches a nonexistent project-root scripts/... 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/python into the PowerShell hook, while setup on Windows can write .venv/Scripts/python.exe into 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 calls manifest.record_existing() for that untouched config. On uninstall, cleanup also aborts parsing, after which manifest.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 ps variant, the later return uses the .ps1 path as the executable. Windows subprocess.run(..., shell=False) cannot execute a PowerShell script directly, so event dispatch fails on the default Windows script type. Prefix the resolved script with pwsh -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_options by passing None. 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 stored parsed_options from state and 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

Comment thread src/specify_cli/integrations/cursor_agent/__init__.py
Comment thread src/specify_cli/integrations/devin/__init__.py
Comment thread src/specify_cli/integrations/copilot/__init__.py
Comment thread src/specify_cli/events.py Outdated
@mnriem

mnriem commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

kanfil added 5 commits July 27, 2026 22:13
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)
@kanfil

kanfil commented Jul 27, 2026

Copy link
Copy Markdown
Author

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 2623ddbd (head now be43bc4c):

  • c5b9a93f — per maintainer request, reverted the CHANGELOG.md/pyproject.toml version bumps from the prior events commits. Both files now match their pre-PR (da6c20d9) state, so the PR makes no net change to them. (Bonus: this also clears the pending merge conflicts with upstream/main's 0.14.2 release — our side no longer diverges on those two files.)
  • aaab4d23chore: Fix typo #8/Support Cursor CLI #9: DevinIntegration.options() and CopilotIntegration.options() now compose with super().options() so --events is declared alongside --skills; previously --integration-options "--events false" was rejected as unknown for both adapters.
  • 524c2107adding executable permission to the scripts so they execute when the … #7: Cursor's .cursor/hooks.json now gets top-level version: 1 (added a version kwarg to _merge_json_fragment, preserving a user's value). S3: json-nested groups handlers by distinct matcher (one matcher-group per matcher) instead of placing all under the first handler's matcher. S4: Copilot's bash/powershell fields get independent OS-targeted interpreters (python3 / python) so a config generated on one OS works on the other.
  • ceee5071 — S2: the py variant now anchors scripts/... under .specify/ (shared with sh/ps) before prepending the interpreter. S6: the ps variant prefixes argv with pwsh -File (fallback powershell -File) so subprocess.run(shell=False) can execute .ps1 on Windows.
  • be43bc4c — S5: merge helpers return bool; install branches skip manifest.record_existing() when a merge is skipped on parse failure, so an untouched JSONC/malformed config isn't tracked and uninstall() can't later delete it. S1: remove_integration_events drops this integration's manifest claim on the shared dispatcher even when the file is retained, so the subsequent manifest.uninstall() in teardown() doesn't delete a file another integration references (test now exercises full teardown(), not just remove_integration_events). S7: refresh_integration_events passes each integration's stored parsed_options to resolve_events, so a persisted --events false survives extension add/enable/disable. Include instructions for uvx dependency #10: an invalid override entry abandons the whole override and keeps accumulated built-in+extension layers (instead of resetting to {} and disabling all hooks on a typo); only a fully-valid override, including explicit events: {}, replaces.

Tests: tests/integrations/test_events.py grew 56→61 tests; added regressions for each finding (options composition for all event-capable adapters, Cursor version + preservation, matcher grouping per-distinct/shared, Copilot bash≠powershell, py anchoring, ps launcher prefix, override preserve-layers, skipped-merge not tracked, full-teardown dispatcher retention, refresh honors --events false). 312 adapter tests still pass. uvx ruff@0.15.0 check src tests clean.

Note on stale inline comments: GitHub re-anchored the six comments from the original review (original_commit_id: da6c20d9) onto the new HEAD — they describe pre-fix code and correspond to findings already addressed in the prior round (#3 empty-map removal, #7 Gemini ms, #9 marker recursion, #12 safe-write, #13 opencode throw, #15 PR contract), so they're not actionable here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: .registry stores entries under extensions, and each registry entry stores registered_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.extension maps to commands/selftest.md). Such event commands always resolve as missing; load the installed extension manifest and use its provides.commands[].file mapping.
        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 calls manifest.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

Comment thread src/specify_cli/events.py
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/integrations/tabnine/__init__.py
@mnriem

mnriem commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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)
kanfil added 2 commits July 28, 2026 00:05
…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)
@kanfil

kanfil commented Jul 27, 2026

Copy link
Copy Markdown
Author

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 be43bc4c (head now 6761c948); CHANGELOG.md/pyproject.toml remain untouched per the earlier instruction.

  • c74e5a44 — CI: tests/test_extensions.py::test_no_commands_no_hooks matched the old validation string; updated the regex to the current "must provide at least one command, hook, or event" message (this was the sole failing test).
  • 123e10a3 — data-loss & resolution:
    • S9: _remove_native_event_hooks now unconditionally drops this integration's manifest claim on the native config (not only when the file is 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. 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 (the merge path already did), so a symlink swap of .codex/config.toml after install can't make teardown overwrite a file outside the project.
  • 6761c948 — native-config hardening:
    • R1: the generated dispatcher and resolve_and_run_event_command now run their subprocesses with cwd = the project root. Previously specify event run (and the resolved script) inherited the agent's CWD while event_run resolves the project via Path.cwd(), so a hook fired from a subdirectory targeted the wrong project.
    • R2: _dispatcher_command shell-quotes each component for the target shell (POSIX shlex.quote; PowerShell single-quoted literals), so an interpreter path with spaces or an override command with shell metacharacters is passed as one argument. Claude's ${CLAUDE_PROJECT_DIR} prefix is left unquoted so the shell still expands it.
    • R4: the Codex TOML matcher is now rendered through the shared TOML escaper like command, so a matcher with a quote/backslash/newline/control char 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).

Tests: tests/integrations/test_events.py grew 64→70 tests; added regressions for forced-teardown user-config preservation, manifest-driven command resolution (stem ≠ name), TOML teardown symlink rejection, cwd-forced execution from a subdirectory, POSIX/PowerShell quoting of space/metacharacter components, TOML matcher escaping, and Tabnine ms conversion. Full events suite + 235 adapter tests pass; uvx ruff@0.15.0 check src tests clean.

Note on R5: Tabnine's ms timeout unit is inferred from its Gemini-parity native mapping (BeforeTool/AfterTool) since the repo doesn't pin Tabnine's hook schema; happy to revert if Tabnine actually uses seconds.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 omits events.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 to remove_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: [] or stop: bad-value normalizes to no handlers, but this branch merely skips it and still adopts resolved_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 retaining events: {} 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

Comment thread src/specify_cli/integrations/qwen/__init__.py
Comment thread src/specify_cli/integrations/devin/__init__.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/integrations/copilot/__init__.py
Comment thread src/specify_cli/events.py
Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/events.py Outdated
kanfil added 6 commits July 28, 2026 09:40
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)
@kanfil

kanfil commented Jul 28, 2026

Copy link
Copy Markdown
Author

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 6761c948 (head now 9b3f2496); CHANGELOG.md/pyproject.toml remain untouched per the standing instruction.

Windows CI (3 failures):

  • ea333696EVENTS_DISPATCHER_REL was str(Path(".specify")/"events.py"), which yields .specify\events.py on Windows; manifest keys are POSIX-form, so the shared-dispatcher manifest-claim drop was skipped and uninstall(force=True) deleted the dispatcher another integration depended on. Now a POSIX constant (.as_posix()). Also made the py/ps argv assertions platform-agnostic (POSIX-form comparison, case-insensitive launcher stem) so they hold on Windows.

Copilot review — 15 findings:

  • f7715a5c — C4: a malformed override handler (stop: []) now abandons the whole override (keeps prior layers) instead of disabling all hooks. C6: a non-mapping integration entry (claude: bad) is ignored as malformed, not treated as a valid disable. C10: matcher validated as a string in both manifest and override paths (a matcher: [] previously crashed by_matcher.setdefault). C11: event command refs are now canonicalized via rename_map in ExtensionManifest._validate, mirroring hook refs.
  • a51ff920 — C3: shared dispatcher added to events_stale_exclusions so an --events false upgrade can't delete it via the generic stale pass and break other integrations. C5: a Spec-Kit-created Cursor file retaining only {"version": 1} is now deleted on teardown (not left as a stub). C12: refresh_integration_events resolves first and installs once; a mid-refresh failure no longer destroys the working config (the destructive pre-strip is gone — install_integration_events already handles cleanup).
  • cb66271e — C1: target_os="host" always uses POSIX shlex.quote (safe tokens bare on every platform); the previous Windows-PS 'python' form wasn't invocable without &. PowerShell quoting is now only for the explicit windows target (Copilot's powershell field), prefixed with & . C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher is double-quoted so the variable still expands but a spaced project path doesn't word-split.
  • e113956a — opencode TS plugin rewrite: C8 dispatcher+interpreter resolved from the directory OpenCode passes (not process.cwd()); C9 launched via execFileSync with an argv array (no shell, no injection); C7 both input and output forwarded to runEvent so pre/post_tool_use can inspect args/results.
  • 9b3f2496 — U1: Qwen events_timeout_unit="ms" (Qwen Code command hooks use ms per its docs). U2: Devin json-root-nested format — its hooks.v1.json is a root event map with no hooks wrapper (per Devin docs); added _merge_json_root/_remove_json_root_entries. U3: Copilot "stop": "agentStop".

Verification: tests/integrations/test_events.py 70→85 tests (all pass); the previously-failing test_extensions.py::test_no_commands_no_hooks passes; 312 adapter tests pass; uvx ruff@0.15.0 check src tests clean. CHANGELOG.md/pyproject.toml net-diff vs the pre-PR base is empty.

Note on U1/U2/U3: these were verified against each agent's published hook documentation (Qwen Code command-hook timeout = ms; Devin hooks.v1.json = root event map, "the hooks object is the entire file"; Copilot agentStop native name). Happy to revert any if a maintainer confirms otherwise.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (7)

src/specify_cli/events.py:1243

  • command and 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 .venv therefore fail every generated OpenCode event. Select python on Windows (and python3 on 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 false upgrade the new manifest no longer claims it and stale cleanup excludes it, so if this is the last event integration .specify/events.py becomes 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 sh script type on Windows, subprocess.run(shell=False) cannot execute a .sh file directly; chmod does not change that. Prefix the script with an available Bash/sh launcher on Windows, as the PowerShell branch already does for .ps1 files.
    # 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 BeforeAgent for the lifecycle point represented by canonical user_prompt_submit (its own Claude-hook migration maps UserPromptSubmit to BeforeAgent). 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 BeforeAgent and AfterAgent, but these mappings omit canonical user_prompt_submit and stop. 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

Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py Outdated
Comment thread src/specify_cli/events.py
@mnriem

mnriem commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

kanfil added 6 commits July 28, 2026 19:03
…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)
@kanfil

kanfil commented Jul 28, 2026

Copy link
Copy Markdown
Author

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 9b3f2496 (head now dbf1fcb5); CHANGELOG.md/pyproject.toml remain untouched per the standing instruction.

  • 7b12ebef — 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; an obsolete ref like my-ext.boot is now resolved to speckit.my-ext.boot so refresh doesn't emit a hook that silently no-ops. R3: refresh_integration_events accumulates per-integration failures and raises EventRefreshError so the extension lifecycle commands (add/remove/enable/disable) can't claim full deactivation while a stale native hook may still be active; a _refresh_events_and_warn helper surfaces the aggregated failures at each call site.
  • 78ffdf94 — R2: the dispatcher's _find_specify and the opencode TS resolver now probe a project venv python for specify_cli importability before selecting it, so an unrelated project venv no longer shadows the PATH specify fallback and fails every event. S2: the opencode TS PATH fallback is now python on Windows (where python3 is commonly absent), python3 on POSIX.
  • 44bebf88 — S1: _build_opencode_plugin serializes every interpolated value (command, event, native hook key, matcher tool names) as a JSON string literal, so a quote/backslash/backtick in a command or matcher can't break the generated TypeScript or inject code.
  • ba3f1aed — S4: the per-handler timeout now flows through the chain (native command → dispatcher 4th arg → event run → inner runner) instead of a fixed 120s cap that killed handlers configured for longer. S5: the sh script variant prefixes a bash/sh launcher on Windows so subprocess.run(shell=False) can execute .sh files (mirroring the ps branch's pwsh -File).
  • 9f721ed6 — S3: the empty-resolved-map install path (--events false upgrade) now runs the shared-dispatcher refcount cleanup, so the last event integration disabling events deletes .specify/events.py instead of orphaning it permanently (the new manifest wouldn't claim it and stale cleanup excludes it).
  • dbf1fcb5 — S6: Gemini user_prompt_submitBeforeAgent (verified against Gemini CLI's hooks docs). S7: Tabnine user_prompt_submitBeforeAgent, stopAfterAgent (Tabnine's Gemini-compatible schema).

Verification: tests/integrations/test_events.py 86→94 tests (all pass); 312 adapter tests pass; uvx ruff@0.15.0 check src tests clean. CHANGELOG.md/pyproject.toml net-diff vs the pre-PR base is empty.

Note on S6: Gemini's BeforeAgent mapping was verified against the Gemini CLI hooks documentation (BeforeAgent fires after the user submits a prompt, before planning). S7 (Tabnine) is inferred from Tabnine's deliberate Gemini-schema parity; happy to revert if Tabnine's docs say otherwise.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Comments suppressed due to low confidence (4)

src/specify_cli/events.py:260

  • Disabled extensions are still searched here because registry.keys() and get_extension() do not filter enabled: 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.py orphaned 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 generated runEvent calls, using milliseconds only for execFileSync and 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 installed extension.yml at 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

Comment thread src/specify_cli/events.py
Comment on lines +115 to +116
# 3. Fallback to system path specify
return ["specify"]
Comment thread src/specify_cli/events.py
Comment on lines +847 to +853
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)}"
@mnriem

mnriem commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants