chisle 3.3.0 → 3.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/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/chisle.mjs +115 -0
- package/README.md +41 -4
- package/bin/install.js +261 -27
- package/hooks/chisle-activate.js +12 -5
- package/hooks/chisle-compress-output.js +186 -25
- package/hooks/chisle-config.js +182 -1
- package/hooks/chisle-mode-tracker.js +20 -6
- package/package.json +25 -1
- package/pi-extension/index.cjs +13 -0
- package/pi-extension/index.js +41 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chisle",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Maximum-efficiency dev mode. Zero-fluff prose combined with YAGNI/ladder-first code decisions. One persona: the senior dev who deletes code for fun and bills by the syllable.",
|
|
5
5
|
"hooks": {
|
|
6
6
|
"SessionStart": [
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// chisle — OpenCode plugin: tool-output compression (the input axis).
|
|
2
|
+
//
|
|
3
|
+
// Prompt-side rules (the fenced ruleset the installer writes into
|
|
4
|
+
// ~/.config/opencode/AGENTS.md) shrink what the model WRITES. This plugin
|
|
5
|
+
// shrinks what it READS: oversized tool results (bash dumps, subagent reports,
|
|
6
|
+
// web fetches, MCP payloads) get their repetitive middle elided — head kept,
|
|
7
|
+
// tail kept, error lines salvaged — before the model sees them. Deterministic,
|
|
8
|
+
// zero LLM, zero network. Read/Edit/Write output is never touched.
|
|
9
|
+
//
|
|
10
|
+
// OpenCode exposes two relevant plugin hooks. `tool.execute.after` receives a
|
|
11
|
+
// completed tool result whose `output.output` is the plain text the model will
|
|
12
|
+
// see; mutating it in place also persists onto the stored tool part, so every
|
|
13
|
+
// later turn reuses the compressed form. `experimental.chat.messages.transform`
|
|
14
|
+
// runs just before parts are converted to model messages, a request-time safety
|
|
15
|
+
// net that re-compresses any full output the store still holds (e.g. recorded
|
|
16
|
+
// before this plugin was installed). Neither needs a schema rebuild.
|
|
17
|
+
//
|
|
18
|
+
// Load it by installing via `npx chisle --only opencode`, which drops this file
|
|
19
|
+
// and the shared compressor core into ~/.config/opencode/plugins/. OpenCode
|
|
20
|
+
// loads every file in that dir at startup.
|
|
21
|
+
//
|
|
22
|
+
// Tunables (shared with the Claude/Pi/Copilot compressor, see chisle-config):
|
|
23
|
+
// CHISLE_COMPRESS=0 kill switch
|
|
24
|
+
// CHISLE_DEFAULT_MODE=off disable by default (env or ~/.config/chisle)
|
|
25
|
+
// CHISLE_COMPRESS_MAX_CHARS outputs at/under this size are not elided
|
|
26
|
+
// CHISLE_COMPRESS_TOOLS=bash,… override the tool allowlist
|
|
27
|
+
|
|
28
|
+
import { createRequire } from 'module';
|
|
29
|
+
import path from 'path';
|
|
30
|
+
import { fileURLToPath } from 'url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
// The compressor core is CommonJS. When installed by `chisle --only opencode`
|
|
36
|
+
// it sits in ./chisle-hooks/; running straight from the repo it's ../../hooks/.
|
|
37
|
+
function loadFrom(names) {
|
|
38
|
+
for (const rel of names) {
|
|
39
|
+
try { return require(rel); }
|
|
40
|
+
catch (e) { if (e && e.code !== 'MODULE_NOT_FOUND' && e.code !== 'ERR_MODULE_NOT_FOUND') throw e; }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const core = loadFrom([
|
|
46
|
+
'./chisle-hooks/chisle-compress-output.js',
|
|
47
|
+
'../../hooks/chisle-compress-output.js',
|
|
48
|
+
]);
|
|
49
|
+
const config = loadFrom([
|
|
50
|
+
'./chisle-hooks/chisle-config.js',
|
|
51
|
+
'../../hooks/chisle-config.js',
|
|
52
|
+
]) || { getDefaultMode: () => 'on' };
|
|
53
|
+
|
|
54
|
+
const compressForOpencode = core && core.compressForOpencode;
|
|
55
|
+
const getDefaultMode = config.getDefaultMode || (() => 'on');
|
|
56
|
+
|
|
57
|
+
// Already compressed: our elision marker. Skip such text so a second pass
|
|
58
|
+
// (messages.transform after tool.execute.after already ran) never nests a
|
|
59
|
+
// marker or eats an earlier salvage block. Keeps compression idempotent.
|
|
60
|
+
const CHISLE_MARK = '[chisle:';
|
|
61
|
+
|
|
62
|
+
export default async () => {
|
|
63
|
+
return {
|
|
64
|
+
// Compress oversized read-only tool output before the model sees it.
|
|
65
|
+
// Never throws: a broken compressor must not break the tool pipeline.
|
|
66
|
+
//
|
|
67
|
+
// For outputs under OpenCode's on-disk store limits (~50 KB / 2000 lines)
|
|
68
|
+
// output.output is the FULL text and mutating it here persists onto the
|
|
69
|
+
// tool part's state.output — so every later turn sees the compressed form.
|
|
70
|
+
// Larger outputs reach this hook already replaced by OpenCode with a notice
|
|
71
|
+
// plus a retained tail; that tail still compresses here.
|
|
72
|
+
'tool.execute.after': async (input, output) => {
|
|
73
|
+
try {
|
|
74
|
+
if (!compressForOpencode) return;
|
|
75
|
+
// MCP tools can hand back a content[] array rather than a string;
|
|
76
|
+
// built-ins give a string. Only the string case is ours to touch.
|
|
77
|
+
if (!output || typeof output.output !== 'string') return;
|
|
78
|
+
if (output.output.includes(CHISLE_MARK)) return;
|
|
79
|
+
const updated = compressForOpencode(input && input.tool, output.output, { mode: getDefaultMode() });
|
|
80
|
+
if (updated != null && updated.length < output.output.length) {
|
|
81
|
+
output.output = updated;
|
|
82
|
+
}
|
|
83
|
+
} catch (e) { /* keep the original output */ }
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Request-time safety net over completed tool parts. Runs after MCP
|
|
87
|
+
// normalization and BEFORE OpenCode converts parts to model messages
|
|
88
|
+
// (and before compaction summarization), so it re-applies compression to
|
|
89
|
+
// any full output that reached the model store uncompressed — e.g. parts
|
|
90
|
+
// recorded before this plugin was installed. Skips parts already carrying
|
|
91
|
+
// our marker, so each part is compressed at most once; the elided original
|
|
92
|
+
// still spills to disk exactly as it does on the tool.execute.after path.
|
|
93
|
+
'experimental.chat.messages.transform': async (_input, output) => {
|
|
94
|
+
try {
|
|
95
|
+
if (!compressForOpencode) return;
|
|
96
|
+
if (!output || !Array.isArray(output.messages)) return;
|
|
97
|
+
const mode = getDefaultMode();
|
|
98
|
+
for (const msg of output.messages) {
|
|
99
|
+
const parts = msg && msg.parts;
|
|
100
|
+
if (!Array.isArray(parts)) continue;
|
|
101
|
+
for (const part of parts) {
|
|
102
|
+
if (!part || part.type !== 'tool') continue;
|
|
103
|
+
const st = part.state;
|
|
104
|
+
if (!st || st.status !== 'completed' || typeof st.output !== 'string') continue;
|
|
105
|
+
if (st.output.includes(CHISLE_MARK)) continue;
|
|
106
|
+
const updated = compressForOpencode(part.tool, st.output, { mode });
|
|
107
|
+
if (updated != null && updated.length < st.output.length) {
|
|
108
|
+
st.output = updated;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (e) { /* leave messages untouched */ }
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<a href="https://www.npmjs.com/package/chisle"><img src="https://img.shields.io/npm/v/chisle?style=flat-square&color=d78a3c" alt="npm version"></a>
|
|
17
|
-
<img src="https://img.shields.io/badge/works%20with-
|
|
17
|
+
<img src="https://img.shields.io/badge/works%20with-11%20agents-d78a3c?style=flat-square" alt="Works with 11 agents">
|
|
18
18
|
<a href="https://github.com/JayPokale/Chisle/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/JayPokale/Chisle/test.yml?style=flat-square&label=CI" alt="CI"></a>
|
|
19
19
|
<img src="https://img.shields.io/badge/deps-0-2da44e?style=flat-square" alt="Zero deps">
|
|
20
20
|
<img src="https://img.shields.io/badge/license-MIT-d78a3c?style=flat-square" alt="MIT">
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
24
|
<p align="center">
|
|
25
|
-
<strong>44% of a bare model on coding
|
|
25
|
+
<strong>44% of a bare model's output tokens on coding prompts · 11 agents · zero dependencies · one command</strong>
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
28
|
<p align="center">
|
|
@@ -122,7 +122,7 @@ Every "be concise" tool has a worst day, the day it makes the model write *more*
|
|
|
122
122
|
|
|
123
123
|
## Install
|
|
124
124
|
|
|
125
|
-
One command. Auto-detects your agents (Claude Code, Pi, Cursor, Windsurf, Cline, Kiro, Codex, Gemini, Copilot) and wires each one. `--uninstall` puts everything back.
|
|
125
|
+
One command. Auto-detects your agents (Claude Code, Pi, Cursor, Windsurf, Cline, Kiro, Codex, Gemini, Copilot, OpenCode, Hermes) and wires each one. `--uninstall` puts everything back.
|
|
126
126
|
|
|
127
127
|
```bash
|
|
128
128
|
npx chisle
|
|
@@ -226,6 +226,8 @@ Nothing here is estimated. Every figure below is recomputed from committed raw d
|
|
|
226
226
|
|
|
227
227
|
### Output axis: vs caveman & ponytail, 20 live tasks
|
|
228
228
|
|
|
229
|
+
**What this suite measures, and what it does not.** Every figure below is *billed output tokens on single-turn prompts with no tools available*. That isolates the ruleset's effect on how the model writes, which is what it was built to measure. It is not whole-session cost: a real agentic session is dominated by tool output and cached input, so a tool can win here and still fail to pay for itself end to end. The input axis below is measured separately, and against its own baseline.
|
|
230
|
+
|
|
229
231
|
**59+ live model runs** across two suites (June 4-arm matrix on Haiku + Sonnet sweep; July re-verification run). Arms differ only in the injected system prompt. Billed output tokens vs the no-tool baseline:
|
|
230
232
|
|
|
231
233
|
| | total bill (all 20 tasks) | average task | worst case | backfires |
|
|
@@ -446,6 +448,41 @@ export CHISLE_DEFAULT_MODE=off
|
|
|
446
448
|
|
|
447
449
|
Resolution: env var → config file → `on`. Valid: `off`, `on`.
|
|
448
450
|
|
|
451
|
+
**Output styles are detected automatically.** Claude Code's output-style mechanism also governs
|
|
452
|
+
prose structure, so an active style and Chisle's prose section give directly conflicting
|
|
453
|
+
instructions — the style mandates tables and bullets for comparisons, Chisle's prose section forbids
|
|
454
|
+
manufactured tables and bullets the question didn't ask for. Chisle now reads the active style and
|
|
455
|
+
steps its prose rules aside on its own. Nothing to configure: run `/output-style Explanatory` and the
|
|
456
|
+
prose section stops shipping; switch back to `default` and it returns.
|
|
457
|
+
|
|
458
|
+
The code rules, the ladder, the context diet and the compressor are all unaffected — output styles
|
|
459
|
+
govern prose, so only the prose section yields.
|
|
460
|
+
|
|
461
|
+
Detection reads the `outputStyle` key across Claude Code's own settings precedence
|
|
462
|
+
(`.claude/settings.local.json`, where `/output-style` writes, then `.claude/settings.json`, then
|
|
463
|
+
`~/.claude/settings.json`). Claude Code and Pi only; Pi has no output-style mechanism, so nothing
|
|
464
|
+
changes there.
|
|
465
|
+
|
|
466
|
+
**Suppress a rule group manually.** The same `sections` key gives explicit control, and it always
|
|
467
|
+
beats detection — set `prose: true` to keep Chisle's prose rules even with a style active:
|
|
468
|
+
|
|
469
|
+
```json
|
|
470
|
+
{ "sections": { "prose": false } }
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`sections.prose: false` suppresses the prose rules; `sections.code: false` suppresses the code
|
|
474
|
+
rules. Config file only, no env override. Anything absent, malformed, or non-boolean falls back to
|
|
475
|
+
enabled, which is also what lets automatic detection apply — only an explicitly written boolean
|
|
476
|
+
overrides it. With no style active and nothing configured, the emitted ruleset is byte-identical to
|
|
477
|
+
before either feature existed. The always-on sections
|
|
478
|
+
(Persistence, Thinking Is Billed Too, Auto-Clarity, When NOT to be lazy, Boundaries) ship regardless
|
|
479
|
+
of either setting.
|
|
480
|
+
|
|
481
|
+
Scope: this only affects the runtime-hook agents that read `skills/chisle/SKILL.md` live — Claude
|
|
482
|
+
Code and Pi. The seven static-rule agents built by `scripts/build-rules.js` (Cursor, Windsurf,
|
|
483
|
+
Cline, Kiro, Codex, Gemini, Copilot) get flat files baked at build time with no runtime config to
|
|
484
|
+
read, so they can't honor this key.
|
|
485
|
+
|
|
449
486
|
---
|
|
450
487
|
|
|
451
488
|
## Prior art & what stacks with it
|
|
@@ -464,7 +501,7 @@ Chisle borrows the best published token-saving techniques and implements the one
|
|
|
464
501
|
|
|
465
502
|
## Multi-agent
|
|
466
503
|
|
|
467
|
-
Ships to
|
|
504
|
+
Ships to eleven agents: Claude Code and Pi get both axes, live `/chisle` toggling, and a status badge; Cursor, Windsurf, Cline, Kiro, Codex, Gemini, and Copilot get the always-on ruleset; OpenCode gets the global fenced ruleset plus on-demand skills; Hermes gets the skills as `/chisle` commands. Per-agent static copies come from `scripts/build-rules.js`; Pi uses its package extension plus the Agent Skills standard. See [`docs/agent-portability.md`](./docs/agent-portability.md).
|
|
468
505
|
|
|
469
506
|
## FAQ
|
|
470
507
|
|
package/bin/install.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// - Pi → Pi package (extension + skill)
|
|
8
8
|
// - Gemini CLI → gemini extensions install
|
|
9
9
|
// - Codex → fenced ruleset appended to ~/.codex/AGENTS.md
|
|
10
|
+
// - OpenCode → fenced ruleset in ~/.config/opencode/AGENTS.md + skills copy
|
|
11
|
+
// - Hermes → skills copy in ~/.hermes/skills (Agent Skills standard)
|
|
10
12
|
// - Cursor/Windsurf/Cline/Kiro/Copilot → project rule file dropped into CWD
|
|
11
13
|
//
|
|
12
14
|
// Usage:
|
|
@@ -43,6 +45,8 @@ const PROVIDERS = [
|
|
|
43
45
|
{ id: 'pi', label: 'Pi', scope: 'global', detect: 'cmd:pi' },
|
|
44
46
|
{ id: 'gemini', label: 'Gemini CLI', scope: 'global', detect: 'cmd:gemini' },
|
|
45
47
|
{ id: 'codex', label: 'Codex CLI', scope: 'global', detect: 'cmd:codex||dir:~/.codex' },
|
|
48
|
+
{ id: 'opencode', label: 'OpenCode', scope: 'global', detect: 'cmd:opencode||dir:~/.config/opencode' },
|
|
49
|
+
{ id: 'hermes', label: 'Hermes Agent', scope: 'global', detect: 'cmd:hermes||dir:~/.hermes' },
|
|
46
50
|
{ id: 'cursor', label: 'Cursor', scope: 'project', detect: 'cmd:cursor||dir:~/.cursor',
|
|
47
51
|
rule: '.cursor/rules/chisle.mdc' },
|
|
48
52
|
{ id: 'windsurf', label: 'Windsurf', scope: 'project', detect: 'cmd:windsurf||dir:~/.windsurf||dir:~/.codeium/windsurf',
|
|
@@ -157,6 +161,14 @@ function run(cmd, args, dry) {
|
|
|
157
161
|
process.stdout.write(` $ ${cmd} ${args.join(' ')}\n`);
|
|
158
162
|
return spawnX(cmd, args, { stdio: 'inherit' });
|
|
159
163
|
}
|
|
164
|
+
|
|
165
|
+
// spawnSync reports a missing binary as { status: null, error: ENOENT } and a
|
|
166
|
+
// signal death as { status: null, signal }. `(r.status || 0) === 0` reads both
|
|
167
|
+
// as success, so a machine without the CLI was told the install succeeded.
|
|
168
|
+
// Only an explicit exit code of 0 counts.
|
|
169
|
+
function ranOk(r) {
|
|
170
|
+
return !!r && !r.error && r.status === 0;
|
|
171
|
+
}
|
|
160
172
|
function capture(cmd, args) {
|
|
161
173
|
try { return spawnX(cmd, args, { encoding: 'utf8' }); } catch (_) { return { status: 1, stdout: '', stderr: '' }; }
|
|
162
174
|
}
|
|
@@ -168,6 +180,10 @@ function claudeDir(opts) {
|
|
|
168
180
|
return path.join(os.homedir(), '.claude');
|
|
169
181
|
}
|
|
170
182
|
|
|
183
|
+
// Home dir for Chisle-managed global targets. CHISLE_HOME overrides it
|
|
184
|
+
// (tests, containers); mirrors the CLAUDE_CONFIG_DIR precedent for Claude.
|
|
185
|
+
function homeDir() { return process.env.CHISLE_HOME || os.homedir(); }
|
|
186
|
+
|
|
171
187
|
// ── what is already installed ───────────────────────────────────────────────
|
|
172
188
|
// Every install path skips when Chisle is already present, which is right for
|
|
173
189
|
// `npx chisle` and useless for an upgrade: the run reports success and changes
|
|
@@ -200,9 +216,18 @@ function installedFor(id, opts) {
|
|
|
200
216
|
return r.status === 0 && /chisle/i.test(r.stdout || '');
|
|
201
217
|
}
|
|
202
218
|
case 'codex': {
|
|
203
|
-
const md = path.join(
|
|
219
|
+
const md = path.join(homeDir(), '.codex', 'AGENTS.md');
|
|
204
220
|
return fs.existsSync(md) && fs.readFileSync(md, 'utf8').includes(FENCE_BEGIN);
|
|
205
221
|
}
|
|
222
|
+
case 'opencode': {
|
|
223
|
+
try {
|
|
224
|
+
const md = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
|
|
225
|
+
if (fs.existsSync(md) && fs.readFileSync(md, 'utf8').includes(FENCE_BEGIN)) return true;
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
return fs.existsSync(path.join(homeDir(), '.config', 'opencode', 'skills', 'chisle', 'SKILL.md'));
|
|
228
|
+
}
|
|
229
|
+
case 'hermes':
|
|
230
|
+
return fs.existsSync(path.join(homeDir(), '.hermes', 'skills', 'chisle', 'SKILL.md'));
|
|
206
231
|
default: {
|
|
207
232
|
const prov = PROVIDERS.find(x => x.id === id);
|
|
208
233
|
return !!(prov && prov.rule && fs.existsSync(path.join(process.cwd(), prov.rule)));
|
|
@@ -231,7 +256,7 @@ function installClaude(ctx) {
|
|
|
231
256
|
} else {
|
|
232
257
|
const r1 = run('claude', ['plugin', 'marketplace', 'add', REPO], opts.dryRun);
|
|
233
258
|
const r2 = run('claude', ['plugin', 'install', 'chisle@chisle'], opts.dryRun);
|
|
234
|
-
if ((r1
|
|
259
|
+
if (ranOk(r1) && ranOk(r2)) { results.installed.push('claude'); pluginOK = true; }
|
|
235
260
|
else warn(' claude plugin install failed, falling back to standalone hooks');
|
|
236
261
|
}
|
|
237
262
|
} else {
|
|
@@ -343,7 +368,7 @@ function installPi(ctx) {
|
|
|
343
368
|
}
|
|
344
369
|
}
|
|
345
370
|
const r = run('pi', ['install', 'npm:chisle'], opts.dryRun);
|
|
346
|
-
if ((r
|
|
371
|
+
if (ranOk(r)) results.installed.push('pi');
|
|
347
372
|
else results.failed.push(['pi', 'pi package install failed']);
|
|
348
373
|
process.stdout.write('\n');
|
|
349
374
|
}
|
|
@@ -361,11 +386,105 @@ function installGemini(ctx) {
|
|
|
361
386
|
}
|
|
362
387
|
}
|
|
363
388
|
const r = run('gemini', ['extensions', 'install', `https://github.com/${REPO}`], opts.dryRun);
|
|
364
|
-
if ((r
|
|
389
|
+
if (ranOk(r)) results.installed.push('gemini');
|
|
365
390
|
else results.failed.push(['gemini', 'gemini extensions install failed']);
|
|
366
391
|
process.stdout.write('\n');
|
|
367
392
|
}
|
|
368
393
|
|
|
394
|
+
// Skills-root helpers for Agent-Skills hosts (OpenCode, Hermes). Both load
|
|
395
|
+
// one dir per skill through the host's native `skill` tool, so the bundled
|
|
396
|
+
// skills/ tree copies over verbatim: no conversion, no second system
|
|
397
|
+
// message, existing user instructions untouched.
|
|
398
|
+
function ownedSkillNames() {
|
|
399
|
+
var srcRoot = path.join(REPO_ROOT, 'skills');
|
|
400
|
+
try {
|
|
401
|
+
return fs.readdirSync(srcRoot).filter(function (n) {
|
|
402
|
+
try {
|
|
403
|
+
return fs.statSync(path.join(srcRoot, n)).isDirectory()
|
|
404
|
+
&& fs.existsSync(path.join(srcRoot, n, 'SKILL.md'));
|
|
405
|
+
} catch (_) { return false; }
|
|
406
|
+
});
|
|
407
|
+
} catch (_) { return []; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Overwrites stale files, keeps foreign siblings, never touches files
|
|
411
|
+
// outside the named skill dirs. Returns files written.
|
|
412
|
+
function copySkills(dstRoot, opts) {
|
|
413
|
+
var names = ownedSkillNames();
|
|
414
|
+
var written = 0;
|
|
415
|
+
for (var i = 0; i < names.length; i++) {
|
|
416
|
+
var src = path.join(REPO_ROOT, 'skills', names[i]);
|
|
417
|
+
var dst = path.join(dstRoot, names[i]);
|
|
418
|
+
var files = fs.readdirSync(src);
|
|
419
|
+
if (!opts.dryRun) fs.mkdirSync(dst, { recursive: true });
|
|
420
|
+
for (var j = 0; j < files.length; j++) {
|
|
421
|
+
var st = null;
|
|
422
|
+
try { st = fs.statSync(path.join(src, files[j])); } catch (_) { continue; }
|
|
423
|
+
if (!st.isFile()) continue;
|
|
424
|
+
if (opts.dryRun) { written++; continue; }
|
|
425
|
+
fs.copyFileSync(path.join(src, files[j]), path.join(dst, files[j]));
|
|
426
|
+
written++;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return written;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Removes only the skill dirs Chisle owns; foreign skills stay put.
|
|
433
|
+
function pruneSkills(dstRoot) {
|
|
434
|
+
var removed = 0;
|
|
435
|
+
var names = ownedSkillNames();
|
|
436
|
+
for (var i = 0; i < names.length; i++) {
|
|
437
|
+
var dst = path.join(dstRoot, names[i]);
|
|
438
|
+
if (fs.existsSync(dst)) {
|
|
439
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
440
|
+
removed++;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return removed;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Fenced AGENTS.md append/refresh for Codex-style targets. Appends after
|
|
447
|
+
// existing content (never prepends, never clobbers), refreshes one fenced
|
|
448
|
+
// block in place under --force. Returns 'installed', 'refreshed', 'skipped'.
|
|
449
|
+
function writeFencedRuleset(target, opts, note) {
|
|
450
|
+
var raw = fs.readFileSync(path.join(REPO_ROOT, 'AGENTS.md'), 'utf8');
|
|
451
|
+
var block = FENCE_BEGIN + '\n' + raw.trimEnd() + '\n' + FENCE_END + '\n';
|
|
452
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
453
|
+
var existing = '';
|
|
454
|
+
try { existing = fs.readFileSync(target, 'utf8'); } catch (_) {}
|
|
455
|
+
if (existing.indexOf(FENCE_BEGIN) !== -1) {
|
|
456
|
+
if (!opts.force) {
|
|
457
|
+
note(' ' + target + ' already contains chisle ruleset (--force to refresh)');
|
|
458
|
+
return 'skipped';
|
|
459
|
+
}
|
|
460
|
+
var b = existing.indexOf(FENCE_BEGIN);
|
|
461
|
+
var e = existing.indexOf(FENCE_END, b);
|
|
462
|
+
// A begin marker with no end marker means the file was hand-edited or a
|
|
463
|
+
// write was cut short. Everything after the begin marker is then user
|
|
464
|
+
// content of unknown extent, so refuse rather than guess: rewriting would
|
|
465
|
+
// delete the rest of their global instructions.
|
|
466
|
+
if (e === -1) {
|
|
467
|
+
note(' ' + target + ' has a chisle-begin marker with no chisle-end; leaving it alone');
|
|
468
|
+
note(' remove the stray marker by hand, then re-run');
|
|
469
|
+
return 'skipped';
|
|
470
|
+
}
|
|
471
|
+
var tail = existing.slice(e + FENCE_END.length);
|
|
472
|
+
if (tail.charAt(0) === '\n') tail = tail.slice(1);
|
|
473
|
+
fs.writeFileSync(target, existing.slice(0, b) + block + tail, { mode: 0o644 });
|
|
474
|
+
process.stdout.write(' refreshed ruleset in ' + target + '\n');
|
|
475
|
+
return 'refreshed';
|
|
476
|
+
}
|
|
477
|
+
var sep = '';
|
|
478
|
+
if (existing) {
|
|
479
|
+
if (existing.slice(-2) === '\n\n') sep = '';
|
|
480
|
+
else if (existing.slice(-1) === '\n') sep = '\n';
|
|
481
|
+
else sep = '\n\n';
|
|
482
|
+
}
|
|
483
|
+
fs.writeFileSync(target, existing + sep + block, { mode: 0o644 });
|
|
484
|
+
process.stdout.write(' installed: ' + target + '\n');
|
|
485
|
+
return 'installed';
|
|
486
|
+
}
|
|
487
|
+
|
|
369
488
|
// ── Codex (fenced ruleset in ~/.codex/AGENTS.md) ────────────────────────────
|
|
370
489
|
const FENCE_BEGIN = '<!-- chisle-begin -->';
|
|
371
490
|
const FENCE_END = '<!-- chisle-end -->';
|
|
@@ -373,34 +492,111 @@ const FENCE_END = '<!-- chisle-end -->';
|
|
|
373
492
|
function installCodex(ctx) {
|
|
374
493
|
const { say, note, opts, results } = ctx;
|
|
375
494
|
results.detected++;
|
|
376
|
-
say('
|
|
377
|
-
const target = path.join(
|
|
378
|
-
const body = fs.readFileSync(path.join(REPO_ROOT, 'AGENTS.md'), 'utf8').trimEnd() + '\n';
|
|
379
|
-
const block = `${FENCE_BEGIN}\n${body}${FENCE_END}\n`;
|
|
495
|
+
say('\u2192 Codex detected');
|
|
496
|
+
const target = path.join(homeDir(), '.codex', 'AGENTS.md');
|
|
380
497
|
|
|
381
|
-
if (opts.dryRun) { note(
|
|
498
|
+
if (opts.dryRun) { note(' would write chisle ruleset to ' + target); results.installed.push('codex'); process.stdout.write('\n'); return; }
|
|
382
499
|
|
|
383
500
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (existing.includes(FENCE_BEGIN)) {
|
|
388
|
-
if (opts.force) {
|
|
389
|
-
const rewritten = existing.replace(new RegExp(`${FENCE_BEGIN}[\\s\\S]*?${FENCE_END}\\n?`), block);
|
|
390
|
-
fs.writeFileSync(target, rewritten, { mode: 0o644 });
|
|
391
|
-
process.stdout.write(` refreshed ruleset in ${target}\n`);
|
|
392
|
-
} else { note(` ${target} already contains chisle ruleset (--force to refresh)`); }
|
|
393
|
-
results.skipped.push(['codex', 'already present']);
|
|
394
|
-
} else {
|
|
395
|
-
const sep = existing && !existing.endsWith('\n\n') ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
|
|
396
|
-
fs.writeFileSync(target, existing + sep + block, { mode: 0o644 });
|
|
397
|
-
process.stdout.write(` installed: ${target}\n`);
|
|
398
|
-
results.installed.push('codex');
|
|
399
|
-
}
|
|
501
|
+
const st = writeFencedRuleset(target, opts, note);
|
|
502
|
+
if (st === 'installed') results.installed.push('codex');
|
|
503
|
+
else results.skipped.push(['codex', 'already present']);
|
|
400
504
|
} catch (e) { results.failed.push(['codex', (e && e.message) || 'write failed']); }
|
|
401
505
|
process.stdout.write('\n');
|
|
402
506
|
}
|
|
403
507
|
|
|
508
|
+
// ── OpenCode ─────────────────────────────────────────────────────────────
|
|
509
|
+
// Global ruleset (~/.config/opencode/AGENTS.md) carries the always-on output
|
|
510
|
+
// axis next to existing user instructions; the bundled skills copy into
|
|
511
|
+
// ~/.config/opencode/skills for on-demand loading via the native skill tool
|
|
512
|
+
// (the global skills dir is scanned by default, so skills.paths needs no
|
|
513
|
+
// edit and no second system message is added).
|
|
514
|
+
// Copies the OpenCode compression plugin and the shared compressor core it
|
|
515
|
+
// requires into ~/.config/opencode/plugins/. OpenCode auto-discovers any *.js
|
|
516
|
+
// or *.ts file in that dir at startup (not *.mjs), and its config dir is
|
|
517
|
+
// type:module, so the ESM source ships as chisle.js. Returns the number of
|
|
518
|
+
// files written. Never called on a dry run (installOpencode returns early),
|
|
519
|
+
// so no dry-run branch.
|
|
520
|
+
//
|
|
521
|
+
// chisle-hooks/package.json pins the core as CommonJS. Without it the config
|
|
522
|
+
// dir's own "type": "module" propagates down to these .js files, and the core
|
|
523
|
+
// dies on its first `require` with a ReferenceError that takes the whole
|
|
524
|
+
// plugin with it (loadFrom only swallows MODULE_NOT_FOUND). Bun is lenient
|
|
525
|
+
// enough not to care; Node is not, so pin it rather than rely on the runtime.
|
|
526
|
+
function copyOpencodePlugin() {
|
|
527
|
+
const pluginDir = path.join(homeDir(), '.config', 'opencode', 'plugins');
|
|
528
|
+
const hooksDir = path.join(pluginDir, 'chisle-hooks');
|
|
529
|
+
const files = [
|
|
530
|
+
['.opencode/plugins/chisle.mjs', path.join(pluginDir, 'chisle.js')],
|
|
531
|
+
['hooks/chisle-compress-output.js', path.join(hooksDir, 'chisle-compress-output.js')],
|
|
532
|
+
['hooks/chisle-config.js', path.join(hooksDir, 'chisle-config.js')],
|
|
533
|
+
];
|
|
534
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
535
|
+
var n = 0;
|
|
536
|
+
for (var i = 0; i < files.length; i++) {
|
|
537
|
+
fs.copyFileSync(path.join(REPO_ROOT, files[i][0]), files[i][1]);
|
|
538
|
+
n++;
|
|
539
|
+
}
|
|
540
|
+
fs.writeFileSync(path.join(hooksDir, 'package.json'), '{ "type": "commonjs" }\n');
|
|
541
|
+
n++;
|
|
542
|
+
return n;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function installOpencode(ctx) {
|
|
546
|
+
const { say, note, opts, results } = ctx;
|
|
547
|
+
results.detected++;
|
|
548
|
+
say('\u2192 OpenCode detected');
|
|
549
|
+
const target = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
|
|
550
|
+
const skillsDst = path.join(homeDir(), '.config', 'opencode', 'skills');
|
|
551
|
+
const pluginDst = path.join(homeDir(), '.config', 'opencode', 'plugins');
|
|
552
|
+
|
|
553
|
+
if (opts.dryRun) {
|
|
554
|
+
note(' would write chisle ruleset to ' + target);
|
|
555
|
+
note(' would copy skills to ' + skillsDst);
|
|
556
|
+
note(' would copy compression plugin to ' + pluginDst);
|
|
557
|
+
results.installed.push('opencode');
|
|
558
|
+
process.stdout.write('\n');
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const st = writeFencedRuleset(target, opts, note);
|
|
564
|
+
const n = copySkills(skillsDst, opts);
|
|
565
|
+
process.stdout.write(' installed: ' + n + ' skill file(s) to ' + skillsDst + '\n');
|
|
566
|
+
const p = copyOpencodePlugin();
|
|
567
|
+
process.stdout.write(' installed: ' + p + ' plugin file(s) to ' + pluginDst + '\n');
|
|
568
|
+
if (st === 'installed') results.installed.push('opencode');
|
|
569
|
+
else results.skipped.push(['opencode', 'ruleset already present; skills + plugin refreshed']);
|
|
570
|
+
} catch (e) { results.failed.push(['opencode', (e && e.message) || 'write failed']); }
|
|
571
|
+
process.stdout.write('\n');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Hermes ──────────────────────────────────────────────────────────
|
|
575
|
+
// Agent-Skills host: bundled skills land verbatim in ~/.hermes/skills, where
|
|
576
|
+
// Hermes discovers them as /chisle slash commands. No ruleset is injected:
|
|
577
|
+
// Hermes already reads project AGENTS.md, which carries the always-on axis
|
|
578
|
+
// when the repo ships it. Portable: honors CHISLE_HOME, no hardcoded paths.
|
|
579
|
+
function installHermes(ctx) {
|
|
580
|
+
const { say, opts, results } = ctx;
|
|
581
|
+
results.detected++;
|
|
582
|
+
say('\u2192 Hermes Agent detected');
|
|
583
|
+
const skillsDst = path.join(homeDir(), '.hermes', 'skills');
|
|
584
|
+
|
|
585
|
+
if (opts.dryRun) {
|
|
586
|
+
process.stdout.write(' would copy skills to ' + skillsDst + '\n');
|
|
587
|
+
results.installed.push('hermes');
|
|
588
|
+
process.stdout.write('\n');
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
const n = copySkills(skillsDst, opts);
|
|
594
|
+
process.stdout.write(' installed: ' + n + ' skill file(s) to ' + skillsDst + '\n');
|
|
595
|
+
results.installed.push('hermes');
|
|
596
|
+
} catch (e) { results.failed.push(['hermes', (e && e.message) || 'copy failed']); }
|
|
597
|
+
process.stdout.write('\n');
|
|
598
|
+
}
|
|
599
|
+
|
|
404
600
|
// ── Project-scoped rule agents (Cursor/Windsurf/Cline/Kiro/Copilot) ─────────
|
|
405
601
|
function installProjectRule(ctx, prov) {
|
|
406
602
|
const { say, note, warn, opts, results } = ctx;
|
|
@@ -473,12 +669,12 @@ function uninstall(ctx) {
|
|
|
473
669
|
if (wants('pi') && hasCmd('pi')) {
|
|
474
670
|
const sources = piSources(capture('pi', ['list']).stdout);
|
|
475
671
|
for (const source of sources) {
|
|
476
|
-
if ((run('pi', ['remove', source], opts.dryRun)
|
|
672
|
+
if (ranOk(run('pi', ['remove', source], opts.dryRun))) touched++;
|
|
477
673
|
}
|
|
478
674
|
}
|
|
479
675
|
|
|
480
676
|
if (wants('codex')) {
|
|
481
|
-
const codexMd = path.join(
|
|
677
|
+
const codexMd = path.join(homeDir(), '.codex', 'AGENTS.md');
|
|
482
678
|
if (fs.existsSync(codexMd)) {
|
|
483
679
|
const txt = fs.readFileSync(codexMd, 'utf8');
|
|
484
680
|
if (txt.includes(FENCE_BEGIN)) {
|
|
@@ -489,6 +685,42 @@ function uninstall(ctx) {
|
|
|
489
685
|
}
|
|
490
686
|
}
|
|
491
687
|
|
|
688
|
+
if (wants('opencode')) {
|
|
689
|
+
const ocMd = path.join(homeDir(), '.config', 'opencode', 'AGENTS.md');
|
|
690
|
+
if (fs.existsSync(ocMd)) {
|
|
691
|
+
const txt = fs.readFileSync(ocMd, 'utf8');
|
|
692
|
+
const b = txt.indexOf(FENCE_BEGIN);
|
|
693
|
+
const e = b === -1 ? -1 : txt.indexOf(FENCE_END, b);
|
|
694
|
+
if (b !== -1 && e === -1) {
|
|
695
|
+
note(' ' + ocMd + ' has a chisle-begin marker with no chisle-end; leaving it alone');
|
|
696
|
+
} else if (b !== -1) {
|
|
697
|
+
const tail = txt.slice(e + FENCE_END.length).replace(/^\n/, '');
|
|
698
|
+
const stripped = (txt.slice(0, b) + tail).replace(/\n{3,}/g, '\n\n');
|
|
699
|
+
if (!opts.dryRun) fs.writeFileSync(ocMd, stripped, { mode: 0o644 });
|
|
700
|
+
note(' removed chisle block from ' + ocMd); touched++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const ocSkills = path.join(homeDir(), '.config', 'opencode', 'skills');
|
|
704
|
+
const ocGone = opts.dryRun ? ownedSkillNames().filter(function (nm) { return fs.existsSync(path.join(ocSkills, nm)); }).length : pruneSkills(ocSkills);
|
|
705
|
+
if (ocGone > 0) { note(' removed ' + ocGone + ' chisle skill dir(s) from ' + ocSkills); touched++; }
|
|
706
|
+
|
|
707
|
+
const ocPlugins = path.join(homeDir(), '.config', 'opencode', 'plugins');
|
|
708
|
+
for (const rel of ['chisle.js', 'chisle-hooks']) {
|
|
709
|
+
const dst = path.join(ocPlugins, rel);
|
|
710
|
+
if (fs.existsSync(dst)) { if (!opts.dryRun) fs.rmSync(dst, { recursive: true, force: true }); note(' removed ' + dst); touched++; }
|
|
711
|
+
}
|
|
712
|
+
for (const f of ['chisle-spill', '.chisle-compress-stats.json', '.chisle-compress-last.json']) {
|
|
713
|
+
const p = path.join(homeDir(), '.config', 'opencode', f);
|
|
714
|
+
if (fs.existsSync(p)) { if (!opts.dryRun) fs.rmSync(p, { recursive: true, force: true }); note(' removed ' + p); touched++; }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (wants('hermes')) {
|
|
719
|
+
const hSkills = path.join(homeDir(), '.hermes', 'skills');
|
|
720
|
+
const hGone = opts.dryRun ? ownedSkillNames().filter(function (nm) { return fs.existsSync(path.join(hSkills, nm)); }).length : pruneSkills(hSkills);
|
|
721
|
+
if (hGone > 0) { note(' removed ' + hGone + ' chisle skill dir(s) from ' + hSkills); touched++; }
|
|
722
|
+
}
|
|
723
|
+
|
|
492
724
|
if (!opts.only.length || opts.only.some(id => PROVIDERS.find(p => p.id === id).scope === 'project')) {
|
|
493
725
|
note('');
|
|
494
726
|
note('Project-scoped rule files (.cursor/, .windsurf/, .clinerules/, .kiro/, .github/copilot-instructions.md)');
|
|
@@ -618,6 +850,8 @@ function main() {
|
|
|
618
850
|
else if (p.id === 'pi') installPi(ctx);
|
|
619
851
|
else if (p.id === 'gemini') installGemini(ctx);
|
|
620
852
|
else if (p.id === 'codex') installCodex(ctx);
|
|
853
|
+
else if (p.id === 'opencode') installOpencode(ctx);
|
|
854
|
+
else if (p.id === 'hermes') installHermes(ctx);
|
|
621
855
|
else installProjectRule(ctx, p);
|
|
622
856
|
}
|
|
623
857
|
|
package/hooks/chisle-activate.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
|
-
const { getDefaultMode, getClaudeDir, safeWriteFlag, legacySetting } = require('./chisle-config');
|
|
12
|
+
const { getDefaultMode, getClaudeDir, safeWriteFlag, legacySetting, resolveSections, filterSections } = require('./chisle-config');
|
|
13
13
|
|
|
14
14
|
// ── Update-notice helpers (pure — tested directly) ─────────────────────────
|
|
15
15
|
// Minor/patch releases stay quiet: a nudge per major is signal, more is spam.
|
|
@@ -90,7 +90,9 @@ function run(source) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// 3. Read SKILL.md — single source of truth for behaviour. No level
|
|
93
|
-
// filtering any more: there is one mode, so the whole body ships
|
|
93
|
+
// filtering any more: there is one mode, so the whole body ships, minus
|
|
94
|
+
// whatever `sections` in config.json opts out of (prose and/or code rules;
|
|
95
|
+
// config-file only, defaults to both enabled — see chisle-config.js).
|
|
94
96
|
let skillContent = '';
|
|
95
97
|
try {
|
|
96
98
|
skillContent = fs.readFileSync(
|
|
@@ -98,14 +100,18 @@ function run(source) {
|
|
|
98
100
|
);
|
|
99
101
|
} catch (e) {}
|
|
100
102
|
|
|
103
|
+
// Yields the prose section to an active Claude Code output style, which
|
|
104
|
+
// governs the same thing and would otherwise contradict it (#15/#17).
|
|
105
|
+
// An explicit sections.prose in config.json still wins.
|
|
106
|
+
const sections = resolveSections({ cwd: process.cwd(), claudeDir });
|
|
101
107
|
let output;
|
|
102
108
|
|
|
103
109
|
if (skillContent) {
|
|
104
|
-
output = 'CHISLE ACTIVE\n\n' +
|
|
110
|
+
output = 'CHISLE ACTIVE\n\n' +
|
|
111
|
+
filterSections(skillContent.replace(/^---[\s\S]*?---\s*/, ''), sections);
|
|
105
112
|
} else {
|
|
106
113
|
// Fallback ruleset when SKILL.md not found
|
|
107
|
-
|
|
108
|
-
'CHISLE ACTIVE\n\n' +
|
|
114
|
+
const fallbackBody =
|
|
109
115
|
'Chisle: maximum-efficiency dev mode. Zero-fluff prose. YAGNI-first code.\n\n' +
|
|
110
116
|
'## Persistence\n\n' +
|
|
111
117
|
'ACTIVE EVERY RESPONSE. Off only: "stop chisle" / "normal mode".\n\n' +
|
|
@@ -114,6 +120,7 @@ function run(source) {
|
|
|
114
120
|
'## Code\n\n' +
|
|
115
121
|
'Ladder: YAGNI → reuse → stdlib → native → installed dep → one line → min code.\n' +
|
|
116
122
|
'No unrequested abstractions. Deletion over addition. Shortest diff wins.';
|
|
123
|
+
output = 'CHISLE ACTIVE\n\n' + filterSections(fallbackBody, sections, ['Prose'], ['Code']);
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
// 3. Detect missing statusline config
|