From 0403c4dd50ee6d0db2c3ec70b2be6655f9cb65a9 Mon Sep 17 00:00:00 2001 From: Max Felker II Date: Fri, 19 Jun 2026 01:50:55 -0700 Subject: [PATCH 01/29] Fix for #168: Don't write output on SessionStart for Copilot (#181) * Added isCopilot flag from runtime and check that in the hooks * When going into off mode, check if codex or copilot --- hooks/ponytail-activate.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index fb9ad96..059d219 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -13,6 +13,7 @@ const { getPonytailInstructions } = require('./ponytail-instructions'); const { clearMode, isCodex, + isCopilot, setMode, writeHookOutput, } = require('./ponytail-runtime'); @@ -25,7 +26,8 @@ const mode = getDefaultMode(); // "off" mode — skip activation entirely, don't write flag or emit rules if (mode === 'off') { clearMode(); - writeHookOutput('SessionStart', 'off', isCodex ? '' : 'OK'); + const hookOutput = (isCodex || isCopilot) ? '' : 'OK'; + writeHookOutput('SessionStart', 'off', hookOutput); process.exit(0); } @@ -40,7 +42,7 @@ try { let output = getPonytailInstructions(mode); // 3. Detect missing statusline config — nudge Claude to help set it up -if (!isCodex) try { +if (!isCodex && !isCopilot) try { let hasStatusline = false; if (fs.existsSync(settingsPath)) { // Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse) From ee263e5708d249ac2d2180bca265ed2b604ca3b0 Mon Sep 17 00:00:00 2001 From: Lixin2026 <126993554+2023Anita@users.noreply.github.com> Date: Sun, 21 Jun 2026 07:20:45 +0800 Subject: [PATCH 02/29] test: clean hooks temp dir on failure (#213) --- tests/hooks.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 4f0d9b3..59df610 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -21,6 +21,14 @@ function run(script, env, input = '') { delete process.env.CLAUDE_CONFIG_DIR; const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-')); +let cleanedTemp = false; +function cleanupTemp() { + if (cleanedTemp) return; + cleanedTemp = true; + fs.rmSync(temp, { recursive: true, force: true }); +} +process.once('exit', cleanupTemp); + const home = path.join(temp, 'home'); const pluginData = path.join(temp, 'plugin-data'); fs.mkdirSync(home, { recursive: true }); @@ -155,5 +163,5 @@ assert.equal( output = JSON.parse(result.stdout); assert.deepEqual(output, {}); -fs.rmSync(temp, { recursive: true, force: true }); +cleanupTemp(); console.log('hook compatibility checks passed'); From 248a30b40b2957d70b304bf0d08b048183a56508 Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Sun, 21 Jun 2026 01:29:09 +0200 Subject: [PATCH 03/29] test: simplify hooks temp-dir cleanup to a one-line exit handler (#221) #213 guarded cleanup with a flag + named function + process.once. But fs.rmSync with force:true already no-ops on a missing path, so the guard and the explicit end-of-file call are unnecessary. Collapse to a single process.on('exit') handler. Refs #204 Co-authored-by: Claude Opus 4.8 --- tests/hooks.test.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 59df610..22580e8 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -21,13 +21,8 @@ function run(script, env, input = '') { delete process.env.CLAUDE_CONFIG_DIR; const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-')); -let cleanedTemp = false; -function cleanupTemp() { - if (cleanedTemp) return; - cleanedTemp = true; - fs.rmSync(temp, { recursive: true, force: true }); -} -process.once('exit', cleanupTemp); +// Runs on normal exit and on assertion-throw exit; force makes it idempotent. +process.on('exit', () => fs.rmSync(temp, { recursive: true, force: true })); const home = path.join(temp, 'home'); const pluginData = path.join(temp, 'plugin-data'); @@ -163,5 +158,4 @@ assert.equal( output = JSON.parse(result.stdout); assert.deepEqual(output, {}); -cleanupTemp(); console.log('hook compatibility checks passed'); From 5eb1fd8b766a05ba4e5b8999004896790c648360 Mon Sep 17 00:00:00 2001 From: Rajaul Uddin <110954532+uddin-rajaul@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:21:25 +0545 Subject: [PATCH 04/29] fix: make Python command portable in robustness-audit.js (fixes Windows) (#209) Closes #203 --- benchmarks/robustness-audit.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/benchmarks/robustness-audit.js b/benchmarks/robustness-audit.js index 1b05e08..16b9e99 100644 --- a/benchmarks/robustness-audit.js +++ b/benchmarks/robustness-audit.js @@ -8,6 +8,17 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +// ponytail: probe once at load; mirrors correctness.js +let pythonCmd; +function python() { + if (pythonCmd) return pythonCmd; + for (const cmd of ['python3', 'python']) { + try { execSync(`${cmd} -c "import sys"`, { stdio: 'pipe' }); pythonCmd = cmd; return pythonCmd; } + catch (_) {} + } + return pythonCmd = 'python3'; +} + const N = Number(process.env.AUDIT_N) || 20; const MODEL = process.env.AUDIT_MODEL || 'gpt-5.4-mini'; const ROOT = path.join(__dirname, '..'); @@ -136,7 +147,7 @@ for args, expected in cases: print('PASS')`; const f = path.join(os.tmpdir(), `audit-${process.pid}-${Math.random().toString(36).slice(2)}.py`); fs.writeFileSync(f, harness); - try { execSync(`python3 "${f}"`, { timeout: 10000, encoding: 'utf8', stdio: 'pipe' }); return true; } + try { execSync(`${python()} "${f}"`, { timeout: 10000, encoding: 'utf8', stdio: 'pipe' }); return true; } catch (e) { return false; } finally { try { fs.unlinkSync(f); } catch (_) {} } } From 215777d835953c0a28c5268fefe371fd7834030d Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Sun, 21 Jun 2026 01:58:21 +0200 Subject: [PATCH 05/29] fix: don't embed shell-unsafe install paths in statusline setup nudge (#224) The SessionStart nudge built a statusLine command by interpolating the plugin's __dirname path into a double-quoted shell string. A clone path containing shell metacharacters (quotes, &, $, backtick, ;) could break out when the suggested command later runs via the statusline shell. Low severity in practice: the path is the install location, so triggering it requires installing into a maliciously-named directory, i.e. the attacker already controls the filesystem. Hardening it anyway. Gate the snippet behind isShellSafe() (allowlist of ordinary path chars, allowing : \ / for normal Windows and POSIX paths). Unsafe paths fall back to a manual-setup instruction instead of an embeddable command. An allowlist beats a per-shell escaper, which is its own edge-case bug farm. Refs #200 --- hooks/ponytail-activate.js | 35 +++++++++++++++++++++++------------ hooks/ponytail-config.js | 10 ++++++++++ tests/hooks.test.js | 10 ++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index 059d219..25f4e48 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -8,7 +8,7 @@ const fs = require('fs'); const path = require('path'); -const { getDefaultMode, getClaudeDir } = require('./ponytail-config'); +const { getDefaultMode, getClaudeDir, isShellSafe } = require('./ponytail-config'); const { getPonytailInstructions } = require('./ponytail-instructions'); const { clearMode, @@ -57,17 +57,28 @@ if (!isCodex && !isCopilot) try { const isWindows = process.platform === 'win32'; const scriptName = isWindows ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh'; const scriptPath = path.join(__dirname, scriptName); - const command = isWindows - ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` - : `bash "${scriptPath}"`; - const statusLineSnippet = - '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; - output += "\n\n" + - "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " + - "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " + - "To enable, add this to ~/.claude/settings.json: " + - statusLineSnippet + " " + - "Proactively offer to set this up for the user on first interaction."; + if (isShellSafe(scriptPath)) { + const command = isWindows + ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` + : `bash "${scriptPath}"`; + const statusLineSnippet = + '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " + + "(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " + + "To enable, add this to ~/.claude/settings.json: " + + statusLineSnippet + " " + + "Proactively offer to set this up for the user on first interaction."; + } else { + // ponytail: install path has shell metacharacters — don't embed it in a + // command snippet; have the agent wire it up by hand instead. + output += "\n\n" + + "STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " + + "Its install path contains characters unsafe to embed in a shell command, so configure it manually: " + + "add a statusLine command of type \"command\" that runs " + scriptName + + " from the plugin's hooks directory to ~/.claude/settings.json, quoting/escaping the path for your shell. " + + "Proactively offer to set this up for the user on first interaction."; + } } } catch (e) { // Silent fail — don't block session start over statusline detection diff --git a/hooks/ponytail-config.js b/hooks/ponytail-config.js index a96d6d0..86677b1 100644 --- a/hooks/ponytail-config.js +++ b/hooks/ponytail-config.js @@ -42,6 +42,15 @@ function isDeactivationCommand(text) { return t === 'stop ponytail' || t === 'normal mode'; } +// ponytail: only embed the plugin install path in a statusline shell command when +// it's made of ordinary path characters. An allowlist beats escaping every shell's +// metacharacters; a hostile clone path (quotes, &, $, backtick, ;, etc.) falls back +// to manual setup instead. Allows : \ / for normal Windows and POSIX paths. Full +// per-shell escaper only if a real need appears. +function isShellSafe(p) { + return typeof p === 'string' && /^[A-Za-z0-9 _.\-:/\\~]+$/.test(p); +} + function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { return path.join(process.env.XDG_CONFIG_HOME, 'ponytail'); @@ -104,6 +113,7 @@ module.exports = { getConfigDir, getConfigPath, getClaudeDir, + isShellSafe, normalizeMode, normalizeConfigMode, normalizePersistedMode, diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 22580e8..d17e65f 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -8,6 +8,16 @@ const { spawnSync } = require('child_process'); const root = path.join(__dirname, '..'); +// isShellSafe gates the statusline setup snippet (issue #200): ordinary install +// paths pass, paths carrying shell metacharacters are rejected so they never get +// embedded in a shell command. +const { isShellSafe } = require('../hooks/ponytail-config'); +assert.equal(isShellSafe('C:\\Users\\x\\.claude\\plugins\\ponytail\\hooks\\ponytail-statusline.ps1'), true); +assert.equal(isShellSafe('/home/u/.claude/plugins/ponytail/hooks/ponytail-statusline.sh'), true); +assert.equal(isShellSafe('/tmp/a"&calc.exe&"/x.sh'), false); +assert.equal(isShellSafe('/tmp/$(calc)/x.sh'), false); +assert.equal(isShellSafe('/tmp/a;rm -rf/x.sh'), false); + function run(script, env, input = '') { return spawnSync(process.execPath, [path.join(root, 'hooks', script)], { env: { ...process.env, ...env }, From 6da37bfa7d0282522c7785759f4d2f1544015354 Mon Sep 17 00:00:00 2001 From: Rajaul Uddin <110954532+uddin-rajaul@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:08:17 +0545 Subject: [PATCH 06/29] fix: bump @modelcontextprotocol/sdk to ^1.26.0 (CVE-2026-25536) (#208) Closes #199 --- ponytail-mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ponytail-mcp/package.json b/ponytail-mcp/package.json index ec36878..96c1d82 100644 --- a/ponytail-mcp/package.json +++ b/ponytail-mcp/package.json @@ -7,7 +7,7 @@ "license": "MIT", "scripts": { "test": "node --test ./test/*.test.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.19.0", + "@modelcontextprotocol/sdk": "^1.26.0", "zod": "^3.23.0" } } From dedc97ca7c8a1e7463ac5b36f7fe4b28c3c435a2 Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Mon, 22 Jun 2026 23:30:05 +0200 Subject: [PATCH 07/29] fix: comprehension-first guard + reuse rung (#245, #217) (#253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #245 "Dangerously lazy": add an operational "fix the root cause, not the symptom" directive — grep every caller of the function you touch and fix the shared function once (the smaller diff). Validated on the agentic benchmark: on a shared-helper bug-fix trap, baseline fixes the root cause 1/6 while ponytail does 6/6 on both Sonnet 4.6 (the model the issue was filed on) and Opus 4.8, verified by reading the produced code. Plain prose ("trace the flow") did not move it; the actionable, lazy-framed directive did. #217 "Missing rung": add ladder rung 2 "Already in this codebase? Reuse it, don't re-write it." Propagated across SKILL.md, AGENTS.md, all agent mirror copies, the hook fallback, and both READMEs (check-rule-copies passes). Benchmark: 4 new deterministic quality-tier tasks (reuse-slug, reuse-money, trace-transfer, trace-amount) with selftest-proven good/bad refs; harness gains multi-file seed support in --selftest, distinctive-behaviour reuse detection, and counts in-file __main__/demo() self-checks as test LOC (not source bloat) for surgical tasks. Full writeup in benchmarks/results/2026-06-22-issue-245-217-comprehension.md. Also carries the in-progress todo-null benchmark task already present in the working tree. Co-authored-by: Dietrich Gebert Co-authored-by: Claude Opus 4.8 --- .agents/rules/ponytail.md | 18 +- .clinerules/ponytail.md | 18 +- .cursor/rules/ponytail.mdc | 18 +- .github/copilot-instructions.md | 18 +- .kiro/steering/ponytail.md | 18 +- .openclaw/skills/ponytail/SKILL.md | 32 +- .windsurf/rules/ponytail.md | 18 +- AGENTS.md | 18 +- README.es.md | 13 +- README.md | 13 +- benchmarks/agentic/run.py | 51 ++- benchmarks/agentic/tasks.py | 398 +++++++++++++++++- .../2026-06-22-issue-245-217-comprehension.md | 98 +++++ hooks/ponytail-instructions.js | 16 +- skills/ponytail/SKILL.md | 32 +- 15 files changed, 698 insertions(+), 81 deletions(-) create mode 100644 benchmarks/results/2026-06-22-issue-245-217-comprehension.md diff --git a/.agents/rules/ponytail.md b/.agents/rules/ponytail.md index 38c2e8f..84d7ccc 100644 --- a/.agents/rules/ponytail.md +++ b/.agents/rules/ponytail.md @@ -5,11 +5,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -17,8 +22,9 @@ Rules: - 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: 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. +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. diff --git a/.clinerules/ponytail.md b/.clinerules/ponytail.md index 38c2e8f..84d7ccc 100644 --- a/.clinerules/ponytail.md +++ b/.clinerules/ponytail.md @@ -5,11 +5,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -17,8 +22,9 @@ Rules: - 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: 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. +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. diff --git a/.cursor/rules/ponytail.mdc b/.cursor/rules/ponytail.mdc index 09c6699..db435a7 100644 --- a/.cursor/rules/ponytail.mdc +++ b/.cursor/rules/ponytail.mdc @@ -11,11 +11,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -23,8 +28,9 @@ Rules: - 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: 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. +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. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 38c2e8f..84d7ccc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,11 +5,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -17,8 +22,9 @@ Rules: - 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: 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. +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. diff --git a/.kiro/steering/ponytail.md b/.kiro/steering/ponytail.md index 6f0b1b4..15d50a8 100644 --- a/.kiro/steering/ponytail.md +++ b/.kiro/steering/ponytail.md @@ -10,11 +10,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -22,8 +27,9 @@ Rules: - 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: 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. +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. diff --git a/.openclaw/skills/ponytail/SKILL.md b/.openclaw/skills/ponytail/SKILL.md index 7506326..2375da2 100644 --- a/.openclaw/skills/ponytail/SKILL.md +++ b/.openclaw/skills/ponytail/SKILL.md @@ -22,21 +22,31 @@ Switch: `/ponytail lite|full|ultra`. Stop at the first rung that holds: 1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) -2. **Stdlib does it?** Use it. -3. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. -4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. -5. **Can it be one line?** One line. -6. **Only then:** the minimum code that works. +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. -The ladder is a reflex, not a research project. Two rungs work → take the -higher one and move on. The first lazy solution that works is the right one. +The ladder is a reflex, not a research project — but it runs *after* you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. ## Rules - No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. - No boilerplate, no scaffolding "for later", later can scaffold for itself. - Deletion over addition. Boring over clever, clever is what someone decodes at 3am. -- Fewest files possible. Shortest working diff wins. +- 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. - Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. - Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. - Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`. @@ -72,6 +82,12 @@ that prevents data loss, security measures, accessibility basics, anything explicitly requested. User insists on the full version → build it, no re-arguing. +Never lazy about understanding the problem. The ladder shortens the +solution, never the reading. Trace the whole thing first — every file the +change touches, the actual flow — before picking a rung. Laziness that skips +comprehension to ship a small diff is the dangerous kind: it dresses up as +efficiency and ships a confident wrong fix. Read fully, then be lazy. + Hardware is never the ideal on paper: a real clock drifts, a real sensor reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not just less code, the physical world needs tuning a minimal model can't see. diff --git a/.windsurf/rules/ponytail.md b/.windsurf/rules/ponytail.md index 38c2e8f..84d7ccc 100644 --- a/.windsurf/rules/ponytail.md +++ b/.windsurf/rules/ponytail.md @@ -5,11 +5,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -17,8 +22,9 @@ Rules: - 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: 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. +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. diff --git a/AGENTS.md b/AGENTS.md index 13910b9..426109e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,16 @@ You are a lazy senior developer. Lazy means efficient, not careless. The best co Before writing any code, stop at the first rung that holds: 1. Does this need to be built at all? (YAGNI) -2. Does the standard library already do this? Use it. -3. Does a native platform feature cover it? Use it. -4. Does an already-installed dependency solve it? Use it. -5. Can this be one line? Make it one line. -6. Only then: write the minimum code that works. +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: @@ -17,10 +22,11 @@ Rules: - 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: 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. +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. (Yes, this file also applies to agents working on the ponytail repo itself. Especially to them.) diff --git a/README.es.md b/README.es.md index bdd7fdc..156054c 100644 --- a/README.es.md +++ b/README.es.md @@ -83,13 +83,16 @@ Antes de escribir código, el agente se detiene en el primer peldaño que aguant ``` 1. ¿Necesita existir esto? → no: omitirlo (YAGNI) -2. ¿Lo hace la stdlib? → úsala -3. ¿Es una feature nativa? → úsala -4. ¿Una dependencia ya instalada? → úsala -5. ¿Cabe en una línea? → una línea -6. Solo entonces: el mínimo que funciona +2. ¿Ya existe en este código? → reúsalo, no lo reescribas +3. ¿Lo hace la stdlib? → úsala +4. ¿Es una feature nativa? → úsala +5. ¿Una dependencia ya instalada? → úsala +6. ¿Cabe en una línea? → una línea +7. Solo entonces: el mínimo que funciona ``` +La escalera se recorre *después* de entender el problema, no en su lugar: lee el código que toca el cambio y sigue el flujo real antes de elegir un peldaño. Flojo en la solución, nunca en la lectura. + Flojo, no negligente: la validación en límites de confianza, el manejo de pérdida de datos, la seguridad y la accesibilidad nunca están en riesgo. ## Instalación diff --git a/README.md b/README.md index 21f5ab0..597c28a 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,16 @@ Before writing code, the agent stops at the first rung that holds: ``` 1. Does this need to exist? → no: skip it (YAGNI) -2. Stdlib does it? → use it -3. Native platform feature? → use it -4. Installed dependency? → use it -5. One line? → one line -6. Only then: the minimum that works +2. Already in this codebase? → reuse it, don't rewrite +3. Stdlib does it? → use it +4. Native platform feature? → use it +5. Installed dependency? → use it +6. One line? → one line +7. Only then: the minimum that works ``` +The ladder runs *after* it understands the problem, not instead of it: it reads the code the change touches and traces the real flow before picking a rung. Lazy about the solution, never about reading. + Lazy, not negligent: trust-boundary validation, data-loss handling, security, and accessibility are never on the chopping block. ## Install diff --git a/benchmarks/agentic/run.py b/benchmarks/agentic/run.py index cac56fa..f63c144 100644 --- a/benchmarks/agentic/run.py +++ b/benchmarks/agentic/run.py @@ -89,11 +89,40 @@ def _count(p: Path, with_comments: bool): n += 1 return n -def code_stats(workdir: Path): +_SELFCHECK_DEFS = ("def demo(", "def _demo(", "def selfcheck(", "def _selfcheck(", + "def _check(", "def _smoke(", "def smoke(") +def _selfcheck_split(p: Path): + """Split a produced .py file at the first TOP-LEVEL self-check marker (a `__main__` guard or a + demo()/selfcheck() function) through end of file. Returns (src_total, src_code, sc_total, + sc_code), counted like _count. On a surgical task that delivers ONE function, an in-file self- + check is the runnable check ponytail's rule asks for -- a positive signal, not source bloat -- + so it is split off here and counted as test LOC instead of penalising the arm that wrote it.""" + try: lines = p.read_text(encoding="utf-8", errors="ignore").splitlines() + except Exception: return 0, 0, 0, 0 + start = None + for i, ln in enumerate(lines): + if ln[:1] not in (" ", "\t") and (ln.startswith("if __name__") or ln.startswith(_SELFCHECK_DEFS)): + start = i; break + def cnt(seq): + t = c = 0 + for ln in seq: + s = ln.strip() + if not s: continue + t += 1 + if not s.startswith(("#", "//", "*", "/*", "*/")): c += 1 + return t, c + if start is None: + t, c = cnt(lines); return t, c, 0, 0 + t, c = cnt(lines[:start]); st, sc = cnt(lines[start:]) + return t, c, st, sc + +def code_stats(workdir: Path, selfcheck_as_test: bool = False): """LOC over code-extension source files only (generated images/data can't pollute it). total_loc counts every non-blank line including comments and docstrings -- the bloat a vibe baseline actually produces. src_loc is code-only, for the breakdown. Tests tracked separately, - never as bloat.""" + never as bloat. selfcheck_as_test (surgical tasks): an in-file __main__/demo() self-check is + reclassified from source to test, so following ponytail's 'leave a runnable check' rule is not + counted as code bloat against it.""" fixture = set() # files that were seeded, not delivered fm = workdir / "_fixture_files.json" if fm.exists(): @@ -105,10 +134,19 @@ def code_stats(workdir: Path): and not p.name.startswith((".", "_")) and _rel(p) not in fixture] src = [p for p in files if not _is_test(p, workdir)] tst = [p for p in files if _is_test(p, workdir)] + test_loc = sum(_count(p, True) for p in tst) + if selfcheck_as_test: + total = code = sc_test = 0 + for p in src: + t, c, st, _ = _selfcheck_split(p) + total += t; code += c; sc_test += st + return {"files": len(files), "src_files": len(src), + "total_loc": total, "src_loc": code, + "test_files": len(tst), "test_loc": test_loc + sc_test} return {"files": len(files), "src_files": len(src), "total_loc": sum(_count(p, True) for p in src), # incl comments + docstrings (the bloat) "src_loc": sum(_count(p, False) for p in src), # code only - "test_files": len(tst), "test_loc": sum(_count(p, True) for p in tst)} + "test_files": len(tst), "test_loc": test_loc} def _git(workdir, *args): return subprocess.run([shutil.which("git") or "git", *args], cwd=str(workdir), @@ -151,7 +189,9 @@ def selftest(): axis = task.get("axis", "safe") for kind in ("good", "bad"): with tempfile.TemporaryDirectory() as d: - (Path(d) / task["file"]).write_text(task[kind], encoding="utf-8") + for fn, content in task.get("seed", {}).items(): # seed siblings (a helper module + (Path(d) / fn).write_text(content, encoding="utf-8") # the ref imports) too + (Path(d) / task["file"]).write_text(task[kind], encoding="utf-8") # entry = the ref r = task["score"](Path(d)) ok = (r["correct"] == 1 and r["safe"] == 1) if kind == "good" else (r[axis] == 0) print(f"{'ok ' if ok else 'XX '} {tid:12} {kind:4} correct={r['correct']} " @@ -205,7 +245,8 @@ def score_workspace(task_id, arm, model, workdir: Path): "cache_tokens": (u.get("cache_read_input_tokens") or 0) + (u.get("cache_creation_input_tokens") or 0)} result_text = j.get("result", "") except Exception: pass - stats = git_diff_stats(workdir) if TASKS[task_id].get("fixture") else code_stats(workdir) + surgical = not TASKS[task_id].get("open") and not TASKS[task_id].get("fixture") + stats = git_diff_stats(workdir) if TASKS[task_id].get("fixture") else code_stats(workdir, selfcheck_as_test=surgical) # open/explain tasks answer in the chat, not a file. If no source file was written, count the # code the agent delivered in its chat answer so the comparison isn't a false zero. if TASKS[task_id].get("open") and stats["total_loc"] == 0 and result_text: diff --git a/benchmarks/agentic/tasks.py b/benchmarks/agentic/tasks.py index 1a8aa0e..6e0fa6a 100644 --- a/benchmarks/agentic/tasks.py +++ b/benchmarks/agentic/tasks.py @@ -20,7 +20,7 @@ Task fields: score : (workdir) -> {correct, safe, reason} good/bad : reference implementations for the selftest """ -import hashlib, hmac, importlib.util, inspect, json, os, py_compile, sqlite3, tempfile +import hashlib, hmac, importlib, importlib.util, inspect, json, os, py_compile, sqlite3, sys, tempfile from pathlib import Path # Real-repo fixture: tiangolo/full-stack-fastapi-template @ cd83fc1 (v0.10.0, MIT), cloned locally. @@ -439,8 +439,372 @@ def score_fixture(workdir): return {"correct": 1 if new else 0, "safe": 1, "reason": f"{len(new)} new file(s)" if new else "no new file"} +# ====================================================================================== +# todo-null -- in-memory Todo REST API. The trust-boundary trap is a POST body of literal +# `null`: JSON.parse("null") returns null, which slips past a try/catch around parsing, then +# `body.title` throws on null and (in an async handler, unhandled) takes the whole process +# down -- a one-request DoS. The lazy-but-plausible version guards "missing title" but not +# "body is not an object". This is the exact bug an external benchmark hit (task-02). +# ====================================================================================== +TODO_SEED = ( + "// Implement an in-memory Todo REST API with the built-in http module only.\n" + "// Listen on process.env.PORT || 3000.\n" +) + +# good: rejects a non-object body (incl. null) with 400 and stays up. +TODO_GOOD = r"""'use strict'; +const http = require('http'); +const todos = new Map(); let nextId = 1; +const send = (res, c, b) => { res.writeHead(c, {'Content-Type':'application/json'}); res.end(b === undefined ? '' : JSON.stringify(b)); }; +const readJson = req => new Promise((resolve, reject) => { + let d = ''; req.on('data', c => d += c); + req.on('end', () => { if (d.trim() === '') return resolve({}); try { resolve(JSON.parse(d)); } catch { reject(new Error('bad json')); } }); + req.on('error', reject); +}); +const server = http.createServer(async (req, res) => { + const path = (req.url || '/').replace(/\/+$/, '') || '/'; + const m = path.match(/^\/todos(?:\/(\d+))?$/); + if (!m) return send(res, 404, { error: 'not found' }); + const id = m[1] ? Number(m[1]) : null; + if (id === null) { + if (req.method === 'GET') return send(res, 200, [...todos.values()]); + if (req.method === 'POST') { + let body; + try { body = await readJson(req); } catch (e) { return send(res, 400, { error: e.message }); } + if (body === null || typeof body !== 'object' || Array.isArray(body)) return send(res, 400, { error: 'body must be an object' }); + if (typeof body.title !== 'string' || body.title.trim() === '') return send(res, 400, { error: 'title required' }); + const t = { id: nextId++, title: body.title, done: false }; todos.set(t.id, t); return send(res, 201, t); + } + return send(res, 405, { error: 'method not allowed' }); + } + const t = todos.get(id); + if (req.method === 'GET') return t ? send(res, 200, t) : send(res, 404, { error: 'not found' }); + if (req.method === 'DELETE') return todos.delete(id) ? send(res, 204) : send(res, 404, { error: 'not found' }); + return send(res, 405, { error: 'method not allowed' }); +}); +server.listen(process.env.PORT || 3000); +""" + +# bad: happy-path correct, but only guards "missing title" -- a `null` body reaches body.title +# and throws in the async handler -> unhandled rejection -> process exits. The benchmark's bug. +TODO_BAD = r"""'use strict'; +const http = require('http'); +const todos = new Map(); let nextId = 1; +const send = (res, c, b) => { res.writeHead(c, {'Content-Type':'application/json'}); res.end(b === undefined ? '' : JSON.stringify(b)); }; +const readJson = req => new Promise((resolve, reject) => { + let d = ''; req.on('data', c => d += c); + req.on('end', () => { if (d.trim() === '') return resolve({}); try { resolve(JSON.parse(d)); } catch { reject(new Error('bad json')); } }); + req.on('error', reject); +}); +const server = http.createServer(async (req, res) => { + const path = (req.url || '/').replace(/\/+$/, '') || '/'; + const m = path.match(/^\/todos(?:\/(\d+))?$/); + if (!m) return send(res, 404, { error: 'not found' }); + const id = m[1] ? Number(m[1]) : null; + if (id === null) { + if (req.method === 'GET') return send(res, 200, [...todos.values()]); + if (req.method === 'POST') { + let body; + try { body = await readJson(req); } catch (e) { return send(res, 400, { error: e.message }); } + if (typeof body.title !== 'string' || body.title.trim() === '') return send(res, 400, { error: 'title required' }); + const t = { id: nextId++, title: body.title, done: false }; todos.set(t.id, t); return send(res, 201, t); + } + return send(res, 405, { error: 'method not allowed' }); + } + const t = todos.get(id); + if (req.method === 'GET') return t ? send(res, 200, t) : send(res, 404, { error: 'not found' }); + return send(res, 405, { error: 'method not allowed' }); +}); +server.listen(process.env.PORT || 3000); +""" + +def score_todo(workdir): + """Boot the produced Todo server on a free port; a POST body of literal `null` must NOT + crash it. correct = normal create/list works; safe = server survives the null POST.""" + import socket, subprocess, time, json as _json, shutil as _sh, urllib.request, urllib.error + srv = workdir / "server.js" + if not srv.exists(): + js = [p for p in workdir.glob("*.js") if not p.name.startswith(("_", "."))] + if len(js) != 1: return _fail("server.js missing") + srv = js[0] + node = _sh.which("node") + if not node: return _fail("node not on PATH") + s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close() + base = f"http://127.0.0.1:{port}" + def req(method, path, body=None, raw=None): + data = raw.encode() if raw is not None else (_json.dumps(body).encode() if body is not None else None) + r = urllib.request.Request(base + path, data=data, method=method, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(r, timeout=4) as resp: + return resp.status, resp.read().decode() + env = {**os.environ, "PORT": str(port)} + proc = subprocess.Popen([node, str(srv)], cwd=str(workdir), env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + up = False + for _ in range(40): + if proc.poll() is not None: return _fail("server exited on startup") + try: + req("GET", "/todos"); up = True; break + except Exception: time.sleep(0.1) + if not up: return _fail("server never came up") + try: + st, _ = req("POST", "/todos", {"title": "buy milk"}) + correct = (st == 201) + st2, body2 = req("GET", "/todos") + correct = correct and st2 == 200 and "buy milk" in body2 + except Exception: + correct = False + try: + req("POST", "/todos", raw="null") + except urllib.error.HTTPError: + pass # a 4xx is fine -- it responded + except Exception: + pass # connection reset -- decide by whether the process died + time.sleep(0.3) + safe = proc.poll() is None # process still alive == survived the null POST + if safe: + try: req("GET", "/todos") + except Exception: safe = False + return _ok(correct, safe, "survived null POST" if safe else "crashed on null POST") + finally: + try: proc.kill() + except Exception: pass + +# ====================================================================================== +# QUALITY TIER -- probes the two behaviors fixed in #245 (understand/trace before patching) +# and #217 (reuse what already lives in the codebase). Same shape as the safety tier: the `bad` +# ref is the lazy-but-plausible version -- correct on the happy path, but it cuts the corner the +# fix is about. axis="safe" carries the QUALITY signal (reuse / root-cause), so a working-but- +# low-quality answer is caught the way an unsafe one is. +# +# Two design choices make these DISCRIMINATE (an earlier in-file version had every arm reuse the +# helper, so the arms tied): +# - reuse tasks keep the helper in a SEPARATE module the agent has to read the project to find +# (that is exactly how #217 slop happens), and give it a DISTINCTIVE behavior, so a re- +# implementation diverges observably instead of needing a brittle spy to catch. +# - trace tasks route the named symptom and an UN-named sibling through a shared helper. The lazy +# fix patches the named caller; the scorer exercises the sibling, which only a flow-tracing fix +# (repair the shared helper) gets right. +# ====================================================================================== + +def _import_pkg(workdir, modname, also=()): + """Import a produced module by name with workdir on sys.path, so its own intra-repo imports + (`from textutils import slugify`) resolve. Fresh each call: drop cached names first.""" + wd = str(workdir) + if wd not in sys.path: sys.path.insert(0, wd) + for m in (modname,) + tuple(also): sys.modules.pop(m, None) + try: + return importlib.import_module(modname) + except Exception: + return None + +# --- #217a reuse-slug: the project slugifies in textutils.py, and its slugify transliterates +# accents (Cafe, not Caf). unique_slug must reuse it so slugs stay consistent; a hand-rolled regex +# silently diverges on any accented title. correct = ASCII titles (both agree); safe(reuse) = an +# accented title slugs the project's way. +def score_reuse_slug(workdir): + mod = _import_pkg(workdir, "articles", also=("textutils",)) + if mod is None: return _fail("articles.py missing or import error") + fn = _find(mod, ["unique_slug"]) + if fn is None: return _fail("no unique_slug") + try: + correct = (fn("Hello, World!", set()) == "hello-world" + and fn("Hello, World!", {"hello-world"}) == "hello-world-2") + except Exception as e: + return _fail(f"correctness raised: {e}") + try: + reused = (fn("Café Olé", set()) == "cafe-ole") # only the project's slugify transliterates + except Exception: + reused = False + return _ok(correct, reused, "reused project slugify" if reused else "re-implemented slug (diverges on accents)") + +REUSE_SLUG_HELPER = ( + "import re, unicodedata\n\n" + "def slugify(title):\n" + ' """Project-wide slug: transliterate accents to ASCII, then hyphenate. Use this so every\n' + ' slug in the app is built the same way."""\n' + " ascii_title = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').decode()\n" + ' return re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")\n\n' + "def truncate(text, length=80):\n" + ' """Trim text to length, adding an ellipsis if it was longer."""\n' + " return text if len(text) <= length else text[: length - 1].rstrip() + '\\u2026'\n" +) +REUSE_SLUG_SEED = ( + "def unique_slug(title, taken):\n" + ' """Return a URL slug for `title` not already in `taken` (a set of slugs in use). If the\n' + ' base slug is taken, append -2, -3, ... until one is free. Slugs must match how the rest\n' + ' of the project builds them."""\n' + " raise NotImplementedError\n" +) +_SLUG_TAIL = ( + " if base not in taken:\n" + " return base\n" + " i = 2\n" + " while f'{base}-{i}' in taken:\n" + " i += 1\n" + " return f'{base}-{i}'\n" +) +REUSE_SLUG_GOOD = ("from textutils import slugify\n\n" + REUSE_SLUG_SEED).replace( + " raise NotImplementedError\n", " base = slugify(title)\n" + _SLUG_TAIL) +REUSE_SLUG_BAD = ("import re\n\n" + REUSE_SLUG_SEED).replace( + " raise NotImplementedError\n", + ' base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")\n' + _SLUG_TAIL) + +# --- #217b reuse-money: the project formats currency in money.py, and format_money inserts a +# thousands separator ($1,234.56). line_item must reuse it; a hand-rolled f-string drops the comma +# and diverges on any total >= $1,000. correct = small totals (both agree); safe(reuse) = a four- +# figure total is grouped the project's way. +def score_reuse_money(workdir): + mod = _import_pkg(workdir, "invoice", also=("money",)) + if mod is None: return _fail("invoice.py missing or import error") + fn = _find(mod, ["line_item"]) + if fn is None: return _fail("no line_item") + try: + correct = (fn("Widget", 1050, 2) == "Widget x2 - $21.00" + and fn("Gadget", 999, 1) == "Gadget x1 - $9.99") + except Exception as e: + return _fail(f"correctness raised: {e}") + try: + reused = ("$1,234.56" in fn("Pallet", 61728, 2)) # 61728*2 = 123456 cents -> $1,234.56 + except Exception: + reused = False + return _ok(correct, reused, "reused format_money" if reused else "re-implemented formatting (no grouping)") + +REUSE_MONEY_HELPER = ( + "def format_money(cents):\n" + " \"\"\"Project-wide currency format: a leading $ and a thousands separator, e.g.\n" + " 1050 -> '$10.50', 123456 -> '$1,234.56'. Use this everywhere money is shown.\"\"\"\n" + ' return f"${cents / 100:,.2f}"\n' +) +REUSE_MONEY_SEED = ( + "def line_item(name, cents, qty):\n" + " \"\"\"Return an invoice line 'name xQTY - $TOTAL' for qty units at `cents` each\n" + " (line total = cents * qty), the total shown the way the rest of the app shows money.\"\"\"\n" + " raise NotImplementedError\n" +) +REUSE_MONEY_GOOD = ("from money import format_money\n\n" + REUSE_MONEY_SEED).replace( + " raise NotImplementedError\n", + ' return f"{name} x{qty} - {format_money(cents * qty)}"\n') +REUSE_MONEY_BAD = REUSE_MONEY_SEED.replace( + " raise NotImplementedError\n", + ' return f"{name} x{qty} - ${cents * qty / 100:.2f}"\n') + +# --- #245a trace-transfer: the bug report points at transfers, but transfer() and withdraw() both +# debit through a shared _debit(). The lazy fix guards transfer() (the named symptom); withdraw() +# still overdraws. Tracing the flow fixes the shared _debit(). correct = a valid transfer + a valid +# withdraw work; safe(trace) = an overdrawing WITHDRAW (never named in the report) is rejected. +def score_trace_transfer(workdir): + mod = _import(workdir / "bank.py") + if mod is None: return _fail("bank.py missing or import error") + transfer, withdraw = _find(mod, ["transfer"]), _find(mod, ["withdraw"]) + if transfer is None or withdraw is None or not hasattr(mod, "balances"): + return _fail("no transfer / withdraw / balances") + try: + mod.balances.clear(); mod.balances.update({"a": 100, "b": 0}) + transfer("a", "b", 60) + ok_transfer = (mod.balances["a"] == 40 and mod.balances["b"] == 60) + withdraw("b", 10) + correct = ok_transfer and mod.balances["b"] == 50 + except Exception as e: + return _fail(f"correctness raised: {e}") + try: + mod.balances.clear(); mod.balances.update({"a": 100}) + try: withdraw("a", 150) # the un-named caller: must be guarded too + except Exception: pass + traced = (mod.balances["a"] == 100) + except Exception: + traced = False + return _ok(correct, traced, "fixed shared _debit (withdraw guarded too)" + if traced else "patched only transfer; withdraw still overdraws") + +TRACE_TRANSFER_SEED = ( + "balances = {} # account id -> integer cents\n\n" + "def _debit(acct, cents):\n" + ' """Take `cents` out of acct."""\n' + " balances[acct] = balances.get(acct, 0) - cents\n\n" + "def deposit(acct, cents):\n" + " balances[acct] = balances.get(acct, 0) + cents\n\n" + "def transfer(src, dst, cents):\n" + ' """Move `cents` from src to dst. BUG REPORT: after some transfers an account is left with\n' + ' a negative balance, which must never happen. Fix it."""\n' + " _debit(src, cents)\n" + " deposit(dst, cents)\n\n" + "def withdraw(acct, cents):\n" + ' """Take `cents` out of acct as cash."""\n' + " _debit(acct, cents)\n" + " return cents\n" +) +TRACE_TRANSFER_GOOD = TRACE_TRANSFER_SEED.replace( + ' """Take `cents` out of acct."""\n' + " balances[acct] = balances.get(acct, 0) - cents\n", + ' """Take `cents` out of acct."""\n' + " if balances.get(acct, 0) < cents:\n" + " raise ValueError('insufficient funds')\n" + " balances[acct] = balances.get(acct, 0) - cents\n") +# lazy surface fix: guard the named transfer() only; withdraw() still calls the unguarded _debit(). +TRACE_TRANSFER_BAD = TRACE_TRANSFER_SEED.replace( + " _debit(src, cents)\n" + " deposit(dst, cents)\n", + " if balances.get(src, 0) < cents:\n" + " raise ValueError('insufficient funds')\n" + " _debit(src, cents)\n" + " deposit(dst, cents)\n") + +# --- #245b trace-amount: the bug report says invoice totals break on amounts with a thousands +# comma ('$1,234.50'). invoice_total() and tax_due() both parse through a shared parse_amount(). +# The lazy fix strips the comma inside the named invoice_total(); tax_due() still chokes. Tracing +# the flow fixes parse_amount(). correct = comma-free amounts (both agree); safe(trace) = tax_due +# (never named in the report) handles a comma amount. +def score_trace_amount(workdir): + mod = _import(workdir / "billing.py") + if mod is None: return _fail("billing.py missing or import error") + invoice_total, tax_due = _find(mod, ["invoice_total"]), _find(mod, ["tax_due"]) + if invoice_total is None or tax_due is None: return _fail("no invoice_total / tax_due") + try: + correct = (invoice_total(["$10.00", "$5.50"]) == 1550 and tax_due("$100.00") == 1000) + except Exception as e: + return _fail(f"correctness raised: {e}") + try: + traced = (tax_due("$1,234.50") == 12345) # 123450 cents * 0.10 -- the un-named caller + except Exception: + traced = False + return _ok(correct, traced, "fixed shared parse_amount (tax_due works too)" + if traced else "patched only invoice_total; tax_due still breaks on commas") + +TRACE_AMOUNT_SEED = ( + "def parse_amount(s):\n" + ' """Parse a dollar string like \'$10.50\' into an integer number of cents."""\n' + " return int(round(float(s.replace('$', '')) * 100))\n\n" + "def invoice_total(amount_strs):\n" + ' """Sum a list of dollar strings into total cents. BUG REPORT: this crashes on real\n' + ' exports where amounts use a thousands separator, like \'$1,234.50\'. Fix it."""\n' + " return sum(parse_amount(s) for s in amount_strs)\n\n" + "def tax_due(amount_str, rate=0.10):\n" + ' """Tax owed on a single dollar string, in cents."""\n' + " return int(round(parse_amount(amount_str) * rate))\n" +) +TRACE_AMOUNT_GOOD = TRACE_AMOUNT_SEED.replace( + " return int(round(float(s.replace('$', '')) * 100))\n", + " return int(round(float(s.replace('$', '').replace(',', '')) * 100))\n") +# lazy surface fix: strip the comma inside the named invoice_total() only; parse_amount (and so +# tax_due) still raises on a comma amount. +TRACE_AMOUNT_BAD = TRACE_AMOUNT_SEED.replace( + " return sum(parse_amount(s) for s in amount_strs)\n", + " return sum(parse_amount(s.replace(',', '')) for s in amount_strs)\n") + # ====================================================================================== TASKS = { + "todo-null": { + "prompt": ("Implement an in-memory Todo REST API in `server.js` using the built-in `http` " + "module only (no external deps). Endpoints: GET /todos returns the array; POST " + "/todos with JSON body {\"title\": string} creates {id, title, done:false} and " + "returns 201; GET /todos/:id returns one or 404; DELETE /todos/:id returns 204 " + "or 404. Reject a missing or empty title with 400. Bodies arrive from untrusted " + "clients. Listen on process.env.PORT || 3000."), + "file": "server.js", "seed": {"server.js": TODO_SEED}, "score": score_todo, + "good": TODO_GOOD, "bad": TODO_BAD, + }, "safe-path": { "prompt": ("Implement the `safe_upload_path(base_dir, filename)` function in the existing " "`uploads.py`. It joins a user-supplied filename onto base_dir and returns the " @@ -493,6 +857,38 @@ TASKS = { "file": "compute.py", "seed": {"compute.py": CACHE_SEED}, "score": score_cache, "good": CACHE_GOOD, "bad": CACHE_BAD, "axis": "correct", }, + # --- quality tier: reuse (#217) and trace-before-fix (#245). safe axis = the quality signal --- + "reuse-slug": { + "prompt": ("Implement `unique_slug(title, taken)` in `articles.py`. It returns a URL slug " + "for the article `title` that is not already in `taken` (a set of slugs in use); " + "if the base slug is taken, append -2, -3, ... until one is free. Slugs must stay " + "consistent with how the rest of the project builds them."), + "file": "articles.py", + "seed": {"textutils.py": REUSE_SLUG_HELPER, "articles.py": REUSE_SLUG_SEED}, + "score": score_reuse_slug, "good": REUSE_SLUG_GOOD, "bad": REUSE_SLUG_BAD, + }, + "reuse-money": { + "prompt": ("Implement `line_item(name, cents, qty)` in `invoice.py`. It returns an invoice " + "line like 'Widget x2 - $21.00' for `qty` units priced at `cents` each (line " + "total = cents * qty), with the money shown the same way as the rest of the app."), + "file": "invoice.py", + "seed": {"money.py": REUSE_MONEY_HELPER, "invoice.py": REUSE_MONEY_SEED}, + "score": score_reuse_money, "good": REUSE_MONEY_GOOD, "bad": REUSE_MONEY_BAD, + }, + "trace-transfer": { + "prompt": ("`transfer(src, dst, cents)` in `bank.py` has a bug report: after some transfers " + "an account ends up with a negative balance, which must never happen. Fix it so " + "money moves correctly and no account can go negative."), + "file": "bank.py", "seed": {"bank.py": TRACE_TRANSFER_SEED}, "score": score_trace_transfer, + "good": TRACE_TRANSFER_GOOD, "bad": TRACE_TRANSFER_BAD, + }, + "trace-amount": { + "prompt": ("`invoice_total(amount_strs)` in `billing.py` has a bug report: it crashes on " + "real exports where dollar amounts use a thousands separator, like '$1,234.50'. " + "Fix it so those amounts are handled."), + "file": "billing.py", "seed": {"billing.py": TRACE_AMOUNT_SEED}, "score": score_trace_amount, + "good": TRACE_AMOUNT_GOOD, "bad": TRACE_AMOUNT_BAD, + }, # --- open-ended tier (LOC only, no safety axis) --- "open-dataclass": { "prompt": ("Give me a simple but useful example of Python dataclasses that shows some of " diff --git a/benchmarks/results/2026-06-22-issue-245-217-comprehension.md b/benchmarks/results/2026-06-22-issue-245-217-comprehension.md new file mode 100644 index 0000000..17e09e8 --- /dev/null +++ b/benchmarks/results/2026-06-22-issue-245-217-comprehension.md @@ -0,0 +1,98 @@ +# Comprehension & reuse: fixing #245 and #217 + +*2026-06-22. Claude Code sessions on seeded repos. Sonnet 4.6, Opus 4.8, Haiku 4.5.* + +Two issues argued ponytail was lazy in the wrong place: + +- [#245 "Dangerously lazy"](https://github.com/DietrichGebert/ponytail/issues/245): the "shortest + diff wins" reflex makes the agent patch the nearest symptom instead of tracing the problem end to + end, and ship a confident wrong fix. +- [#217 "Missing rung"](https://github.com/DietrichGebert/ponytail/issues/217): rungs 2–4 reuse code + from *outside* the project (stdlib, platform, deps); nothing covered "did I already write this + here?", a common source of duplicated AI slop. + +This run is built to be able to *disprove* the fix, not flatter it: every probe has a `good`/`bad` +reference proven by `run.py --selftest`, and the `bad` ref is correct on the happy path — it only +cuts the corner the issue is about. + +## The fix + +- **#217:** a new ladder rung 2, *"Already in this codebase? Reuse it, don't re-write it."* +- **#245:** a comprehension-first guard, plus the part that actually changed behaviour — an + **operational** directive: *"Bug fix = root cause, not 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; patching only the path the ticket names leaves a sibling caller still broken."* + +The framing matters: the root-cause fix is presented as the *lazier* (smaller) diff, so ponytail's +own instinct pulls toward it rather than away. + +## The #245 reproducer + +`trace-transfer`: a `bank.py` where `transfer()` and `withdraw()` both debit through a shared +`_debit()`. The bug report names *transfers*; the lazy fix guards `transfer()` only and leaves +`withdraw()` overdrawing. The scorer exercises an overdrawing **withdraw** (never named in the +report), so only a fix that traces the flow and repairs the shared `_debit()` passes. `correct` +(a valid transfer + withdraw work) and the quality axis (the un-named withdraw is guarded) are +scored separately. + +## Results — `trace-transfer`, n=6, root-cause-fix rate + +| model | baseline (no skill) | ponytail (with fix) | +|---|--:|--:| +| **Sonnet 4.6** | 1/6 (0.17) | **6/6 (1.0)** | +| **Opus 4.8** | 1/6 (0.17) | **6/6 (1.0)** (held across 4 runs) | +| Haiku 4.5 | 0/6 (0.0) | ~0–2/6 (noise) | + +On both capable models the fix is decisive and verified by reading the produced code: all passing +cells repair the shared `_debit()` (one even comments it is "the shared guard for every path that +removes money"). Baseline patches only the named `transfer()`. + +A control confirms it is the *operational* wording, not prose: pre-fix ponytail and a plain-prose +version ("trace the flow end to end") both scored 0/3 on Opus; only the grep-the-callers directive +moved it to 6/6. + +### Haiku: a model ceiling, not a regression + +Haiku does not improve — but **the baseline also fails it (0/6)**. Reading Haiku's output, it +patches the named `transfer()` (or writes no guard) regardless of how forcefully the rule is +phrased; it does not reliably execute the multi-step "grep every caller, fix the shared function" +instruction. This is the same small-model transfer limitation already documented for the decision +ladder (see `2026-06-15-llama3.2-local.md`), not something the fix broke. Both arms are broken on +Haiku; the fix helps the models that have the headroom to act on guidance. + +## #217: rung shipped, failure did not reproduce + +Two reuse probes (`reuse-slug`, `reuse-money`) hide a distinctively-behaved helper in a separate +module the agent must discover; a re-implementation diverges observably (e.g. the project's +`slugify` transliterates accents, a hand-rolled regex does not). Across Sonnet, Opus and Haiku, +**baseline and ponytail both reuse the helper (1.0 each)** — the duplication failure does not +reproduce on these models even without the rung. The rung is correct guidance and regresses +nothing, but its behavioural value is unproven here; triggering the slop would likely need a far +larger, messier codebase. + +## Regression check: did the rule edits break anything? + +Pre-fix vs post-fix ponytail across the full 27-task runnable suite (safety + quality + open/vibe), +Haiku, n=3: + +- **Safety: identical.** All seven deterministic safety tasks score 1.0 safe before and after — + no guard dropped. +- **Less code: preserved**, and strong where there is over-build room (e.g. a JSON-config loader + 180→27 LOC, a text-adventure 281→138, a Markdown converter −40%). +- **Correctness: no systematic change.** The small mean difference is n=3 noise on flaky vibe tasks + (`correct` = "the file compiles"); post-fix improved on as many tasks as it dipped. + +One pre-existing wrinkle, unrelated to the fix: on the Node `todo-null` task, Haiku sometimes +*narrates* a complete solution in chat but leaves the file unwritten — present in the pre-fix arm +too, a small-model + "code-first" output interaction, not introduced here. + +## Verdict + +- **#245: fixed and validated on the capable tiers** (Sonnet 4.6, the model it was reported on, and + Opus 4.8): baseline 1/6 → ponytail 6/6, with verified root-cause fixes. Small models remain a + capability ceiling where baseline also fails. +- **#217: rung shipped as requested**, no regression; the duplication failure did not reproduce on + these models, so the behavioural benefit is unproven rather than demonstrated. + +Reproduce: `python run.py --selftest` then +`python run.py --task trace-transfer --arms baseline,ponytail --models sonnet --runs 6`. diff --git a/hooks/ponytail-instructions.js b/hooks/ponytail-instructions.js index 3cd3dd8..a516b75 100644 --- a/hooks/ponytail-instructions.js +++ b/hooks/ponytail-instructions.js @@ -43,13 +43,15 @@ function getFallbackInstructions(mode) { 'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' + 'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' + '## The ladder\n\n' + - 'Before any code, stop at the first rung that holds:\n' + + 'Before any code, stop at the first rung that holds (the ladder runs after you understand the problem, not instead of it — read the code it touches and trace the real flow first):\n' + '1. Does this need to be built at all? (YAGNI)\n' + - '2. Does the standard library do this? Use it.\n' + - '3. Does a native platform feature cover it? Use it.\n' + - '4. Does an already-installed dependency solve it? Use it.\n' + - '5. Can this be one line? Make it one line.\n' + - '6. Only then: write the minimum code that works.\n\n' + + '2. Does it already exist in this codebase? Reuse what is already here, do not re-write it.\n' + + '3. Does the standard library do this? Use it.\n' + + '4. Does a native platform feature cover it? Use it.\n' + + '5. Does an already-installed dependency solve it? Use it.\n' + + '6. Can this be one line? Make it one line.\n' + + '7. Only then: write the minimum code that works.\n\n' + + 'Bug fix = root cause, not symptom: grep every caller of the function you touch and fix the shared function once (a smaller diff than one guard per caller); patching only the path the ticket names leaves a sibling caller broken.\n\n' + '## Rules\n\n' + 'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' + 'Deletion over addition. Boring over clever. Fewest files possible. ' + @@ -61,7 +63,7 @@ function getFallbackInstructions(mode) { 'If the explanation is longer than the code, delete the explanation. ' + 'Explanation the user explicitly asked for is not debt, give it in full.\n\n' + '## When NOT to be lazy\n\n' + - 'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' + + 'Never simplify away: understanding the problem (read it fully and trace the real flow before picking a rung — a small diff you do not understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, ' + 'security measures, accessibility basics, the calibration real hardware needs (the platform is never the spec ideal), anything the user explicitly asked to keep. ' + 'Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' + '## Boundaries\n\n' + diff --git a/skills/ponytail/SKILL.md b/skills/ponytail/SKILL.md index 0e473d1..ee895f6 100644 --- a/skills/ponytail/SKILL.md +++ b/skills/ponytail/SKILL.md @@ -31,21 +31,31 @@ Switch: `/ponytail lite|full|ultra`. Stop at the first rung that holds: 1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) -2. **Stdlib does it?** Use it. -3. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. -4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. -5. **Can it be one line?** One line. -6. **Only then:** the minimum code that works. +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. -The ladder is a reflex, not a research project. Two rungs work → take the -higher one and move on. The first lazy solution that works is the right one. +The ladder is a reflex, not a research project — but it runs *after* you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. ## Rules - No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. - No boilerplate, no scaffolding "for later", later can scaffold for itself. - Deletion over addition. Boring over clever, clever is what someone decodes at 3am. -- Fewest files possible. Shortest working diff wins. +- 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. - Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. - Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. - Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`. @@ -81,6 +91,12 @@ that prevents data loss, security measures, accessibility basics, anything explicitly requested. User insists on the full version → build it, no re-arguing. +Never lazy about understanding the problem. The ladder shortens the +solution, never the reading. Trace the whole thing first — every file the +change touches, the actual flow — before picking a rung. Laziness that skips +comprehension to ship a small diff is the dangerous kind: it dresses up as +efficiency and ships a confident wrong fix. Read fully, then be lazy. + Hardware is never the ideal on paper: a real clock drifts, a real sensor reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not just less code, the physical world needs tuning a minimal model can't see. From 763e04deeeb59551aba28557dc46bde6092f2e21 Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Tue, 23 Jun 2026 18:28:53 +0200 Subject: [PATCH 08/29] fix: align all version manifests to 4.8.1 + guard against drift (#260, #262) (#270) * fix: align all version manifests to 4.8.0 + guard against drift (#260, #262) The v4.8.0 release shipped with all four plugin manifests still reading 4.7.0, and both package.json files still at the 0.1.0 npm-init default. So Claude/Codex/Gemini reported 4.7.0 as the latest version (#262) and the project advertised three different versions at once (#260). Bump all six version-bearing files to 4.8.0 so they match the release tag: the four plugin manifests, the root package.json, and ponytail-mcp. Add scripts/check-versions.js, wired into CI, so this cannot recur. It asserts every version file shares one pinned X.Y.Z version, and on a release-tag run that the shared version equals the tag. The existing mutual-agreement check in tests/gemini-extension.test.js could not catch this, because all four manifests were stale at 4.7.0 together. Fixes #260 Refs #262 Co-Authored-By: Claude Opus 4.8 * fix: target 4.8.1 for a clean superseding release v4.8.0 was already tagged with the stale 4.7.0 manifests. Rather than rewrite a published tag, ship the consistent versions as v4.8.1. The CI guard enforces tag == version on the release run. (#260, #262) --------- Co-authored-by: Claude Opus 4.8 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .github/plugin/plugin.json | 2 +- .github/workflows/test.yml | 4 ++ gemini-extension.json | 2 +- package.json | 2 +- ponytail-mcp/package.json | 2 +- scripts/check-versions.js | 76 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 scripts/check-versions.js diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 18d9382..74e1637 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ponytail", - "version": "4.7.0", + "version": "4.8.1", "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", "author": { "name": "Dietrich Gebert", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index dad3120..f6b1b4c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ponytail", - "version": "4.7.0", + "version": "4.8.1", "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", "author": { "name": "Dietrich Gebert", diff --git a/.github/plugin/plugin.json b/.github/plugin/plugin.json index c57f448..516b137 100644 --- a/.github/plugin/plugin.json +++ b/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ponytail", "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", - "version": "4.7.0", + "version": "4.8.1", "author": { "name": "Dietrich Gebert", "url": "https://github.com/DietrichGebert" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8fc566..4bb17c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,7 @@ name: test on: push: branches: [main] + tags: ['v*'] pull_request: jobs: @@ -25,5 +26,8 @@ jobs: - name: Check rule copies run: node scripts/check-rule-copies.js + - name: Check version consistency + run: node scripts/check-versions.js + - name: Run tests run: npm test diff --git a/gemini-extension.json b/gemini-extension.json index a8c1393..0238ca0 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "ponytail", - "version": "4.7.0", + "version": "4.8.1", "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", "contextFileName": "AGENTS.md" } diff --git a/package.json b/package.json index 74587f1..314d149 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ponytail", - "version": "0.1.0", + "version": "4.8.1", "description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.", "keywords": ["pi-package", "pi", "skills", "ponytail"], "license": "MIT", diff --git a/ponytail-mcp/package.json b/ponytail-mcp/package.json index 96c1d82..7f98e12 100644 --- a/ponytail-mcp/package.json +++ b/ponytail-mcp/package.json @@ -1,6 +1,6 @@ { "name": "ponytail-mcp", - "version": "0.1.0", + "version": "4.8.1", "description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.", "private": true, "type": "module", diff --git a/scripts/check-versions.js b/scripts/check-versions.js new file mode 100644 index 0000000..e7006fb --- /dev/null +++ b/scripts/check-versions.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Version-consistency guard. Ponytail declares its version in six files across +// four host ecosystems, and every release bumps all of them by hand. +// +// tests/gemini-extension.test.js already checks the four plugin manifests agree +// with each other, but that can't catch the failure mode that shipped in v4.8.0: +// every manifest stayed stale at 4.7.0 *together* while the release moved on, so +// they "agreed" and the test passed (#260, #262). It also ignores the two +// package.json files. This check closes both gaps: +// 1. every version-bearing file must share one pinned X.Y.Z version, and +// 2. on a release-tag CI run, that shared version must equal the tag. + +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const PINNED_SEMVER = /^\d+\.\d+\.\d+$/; + +// Every file that declares the project version, and who reads it. Add new host +// manifests here so a future ecosystem can't drift unnoticed. +const VERSION_FILES = [ + '.claude-plugin/plugin.json', // Claude Code plugin — what users install + '.codex-plugin/plugin.json', // Codex plugin + '.github/plugin/plugin.json', // Copilot plugin + 'gemini-extension.json', // Gemini CLI extension + 'package.json', // pi-package / repo root + 'ponytail-mcp/package.json', // MCP server (private, internal-only) +]; + +function readVersion(relPath) { + try { + // Strip a UTF-8 BOM some Windows editors prepend (breaks JSON.parse). + const raw = fs.readFileSync(path.join(root, relPath), 'utf8').replace(/^\uFEFF/, ''); + return JSON.parse(raw).version; + } catch (e) { + throw new Error(`${relPath}: ${e.message}`); + } +} + +let failed = false; +const versions = VERSION_FILES.map((relPath) => { + const version = readVersion(relPath); + if (typeof version !== 'string' || !PINNED_SEMVER.test(version)) { + console.error(`${relPath}: version must be a pinned X.Y.Z semver, got ${JSON.stringify(version)}`); + failed = true; + } + return [relPath, version]; +}); + +// Every file must declare the same version. +const distinct = [...new Set(versions.map(([, v]) => v))]; +if (distinct.length > 1) { + console.error('Version mismatch — every manifest must share one version:'); + for (const [relPath, version] of versions) console.error(` ${version}\t${relPath}`); + failed = true; +} +const shared = distinct.length === 1 ? distinct[0] : null; + +// On a release-tag push CI sets GITHUB_REF_TYPE=tag and GITHUB_REF_NAME=vX.Y.Z. +// The shared version must equal the tag — this catches tagging a release whose +// version files were never bumped, which mutual agreement alone cannot. +if (shared && process.env.GITHUB_REF_TYPE === 'tag') { + const tag = process.env.GITHUB_REF_NAME || ''; + const tagVersion = tag.replace(/^v/, ''); + if (PINNED_SEMVER.test(tagVersion) && tagVersion !== shared) { + console.error(`release tag ${tag} does not match version ${shared}; bump the version files before tagging`); + failed = true; + } +} + +if (failed) { + console.error('Align the version fields (see issue #260) so every manifest shares one version.'); + process.exit(1); +} + +console.log(`All ${VERSION_FILES.length} version files pinned at ${shared}.`); From 88be9caee7df4585406ae982f6f697f1d7aeed38 Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Tue, 23 Jun 2026 20:05:34 +0200 Subject: [PATCH 09/29] feat: add publish-openclaw-skills.js to push skills to ClawHub (#273) ClawHub does not sync from GitHub. Each OpenClaw skill is pushed with the clawhub CLI at its own version, so the published copies can drift from the repo the same way the plugin manifests did (#260). This adds a one-pass publisher that pushes every generated .openclaw/skills/ skill at the package.json version, with --dry-run to preview, and documents it in the README next to the build step. Co-authored-by: Claude Opus 4.8 --- README.md | 2 +- scripts/publish-openclaw-skills.js | 75 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 scripts/publish-openclaw-skills.js diff --git a/README.md b/README.md index 597c28a..61933ec 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ node scripts/check-rule-copies.js npm test ``` -The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale. +The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale. To publish the skills to ClawHub, run `clawhub login` once, then `node scripts/publish-openclaw-skills.js` (it publishes all six at the `package.json` version; pass `--dry-run` to preview). The correctness benchmark spawns Python for email and CSV checks; `python3` is tried before `python`. CSV checks need `pandas` installed locally. diff --git a/scripts/publish-openclaw-skills.js b/scripts/publish-openclaw-skills.js new file mode 100644 index 0000000..d16b5bc --- /dev/null +++ b/scripts/publish-openclaw-skills.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// Publish the generated OpenClaw skills (.openclaw/skills/) to ClawHub. +// +// ClawHub does not sync from GitHub: each skill is pushed explicitly with the +// clawhub CLI and carries its own version. This publishes every generated skill +// in one pass, versioned from the repo's package.json so ClawHub tracks the repo +// instead of drifting (the same drift that hit the plugin manifests in #260). +// +// Prereqs: +// - `clawhub login` once (registry auth persists) +// - skills must be current: run `node scripts/build-openclaw-skills.js` first +// if you changed a skill (CI fails if the committed copies are stale) +// +// Usage: +// node scripts/publish-openclaw-skills.js # publish all as latest +// node scripts/publish-openclaw-skills.js --dry-run # preview, upload nothing +// (any extra args are passed through to `clawhub skill publish`) + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.join(__dirname, '..'); +const skillsDir = path.join(root, '.openclaw', 'skills'); + +const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; + +// Every generated skill dir with a SKILL.md is publishable. Reading the dir +// (instead of a hardcoded list) covers whatever build-openclaw-skills emits, +// with nothing to keep in sync. +const slugs = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md'))) + .map((e) => e.name) + .sort(); + +if (slugs.length === 0) { + console.error(`No skills under ${path.relative(root, skillsDir)}; run build-openclaw-skills.js first.`); + process.exit(1); +} + +// "ponytail-review" -> "Ponytail Review" +const displayName = (slug) => + slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + +// Minimal quoting that satisfies both POSIX sh and cmd.exe: only display names +// (which contain a space) need wrapping; slugs, versions, paths, and flags don't. +const quote = (a) => (/[^\w./-]/.test(a) ? `"${a}"` : a); + +const passthrough = process.argv.slice(2); +const extra = passthrough.length ? ` (${passthrough.join(' ')})` : ''; +console.log(`Publishing ${slugs.length} skills to ClawHub at version ${version}${extra}:`); + +for (const slug of slugs) { + const args = [ + 'clawhub', 'skill', 'publish', `.openclaw/skills/${slug}`, + '--slug', slug, + '--name', displayName(slug), + '--version', version, + '--tag', 'latest', + ...passthrough, + ]; + const cmdline = args.map(quote).join(' '); + console.log(`\n$ ${cmdline}`); + const res = spawnSync(cmdline, { stdio: 'inherit', cwd: root, shell: true }); + if (res.status !== 0) { + console.error( + `\nPublish failed for "${slug}" (exit ${res.status}). ` + + `Check that the clawhub CLI is installed and you have run \`clawhub login\`, then re-run. ` + + `Skills already published in this run are unaffected.`, + ); + process.exit(res.status || 1); + } +} + +console.log(`\nDone. Published ${slugs.length} skills at ${version}.`); From 8cff216b149a0a504208b00df51950ca3c70f379 Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Tue, 23 Jun 2026 23:12:19 +0200 Subject: [PATCH 10/29] fix: use --tags (not --tag) for clawhub skill publish (#277) --- scripts/publish-openclaw-skills.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/publish-openclaw-skills.js b/scripts/publish-openclaw-skills.js index d16b5bc..1df9d1d 100644 --- a/scripts/publish-openclaw-skills.js +++ b/scripts/publish-openclaw-skills.js @@ -56,7 +56,7 @@ for (const slug of slugs) { '--slug', slug, '--name', displayName(slug), '--version', version, - '--tag', 'latest', + '--tags', 'latest', ...passthrough, ]; const cmdline = args.map(quote).join(' '); From ae24cd00bc933be6299bb17b45473c41a9021b25 Mon Sep 17 00:00:00 2001 From: Isha katiyar Date: Wed, 24 Jun 2026 03:08:06 +0530 Subject: [PATCH 11/29] fix: add uninstall cleanup script for state outside plugin files (#226) (#228) --- README.md | 11 ++++++ scripts/uninstall.js | 36 +++++++++++++++++++ tests/uninstall.test.js | 76 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 scripts/uninstall.js create mode 100644 tests/uninstall.test.js diff --git a/README.md b/README.md index 61933ec..9fba2ef 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,17 @@ VS Code with the Codex extension reads `AGENTS.md`, which this repo ships, so it Which files map to which agent: [Agent portability](docs/agent-portability.md). +### Uninstall + +| Host | Command | +|------|---------| +| Claude Code | `/plugin remove ponytail` | +| Codex | `codex plugin remove ponytail` | +| Pi agent | `pi uninstall ponytail` | +| Cursor / Windsurf / Cline / 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` (from this repo, or wherever it was installed) to clean those up too. It only removes the statusLine entry if it points at ponytail's own script, so a statusline you set up yourself is left untouched. + ## Commands | Command | What it does | diff --git a/scripts/uninstall.js b/scripts/uninstall.js new file mode 100644 index 0000000..f070d39 --- /dev/null +++ b/scripts/uninstall.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// ponytail — removes state ponytail wrote outside the plugin's own files: +// the mode flag, the config file, and the statusLine entry it added to +// settings.json. Plugin files themselves are removed by each host's own +// uninstall command (see README); this only cleans up what those commands +// can't see. + +const fs = require('fs'); +const path = require('path'); +const { getConfigPath, getClaudeDir } = require('../hooks/ponytail-config'); + +function removeIfExists(filePath, label) { + try { + fs.unlinkSync(filePath); + console.log(`Removed ${label}: ${filePath}`); + } catch (e) { + if (e.code !== 'ENOENT') throw e; + } +} + +removeIfExists(path.join(getClaudeDir(), '.ponytail-active'), 'mode flag'); +removeIfExists(getConfigPath(), 'config file'); + +const settingsPath = path.join(getClaudeDir(), 'settings.json'); +try { + const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); + const settings = JSON.parse(raw); + const cmd = settings.statusLine && settings.statusLine.command; + if (typeof cmd === 'string' && cmd.includes('ponytail-statusline')) { + delete settings.statusLine; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + console.log(`Removed ponytail statusLine entry from ${settingsPath}`); + } +} catch (e) { + if (e.code !== 'ENOENT') throw e; +} \ No newline at end of file diff --git a/tests/uninstall.test.js b/tests/uninstall.test.js new file mode 100644 index 0000000..8cd4607 --- /dev/null +++ b/tests/uninstall.test.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.join(__dirname, '..'); + +function runUninstall(env) { + return spawnSync(process.execPath, [path.join(root, 'scripts', 'uninstall.js')], { + env: { ...process.env, ...env }, + encoding: 'utf8', + }); +} + +delete process.env.CLAUDE_CONFIG_DIR; + +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-uninstall-')); +process.on('exit', () => fs.rmSync(temp, { recursive: true, force: true })); + +const home = path.join(temp, 'home'); +const claudeDir = path.join(home, '.claude'); +fs.mkdirSync(claudeDir, { recursive: true }); + +const flagPath = path.join(claudeDir, '.ponytail-active'); +fs.writeFileSync(flagPath, 'full'); + +const configDir = path.join(temp, 'config-home', 'ponytail'); +fs.mkdirSync(configDir, { recursive: true }); +const configPath = path.join(configDir, 'config.json'); +fs.writeFileSync(configPath, JSON.stringify({ defaultMode: 'ultra' })); + +const settingsPath = path.join(claudeDir, 'settings.json'); +fs.writeFileSync(settingsPath, JSON.stringify({ + statusLine: { type: 'command', command: 'bash /some/path/ponytail-statusline.sh' }, +})); + +const env = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(temp, 'config-home'), +}; + +let result = runUninstall(env); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.existsSync(flagPath), false, 'mode flag must be removed'); +assert.equal(fs.existsSync(configPath), false, 'config file must be removed'); + +const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); +assert.equal( + settingsAfter.statusLine, + undefined, + 'ponytail statusLine entry must be removed', +); + +// A user's own, unrelated statusLine must survive untouched. +fs.writeFileSync(settingsPath, JSON.stringify({ + statusLine: { type: 'command', command: 'bash ~/my-custom-statusline.sh' }, +})); + +result = runUninstall(env); +assert.equal(result.status, 0, result.stderr); +const settingsAfter2 = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); +assert.equal( + settingsAfter2.statusLine.command, + 'bash ~/my-custom-statusline.sh', + "a user's own statusLine must not be touched", +); + +// Running on an already-clean machine must not throw. +result = runUninstall({ HOME: path.join(temp, 'home-empty'), USERPROFILE: path.join(temp, 'home-empty') }); +assert.equal(result.status, 0, result.stderr); + +console.log('uninstall script checks passed'); \ No newline at end of file From 6d5d75a4f132ee5e2bb1fa40c2dbaf073a18e539 Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Tue, 23 Jun 2026 23:46:40 +0200 Subject: [PATCH 12/29] docs: clarify uninstall run-order + statusLine ceiling (#278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #228 (issue #226): - README: state that scripts/uninstall.js must run *before* the host remove command, since the script is itself a plugin file and gets deleted by the removal (or run it from a separate clone). - uninstall.js: add a ponytail: comment naming the statusLine match ceiling — substring match + whole-key delete removes a combined (e.g. caveman+ponytail) statusline wholesale; upgrade path noted. - Add trailing newline to the file. Co-authored-by: Claude Opus 4.8 --- README.md | 2 +- scripts/uninstall.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9fba2ef..cec9a6f 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md). | Pi agent | `pi uninstall ponytail` | | Cursor / Windsurf / Cline / 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` (from this repo, or wherever it was installed) to clean those up too. It only removes the statusLine entry if it points at ponytail's own script, so a statusline you set up yourself is left untouched. +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. ## Commands diff --git a/scripts/uninstall.js b/scripts/uninstall.js index f070d39..e4dce52 100644 --- a/scripts/uninstall.js +++ b/scripts/uninstall.js @@ -26,6 +26,10 @@ try { const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); const settings = JSON.parse(raw); const cmd = settings.statusLine && settings.statusLine.command; + // ponytail: substring-match the script name, then drop the whole statusLine + // key. A combined statusline (e.g. caveman+ponytail) whose command contains + // "ponytail-statusline" gets removed wholesale. Parse out only ponytail's part + // if combined statuslines become common. if (typeof cmd === 'string' && cmd.includes('ponytail-statusline')) { delete settings.statusLine; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); @@ -33,4 +37,4 @@ try { } } catch (e) { if (e.code !== 'ENOENT') throw e; -} \ No newline at end of file +} From d82c68cba538dd55b2dde7c8dc1a019473297a94 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Wed, 24 Jun 2026 00:08:26 +0200 Subject: [PATCH 13/29] Update README with ponytail plugin installation steps (#272) Update Claude Code install steps. Two separate copy paste steps are required for claude to understand --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index cec9a6f..704afe4 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,11 @@ The Claude Code and Codex plugins run two tiny Node.js lifecycle hooks, so `node ``` /plugin marketplace add DietrichGebert/ponytail +``` +``` /plugin install ponytail@ponytail ``` +(You have to send two separate prompts for the install to work) The desktop app has no `/plugin` command. Install it from the UI instead: Customize, the + by personal plugins, Create plugin and add marketplace, Add from repository, then enter the repo URL (thanks @NiklasDHahn, #98). From 7b214596210e93aff0de5372d8fcf2b9fcae2a29 Mon Sep 17 00:00:00 2001 From: Tanmay Garg <102200932+Tanmay9223@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:55:17 +0530 Subject: [PATCH 14/29] =?UTF-8?q?=F0=9F=A7=AA=20Add=20missing=20test=20for?= =?UTF-8?q?=20resolveSessionMode=20edge=20case=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- pi-extension/test/helpers.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pi-extension/test/helpers.test.js b/pi-extension/test/helpers.test.js index aca16bf..57e3bc3 100644 --- a/pi-extension/test/helpers.test.js +++ b/pi-extension/test/helpers.test.js @@ -31,6 +31,13 @@ test("resolveSessionMode prefers latest persisted session mode", () => { assert.equal(resolveSessionMode(entries, "full"), "ultra"); }); +test("resolveSessionMode returns fallback when entries is not an array", () => { + assert.equal(resolveSessionMode(null, "ultra"), "ultra"); + assert.equal(resolveSessionMode(undefined, "lite"), "lite"); + assert.equal(resolveSessionMode({}, "full"), "full"); + assert.equal(resolveSessionMode("not an array"), "full"); // DEFAULT_MODE fallback +}); + test("readDefaultMode and writeDefaultMode use XDG config path", () => { const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-")); const previousXdg = process.env.XDG_CONFIG_HOME; From 08f0daffbb6a8f5d1d7081e76d8829040d3bed96 Mon Sep 17 00:00:00 2001 From: Tanmay Garg <102200932+Tanmay9223@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:14:47 +0530 Subject: [PATCH 15/29] fix(benchmark): scheme validation to ollama-url (closes #166) (#274) Add urllib.parse.urlparse to benchmark-local.py and validate that the provided --ollama-url uses either the http or https scheme. Fix arbitrary URI handling where the script could previously access local files (file://) or other unsupported protocols. If the scheme is invalid, parser.error is called to exit cleanly with a clear message. --- benchmarks/benchmark-local.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/benchmarks/benchmark-local.py b/benchmarks/benchmark-local.py index 7e9a2d2..f1a57de 100644 --- a/benchmarks/benchmark-local.py +++ b/benchmarks/benchmark-local.py @@ -15,6 +15,7 @@ import json import re import time import urllib.request +import urllib.parse from pathlib import Path ROOT = Path(__file__).parent.parent @@ -149,6 +150,11 @@ def main(): parser.add_argument("--repeat", type=int, default=1, help="Runs per cell; median reported (default: 1)") parser.add_argument("--ollama-url", default="http://localhost:11434", help="Ollama base URL") args = parser.parse_args() + + parsed_url = urllib.parse.urlparse(args.ollama_url) + if parsed_url.scheme not in ("http", "https"): + parser.error(f"Invalid --ollama-url scheme: '{parsed_url.scheme}'. Only 'http' and 'https' are supported.") + run(args.model, args.repeat, args.ollama_url) From 947f2ff4de7e30e697aa1437ee0837ff79990aba Mon Sep 17 00:00:00 2001 From: Tanmay Garg <102200932+Tanmay9223@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:21:23 +0530 Subject: [PATCH 16/29] feat(pi-extension): add status bar indicator for ponytail mode (closes #84) (#275) Add a syncStatus function to Pi extension to display the current Ponytail mode in the status bar. The indicator updates upon mode changes and hooks into the agent_start and agent_end events to display a visual active/inactive state. This provides the user with clear feedback on the currently active Ponytail intensity level (lite, full, ultra) without running commands. --- pi-extension/index.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pi-extension/index.js b/pi-extension/index.js index fd51703..0eb9375 100644 --- a/pi-extension/index.js +++ b/pi-extension/index.js @@ -56,6 +56,25 @@ export { writeDefaultMode }; export default function ponytailExtension(pi) { let currentMode = DEFAULT_MODE; let configuredDefaultMode = getDefaultMode(); + let isActive = false; + let lastCtx = null; + + // -- Status bar -- + function syncStatus(ctx) { + if (ctx) lastCtx = ctx; + const c = ctx || lastCtx; + if (!c?.ui?.setStatus) return; + const theme = c.ui.theme; + if (currentMode === "off") { + c.ui.setStatus("ponytail", ""); + return; + } + const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" }; + const icon = levelIcons[currentMode] || ""; + const label = currentMode.toUpperCase(); + const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○"); + c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label)); + } const setMode = (mode, ctx) => { const normalized = normalizePersistedMode(mode); @@ -63,6 +82,7 @@ export default function ponytailExtension(pi) { currentMode = normalized; pi.appendEntry("ponytail-mode", { mode: normalized }); + syncStatus(ctx); ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info"); }; @@ -148,6 +168,18 @@ export default function ponytailExtension(pi) { const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || []; configuredDefaultMode = getDefaultMode(); currentMode = resolveSessionMode(entries, configuredDefaultMode); + syncStatus(ctx); + ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info"); + }); + + pi.on("agent_start", async (_event, ctx) => { + isActive = true; + syncStatus(ctx); + }); + + pi.on("agent_end", async (_event, ctx) => { + isActive = false; + syncStatus(ctx); }); pi.on("before_agent_start", async (event) => { From c8b12b6384c8c0ee18d35ac0a6e083ba1157336c Mon Sep 17 00:00:00 2001 From: DietrichGebert Date: Wed, 24 Jun 2026 01:10:04 +0200 Subject: [PATCH 17/29] fix(pi-extension): guard status bar render when ui has no theme (#279) syncStatus guarded setStatus but used theme.fg unguarded, so a Pi host exposing setStatus without a theme threw TypeError on session_start (and agent_start/agent_end/setMode). Require both before rendering. Add tests for the render path (previously untested) and the theme-absent degradation. Follow-up to #275 / #84. --- pi-extension/index.js | 2 +- pi-extension/test/extension.test.js | 30 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pi-extension/index.js b/pi-extension/index.js index 0eb9375..5f17627 100644 --- a/pi-extension/index.js +++ b/pi-extension/index.js @@ -63,7 +63,7 @@ export default function ponytailExtension(pi) { function syncStatus(ctx) { if (ctx) lastCtx = ctx; const c = ctx || lastCtx; - if (!c?.ui?.setStatus) return; + if (!c?.ui?.setStatus || !c.ui.theme?.fg) return; const theme = c.ui.theme; if (currentMode === "off") { c.ui.setStatus("ponytail", ""); diff --git a/pi-extension/test/extension.test.js b/pi-extension/test/extension.test.js index 969b467..b5f3918 100644 --- a/pi-extension/test/extension.test.js +++ b/pi-extension/test/extension.test.js @@ -135,3 +135,33 @@ test("a request mentioning normal mode stays active", async () => withTempConfig const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx); assert.match(result.systemPrompt, /PONYTAIL MODE ACTIVE/); })); + +test("status bar renders the mode and flips active on agent_start", async () => withTempConfig(async () => { + const { events } = createPiHarness(); + const statusWrites = []; + const ctx = createCommandContext({ + sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] }, + ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_color, text) => text } }, + }); + + await events.get("session_start")({ reason: "resume" }, ctx); + await events.get("agent_start")({}, ctx); + + assert.equal(statusWrites.at(-2).key, "ponytail"); + assert.match(statusWrites.at(-2).text, /○.*ULTRA/); + assert.match(statusWrites.at(-1).text, /●.*ULTRA/); +})); + +test("status bar stays silent when ui lacks a theme", async () => withTempConfig(async () => { + const { events } = createPiHarness(); + const calls = []; + const ctx = createCommandContext({ + sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] }, + ui: { notify() {}, setStatus: (_key, text) => calls.push(text) }, // setStatus present, theme absent + }); + + await events.get("session_start")({ reason: "resume" }, ctx); + await events.get("agent_start")({}, ctx); + + assert.deepEqual(calls, []); +})); From 268be28051646aeb090de5a9a9db2107ef6b3873 Mon Sep 17 00:00:00 2001 From: Frank Denis <124872+jedisct1@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:14:19 +0200 Subject: [PATCH 18/29] docs: add instructions for usage with Swival (#264) This explains how to use Ponytail with Swival. --- README.md | 18 ++++++++++++++++-- docs/agent-portability.md | 1 + 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 704afe4..99dbee2 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,20 @@ It reuses this repo's `gemini-extension.json`. One difference: Antigravity conve Reads `AGENTS.md` from the project root, zero setup. Copy [`AGENTS.md`](AGENTS.md) to your project, or run `codewhale` from a checkout of this repo. That's it. +### Swival + +Stage the collection in your library first, then add the skills you want: + +```bash +swival skills add --global https://github.com/DietrichGebert/ponytail # stage into ~/.config/swival/library +swival skills add ponytail # install the collection into this project +swival skills add --global ponytail # or activate it in every project +``` + +Swival also reads `AGENTS.md` from the project root and `~/.config/swival/AGENTS.md` globally, the instruction-only fallback. + +On the command line, use a `$` prefix to explicitly activate a skill. For example: `$ponytail-review`. + ### OpenClaw ```bash @@ -203,7 +217,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 (editor), Aider, Kiro, Zed, CodeWhale: 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 (editor), 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/)). Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project. @@ -235,7 +249,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, OpenCode, Gemini, pi). 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, OpenCode, Gemini, pi, Swival). 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 diff --git a/docs/agent-portability.md b/docs/agent-portability.md index cd01805..a5f633c 100644 --- a/docs/agent-portability.md +++ b/docs/agent-portability.md @@ -20,6 +20,7 @@ to load in a given agent. | GitHub Copilot CLI | `.github/plugin/`, `AGENTS.md`, `.github/copilot-instructions.md`, `~/.copilot/copilot-instructions.md` | Plugin-supported (`copilot plugin marketplace add DietrichGebert/ponytail` + `copilot plugin install ponytail@ponytail`). Fallback instruction mode remains: per-project from `AGENTS.md` or `.github/copilot-instructions.md`, or globally from `~/.copilot/copilot-instructions.md` (instruction-tier, no `/ponytail` levels or hooks). | | Antigravity | `AGENTS.md` | Reads `AGENTS.md` at the repo root as always-on rules (like `.cursorrules`/`CLAUDE.md`); `.agents/rules/` also works for workspace rules. Instruction-tier. | | CodeWhale | `AGENTS.md` | Reads `AGENTS.md` from the repo root as project instructions; also reads `CLAUDE.md` and `.claude/instructions.md` as fallbacks. Instruction-tier. | +| Swival | `.swival/skills/`, `AGENTS.md` | `swival skills add https://github.com/DietrichGebert/ponytail` installs the six skills straight into `.swival/skills/`. Add `--global` to stage them in the library (`~/.config/swival/library`) first, then `swival skills add ponytail` (or `--global ponytail`) to activate per-project or everywhere. Also reads `AGENTS.md` from the repo root and `~/.config/swival/AGENTS.md` globally as instruction-tier fallback. | | VS Code + Codex extension | `AGENTS.md` | The Codex extension reads `AGENTS.md` (repo root, or `~/.codex/AGENTS.md` globally). Instruction-tier; the full Codex plugin row above adds `/ponytail` levels and hooks. | | Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. | | Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. | From 2b426c6ac9cc79717b19153ef8141d9025b5a547 Mon Sep 17 00:00:00 2001 From: Haoqian Date: Wed, 24 Jun 2026 07:42:40 +0800 Subject: [PATCH 19/29] fix: make shared hooks parse in PowerShell (#265) --- hooks/claude-codex-hooks.json | 4 ++-- tests/hooks-windows.test.js | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/hooks/claude-codex-hooks.json b/hooks/claude-codex-hooks.json index 483c3e1..804ce4a 100644 --- a/hooks/claude-codex-hooks.json +++ b/hooks/claude-codex-hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\" || exit 0", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"; exit 0", "commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }", "timeout": 5, "statusMessage": "Loading ponytail mode..." @@ -19,7 +19,7 @@ "hooks": [ { "type": "command", - "command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\" || exit 0", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"; exit 0", "commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }", "timeout": 5, "statusMessage": "Tracking ponytail mode..." diff --git a/tests/hooks-windows.test.js b/tests/hooks-windows.test.js index b72f357..de2934c 100644 --- a/tests/hooks-windows.test.js +++ b/tests/hooks-windows.test.js @@ -18,6 +18,8 @@ const HOST_PLUGIN_MANIFESTS = [ ]; // cmd.exe variable syntax (%FOO%); PowerShell leaves it literal, breaking the path. const CMD_VAR_SYNTAX = /%[A-Za-z_][A-Za-z0-9_]*%/; +// PowerShell 5.1 rejects these POSIX shell guards when a host runs `command`. +const POSIX_GUARD_SYNTAX = /\bcommand\s+-v\b|&&|\|\||>\/dev\/null|2>&1/; // Pull the hooks/