feat: add Qoder hooks support (UserPromptSubmit + PreToolUse) (#529)

* feat: add Qoder hooks support (UserPromptSubmit + PreToolUse)

- Add isQoder detection via QODER_SESSION_ID in ponytail-runtime.js
- Add UserPromptSubmit double-duty: auto-activate + ruleset injection
- Add PreToolUse hook with task|Task matcher for subagent injection
- Create hooks/qoder-hooks.json reference template
- Update .qoder-plugin/plugin.json with hooks field
- Add .qoder/rules/ponytail.md for per-project rules
- Add comprehensive tests for Qoder runtime behavior
- Update README.md and docs/agent-portability.md

* check-versions: enforce .qoder-plugin/plugin.json version too

---------

Co-authored-by: shiding <shiding@ruigushop.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
This commit is contained in:
shixiaoding 2026-07-09 23:13:59 +08:00 committed by GitHub
parent 8f32ae0f6e
commit 83493b97bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 302 additions and 13 deletions

16
.qoder-plugin/plugin.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "ponytail",
"version": "4.8.4",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"homepage": "https://github.com/DietrichGebert/ponytail",
"repository": "https://github.com/DietrichGebert/ponytail",
"license": "MIT",
"keywords": ["yagni", "minimalism", "code-review", "productivity"],
"skills": "./skills/",
"rules": "./.qoder/rules/",
"hooks": "./hooks/qoder-hooks.json"
}

30
.qoder/rules/ponytail.md Normal file
View File

@ -0,0 +1,30 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

View File

@ -15,7 +15,7 @@
<img src="https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat-square&color=111111&label=stars" alt="Stars">
<img src="https://img.shields.io/github/v/release/DietrichGebert/ponytail?style=flat-square&color=111111&label=release" alt="Release">
<img src="https://img.shields.io/npm/v/@dietrichgebert/ponytail?style=flat-square&color=111111&label=npm" alt="npm">
<img src="https://img.shields.io/badge/works%20with-19%20agents-111111?style=flat-square" alt="Works with 19 agents">
<img src="https://img.shields.io/badge/works%20with-20%20agents-111111?style=flat-square" alt="Works with 20 agents">
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
</p>
@ -189,6 +189,12 @@ gemini extensions install https://github.com/DietrichGebert/ponytail
Loads the ruleset as always-on context every session and registers the `/ponytail` commands; the `skills/` ship too, activated when a task needs them.
The Gemini adapter intentionally does not ship a root `hooks/hooks.json`: Gemini auto-loads that path, while Ponytail's lifecycle hooks use Claude/Codex event names.
### Qoder
Qoder auto-loads `AGENTS.md` from the repo root as always-on context, so running ponytail from a checkout works with zero setup. For per-project rules, copy [`.qoder/rules/ponytail.md`](.qoder/rules/ponytail.md) into your project's `.qoder/rules/`. The six ponytail skills (`/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, `/ponytail-help`) are available via Qoder's Skill system; the plugin manifest at [`.qoder-plugin/plugin.json`](.qoder-plugin/plugin.json) points at the `skills/` directory.
For full plugin-tier support (automatic mode activation + ruleset injection on every prompt), add the hooks from [`hooks/qoder-hooks.json`](hooks/qoder-hooks.json) to your `.qoder/settings.json`. Replace `PONYTAIL_DIR` with the path to your ponytail checkout. Qoder's `UserPromptSubmit` hook activates the default mode on first prompt and injects the ruleset every turn; `PreToolUse` with `task|Task` matcher injects the ruleset into subagents. Level switches (`/ponytail lite|full|ultra|off`) work automatically.
### Antigravity CLI
Google is renaming Gemini CLI to Antigravity CLI (the `agy` binary); the same extension installs there:
@ -247,7 +253,7 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/
Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`.
Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/)).
Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/), [`.qoder/rules/`](.qoder/rules/)).
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
@ -271,7 +277,7 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md).
| Codex | `codex plugin remove ponytail` |
| Devin CLI | `devin plugins remove ponytail` |
| Pi agent | `pi uninstall ponytail` |
| Cursor / Windsurf / Cline / etc. | Delete the copied rule file |
| Cursor / Windsurf / Cline / Qoder / etc. | Delete the copied rule file |
These remove the plugin's own files. They leave behind a small amount of state ponytail writes outside the plugin folder: the mode flag, `~/.config/ponytail/config.json`, and (if you accepted the setup nudge) a `statusLine` entry in `~/.claude/settings.json`. Run `node scripts/uninstall.js` to clean those up too. **Run it before the host remove command above** — the script is itself a plugin file, so removing the plugin first deletes it (or run it from a separate clone of this repo). It only removes the statusLine entry if it points at ponytail's own script, so a statusline you set up yourself is left untouched.
@ -286,7 +292,7 @@ These remove the plugin's own files. They leave behind a small amount of state p
| `/ponytail-gain` | Show the measured impact scoreboard (less code, less cost, more speed) from the benchmark. |
| `/ponytail-help` | Quick reference for the commands above. |
Commands need a skill-capable host (Claude Code, Codex, Devin CLI, OpenCode, Gemini, pi, Swival, Hermes Agent). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
Commands need a skill-capable host (Claude Code, Codex, Devin CLI, OpenCode, Gemini, pi, Swival, Hermes Agent, Qoder). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
## Development

View File

@ -27,6 +27,7 @@ to load in a given agent.
| Amp (Sourcegraph) | `AGENTS.md` | Amp reads `AGENTS.md` from the working directory and parent directories up to `$HOME` (plus global config like `~/.config/amp/AGENTS.md`); falls back to `AGENT.md`/`CLAUDE.md`. Instruction-tier. |
| Jules (Google) | `AGENTS.md` | Jules automatically reads `AGENTS.md` from the repository root. Instruction-tier. |
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
| Qoder | `.qoder/rules/ponytail.md`, `.qoder-plugin/plugin.json`, `hooks/qoder-hooks.json`, `skills/`, `AGENTS.md` | Qoder auto-loads `AGENTS.md` as always-on context; `.qoder/rules/ponytail.md` provides per-project rules; the plugin manifest points at `skills/` for the six ponytail skills (invoked as `/ponytail`, `/ponytail-review`, etc. via the Skill system). Full plugin-tier: `hooks/qoder-hooks.json` template registers `UserPromptSubmit` (mode activation + ruleset injection) and `PreToolUse` with `task|Task` matcher (subagent injection). Instruction-tier works from repo root with zero setup via `AGENTS.md`. |
| Zed | `AGENTS.md` | Auto-includes `AGENTS.md` from the worktree root as one of its default rule files for the Agent Panel. Instruction-tier. |
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |

View File

@ -3,7 +3,8 @@
// Inspects user input for /ponytail commands and writes mode to flag file
const { getDefaultMode, isDeactivationCommand, writeDefaultMode } = require('./ponytail-config');
const { clearMode, setMode, writeHookOutput, readMode } = require('./ponytail-runtime');
const { clearMode, isQoder, readMode, setMode, writeHookOutput } = require('./ponytail-runtime');
const { getPonytailInstructions } = require('./ponytail-instructions');
let input = '';
let done = false;
@ -17,6 +18,8 @@ function finish() {
const prompt = (data.prompt || '').trim().toLowerCase();
// Match /ponytail commands
let modeSwitched = false;
let deactivated = false;
if (/^[/@$]ponytail/.test(prompt)) {
const parts = prompt.split(/\s+/);
const cmd = parts[0].replace(/^[@$]/, '/');
@ -60,22 +63,54 @@ function finish() {
);
} else if (mode && mode !== 'off') {
setMode(mode);
writeHookOutput(
'UserPromptSubmit',
mode,
'PONYTAIL MODE CHANGED — level: ' + mode,
);
modeSwitched = true;
// ponytail: Qoder needs the full ruleset every turn, so when a mode
// switch happens we fold the confirmation into the ruleset output
// below (one JSON on stdout) instead of emitting two separate writes.
if (!isQoder) {
writeHookOutput(
'UserPromptSubmit',
mode,
'PONYTAIL MODE CHANGED — level: ' + mode,
);
}
} else if (mode === 'off') {
clearMode();
deactivated = true;
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
}
}
// Detect deactivation
if (isDeactivationCommand(prompt)) {
if (!modeSwitched && !deactivated && isDeactivationCommand(prompt)) {
clearMode();
deactivated = true;
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
}
// Qoder has no SessionStart event, so UserPromptSubmit does double duty:
// activate the default mode on first prompt (if no flag exists yet), then
// inject the ruleset on every prompt. Claude Code/Codex do this in
// SessionStart via ponytail-activate.js; Qoder can't, so we do it here.
// Skip when deactivated — user just turned ponytail off.
if (isQoder && !deactivated) {
let currentMode = readMode();
if (!currentMode) {
// First prompt in session — initialize from config/env default
currentMode = getDefaultMode();
if (currentMode !== 'off') {
try { setMode(currentMode); } catch (e) {}
}
}
if (currentMode && currentMode !== 'off') {
// ponytail: one JSON per invocation — mode-switch confirmation is
// folded into the ruleset header so Qoder gets both in one write.
const header = modeSwitched
? 'PONYTAIL MODE CHANGED — level: ' + currentMode + '\n\n'
: '';
writeHookOutput('UserPromptSubmit', currentMode, header + getPonytailInstructions(currentMode));
}
}
} catch (e) {
// Silent fail
}

View File

@ -1,14 +1,17 @@
const fs = require('fs');
const path = require('path');
const { getClaudeDir } = require('./ponytail-config');
const os = require('os');
const { getClaudeDir, getConfigDir } = require('./ponytail-config');
const STATE_FILE = '.ponytail-active';
const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA);
const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA);
const isQoder = !isCopilot && !isCodex && Boolean(process.env.QODER_SESSION_ID);
let stateDir = getClaudeDir();
if (isCodex) stateDir = process.env.PLUGIN_DATA;
if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA;
if (isQoder) stateDir = path.join(os.homedir(), '.qoder');
const statePath = path.join(stateDir, STATE_FILE);
@ -48,6 +51,19 @@ function writeHookOutput(event, mode, context = '') {
process.stdout.write(JSON.stringify(output));
return;
}
if (isQoder) {
// Qoder: hookSpecificOutput JSON, same shape as Codex minus systemMessage.
// UserPromptSubmit additionalContext is injected into the Agent's conversation.
const output = {};
if (context) {
output.hookSpecificOutput = {
hookEventName: event,
additionalContext: context,
};
}
process.stdout.write(JSON.stringify(output));
return;
}
// Native Claude: SessionStart accepts raw stdout, but SubagentStart needs the
// hookSpecificOutput JSON form or the context is dropped.
if (event === 'SubagentStart') {
@ -62,6 +78,7 @@ module.exports = {
clearMode,
isCodex,
isCopilot,
isQoder,
readMode,
setMode,
writeHookOutput,

26
hooks/qoder-hooks.json Normal file
View File

@ -0,0 +1,26 @@
{
"_comment": "Reference template — copy the 'hooks' object into your .qoder/settings.json or ~/.qoder/settings.json. Replace PONYTAIL_DIR with the path to your ponytail checkout (e.g. ~/.qoder/plugins/ponytail or the npm global install path).",
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node PONYTAIL_DIR/hooks/ponytail-mode-tracker.js"
}
]
}
],
"PreToolUse": [
{
"matcher": "task|Task",
"hooks": [
{
"type": "command",
"command": "node PONYTAIL_DIR/hooks/ponytail-subagent.js"
}
]
}
]
}
}

View File

@ -2,7 +2,7 @@
"name": "@dietrichgebert/ponytail",
"version": "4.8.4",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"keywords": ["opencode-plugin", "opencode", "ponytail", "pi-package", "pi", "skills"],
"keywords": ["opencode-plugin", "opencode", "ponytail", "pi-package", "pi", "skills", "qoder"],
"license": "MIT",
"author": {
"name": "Dietrich Gebert",
@ -26,6 +26,8 @@
"hooks/",
"skills/",
".opencode/",
".qoder/",
".qoder-plugin/",
"pi-extension/",
"scripts/uninstall.js",
"assets/",

View File

@ -21,6 +21,7 @@ const copies = [
['.windsurf/rules/ponytail.md', text => text.trim()],
['.clinerules/ponytail.md', text => text.trim()],
['.agents/rules/ponytail.md', text => text.trim()],
['.qoder/rules/ponytail.md', text => text.trim()],
['.github/copilot-instructions.md', text => text.trim()],
['.kiro/steering/ponytail.md', stripFrontmatter],
];

View File

@ -23,6 +23,7 @@ const VERSION_FILES = [
'.codex-plugin/plugin.json', // Codex plugin
'.devin-plugin/plugin.json', // Devin CLI plugin
'.github/plugin/plugin.json', // Copilot plugin
'.qoder-plugin/plugin.json', // Qoder plugin
'gemini-extension.json', // Gemini CLI extension
'package.json', // pi-package / repo root
'ponytail-mcp/package.json', // MCP server (private, internal-only)

View File

@ -34,6 +34,7 @@ function run(script, env, input = '') {
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.PLUGIN_DATA;
delete process.env.COPILOT_PLUGIN_DATA;
delete process.env.QODER_SESSION_ID;
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-'));
// Runs on normal exit and on assertion-throw exit; force makes it idempotent.
@ -229,6 +230,75 @@ assert.equal(output.systemMessage, 'PONYTAIL:FULL');
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
// Qoder: no SessionStart event, so UserPromptSubmit does double duty —
// it activates the default mode on first prompt (writes flag), then injects
// the ruleset via additionalContext on every prompt. Output is
// hookSpecificOutput JSON (same shape as Codex minus systemMessage).
const qoderHome = path.join(temp, 'qoder-home');
const qoderState = path.join(qoderHome, '.qoder', '.ponytail-active');
fs.mkdirSync(qoderHome, { recursive: true });
const qoderEnv = {
HOME: qoderHome,
USERPROFILE: qoderHome,
QODER_SESSION_ID: 'test-session-123',
PONYTAIL_DEFAULT_MODE: 'full',
};
// First prompt: no flag file yet → mode-tracker initializes from default,
// writes flag, and injects the ruleset.
result = run(
'ponytail-mode-tracker.js',
qoderEnv,
JSON.stringify({ prompt: 'write a function' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(qoderState, 'utf8'), 'full');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
);
// /ponytail ultra: mode tracker updates flag and injects ultra ruleset.
result = run(
'ponytail-mode-tracker.js',
qoderEnv,
JSON.stringify({ prompt: '/ponytail ultra' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(qoderState, 'utf8'), 'ultra');
output = JSON.parse(result.stdout);
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE CHANGED — level: ultra/,
);
// "stop ponytail": deactivates, clears flag, no ruleset output.
result = run(
'ponytail-mode-tracker.js',
qoderEnv,
JSON.stringify({ prompt: 'stop ponytail' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(qoderState), false, 'flag must be cleared after stop ponytail');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.additionalContext, 'PONYTAIL MODE OFF');
// Subagent injection via PreToolUse (task|Task matcher): when ponytail is
// active, the subagent hook injects the ruleset. Qoder shares the same
// ponytail-subagent.js script; the isQoder branch outputs hookSpecificOutput
// JSON instead of raw stdout.
fs.writeFileSync(qoderState, 'full');
result = run('ponytail-subagent.js', qoderEnv);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
);
// writeDefaultMode must merge into existing config, not overwrite it (#490).
const mergeHome = path.join(temp, 'merge-home');
const mergeConfigDir = path.join(mergeHome, '.config', 'ponytail');

View File

@ -0,0 +1,84 @@
#!/usr/bin/env node
// Smoke test for the Qoder plugin adapter: verify manifest, rules, and skills
// wiring are present and consistent.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const SKILL_DIRS = [
'ponytail',
'ponytail-review',
'ponytail-audit',
'ponytail-debt',
'ponytail-gain',
'ponytail-help',
];
function readJSON(relPath) {
return JSON.parse(fs.readFileSync(path.join(root, relPath), 'utf8'));
}
test('qoder plugin manifest exists and has required fields', () => {
const manifest = readJSON('.qoder-plugin/plugin.json');
assert.equal(manifest.name, 'ponytail');
assert.ok(manifest.version, 'manifest must declare a version');
assert.ok(manifest.description, 'manifest must declare a description');
assert.ok(manifest.author, 'manifest must declare an author');
assert.equal(manifest.license, 'MIT');
assert.equal(manifest.skills, './skills/');
assert.equal(manifest.rules, './.qoder/rules/');
assert.equal(manifest.hooks, './hooks/qoder-hooks.json');
});
test('qoder hooks config exists and registers UserPromptSubmit', () => {
const hooksConfig = readJSON('hooks/qoder-hooks.json');
assert.ok(hooksConfig.hooks, 'hooks config must have a hooks key');
assert.ok(hooksConfig.hooks.UserPromptSubmit, 'must register UserPromptSubmit hook');
assert.ok(Array.isArray(hooksConfig.hooks.UserPromptSubmit), 'UserPromptSubmit must be an array');
const cmd = hooksConfig.hooks.UserPromptSubmit[0].hooks[0].command;
assert.ok(cmd.includes('ponytail-mode-tracker.js'), 'must point at ponytail-mode-tracker.js');
});
test('qoder rules file exists and is non-empty', () => {
const rulesPath = path.join(root, '.qoder', 'rules', 'ponytail.md');
assert.ok(fs.existsSync(rulesPath), '.qoder/rules/ponytail.md must exist');
const content = fs.readFileSync(rulesPath, 'utf8').trim();
assert.ok(content.length > 0, '.qoder/rules/ponytail.md must not be empty');
assert.ok(content.includes('lazy senior developer'), 'rules must contain the ponytail identity');
});
test('qoder manifest points at skills that actually ship', () => {
const manifest = readJSON('.qoder-plugin/plugin.json');
const skillsDir = path.join(root, manifest.skills);
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist');
for (const skill of SKILL_DIRS) {
const skillFile = path.join(skillsDir, skill, 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
`missing skill: skills/${skill}/SKILL.md`,
);
}
});
test('qoder rules match AGENTS.md canonical body', () => {
// Reuse the same logic as check-rule-copies.js: the .qoder copy must be
// byte-identical to AGENTS.md minus the repo-self-application paragraph.
const agents = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8')
.replace(/\r\n/g, '\n').trim();
const canonical = agents.replace(/\n\n\(Yes, this file also applies[\s\S]*?\)$/, '').trim();
const qoderCopy = fs.readFileSync(path.join(root, '.qoder', 'rules', 'ponytail.md'), 'utf8')
.replace(/\r\n/g, '\n').trim();
assert.equal(qoderCopy, canonical, '.qoder/rules/ponytail.md drifted from AGENTS.md');
});
test('qoder runtime detects QODER_SESSION_ID and writes hookSpecificOutput JSON', () => {
const { isQoder } = require('../hooks/ponytail-runtime');
// isQoder is resolved at module load time from process.env; in the test
// process QODER_SESSION_ID is unset, so isQoder must be false here.
// The positive path is exercised in hooks.test.js via spawnSync.
assert.equal(isQoder, false, 'isQoder must be false without QODER_SESSION_ID');
});