diff --git a/.openclaw/skills/ponytail-help/SKILL.md b/.openclaw/skills/ponytail-help/SKILL.md index 50ff0f1e..54687816 100644 --- a/.openclaw/skills/ponytail-help/SKILL.md +++ b/.openclaw/skills/ponytail-help/SKILL.md @@ -42,7 +42,13 @@ Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. ## Configure Default Mode -Default mode = `full`, auto-active every session. Change it: +Default mode = `full`, auto-active every session. `/ponytail off` lasts only for +the current session — the next one starts at the default again. Change it: + +**Command**: +``` +/ponytail default lite +``` **Environment variable** (highest priority): ```bash diff --git a/.openclaw/skills/ponytail/SKILL.md b/.openclaw/skills/ponytail/SKILL.md index a3e4d94b..eb467bc1 100644 --- a/.openclaw/skills/ponytail/SKILL.md +++ b/.openclaw/skills/ponytail/SKILL.md @@ -15,7 +15,8 @@ code is the code never written. ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. -Switch: `/ponytail lite|full|ultra`. +Switch: `/ponytail lite|full|ultra|off`. Bare `/ponytail` reports the level. +`/ponytail default ` persists it to new sessions. ## The ladder diff --git a/README.es.md b/README.es.md index 8e908adf..a7fd4d76 100644 --- a/README.es.md +++ b/README.es.md @@ -234,7 +234,8 @@ Qué archivos corresponden a qué agente: [Portabilidad de agentes](docs/agent-p | Comando | Qué hace | |---------|----------| -| `/ponytail [lite \| full \| ultra \| off]` | Cambia la intensidad, o apágalo. Sin argumento, reporta el nivel actual. | +| `/ponytail [lite \| full \| ultra \| off]` | Cambia la intensidad, o apágalo. Sin argumento, reporta el nivel actual. Dura solo la sesión: una sesión nueva arranca en el valor por defecto. | +| `/ponytail default [lite \| full \| ultra \| off]` | Guarda el valor por defecto, para que las sesiones nuevas arranquen ahí. | | `/ponytail-review` | Revisa el diff actual en busca de sobre-ingeniería y devuelve una lista de qué eliminar. | | `/ponytail-audit` | Audita el repo completo en busca de sobre-ingeniería, no solo el diff. | | `/ponytail-debt` | Recolecta los atajos marcados con `ponytail:` que dejaste pendientes en un registro, para que "después" no se convierta en "nunca". | diff --git a/README.ko.md b/README.ko.md index df8469ca..742fe864 100644 --- a/README.ko.md +++ b/README.ko.md @@ -253,7 +253,8 @@ Codex 확장을 쓰는 VS Code는 이 저장소가 함께 싣는 `AGENTS.md`를 | 명령 | 하는 일 | |---------|--------------| -| `/ponytail [lite \| full \| ultra \| off]` | 강도를 정하거나, 끈다. 인수가 없으면 지금 레벨을 알려 준다. | +| `/ponytail [lite \| full \| ultra \| off]` | 강도를 정하거나, 끈다. 인수가 없으면 지금 레벨을 알려 준다. 세션 한정이라, 새 세션은 기본값에서 시작한다. | +| `/ponytail default [lite \| full \| ultra \| off]` | 기본값을 저장해서, 새 세션이 그 레벨에서 시작하게 한다. | | `/ponytail-review` | 지금 diff를 과잉 구현 관점에서 훑고, 삭제 목록을 돌려준다. | | `/ponytail-audit` | diff만이 아니라 저장소 전체를 과잉 구현 관점에서 감사한다. | | `/ponytail-debt` | 미뤄 둔 `ponytail:` 간소화들을 장부로 모아, "나중에"가 "영영"이 되지 않게 한다. | diff --git a/README.md b/README.md index c2e9b0e2..65c7400e 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,8 @@ These remove the plugin's own files. They leave behind a small amount of state p | Command | What it does | |---------|--------------| -| `/ponytail [lite \| full \| ultra \| off]` | Set the intensity, or turn it off. No argument reports the current level. | +| `/ponytail [lite \| full \| ultra \| off]` | Set the intensity, or turn it off. No argument reports the current level. Session-scoped: a new session starts at the default. | +| `/ponytail default [lite \| full \| ultra \| off]` | Persist the default, so new sessions start there. | | `/ponytail-review` | Review the current diff for over-engineering, hands back a delete-list. | | `/ponytail-audit` | Audit the whole repo for over-engineering, not just the diff. | | `/ponytail-debt` | Harvest the `ponytail:` shortcuts you've deferred into a ledger, so "later" doesn't become "never". | diff --git a/hooks/ponytail-instructions.js b/hooks/ponytail-instructions.js index 3ec3980a..94b8a8a4 100644 --- a/hooks/ponytail-instructions.js +++ b/hooks/ponytail-instructions.js @@ -45,7 +45,8 @@ function getFallbackInstructions(mode) { 'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' + '## Persistence\n\n' + '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' + + 'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra|off`. ' + + 'Bare `/ponytail` reports the level; `/ponytail default ` persists it.\n\n' + '## The ladder\n\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' + diff --git a/hooks/ponytail-mode-tracker.js b/hooks/ponytail-mode-tracker.js index f644f00a..3af7a4d0 100644 --- a/hooks/ponytail-mode-tracker.js +++ b/hooks/ponytail-mode-tracker.js @@ -9,6 +9,24 @@ const { getPonytailInstructions } = require('./ponytail-instructions'); let input = ''; let done = false; +// Name the default only when it differs from the live level, so the common case +// stays a three-word status line and the surprising case explains itself. +function describeDefault(mode) { + const fallback = getDefaultMode(); + return fallback === mode ? '' : ' (default: ' + fallback + ')'; +} + +// Off is session-scoped: clearMode() only drops the flag, and the next +// SessionStart re-activates at the configured default. Say so here, or the +// user reads tomorrow's active session as ponytail ignoring them (#648). +function offMessage() { + const fallback = getDefaultMode(); + return fallback === 'off' + ? 'Ponytail off.' + : 'Ponytail off for this session — default is ' + fallback + + ', so a new session starts there. `/ponytail default off` to persist.'; +} + function finish() { if (done) return; done = true; @@ -39,7 +57,12 @@ function finish() { const dmode = parts[2]; if (dmode === 'off' || dmode === 'lite' || dmode === 'full' || dmode === 'ultra') { writeDefaultMode(dmode); - writeHookOutput('UserPromptSubmit', dmode, 'PONYTAIL DEFAULT SET — new sessions start in ' + dmode + '.'); + writeHookOutput( + 'UserPromptSubmit', + dmode, + 'PONYTAIL DEFAULT SET — new sessions start in ' + dmode + '.', + 'Ponytail default set to ' + dmode + ' — new sessions start there.', + ); } return; // don't fall through to the session-mode switch } @@ -60,6 +83,7 @@ function finish() { 'UserPromptSubmit', mode, 'PONYTAIL MODE ACTIVE — level: ' + mode, + 'Ponytail: ' + mode + describeDefault(mode), ); } else if (mode && mode !== 'off') { setMode(mode); @@ -72,12 +96,13 @@ function finish() { 'UserPromptSubmit', mode, 'PONYTAIL MODE CHANGED — level: ' + mode, + 'Ponytail: ' + mode + describeDefault(mode), ); } } else if (mode === 'off') { clearMode(); deactivated = true; - writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF'); + writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF', offMessage()); } } @@ -85,7 +110,7 @@ function finish() { if (!modeSwitched && !deactivated && isDeactivationCommand(prompt)) { clearMode(); deactivated = true; - writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF'); + writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF', offMessage()); } // Qoder has no SessionStart event, so UserPromptSubmit does double duty: diff --git a/hooks/ponytail-runtime.js b/hooks/ponytail-runtime.js index 37d682f7..ef934cc6 100644 --- a/hooks/ponytail-runtime.js +++ b/hooks/ponytail-runtime.js @@ -33,7 +33,14 @@ function readMode() { } } -function writeHookOutput(event, mode, context = '') { +// `userMessage`, when set, is text the user asked for and must actually see. +// Every other write is context for the model. The distinction matters because on +// native Claude a UserPromptSubmit hook's stdout becomes additionalContext — +// injected into the assistant's conversation, never shown — so a mode report +// written that way reaches everyone except the person who typed the command +// (#648). Codex already has a systemMessage channel below; this gives the native +// path the same, without making every hook write user-visible. +function writeHookOutput(event, mode, context = '', userMessage = '') { if (isCopilot) { // Copilot reads additionalContext on SessionStart; ignores output elsewhere. process.stdout.write(JSON.stringify( @@ -71,6 +78,14 @@ function writeHookOutput(event, mode, context = '') { { hookSpecificOutput: { hookEventName: event, additionalContext: context } })); return; } + if (userMessage) { + const output = { systemMessage: userMessage }; + if (context) { + output.hookSpecificOutput = { hookEventName: event, additionalContext: context }; + } + process.stdout.write(JSON.stringify(output)); + return; + } process.stdout.write(context); } diff --git a/skills/ponytail-help/SKILL.md b/skills/ponytail-help/SKILL.md index ba145c0e..fe8a8a40 100644 --- a/skills/ponytail-help/SKILL.md +++ b/skills/ponytail-help/SKILL.md @@ -43,7 +43,13 @@ Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. ## Configure Default Mode -Default mode = `full`, auto-active every session. Change it: +Default mode = `full`, auto-active every session. `/ponytail off` lasts only for +the current session — the next one starts at the default again. Change it: + +**Command**: +``` +/ponytail default lite +``` **Environment variable** (highest priority): ```bash diff --git a/skills/ponytail/SKILL.md b/skills/ponytail/SKILL.md index 02c0712c..baa2f6d8 100644 --- a/skills/ponytail/SKILL.md +++ b/skills/ponytail/SKILL.md @@ -13,7 +13,7 @@ description: > over-engineering, bloat, boilerplate, or unnecessary dependencies. Do NOT use for non-coding requests (general knowledge, prose, translation, summaries, recipes). -argument-hint: "[lite|full|ultra]" +argument-hint: "[lite|full|ultra|off]" license: MIT --- @@ -27,7 +27,8 @@ code is the code never written. ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. -Switch: `/ponytail lite|full|ultra`. +Switch: `/ponytail lite|full|ultra|off`. Bare `/ponytail` reports the level. +`/ponytail default ` persists it to new sessions. ## The ladder diff --git a/tests/hooks.test.js b/tests/hooks.test.js index bf4a8eb3..2e383227 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -459,4 +459,49 @@ try { if (prevEnvModeRev === undefined) delete process.env.PONYTAIL_DEFAULT_MODE; else process.env.PONYTAIL_DEFAULT_MODE = prevEnvModeRev; } +// #648: on native Claude a UserPromptSubmit hook's raw stdout becomes +// additionalContext (model-only), so anything the user asked to see must ride a +// systemMessage. Report, switch and off all qualify; the ruleset injection that +// nobody asked to read must stay raw stdout. +const msgHome = path.join(temp, 'user-message-home'); +const msgEnv = { + HOME: msgHome, + USERPROFILE: msgHome, + XDG_CONFIG_HOME: path.join(msgHome, '.config'), +}; + +function systemMessage(prompt) { + const r = run('ponytail-mode-tracker.js', msgEnv, JSON.stringify({ prompt })); + assert.equal(r.status, 0, r.stderr); + if (!r.stdout.startsWith('{')) return null; + return JSON.parse(r.stdout).systemMessage || null; +} + +// Switching says which level took effect. +assert.match(systemMessage('/ponytail ultra') || '', /ultra/, 'a mode switch must be visible to the user'); + +// Bare /ponytail reports the live level, and does not change it. +assert.match(systemMessage('/ponytail') || '', /ultra/, 'the level report must be visible to the user'); +assert.equal(fs.readFileSync(path.join(msgHome, '.claude', '.ponytail-active'), 'utf8'), 'ultra', + 'the report must not change the mode'); + +// Off names the default it will revert to, since clearMode() is session-scoped. +const offMsg = systemMessage('/ponytail off') || ''; +assert.match(offMsg, /off/i, 'deactivation must be visible to the user'); +assert.match(offMsg, /default is full/, 'off must name the default that revives ponytail next session'); + +// Same for the natural-language form. +assert.match(systemMessage('stop ponytail') || '', /off/i, '"stop ponytail" must be visible to the user'); + +// A default change is user-visible too, and distinguishable from a switch. +assert.match(systemMessage('/ponytail default lite') || '', /default set to lite/i, + 'a default change must be visible to the user'); + +// The ruleset injection is not a user message: it stays raw stdout so Claude +// Code keeps treating it as additionalContext. +const activate = run('ponytail-activate.js', msgEnv); +assert.equal(activate.status, 0, activate.stderr); +assert.ok(!activate.stdout.trimStart().startsWith('{'), + 'SessionStart ruleset must stay raw stdout, not a systemMessage envelope'); + console.log('hook compatibility checks passed');