chill-agent 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.clinerules/ponytail.md +30 -0
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.grok-plugin/marketplace.json +16 -0
- package/.kiro/steering/ponytail.md +35 -0
- package/.openclaw/skills/ponytail/SKILL.md +52 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +103 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +44 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +52 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +101 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +19 -0
- package/__init__.py +228 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/package.json +9 -3
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/qoder-plugin.test.js +84 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { createHash } = require('crypto');
|
|
5
|
+
const { getClaudeDir, getQoderDir } = require('./ponytail-config');
|
|
6
|
+
|
|
7
|
+
const STATE_FILE = '.ponytail-active';
|
|
8
|
+
|
|
9
|
+
// ponytail: VS Code Copilot never sets COPILOT_PLUGIN_DATA — it only injects
|
|
10
|
+
// CLAUDE_PLUGIN_ROOT, pointed at an install path under .vscode/agent-plugins/
|
|
11
|
+
// (#528). Without this fallback isCopilot was false, so ponytail assumed
|
|
12
|
+
// native Claude Code and emitted the statusline nudge, which VS Code Copilot
|
|
13
|
+
// doesn't read.
|
|
14
|
+
function isVsCodeCopilotRoot(pluginRoot) {
|
|
15
|
+
if (!pluginRoot) return false;
|
|
16
|
+
return pluginRoot.split(/[\\/]+/).includes('agent-plugins') &&
|
|
17
|
+
pluginRoot.toLowerCase().includes('.vscode');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA) ||
|
|
21
|
+
isVsCodeCopilotRoot(process.env.CLAUDE_PLUGIN_ROOT);
|
|
22
|
+
const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA);
|
|
23
|
+
const isQoder = !isCopilot && !isCodex && Boolean(process.env.QODER_SESSION_ID);
|
|
24
|
+
// CodeBuddy (#854) loads the Claude-format plugin and sets CLAUDE_PLUGIN_ROOT
|
|
25
|
+
// too, but adds CODEBUDDY_PLUGIN_ROOT only for its own plugin hook processes.
|
|
26
|
+
const isCodeBuddy = !isCopilot && !isCodex && !isQoder && Boolean(process.env.CODEBUDDY_PLUGIN_ROOT);
|
|
27
|
+
// Cursor (#817): CURSOR_VERSION is set only in the environment Cursor builds
|
|
28
|
+
// for hook processes (Cursor 3.20.17 assigns it in exactly one place, the hook
|
|
29
|
+
// env builder), so it never leaks into a Claude Code session running inside
|
|
30
|
+
// Cursor's terminal. Cursor also sets it when it runs a Claude-format plugin's
|
|
31
|
+
// hooks next to CLAUDE_PLUGIN_ROOT, and it needs Cursor-shaped JSON either
|
|
32
|
+
// way, so this check comes after the hosts with their own data dirs.
|
|
33
|
+
const isCursor = !isCopilot && !isCodex && !isQoder && !isCodeBuddy && Boolean(process.env.CURSOR_VERSION);
|
|
34
|
+
|
|
35
|
+
let stateDir = getClaudeDir();
|
|
36
|
+
if (isCodex) stateDir = process.env.PLUGIN_DATA;
|
|
37
|
+
// COPILOT_PLUGIN_DATA is unset under VS Code Copilot, so fall back to
|
|
38
|
+
// getClaudeDir() rather than building a path from undefined.
|
|
39
|
+
if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA || getClaudeDir();
|
|
40
|
+
if (isQoder) stateDir = getQoderDir();
|
|
41
|
+
if (isCodeBuddy) stateDir = process.env.CODEBUDDY_CONFIG_DIR || path.join(os.homedir(), '.codebuddy');
|
|
42
|
+
if (isCursor) stateDir = path.join(os.homedir(), '.cursor');
|
|
43
|
+
|
|
44
|
+
const statePath = path.join(stateDir, STATE_FILE);
|
|
45
|
+
|
|
46
|
+
// Claude Code hands every hook its project dir, so the live mode is kept per
|
|
47
|
+
// project and concurrent sessions in different repos stop overwriting each other
|
|
48
|
+
// (#662, #809). Hosts without it keep the single shared flag.
|
|
49
|
+
// ponytail: sessions in the SAME repo still share one mode, and the statusline
|
|
50
|
+
// scripts read the shared flag (last write wins); key by session_id if either matters.
|
|
51
|
+
const projectDir = (process.env.CLAUDE_PROJECT_DIR || '').trim();
|
|
52
|
+
// Replacing separators with '_' aliases e.g. /work/a/b and /work/a_b (#662).
|
|
53
|
+
// Do not read old sanitized keys: they cannot be assigned to one project safely.
|
|
54
|
+
const projectStatePath = projectDir
|
|
55
|
+
? path.join(stateDir, 'ponytail-modes',
|
|
56
|
+
createHash('sha256').update(path.normalize(projectDir)).digest('hex'))
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
// The shared flag is still written, for the statusline and project-less hosts.
|
|
60
|
+
function setMode(mode) {
|
|
61
|
+
for (const file of [projectStatePath, statePath]) {
|
|
62
|
+
if (!file) continue;
|
|
63
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
64
|
+
fs.writeFileSync(file, mode);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearMode() {
|
|
69
|
+
for (const file of [projectStatePath, statePath]) {
|
|
70
|
+
if (file) try { fs.unlinkSync(file); } catch (e) {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Live mode written by activate/mode-tracker. Absent flag = ponytail off.
|
|
75
|
+
function readMode() {
|
|
76
|
+
try {
|
|
77
|
+
return fs.readFileSync(projectStatePath || statePath, 'utf8').trim() || null;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Cursor's always-on project rule (.cursor/rules/ponytail.mdc) already puts the
|
|
84
|
+
// ruleset in front of every prompt and no hook can switch a rule off, so while
|
|
85
|
+
// it is in the workspace the hooks step back instead of injecting a second,
|
|
86
|
+
// possibly contradicting, copy (#817). Cursor hands every hook the workspace
|
|
87
|
+
// root as CURSOR_PROJECT_DIR; project hooks also run from that directory.
|
|
88
|
+
// ponytail: first workspace root only, a rule in a secondary folder of a
|
|
89
|
+
// multi-root workspace goes undetected.
|
|
90
|
+
function cursorRulePath() {
|
|
91
|
+
const root = process.env.CURSOR_PROJECT_DIR || process.cwd();
|
|
92
|
+
const rule = path.join(root, '.cursor', 'rules', 'ponytail.mdc');
|
|
93
|
+
return fs.existsSync(rule) ? rule : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cursorRuleNotice(rule) {
|
|
97
|
+
return 'PONYTAIL: the always-on Cursor rule ' + rule + ' is active in this workspace and ' +
|
|
98
|
+
'already carries the ponytail ruleset, so the ponytail hooks injected nothing further. ' +
|
|
99
|
+
'Mode switching (/ponytail lite|full|ultra|off, "stop ponytail") is unavailable while ' +
|
|
100
|
+
'that rule exists. When the user tries to switch or turn off ponytail, tell them to ' +
|
|
101
|
+
'delete that rule so hooks.json can manage the level.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Synchronous: every hook calls process.exit() right after this, and on macOS a pipe
|
|
105
|
+
// write is async, so a pending write died with the process (#1043).
|
|
106
|
+
function writeOut(text) {
|
|
107
|
+
fs.writeSync(1, text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeHookOutput(event, mode, context = '') {
|
|
111
|
+
if (isCopilot) {
|
|
112
|
+
// Copilot reads additionalContext on SessionStart; ignores output elsewhere.
|
|
113
|
+
writeOut(JSON.stringify(
|
|
114
|
+
event === 'SessionStart' && context ? { additionalContext: context } : {}));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (isCodex) {
|
|
118
|
+
// No systemMessage: Codex maps it to a yellow `warning:` entry (and de-greens the
|
|
119
|
+
// completed-hook bullet), reading as an error every session (#605). The mode still
|
|
120
|
+
// shows via the additionalContext "hook context:" line — active level when on,
|
|
121
|
+
// "PONYTAIL MODE OFF" when off (that path passes context too).
|
|
122
|
+
// ponytail: if openai/codex#16933 lands and hides additionalContext, restore a
|
|
123
|
+
// non-warning mode signal here.
|
|
124
|
+
const output = {};
|
|
125
|
+
if (context) {
|
|
126
|
+
output.hookSpecificOutput = {
|
|
127
|
+
hookEventName: event,
|
|
128
|
+
additionalContext: context,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
writeOut(JSON.stringify(output));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (isQoder || isCodeBuddy) {
|
|
135
|
+
// Qoder: hookSpecificOutput JSON, same shape as Codex minus systemMessage.
|
|
136
|
+
// UserPromptSubmit additionalContext is injected into the Agent's conversation.
|
|
137
|
+
// CodeBuddy would take raw stdout too, but also echoes it into the chat.
|
|
138
|
+
const output = {};
|
|
139
|
+
if (context) {
|
|
140
|
+
output.hookSpecificOutput = {
|
|
141
|
+
hookEventName: event,
|
|
142
|
+
additionalContext: context,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
writeOut(JSON.stringify(output));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (isCursor) {
|
|
149
|
+
// Cursor parses stdout as JSON and treats empty stdout as "nothing to
|
|
150
|
+
// say"; raw text would be logged as a parse error. sessionStart takes
|
|
151
|
+
// additional_context into the conversation's system context;
|
|
152
|
+
// beforeSubmitPrompt needs continue:true and, in Cursor 3.20.17, injects
|
|
153
|
+
// additional_context into that turn (docs/cursor-hooks.md).
|
|
154
|
+
if (!context) return;
|
|
155
|
+
const output = { additional_context: context };
|
|
156
|
+
if (event === 'UserPromptSubmit') output.continue = true;
|
|
157
|
+
writeOut(JSON.stringify(output));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Native Claude: SessionStart accepts raw stdout, but SubagentStart needs the
|
|
161
|
+
// hookSpecificOutput JSON form or the context is dropped.
|
|
162
|
+
if (event === 'SubagentStart') {
|
|
163
|
+
writeOut(JSON.stringify(
|
|
164
|
+
{ hookSpecificOutput: { hookEventName: event, additionalContext: context } }));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
writeOut(context);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
clearMode,
|
|
172
|
+
cursorRuleNotice,
|
|
173
|
+
cursorRulePath,
|
|
174
|
+
isCodeBuddy,
|
|
175
|
+
isCodex,
|
|
176
|
+
isCopilot,
|
|
177
|
+
isCursor,
|
|
178
|
+
isQoder,
|
|
179
|
+
readMode,
|
|
180
|
+
setMode,
|
|
181
|
+
writeHookOutput,
|
|
182
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
|
|
2
|
+
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
|
|
3
|
+
$Flag = Join-Path $ClaudeDir ".ponytail-active"
|
|
4
|
+
if (-not (Test-Path $Flag)) {
|
|
5
|
+
exit 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
$Mode = ""
|
|
9
|
+
try {
|
|
10
|
+
$Mode = (Get-Content $Flag -ErrorAction Stop | Select-Object -First 1).Trim()
|
|
11
|
+
} catch {
|
|
12
|
+
exit 0
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
$Esc = [char]27
|
|
16
|
+
# ultra is the high-intensity mode; flag it amber so it stands out from the
|
|
17
|
+
# default green. The level is still in the text, so color is a redundant cue.
|
|
18
|
+
$Color = if ($Mode -eq "ultra") { "173" } else { "108" }
|
|
19
|
+
if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") {
|
|
20
|
+
[Console]::Write("${Esc}[38;5;${Color}m[PONYTAIL]${Esc}[0m")
|
|
21
|
+
} else {
|
|
22
|
+
$Suffix = $Mode.ToUpperInvariant()
|
|
23
|
+
[Console]::Write("${Esc}[38;5;${Color}m[PONYTAIL:$Suffix]${Esc}[0m")
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
|
|
3
|
+
flag="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.ponytail-active"
|
|
4
|
+
[ -f "$flag" ] || exit 0
|
|
5
|
+
|
|
6
|
+
mode=$(head -n1 "$flag" | tr -d '[:space:]')
|
|
7
|
+
|
|
8
|
+
# ultra is the high-intensity mode; flag it amber so it stands out from the
|
|
9
|
+
# default green at a glance. The level is still in the text, so color is a
|
|
10
|
+
# redundant cue, not the only one.
|
|
11
|
+
color=108
|
|
12
|
+
[ "$mode" = "ultra" ] && color=173
|
|
13
|
+
|
|
14
|
+
if [ -z "$mode" ] || [ "$mode" = "full" ]; then
|
|
15
|
+
printf '\033[38;5;%sm[PONYTAIL]\033[0m' "$color"
|
|
16
|
+
else
|
|
17
|
+
printf '\033[38;5;%sm[PONYTAIL:%s]\033[0m' "$color" "$(printf '%s' "$mode" | tr '[:lower:]' '[:upper:]')"
|
|
18
|
+
fi
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chill-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Zero-bloat Anti-Slop 2.0 Engine & Pragmatic Senior Developer Standard. Fuses Anti-Slop visual craftsmanship with Ponytail YAGNI minimalism across all coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"chill-agent": "bin/chill-agent.js"
|
|
7
|
+
"chill-agent": "bin/chill-agent.js",
|
|
8
|
+
"ponytail": "bin/chill-agent.js"
|
|
8
9
|
},
|
|
9
10
|
"main": "./src/index.js",
|
|
10
11
|
"scripts": {
|
|
11
12
|
"start": "node ./bin/chill-agent.js",
|
|
12
|
-
"test": "node --test tests
|
|
13
|
+
"test": "node --test tests/manifests.test.js tests/rules.test.js test/auditor.test.js",
|
|
13
14
|
"audit": "node ./bin/chill-agent.js audit",
|
|
14
15
|
"lint": "node ./bin/chill-agent.js audit"
|
|
15
16
|
},
|
|
@@ -45,6 +46,8 @@
|
|
|
45
46
|
"hooks/",
|
|
46
47
|
"i18n/",
|
|
47
48
|
"tests/",
|
|
49
|
+
"scripts/",
|
|
50
|
+
"pi-extension/",
|
|
48
51
|
".agents/",
|
|
49
52
|
".claude-plugin/",
|
|
50
53
|
".cursor/",
|
|
@@ -58,8 +61,11 @@
|
|
|
58
61
|
".devin-plugin/",
|
|
59
62
|
".grok-plugin/",
|
|
60
63
|
".qoder-plugin/",
|
|
64
|
+
".kiro/",
|
|
61
65
|
".openclaw/",
|
|
62
66
|
".opencode/",
|
|
67
|
+
"__init__.py",
|
|
68
|
+
"AGENTS.md",
|
|
63
69
|
"chillslop.md",
|
|
64
70
|
"antislop.md",
|
|
65
71
|
"GUIDE.md",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const {
|
|
5
|
+
DEFAULT_MODE,
|
|
6
|
+
RUNTIME_MODES,
|
|
7
|
+
getDefaultMode,
|
|
8
|
+
getQuietStartup,
|
|
9
|
+
getHideStatus,
|
|
10
|
+
normalizeMode,
|
|
11
|
+
normalizePersistedMode,
|
|
12
|
+
isDeactivationCommand,
|
|
13
|
+
writeDefaultMode,
|
|
14
|
+
} = require("../hooks/ponytail-config.js");
|
|
15
|
+
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
|
|
16
|
+
|
|
17
|
+
export { filterSkillBodyForMode };
|
|
18
|
+
export const readDefaultMode = getDefaultMode;
|
|
19
|
+
export const readQuietStartup = getQuietStartup;
|
|
20
|
+
|
|
21
|
+
const RUNTIME_MODE_LIST = RUNTIME_MODES.join("|");
|
|
22
|
+
const PONYTAIL_COMMAND_DESCRIPTION = `Set mode: ${RUNTIME_MODE_LIST}. Commands: status, default <mode>`;
|
|
23
|
+
|
|
24
|
+
// Every item is a complete command: pi submits on Enter, so a bare "default" would run without a mode.
|
|
25
|
+
// Values only: per-level descriptions would be a second copy of the Levels table in SKILL.md.
|
|
26
|
+
export function completePonytailArgs(prefix) {
|
|
27
|
+
const typed = String(prefix || "").trimStart();
|
|
28
|
+
const values = [...RUNTIME_MODES, "status", ...RUNTIME_MODES.map((m) => `default ${m}`)].filter((v) => v.startsWith(typed));
|
|
29
|
+
return values.length ? values.map((value) => ({ value, label: value })) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
|
|
33
|
+
const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
|
|
34
|
+
if (!Array.isArray(entries)) return fallback;
|
|
35
|
+
|
|
36
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
37
|
+
const entry = entries[i];
|
|
38
|
+
if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;
|
|
39
|
+
|
|
40
|
+
const mode = normalizePersistedMode(entry?.data?.mode);
|
|
41
|
+
if (mode) return mode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE, currentMode = null) {
|
|
48
|
+
const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
|
|
49
|
+
const normalizedText = String(text || "").trim().toLowerCase();
|
|
50
|
+
|
|
51
|
+
if (!normalizedText) {
|
|
52
|
+
// Bare /ponytail switches ponytail on, or reports the level when it already is (#639).
|
|
53
|
+
if (currentMode && currentMode !== "off") return { type: "status" };
|
|
54
|
+
return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [primary, secondary] = normalizedText.split(/\s+/);
|
|
58
|
+
|
|
59
|
+
if (primary === "status") return { type: "status" };
|
|
60
|
+
|
|
61
|
+
if (primary === "default") {
|
|
62
|
+
// ponytail: a default must be a runtime level; review is session-only (#377).
|
|
63
|
+
const mode = normalizeMode(secondary);
|
|
64
|
+
return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const mode = normalizeMode(primary);
|
|
68
|
+
return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { writeDefaultMode };
|
|
72
|
+
|
|
73
|
+
export default function ponytailExtension(pi) {
|
|
74
|
+
let currentMode = DEFAULT_MODE;
|
|
75
|
+
let configuredDefaultMode = getDefaultMode();
|
|
76
|
+
let hideStatus = getHideStatus();
|
|
77
|
+
let isActive = false;
|
|
78
|
+
let lastCtx = null;
|
|
79
|
+
|
|
80
|
+
// -- Status bar --
|
|
81
|
+
function syncStatus(ctx) {
|
|
82
|
+
if (ctx) lastCtx = ctx;
|
|
83
|
+
const c = ctx || lastCtx;
|
|
84
|
+
// ponytail: hide the indicator but keep the ruleset active (#324).
|
|
85
|
+
if (hideStatus) return;
|
|
86
|
+
if (!c?.ui?.setStatus) return;
|
|
87
|
+
// ponytail: try/catch guards against pi-web theme proxy throwing before initTheme
|
|
88
|
+
let theme;
|
|
89
|
+
try { theme = c.ui.theme; if (!theme?.fg) return; } catch { return; }
|
|
90
|
+
if (currentMode === "off") {
|
|
91
|
+
c.ui.setStatus("ponytail", undefined);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const levelIcons = { lite: "🌿", full: "⚡", ultra: "🔥" };
|
|
95
|
+
const icon = levelIcons[currentMode] || "";
|
|
96
|
+
const label = currentMode.toUpperCase();
|
|
97
|
+
const indicator = isActive ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
98
|
+
c.ui.setStatus("ponytail", indicator + " 🐴 " + theme.fg("muted", "ponytail: ") + theme.fg("text", icon + " " + label));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const setMode = (mode, ctx) => {
|
|
102
|
+
const normalized = normalizePersistedMode(mode);
|
|
103
|
+
if (!normalized) return;
|
|
104
|
+
|
|
105
|
+
currentMode = normalized;
|
|
106
|
+
pi.appendEntry("ponytail-mode", { mode: normalized });
|
|
107
|
+
syncStatus(ctx);
|
|
108
|
+
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const sendAlias = (skillName, args, ctx) => {
|
|
112
|
+
const normalized = String(args || "").trim();
|
|
113
|
+
const message = normalized ? `${skillName} ${normalized}` : skillName;
|
|
114
|
+
|
|
115
|
+
// pi.sendUserMessage does not expand skill commands on its own, so without
|
|
116
|
+
// expandPromptTemplates the alias lands as the literal text
|
|
117
|
+
// "/skill:ponytail-review" and the skill body never reaches the agent.
|
|
118
|
+
if (ctx?.isIdle?.() === false) {
|
|
119
|
+
pi.sendUserMessage(message, { expandPromptTemplates: true, deliverAs: "followUp" });
|
|
120
|
+
ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pi.sendUserMessage(message, { expandPromptTemplates: true });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
pi.registerCommand("ponytail", {
|
|
128
|
+
description: PONYTAIL_COMMAND_DESCRIPTION,
|
|
129
|
+
getArgumentCompletions: (prefix) => completePonytailArgs(prefix),
|
|
130
|
+
handler: async (args, ctx) => {
|
|
131
|
+
const parsed = parsePonytailCommand(args, configuredDefaultMode, currentMode);
|
|
132
|
+
|
|
133
|
+
if (parsed.type === "status") {
|
|
134
|
+
ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (parsed.type === "set-default") {
|
|
139
|
+
try {
|
|
140
|
+
const written = writeDefaultMode(parsed.mode);
|
|
141
|
+
if (written) {
|
|
142
|
+
configuredDefaultMode = getDefaultMode();
|
|
143
|
+
const message = configuredDefaultMode === written
|
|
144
|
+
? `Default Ponytail mode set to ${written}.`
|
|
145
|
+
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
|
|
146
|
+
ctx?.ui?.notify?.(message, "info");
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
ctx?.ui?.notify?.(`Failed to save default mode: ${e.message}`, "error");
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (parsed.type === "set-mode") {
|
|
155
|
+
setMode(parsed.mode, ctx);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerCommand("ponytail-review", {
|
|
164
|
+
description: "Run /skill:ponytail-review",
|
|
165
|
+
handler: (args, ctx) => sendAlias("/skill:ponytail-review", args, ctx),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.registerCommand("ponytail-audit", {
|
|
169
|
+
description: "Run /skill:ponytail-audit",
|
|
170
|
+
handler: (args, ctx) => sendAlias("/skill:ponytail-audit", args, ctx),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.registerCommand("ponytail-gain", {
|
|
174
|
+
description: "Run /skill:ponytail-gain",
|
|
175
|
+
handler: (args, ctx) => sendAlias("/skill:ponytail-gain", args, ctx),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.registerCommand("ponytail-debt", {
|
|
179
|
+
description: "Run /skill:ponytail-debt",
|
|
180
|
+
handler: (args, ctx) => sendAlias("/skill:ponytail-debt", args, ctx),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
pi.registerCommand("ponytail-help", {
|
|
184
|
+
description: "Run /skill:ponytail-help",
|
|
185
|
+
handler: (args, ctx) => sendAlias("/skill:ponytail-help", args, ctx),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("input", async (event, ctx) => {
|
|
189
|
+
if (event?.source === "extension") return;
|
|
190
|
+
|
|
191
|
+
const text = String(event?.text || "");
|
|
192
|
+
if (currentMode !== "off" && isDeactivationCommand(text)) {
|
|
193
|
+
setMode("off", ctx);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const restoreSessionMode = (ctx) => {
|
|
198
|
+
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
|
|
199
|
+
currentMode = resolveSessionMode(entries, configuredDefaultMode);
|
|
200
|
+
syncStatus(ctx);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
204
|
+
configuredDefaultMode = getDefaultMode();
|
|
205
|
+
hideStatus = getHideStatus();
|
|
206
|
+
restoreSessionMode(ctx);
|
|
207
|
+
if (!getQuietStartup()) {
|
|
208
|
+
ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Both hosts emit session_tree. Pi re-emits session_start on /new, /resume and
|
|
213
|
+
// /fork; OMP emits session_switch and session_branch instead.
|
|
214
|
+
for (const eventName of ["session_tree", "session_switch", "session_branch"]) {
|
|
215
|
+
pi.on(eventName, async (_event, ctx) => {
|
|
216
|
+
restoreSessionMode(ctx);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
221
|
+
isActive = true;
|
|
222
|
+
syncStatus(ctx);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
226
|
+
isActive = false;
|
|
227
|
+
syncStatus(ctx);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
pi.on("before_agent_start", async (event) => {
|
|
231
|
+
if (!currentMode || currentMode === "off") {
|
|
232
|
+
// Clear a stale section when the mode is off so a previous turn does not linger (#953).
|
|
233
|
+
if (event?.systemPromptOptions?.sections) delete event.systemPromptOptions.sections.ponytail;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const instructions = getPonytailInstructions(currentMode);
|
|
237
|
+
// Prefer structured sections so Pi can keep the cached prefix when other
|
|
238
|
+
// extensions change their own section (#953). Fall back to replacement
|
|
239
|
+
// for older Pi versions without systemPromptOptions.sections.
|
|
240
|
+
const sections = event?.systemPromptOptions?.sections;
|
|
241
|
+
if (sections && typeof sections === "object") {
|
|
242
|
+
sections.ponytail = instructions;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// OMP passes the system prompt as an array of parts; keep it one (#776).
|
|
246
|
+
if (Array.isArray(event?.systemPrompt)) {
|
|
247
|
+
return { systemPrompt: [...event.systemPrompt, instructions] };
|
|
248
|
+
}
|
|
249
|
+
// Guard a null/undefined event or a missing systemPrompt: don't crash, and
|
|
250
|
+
// don't prepend the literal string "undefined" to the prompt (#439, #440).
|
|
251
|
+
const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
|
|
252
|
+
return { systemPrompt: `${base}${instructions}` };
|
|
253
|
+
});
|
|
254
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const root = path.join(__dirname, '..');
|
|
6
|
+
|
|
7
|
+
function read(relPath) {
|
|
8
|
+
return fs.readFileSync(path.join(root, relPath), 'utf8').replace(/\r\n/g, '\n').trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function stripFrontmatter(text) {
|
|
12
|
+
return text.replace(/^---\n[\s\S]*?\n---\n*/, '').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const agents = read('AGENTS.md');
|
|
16
|
+
const canonical = agents.replace(/\n\n\(Yes, this file also applies[\s\S]*?\)$/, '').trim();
|
|
17
|
+
|
|
18
|
+
// Compact copies: same body as AGENTS.md, host-specific frontmatter stripped.
|
|
19
|
+
const copies = [
|
|
20
|
+
['.cursor/rules/ponytail.mdc', stripFrontmatter],
|
|
21
|
+
['.windsurf/rules/ponytail.md', text => text.trim()],
|
|
22
|
+
['.clinerules/ponytail.md', text => text.trim()],
|
|
23
|
+
['.agents/rules/ponytail.md', text => text.trim()],
|
|
24
|
+
['.qoder/rules/ponytail.md', text => text.trim()],
|
|
25
|
+
['.github/copilot-instructions.md', text => text.trim()],
|
|
26
|
+
['.kiro/steering/ponytail.md', stripFrontmatter],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
let failed = false;
|
|
30
|
+
|
|
31
|
+
for (const [relPath, normalize] of copies) {
|
|
32
|
+
const actual = normalize(read(relPath));
|
|
33
|
+
if (actual !== canonical) {
|
|
34
|
+
console.error(`${relPath} drifted from AGENTS.md`);
|
|
35
|
+
failed = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// SKILL.md is the runtime source of truth and is longer than the compact body,
|
|
40
|
+
// so it cannot be byte-compared. ponytail: canary, not full equality. Assert the
|
|
41
|
+
// load-bearing rules survive verbatim in both the source and AGENTS.md. Changing
|
|
42
|
+
// a rule's wording trips this, which is the reminder to propagate it everywhere.
|
|
43
|
+
// Upgrade path: generate the copies from SKILL.md if this ever misses a real drift.
|
|
44
|
+
const INVARIANTS = [
|
|
45
|
+
'in this codebase', // ladder rung: reuse what already exists (#217)
|
|
46
|
+
'grep every caller', // root-cause bug fix (#245)
|
|
47
|
+
'never about the change itself', // scope: callers, tests and fixtures the change breaks
|
|
48
|
+
'shortcut: <the limit>, <when to upgrade>', // shortcut marker comment
|
|
49
|
+
'correct on edge cases', // robust-variant rule
|
|
50
|
+
'Lazy code without its check is unfinished', // test reflex
|
|
51
|
+
// the "never cut" carve-outs: pin each so a reword in either file can't silently drop one.
|
|
52
|
+
'validation at trust boundaries',
|
|
53
|
+
'prevents data loss',
|
|
54
|
+
'security',
|
|
55
|
+
'accessibility',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const skill = read('skills/ponytail/SKILL.md');
|
|
59
|
+
const sources = [['skills/ponytail/SKILL.md', skill], ['AGENTS.md', agents]];
|
|
60
|
+
for (const phrase of INVARIANTS) {
|
|
61
|
+
for (const [label, text] of sources) {
|
|
62
|
+
if (!text.includes(phrase)) {
|
|
63
|
+
console.error(`${label} is missing rule invariant: "${phrase}"`);
|
|
64
|
+
failed = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (failed) {
|
|
70
|
+
console.error('Update the copied rule text, AGENTS.md, or SKILL.md so the shared rules match.');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`Rule copies match AGENTS.md; ${INVARIANTS.length} rule invariants present in SKILL.md and AGENTS.md.`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Version-consistency guard. Ponytail declares its version in seven files across
|
|
3
|
+
// five host ecosystems, and every release bumps all of them by hand.
|
|
4
|
+
//
|
|
5
|
+
// tests/gemini-extension.test.js already checks the four plugin manifests agree
|
|
6
|
+
// with each other, but that can't catch the failure mode that shipped in v4.8.0:
|
|
7
|
+
// every manifest stayed stale at 4.7.0 *together* while the release moved on, so
|
|
8
|
+
// they "agreed" and the test passed (#260, #262). It also ignores the two
|
|
9
|
+
// package.json files. This check closes both gaps:
|
|
10
|
+
// 1. every version-bearing file must share one pinned X.Y.Z version, and
|
|
11
|
+
// 2. on a release-tag CI run, that shared version must equal the tag.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const root = path.join(__dirname, '..');
|
|
17
|
+
const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
18
|
+
|
|
19
|
+
// Every file that declares the project version, and who reads it. Add new host
|
|
20
|
+
// manifests here so a future ecosystem can't drift unnoticed.
|
|
21
|
+
const VERSION_FILES = [
|
|
22
|
+
'.claude-plugin/plugin.json', // Claude Code plugin — what users install
|
|
23
|
+
'.codex-plugin/plugin.json', // Codex plugin
|
|
24
|
+
'.devin-plugin/plugin.json', // Devin CLI plugin
|
|
25
|
+
'.github/plugin/plugin.json', // Copilot plugin
|
|
26
|
+
'.qoder-plugin/plugin.json', // Qoder plugin
|
|
27
|
+
'gemini-extension.json', // Gemini CLI extension
|
|
28
|
+
'package.json', // pi-package / repo root
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function readVersion(relPath) {
|
|
32
|
+
try {
|
|
33
|
+
// Strip a UTF-8 BOM some Windows editors prepend (breaks JSON.parse).
|
|
34
|
+
const raw = fs.readFileSync(path.join(root, relPath), 'utf8').replace(/^\uFEFF/, '');
|
|
35
|
+
return JSON.parse(raw).version;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
throw new Error(`${relPath}: ${e.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let failed = false;
|
|
42
|
+
const versions = VERSION_FILES.map((relPath) => {
|
|
43
|
+
const version = readVersion(relPath);
|
|
44
|
+
if (typeof version !== 'string' || !PINNED_SEMVER.test(version)) {
|
|
45
|
+
console.error(`${relPath}: version must be a pinned X.Y.Z semver, got ${JSON.stringify(version)}`);
|
|
46
|
+
failed = true;
|
|
47
|
+
}
|
|
48
|
+
return [relPath, version];
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Every file must declare the same version.
|
|
52
|
+
const distinct = [...new Set(versions.map(([, v]) => v))];
|
|
53
|
+
if (distinct.length > 1) {
|
|
54
|
+
console.error('Version mismatch — every manifest must share one version:');
|
|
55
|
+
for (const [relPath, version] of versions) console.error(` ${version}\t${relPath}`);
|
|
56
|
+
failed = true;
|
|
57
|
+
}
|
|
58
|
+
const shared = distinct.length === 1 ? distinct[0] : null;
|
|
59
|
+
|
|
60
|
+
// On a release-tag push CI sets GITHUB_REF_TYPE=tag and GITHUB_REF_NAME=vX.Y.Z.
|
|
61
|
+
// The shared version must equal the tag — this catches tagging a release whose
|
|
62
|
+
// version files were never bumped, which mutual agreement alone cannot.
|
|
63
|
+
if (shared && process.env.GITHUB_REF_TYPE === 'tag') {
|
|
64
|
+
const tag = process.env.GITHUB_REF_NAME || '';
|
|
65
|
+
const tagVersion = tag.replace(/^v/, '');
|
|
66
|
+
if (PINNED_SEMVER.test(tagVersion) && tagVersion !== shared) {
|
|
67
|
+
console.error(`release tag ${tag} does not match version ${shared}; bump the version files before tagging`);
|
|
68
|
+
failed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (failed) {
|
|
73
|
+
console.error('Align the version fields (see issue #260) so every manifest shares one version.');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`All ${VERSION_FILES.length} version files pinned at ${shared}.`);
|