From 5618505f9a51256e095850534a41d483f20bbc34 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:34:32 -0400 Subject: [PATCH] feat(#788): expand Qwen Code hook-event coverage (#807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#788): expand Qwen Code hook-event coverage to 4 new events Register SubagentStop, Stop, PreCompact (gsd-context-monitor.js) and UserPromptSubmit (gsd-prompt-guard.js) in the Qwen Code installer. Guard is isQwen-only — Claude Code and all other runtimes are unchanged. Uninstall loop extended to include the 4 new event names. Co-Authored-By: Claude Sonnet 4.6 * fix(#788): reconcile to 3 Qwen-only events — defer UserPromptSubmit gsd-prompt-guard exits unless tool_name is Write|Edit (PreToolUse payload shape); UserPromptSubmit carries raw user-prompt text with no tool_name field, so wiring it would be a silent no-op. Deferred to a follow-on issue. Artifacts made consistent: - bin/install.js: drop UserPromptSubmit registration block; uninstall loop drops UPS from event list - .changeset/788-qwen-hook-events.md: corrected to 3 events + rationale - docs/how-to/install-on-your-runtime.md: remove UPS row from hook table - tests/enh-788-qwen-hook-events.test.cjs: assert UPS NOT registered; fix idempotency suite to persist settings between installs; drop UPS-specific assertions Co-Authored-By: Claude Sonnet 4.6 * chore(#788): update changeset PR number to #807 Co-Authored-By: Claude Sonnet 4.6 * test(#788): prune stale install-bucket allowlist entry for enh-788 test --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/788-qwen-hook-events.md | 5 + bin/install.js | 53 ++++- docs/how-to/install-on-your-runtime.md | 13 ++ tests/enh-788-qwen-hook-events.test.cjs | 268 ++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 .changeset/788-qwen-hook-events.md create mode 100644 tests/enh-788-qwen-hook-events.test.cjs diff --git a/.changeset/788-qwen-hook-events.md b/.changeset/788-qwen-hook-events.md new file mode 100644 index 000000000..c04b19a68 --- /dev/null +++ b/.changeset/788-qwen-hook-events.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 807 +--- +Qwen Code installs now register three additional hook events that Qwen Code supports beyond Claude Code: `SubagentStop`, `Stop`, and `PreCompact` — all wired to `gsd-context-monitor.js` for context headroom tracking at subagent completion, model stop, and pre-compaction. These events are Qwen-only; Claude Code installs are unchanged. `UserPromptSubmit` is deferred: `gsd-prompt-guard` exits unless `tool_name` is `Write|Edit`, making it a no-op for that payload shape. (#788) diff --git a/bin/install.js b/bin/install.js index a6d3fb26c..4a76f7b5d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -7494,8 +7494,11 @@ function uninstall(isGlobal, runtime = 'claude') { } // Remove GSD hooks from settings — per-hook granularity to preserve - // user hooks that share an entry with a GSD hook (#1755 followup) - for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) { + // user hooks that share an entry with a GSD hook (#1755 followup). + // Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop, + // PreCompact) — safe to iterate for all runtimes; non-Qwen installs + // simply find no entries and skip. + for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact']) { if (settings.hooks && settings.hooks[eventName]) { const before = JSON.stringify(settings.hooks[eventName]); settings.hooks[eventName] = settings.hooks[eventName] @@ -10303,6 +10306,52 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) { console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`); } + + // ── Qwen-only extended hook events (#788) ──────────────────────────────── + // Qwen Code exposes 15 hook events — a superset of Claude Code. Three + // additional events are registered for Qwen installs: + // SubagentStop — subagent lifecycle completion (context headroom tracking) + // Stop — model stop / final-response moment (context headroom) + // PreCompact — fires before conversation compaction (most critical + // moment to surface context headroom warnings) + // + // Wire gsd-context-monitor to all three — the same hook already used for + // PostToolUse — so no new hook files are needed. + // + // Note: UserPromptSubmit is NOT wired here. That event carries the raw + // user prompt text, not a tool invocation, so gsd-prompt-guard (which + // exits unless tool_name is Write/Edit) would be a silent no-op. A + // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. + // + // Guard: isQwen is defined at the top of install() (line ~8254). + if (isQwen) { + // SubagentStop, Stop, PreCompact — route through the context monitor so + // agents get context-headroom warnings at subagent completion, model stop, + // and pre-compaction (the most critical moment to surface headroom info). + for (const qwenEvent of ['SubagentStop', 'Stop', 'PreCompact']) { + if (!settings.hooks[qwenEvent]) { + settings.hooks[qwenEvent] = []; + } + const alreadyHasContextMonitor = settings.hooks[qwenEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor')) + ); + if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { + settings.hooks[qwenEvent].push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand, + timeout: 10 + } + ] + }); + console.log(` ${green}✓${reset} Configured ${qwenEvent} context monitor hook (Qwen Code)`); + } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { + console.warn(` ${yellow}⚠${reset} Skipped ${qwenEvent} hook — gsd-context-monitor.js not found at target`); + } + } + } + // ── end Qwen-only extended hook events ──────────────────────────────────── } // Compute the update-banner hook command alongside the others so diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 3bae71c01..3b636f236 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -262,6 +262,19 @@ Skills land in `~/.qwen/skills/gsd-*/SKILL.md`. QWEN_CONFIG_DIR=~/.qwen-alt npx @opengsd/gsd-core@latest --qwen --global ``` +**Hook coverage** + +Qwen Code supports 15 hook events. GSD registers the following events automatically on install: + +| Event | Hook | Purpose | +|---|---|---| +| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | +| `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | +| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | +| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | +| `PreCompact` | `gsd-context-monitor.js` | Context awareness before conversation compaction | + --- ### Augment Code diff --git a/tests/enh-788-qwen-hook-events.test.cjs b/tests/enh-788-qwen-hook-events.test.cjs new file mode 100644 index 000000000..39d2e0d05 --- /dev/null +++ b/tests/enh-788-qwen-hook-events.test.cjs @@ -0,0 +1,268 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #788: Expand Qwen Code hook-event coverage. + * + * Qwen Code supports 15 hook events; gsd previously registered only + * SessionStart and PostToolUse. This suite asserts that a Qwen install + * registers the 3 new high-value events: + * - SubagentStop — subagent lifecycle finalisation (context tracking) + * - Stop — model stop / final-response hook (context tracking) + * - PreCompact — pre-compaction awareness (context tracking) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * PostToolUse — so context headroom warnings surface at these moments too. + * + * Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless + * tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for + * the UserPromptSubmit payload. Deferred to a follow-on issue. + * + * Also asserts the inverse: Claude Code installs do NOT gain these events + * (strict isQwen scope guard). + * + * Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/ + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', +]; + +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Qwen — new events are registered ─────────────────────────────── + +describe('enh-788: Qwen install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-qwen-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + // Pre-populate hook files so installer registration guards (fs.existsSync) + // pass and hooks are actually registered in settings.json. + stubHooksIntoTarget(targetDir); + + const result = install(false, 'qwen'); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Qwen install must return a non-null settings object'); + }); + + test('SubagentStop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('Stop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('PreCompact event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => { + // gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op + // for UserPromptSubmit payloads. Registration is deferred until a + // dedicated hook can process the user-prompt payload shape. + const cmds = hooksForEvent(settings, 'UserPromptSubmit'); + assert.strictEqual(cmds.length, 0, + `UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`); + }); + + test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); +}); + +// ─── Suite 2: Claude install does NOT get the new events ───────────────────── + +describe('enh-788: Claude install does NOT register Qwen-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Claude install does not register SubagentStop', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register Stop', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register PreCompact', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // First install: get settings and persist to disk (simulating finishInstall) + const result1 = install(false, 'qwen'); + persistSettings(result1.settingsPath, result1.settings); + + // Second install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result2 = install(false, 'qwen'); + const s2 = result2.settings; + + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-788: Qwen uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + + // Install and persist to disk so uninstall has a settings.json to clean + const result = install(false, 'qwen'); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'qwen'); + const settingsPath = path.join(tmpDir, '.qwen', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +});