* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore(#788): update changeset PR number to #807 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#788): prune stale install-bucket allowlist entry for enh-788 test --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/788-qwen-hook-events.md
Normal file
5
.changeset/788-qwen-hook-events.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
268
tests/enh-788-qwen-hook-events.test.cjs
Normal file
268
tests/enh-788-qwen-hook-events.test.cjs
Normal file
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user