chisle 2.0.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/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +48 -0
- package/.clinerules/chisle.md +36 -0
- package/.cursor/rules/chisle.mdc +41 -0
- package/.github/copilot-instructions.md +36 -0
- package/.kiro/steering/chisle.md +40 -0
- package/.windsurf/rules/chisle.md +40 -0
- package/AGENTS.md +46 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/bin/install.js +485 -0
- package/bin/lib/settings.js +148 -0
- package/commands/chisle-audit.toml +5 -0
- package/commands/chisle-help.toml +4 -0
- package/commands/chisle-review.toml +5 -0
- package/commands/chisle.toml +8 -0
- package/hooks/chisle-activate.js +160 -0
- package/hooks/chisle-compress-output.js +266 -0
- package/hooks/chisle-config.js +152 -0
- package/hooks/chisle-mode-tracker.js +88 -0
- package/hooks/chisle-statusline.ps1 +54 -0
- package/hooks/chisle-statusline.sh +94 -0
- package/hooks/package.json +1 -0
- package/package.json +73 -0
- package/rules/chisle-activate.md +13 -0
- package/skills/chisle/SKILL.md +142 -0
- package/skills/chisle-audit/SKILL.md +65 -0
- package/skills/chisle-help/SKILL.md +45 -0
- package/skills/chisle-review/SKILL.md +45 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chisle — SessionStart hook
|
|
3
|
+
//
|
|
4
|
+
// 1. Writes flag at $CLAUDE_CONFIG_DIR/.chisle-active
|
|
5
|
+
// 2. Resets session turn counter
|
|
6
|
+
// 3. Emits chisle ruleset (filtered to active level) as system context
|
|
7
|
+
// 4. Nudges user to configure statusline if missing
|
|
8
|
+
// 5. Major-version update notice (majors only, cached, fail-silent)
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { getDefaultMode, getClaudeDir, safeWriteFlag } = require('./chisle-config');
|
|
13
|
+
|
|
14
|
+
// ── Update-notice helpers (pure — tested directly) ─────────────────────────
|
|
15
|
+
// Minor/patch releases stay quiet: a nudge per major is signal, more is spam.
|
|
16
|
+
const UPDATE_CACHE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
function majorOf(v) {
|
|
19
|
+
const m = /^(\d+)\./.exec(String(v || ''));
|
|
20
|
+
return m ? parseInt(m[1], 10) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Notice line for a major jump, else null.
|
|
24
|
+
function majorUpdateNotice(installed, latest) {
|
|
25
|
+
const i = majorOf(installed), l = majorOf(latest);
|
|
26
|
+
if (i == null || l == null || l <= i) return null;
|
|
27
|
+
return '\n\nCHISLE UPDATE AVAILABLE: v' + latest + ' (major; you run v' + installed + '). ' +
|
|
28
|
+
'Mention this to the user once: update with `claude plugin update chisle@chisle` or `npx chisle`.';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function installedVersion() {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(
|
|
34
|
+
path.join(__dirname, '..', '.claude-plugin', 'plugin.json'), 'utf8')).version;
|
|
35
|
+
} catch (e) { return null; } // standalone-hooks install → no manifest → skip
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Registry lookup, cached on disk. Network capped at 1.5s inside the 5s hook budget.
|
|
39
|
+
async function latestVersion(cachePath) {
|
|
40
|
+
try {
|
|
41
|
+
const c = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
42
|
+
if (c && Date.now() - c.checkedAt < UPDATE_CACHE_MS) return c.latest;
|
|
43
|
+
} catch (e) {}
|
|
44
|
+
try {
|
|
45
|
+
const ctrl = new AbortController();
|
|
46
|
+
const t = setTimeout(() => ctrl.abort(), 1500);
|
|
47
|
+
const res = await fetch('https://registry.npmjs.org/chisle/latest', { signal: ctrl.signal });
|
|
48
|
+
clearTimeout(t);
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
const latest = (await res.json()).version;
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now(), latest }), { mode: 0o600 });
|
|
53
|
+
} catch (e) {}
|
|
54
|
+
return latest;
|
|
55
|
+
} catch (e) { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Hook body ───────────────────────────────────────────────────────────────
|
|
59
|
+
function run() {
|
|
60
|
+
const claudeDir = getClaudeDir();
|
|
61
|
+
const flagPath = path.join(claudeDir, '.chisle-active');
|
|
62
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
63
|
+
|
|
64
|
+
const mode = getDefaultMode();
|
|
65
|
+
|
|
66
|
+
if (mode === 'off') {
|
|
67
|
+
try { fs.unlinkSync(flagPath); } catch (e) {}
|
|
68
|
+
process.stdout.write('OK');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 1. Write flag
|
|
73
|
+
safeWriteFlag(flagPath, mode);
|
|
74
|
+
|
|
75
|
+
// 2. Read SKILL.md — single source of truth for behavior
|
|
76
|
+
const modeLabel = mode;
|
|
77
|
+
let skillContent = '';
|
|
78
|
+
try {
|
|
79
|
+
skillContent = fs.readFileSync(
|
|
80
|
+
path.join(__dirname, '..', 'skills', 'chisle', 'SKILL.md'), 'utf8'
|
|
81
|
+
);
|
|
82
|
+
} catch (e) {}
|
|
83
|
+
|
|
84
|
+
let output;
|
|
85
|
+
|
|
86
|
+
if (skillContent) {
|
|
87
|
+
const body = skillContent.replace(/^---[\s\S]*?---\s*/, '');
|
|
88
|
+
|
|
89
|
+
const filtered = body.split('\n').reduce((acc, line) => {
|
|
90
|
+
const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
|
|
91
|
+
if (tableRowMatch) {
|
|
92
|
+
if (tableRowMatch[1] === modeLabel) acc.push(line);
|
|
93
|
+
return acc;
|
|
94
|
+
}
|
|
95
|
+
const exampleMatch = line.match(/^- (\S+?):\s/);
|
|
96
|
+
if (exampleMatch) {
|
|
97
|
+
if (exampleMatch[1] === modeLabel) acc.push(line);
|
|
98
|
+
return acc;
|
|
99
|
+
}
|
|
100
|
+
acc.push(line);
|
|
101
|
+
return acc;
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
output = 'CHISLE MODE ACTIVE — level: ' + modeLabel + '\n\n' + filtered.join('\n');
|
|
105
|
+
} else {
|
|
106
|
+
// Fallback ruleset when SKILL.md not found
|
|
107
|
+
output =
|
|
108
|
+
'CHISLE MODE ACTIVE — level: ' + modeLabel + '\n\n' +
|
|
109
|
+
'Chisle: maximum-efficiency dev mode. Zero-fluff prose. YAGNI-first code.\n\n' +
|
|
110
|
+
'## Persistence\n\n' +
|
|
111
|
+
'ACTIVE EVERY RESPONSE. Off only: "stop chisle" / "normal mode". Switch: `/chisle lite|full|ultra`.\n\n' +
|
|
112
|
+
'## Prose\n\n' +
|
|
113
|
+
'Drop articles/filler/pleasantries/hedging. Fragments OK. Technical terms exact.\n\n' +
|
|
114
|
+
'## Code\n\n' +
|
|
115
|
+
'Ladder: YAGNI → reuse → stdlib → native → installed dep → one line → min code.\n' +
|
|
116
|
+
'No unrequested abstractions. Deletion over addition. Shortest diff wins.';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Detect missing statusline config
|
|
120
|
+
try {
|
|
121
|
+
let hasStatusline = false;
|
|
122
|
+
if (fs.existsSync(settingsPath)) {
|
|
123
|
+
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^/, '');
|
|
124
|
+
const settings = JSON.parse(raw);
|
|
125
|
+
if (settings.statusLine) hasStatusline = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!hasStatusline) {
|
|
129
|
+
const scriptPath = path.join(__dirname, 'chisle-statusline.sh');
|
|
130
|
+
const command = `bash "${scriptPath}"`;
|
|
131
|
+
const statusLineSnippet =
|
|
132
|
+
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
|
|
133
|
+
output += '\n\n' +
|
|
134
|
+
'STATUSLINE SETUP NEEDED: The chisle plugin includes a statusline badge showing active mode ' +
|
|
135
|
+
'(e.g. [CHISLE], [CHISLE:ULTRA]) with token savings. It is not configured yet. ' +
|
|
136
|
+
'To enable, add this to ' + settingsPath + ': ' +
|
|
137
|
+
statusLineSnippet + ' ' +
|
|
138
|
+
'Proactively offer to set this up for the user on first interaction.';
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {}
|
|
141
|
+
|
|
142
|
+
// 4. Update notice, then emit. CHISLE_UPDATE_CHECK=0 disables.
|
|
143
|
+
(async () => {
|
|
144
|
+
try {
|
|
145
|
+
if (process.env.CHISLE_UPDATE_CHECK !== '0') {
|
|
146
|
+
const installed = installedVersion();
|
|
147
|
+
if (installed) {
|
|
148
|
+
const latest = await latestVersion(path.join(claudeDir, '.chisle-update-check.json'));
|
|
149
|
+
const notice = majorUpdateNotice(installed, latest);
|
|
150
|
+
if (notice) output += notice;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {}
|
|
154
|
+
process.stdout.write(output);
|
|
155
|
+
})();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (require.main === module) run();
|
|
159
|
+
|
|
160
|
+
module.exports = { majorOf, majorUpdateNotice };
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chisle — PostToolUse hook: tool-output compression (the input axis).
|
|
3
|
+
//
|
|
4
|
+
// Prompt-side rules shrink what the agent WRITES; this shrinks what it READS.
|
|
5
|
+
// Oversized tool results (Bash dumps, subagent reports, web fetches) get their
|
|
6
|
+
// repetitive middle elided — head kept (command context), tail kept (results/
|
|
7
|
+
// errors), error-looking lines salvaged from the cut — and the trimmed version
|
|
8
|
+
// replaces what the model sees via `updatedToolOutput`. Deterministic, zero
|
|
9
|
+
// LLM, zero network. Small outputs pass through untouched.
|
|
10
|
+
//
|
|
11
|
+
// Correctness guardrails (why this never touches Read/Edit/Write):
|
|
12
|
+
// - Read output feeds later Edit old_string matching — eliding it makes the
|
|
13
|
+
// model edit against text it never saw. Allowlist below, never a blocklist.
|
|
14
|
+
// - Error lines in the elided middle are salvaged, capped, and kept verbatim.
|
|
15
|
+
// - If compression doesn't shrink the output, the original is kept.
|
|
16
|
+
// - Never throws: a broken hook must not break the tool pipeline.
|
|
17
|
+
//
|
|
18
|
+
// Two tiers, applied in order:
|
|
19
|
+
// scrub (output > 1k) — lossless: strip ANSI escapes, collapse blank-line
|
|
20
|
+
// runs, collapse ≥4 identical consecutive lines to
|
|
21
|
+
// one + "[repeated N×]". No information lost.
|
|
22
|
+
// elide (output > mode threshold) — head + tail + error salvage, as above.
|
|
23
|
+
// Plus dedup: a tool output byte-identical to that tool's immediately previous
|
|
24
|
+
// output is replaced by a short marker — the content is already in context.
|
|
25
|
+
//
|
|
26
|
+
// Compression tracks the /chisle mode flag (off → untouched). Tunables via env:
|
|
27
|
+
// CHISLE_COMPRESS=0 — kill switch
|
|
28
|
+
// CHISLE_COMPRESS_SCRUB=0 — disable the lossless scrub tier
|
|
29
|
+
// CHISLE_COMPRESS_DEDUP=0 — disable duplicate-output markers
|
|
30
|
+
// CHISLE_COMPRESS_MAX_CHARS — outputs at/under this size are not elided
|
|
31
|
+
// CHISLE_COMPRESS_HEAD_LINES — lines kept from the top
|
|
32
|
+
// CHISLE_COMPRESS_TAIL_LINES — lines kept from the bottom
|
|
33
|
+
// CHISLE_COMPRESS_TOOLS=Bash,Grep — override the tool allowlist
|
|
34
|
+
//
|
|
35
|
+
// Savings accrue in <claudeDir>/.chisle-compress-stats.json for the statusline.
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const crypto = require('crypto');
|
|
40
|
+
const { getClaudeDir, readFlag } = require('./chisle-config');
|
|
41
|
+
|
|
42
|
+
// Mode → thresholds. Tighter mode, tighter budget. Env overrides all.
|
|
43
|
+
const THRESHOLDS = {
|
|
44
|
+
lite: { maxChars: 16000, headLines: 100, tailLines: 80 },
|
|
45
|
+
full: { maxChars: 8000, headLines: 60, tailLines: 40 },
|
|
46
|
+
ultra: { maxChars: 5000, headLines: 40, tailLines: 30 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Tools whose output is safe to elide. Read/Edit/Write are absent on purpose:
|
|
50
|
+
// their output feeds exact-match edits. mcp__* are read-only info tools.
|
|
51
|
+
const SAFE_TOOLS = ['Bash', 'Agent', 'WebFetch', 'WebSearch', 'Grep', 'Glob'];
|
|
52
|
+
const SALVAGE_RE = /\b(error|err!|fail(ed|ure|ing)?|exception|traceback|panic|fatal|denied|refused|timed?[ _-]?out|assert(ion)?|segfault|npe|undefined reference|cannot find|not found|warning)\b/i;
|
|
53
|
+
const MAX_SALVAGED = 12; // error lines rescued from the elided middle
|
|
54
|
+
const MAX_SALVAGE_LINE = 300; // per-line char cap on salvaged lines
|
|
55
|
+
|
|
56
|
+
function toolAllowed(name) {
|
|
57
|
+
if (!name || typeof name !== 'string') return false;
|
|
58
|
+
const list = process.env.CHISLE_COMPRESS_TOOLS
|
|
59
|
+
? process.env.CHISLE_COMPRESS_TOOLS.split(',').map(s => s.trim()).filter(Boolean)
|
|
60
|
+
: SAFE_TOOLS;
|
|
61
|
+
return list.includes(name) || (!process.env.CHISLE_COMPRESS_TOOLS && name.startsWith('mcp__'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function envInt(name, fallback) {
|
|
65
|
+
const n = parseInt(process.env[name], 10);
|
|
66
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function limitsFor(mode) {
|
|
70
|
+
const base = THRESHOLDS[mode] || THRESHOLDS.full;
|
|
71
|
+
return {
|
|
72
|
+
maxChars: envInt('CHISLE_COMPRESS_MAX_CHARS', base.maxChars),
|
|
73
|
+
headLines: envInt('CHISLE_COMPRESS_HEAD_LINES', base.headLines),
|
|
74
|
+
tailLines: envInt('CHISLE_COMPRESS_TAIL_LINES', base.tailLines),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Pull a plain-text payload out of tool_response, whatever its shape.
|
|
79
|
+
function extractText(response) {
|
|
80
|
+
if (response == null) return null;
|
|
81
|
+
if (typeof response === 'string') return response;
|
|
82
|
+
if (Array.isArray(response)) {
|
|
83
|
+
const parts = response.map(b => (b && typeof b.text === 'string') ? b.text : extractText(b && b.content)).filter(Boolean);
|
|
84
|
+
return parts.length ? parts.join('\n') : null;
|
|
85
|
+
}
|
|
86
|
+
if (typeof response === 'object') {
|
|
87
|
+
const parts = [];
|
|
88
|
+
for (const key of ['stdout', 'stderr', 'output', 'content', 'text', 'result']) {
|
|
89
|
+
const val = response[key];
|
|
90
|
+
if (typeof val === 'string' && val) parts.push(val);
|
|
91
|
+
else if (val && typeof val === 'object') { const t = extractText(val); if (t) parts.push(t); }
|
|
92
|
+
}
|
|
93
|
+
if (parts.length) return parts.join('\n');
|
|
94
|
+
try { return JSON.stringify(response); } catch (e) { return null; }
|
|
95
|
+
}
|
|
96
|
+
return String(response);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Tier 1: lossless scrub ───────────────────────────────────────────────────
|
|
100
|
+
// ANSI/OSC escapes are invisible to the model; blank runs and identical
|
|
101
|
+
// consecutive lines carry their information in one copy.
|
|
102
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
103
|
+
const SCRUB_MIN = 1024; // below this, not worth a hook round-trip
|
|
104
|
+
const REPEAT_MIN = 4; // identical consecutive lines before collapsing
|
|
105
|
+
const MIN_WIN = 64; // emit updated output only if it saves this much
|
|
106
|
+
|
|
107
|
+
function scrub(text) {
|
|
108
|
+
let t = text.replace(ANSI_RE, '');
|
|
109
|
+
t = t.replace(/[ \t]+$/gm, ''); // trailing whitespace
|
|
110
|
+
t = t.replace(/\n{3,}/g, '\n\n'); // blank-line runs → one blank
|
|
111
|
+
// Collapse runs of identical non-empty lines.
|
|
112
|
+
const lines = t.split('\n');
|
|
113
|
+
const out = [];
|
|
114
|
+
for (let i = 0; i < lines.length; ) {
|
|
115
|
+
let j = i;
|
|
116
|
+
while (j < lines.length && lines[j] === lines[i]) j++;
|
|
117
|
+
const n = j - i;
|
|
118
|
+
if (n >= REPEAT_MIN && lines[i].trim()) {
|
|
119
|
+
out.push(lines[i], `... [chisle: line repeated ${n}×] ...`);
|
|
120
|
+
} else {
|
|
121
|
+
for (let k = 0; k < n; k++) out.push(lines[i]);
|
|
122
|
+
}
|
|
123
|
+
i = j;
|
|
124
|
+
}
|
|
125
|
+
return out.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Dedup: identical to this tool's previous output ─────────────────────────
|
|
129
|
+
// The identical content is already in context verbatim, so a marker loses
|
|
130
|
+
// nothing — but ONLY within the same session: a fresh session's context does
|
|
131
|
+
// not contain the earlier copy, so state is keyed by session_id and dedup is
|
|
132
|
+
// skipped when the payload carries none.
|
|
133
|
+
const DEDUP_MIN = 2048;
|
|
134
|
+
|
|
135
|
+
function dedupCheck(toolName, text, sessionId) {
|
|
136
|
+
if (process.env.CHISLE_COMPRESS_DEDUP === '0') return null;
|
|
137
|
+
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
138
|
+
if (text.length < DEDUP_MIN) return null;
|
|
139
|
+
try {
|
|
140
|
+
const p = path.join(getClaudeDir(), '.chisle-compress-last.json');
|
|
141
|
+
try { if (fs.lstatSync(p).isSymbolicLink()) return null; } catch (e) { if (e.code !== 'ENOENT') return null; }
|
|
142
|
+
let state = {};
|
|
143
|
+
try { state = JSON.parse(fs.readFileSync(p, 'utf8')) || {}; } catch (e) {}
|
|
144
|
+
if (state.session !== sessionId) state = { session: sessionId, tools: {} };
|
|
145
|
+
if (!state.tools || typeof state.tools !== 'object') state.tools = {};
|
|
146
|
+
const hash = crypto.createHash('sha256').update(text).digest('hex');
|
|
147
|
+
const dup = state.tools[toolName] === hash;
|
|
148
|
+
state.tools[toolName] = hash;
|
|
149
|
+
const tmp = p + '.' + process.pid + '.tmp';
|
|
150
|
+
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
151
|
+
fs.renameSync(tmp, p);
|
|
152
|
+
if (!dup) return null;
|
|
153
|
+
const lines = text.split('\n');
|
|
154
|
+
const preview = lines.slice(0, 5)
|
|
155
|
+
.map(l => l.length > MAX_SALVAGE_LINE ? l.slice(0, MAX_SALVAGE_LINE) + '…' : l);
|
|
156
|
+
return '[chisle: output byte-identical to the previous ' + toolName + ' result — ' +
|
|
157
|
+
text.length.toLocaleString('en-US') + ' chars / ' + lines.length +
|
|
158
|
+
' lines, unchanged. First lines:]\n' + preview.join('\n');
|
|
159
|
+
} catch (e) { return null; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Tier 2: elision ──────────────────────────────────────────────────────────
|
|
163
|
+
// Keep head + tail; salvage error-looking lines from the elided middle so the
|
|
164
|
+
// one line that mattered in a 3000-line build log survives the cut.
|
|
165
|
+
function compress(text, limits) {
|
|
166
|
+
const { maxChars, headLines, tailLines } = limits;
|
|
167
|
+
const lines = text.split('\n');
|
|
168
|
+
|
|
169
|
+
if (lines.length <= headLines + tailLines) {
|
|
170
|
+
// Big in bytes, few lines (one giant line) — hard char cut.
|
|
171
|
+
const keep = Math.floor(maxChars / 2);
|
|
172
|
+
const elided = text.length - 2 * keep;
|
|
173
|
+
if (elided <= 0) return null;
|
|
174
|
+
return text.slice(0, keep) +
|
|
175
|
+
'\n... [chisle: elided ' + elided.toLocaleString('en-US') + ' chars from the middle] ...\n' +
|
|
176
|
+
text.slice(-keep);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const head = lines.slice(0, headLines);
|
|
180
|
+
const tail = lines.slice(-tailLines);
|
|
181
|
+
const middle = lines.slice(headLines, lines.length - tailLines);
|
|
182
|
+
|
|
183
|
+
const salvaged = [];
|
|
184
|
+
for (const line of middle) {
|
|
185
|
+
if (salvaged.length >= MAX_SALVAGED) break;
|
|
186
|
+
if (SALVAGE_RE.test(line)) salvaged.push(line.length > MAX_SALVAGE_LINE ? line.slice(0, MAX_SALVAGE_LINE) + '…' : line);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const marker = '... [chisle: elided ' + middle.length.toLocaleString('en-US') +
|
|
190
|
+
' lines — kept first ' + headLines + ', last ' + tailLines +
|
|
191
|
+
(salvaged.length ? ', and ' + salvaged.length + ' error-like line(s) below' : '') + '] ...';
|
|
192
|
+
|
|
193
|
+
const out = head.concat([marker], salvaged, tail).join('\n');
|
|
194
|
+
return out.length < text.length ? out : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Best-effort savings ledger. Races between parallel tool calls can drop a
|
|
198
|
+
// count — stats only, never worth a lock file.
|
|
199
|
+
function recordSavings(saved) {
|
|
200
|
+
try {
|
|
201
|
+
const p = path.join(getClaudeDir(), '.chisle-compress-stats.json');
|
|
202
|
+
try { if (fs.lstatSync(p).isSymbolicLink()) return; } catch (e) { if (e.code !== 'ENOENT') return; }
|
|
203
|
+
let stats = { savedChars: 0, events: 0 };
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
206
|
+
if (parsed && Number.isFinite(parsed.savedChars) && Number.isFinite(parsed.events)) stats = parsed;
|
|
207
|
+
} catch (e) {}
|
|
208
|
+
stats.savedChars += saved;
|
|
209
|
+
stats.events += 1;
|
|
210
|
+
const tmp = p + '.' + process.pid + '.tmp';
|
|
211
|
+
fs.writeFileSync(tmp, JSON.stringify(stats), { mode: 0o600 });
|
|
212
|
+
fs.renameSync(tmp, p);
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Scrub + elide on plain text → transformed text, or null if no meaningful
|
|
217
|
+
// win. Stateless — this is what the replay benchmark measures.
|
|
218
|
+
function transform(text, limits) {
|
|
219
|
+
if (!text || text.length <= SCRUB_MIN) return null;
|
|
220
|
+
let t = process.env.CHISLE_COMPRESS_SCRUB === '0' ? text : scrub(text);
|
|
221
|
+
if (t.length > limits.maxChars) {
|
|
222
|
+
const elided = compress(t, limits);
|
|
223
|
+
if (elided != null) t = elided;
|
|
224
|
+
}
|
|
225
|
+
return text.length - t.length >= MIN_WIN ? t : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Full pipeline on one hook payload → updated output string, or null to keep
|
|
229
|
+
// the original. Pure given (payload, mode, env) except dedup state.
|
|
230
|
+
function processPayload(payload, mode) {
|
|
231
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
232
|
+
if (!mode || mode === 'off') return null;
|
|
233
|
+
if (process.env.CHISLE_COMPRESS === '0') return null;
|
|
234
|
+
if (!toolAllowed(payload.tool_name)) return null;
|
|
235
|
+
|
|
236
|
+
const text = extractText(payload.tool_response != null ? payload.tool_response : payload.tool_output);
|
|
237
|
+
if (!text) return null;
|
|
238
|
+
const dup = dedupCheck(payload.tool_name, text, payload.session_id);
|
|
239
|
+
if (dup != null && dup.length < text.length) return dup;
|
|
240
|
+
return transform(text, limitsFor(mode));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function main() {
|
|
244
|
+
let input = '';
|
|
245
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
246
|
+
process.stdin.on('end', () => {
|
|
247
|
+
try {
|
|
248
|
+
const payload = JSON.parse(input.replace(/^/, ''));
|
|
249
|
+
const mode = readFlag(path.join(getClaudeDir(), '.chisle-active'));
|
|
250
|
+
const updated = processPayload(payload, mode);
|
|
251
|
+
if (updated == null) return;
|
|
252
|
+
const original = extractText(payload.tool_response != null ? payload.tool_response : payload.tool_output);
|
|
253
|
+
recordSavings(original.length - updated.length);
|
|
254
|
+
process.stdout.write(JSON.stringify({
|
|
255
|
+
hookSpecificOutput: {
|
|
256
|
+
hookEventName: 'PostToolUse',
|
|
257
|
+
updatedToolOutput: updated,
|
|
258
|
+
},
|
|
259
|
+
}));
|
|
260
|
+
} catch (e) {} // silent — never break the tool pipeline
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (require.main === module) main();
|
|
265
|
+
|
|
266
|
+
module.exports = { extractText, scrub, compress, transform, limitsFor, toolAllowed, processPayload, THRESHOLDS, SAFE_TOOLS };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chisle — shared configuration resolver
|
|
3
|
+
//
|
|
4
|
+
// Resolution order for default mode:
|
|
5
|
+
// 1. CHISLE_DEFAULT_MODE environment variable
|
|
6
|
+
// 2. User config: $XDG_CONFIG_HOME/chisle/config.json, ~/.config/chisle/config.json
|
|
7
|
+
// 3. 'full'
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const VALID_MODES = ['off', 'lite', 'full', 'ultra'];
|
|
14
|
+
|
|
15
|
+
function getClaudeDir() {
|
|
16
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getConfigDir() {
|
|
20
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
21
|
+
return path.join(process.env.XDG_CONFIG_HOME, 'chisle');
|
|
22
|
+
}
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
return path.join(
|
|
25
|
+
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
|
26
|
+
'chisle'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return path.join(os.homedir(), '.config', 'chisle');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readModeFromConfigFile(configPath) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
35
|
+
const config = JSON.parse(raw);
|
|
36
|
+
if (config && config.defaultMode &&
|
|
37
|
+
VALID_MODES.includes(String(config.defaultMode).toLowerCase())) {
|
|
38
|
+
return String(config.defaultMode).toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getDefaultMode() {
|
|
45
|
+
const envMode = process.env.CHISLE_DEFAULT_MODE;
|
|
46
|
+
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
|
|
47
|
+
return envMode.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
const userMode = readModeFromConfigFile(path.join(getConfigDir(), 'config.json'));
|
|
50
|
+
if (userMode) return userMode;
|
|
51
|
+
return 'full';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Symlink-safe, atomic flag write with 0600 perms.
|
|
55
|
+
// Defends against local attacker replacing predictable path with symlink to clobber other files.
|
|
56
|
+
function safeWriteFlag(flagPath, content) {
|
|
57
|
+
const debug = process.env.CHISLE_DEBUG === '1';
|
|
58
|
+
try {
|
|
59
|
+
const flagDir = path.dirname(flagPath);
|
|
60
|
+
fs.mkdirSync(flagDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
let realFlagDir;
|
|
63
|
+
try {
|
|
64
|
+
const lstat = fs.lstatSync(flagDir);
|
|
65
|
+
if (lstat.isSymbolicLink()) {
|
|
66
|
+
realFlagDir = fs.realpathSync(flagDir);
|
|
67
|
+
const realStat = fs.statSync(realFlagDir);
|
|
68
|
+
if (!realStat.isDirectory()) {
|
|
69
|
+
if (debug) process.stderr.write(`[chisle] safeWriteFlag: symlink target not a directory\n`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (typeof process.getuid === 'function') {
|
|
73
|
+
if (realStat.uid !== process.getuid()) {
|
|
74
|
+
if (debug) process.stderr.write(`[chisle] safeWriteFlag: symlink target owned by different uid\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const home = os.homedir();
|
|
79
|
+
const normalizedReal = path.resolve(realFlagDir).toLowerCase();
|
|
80
|
+
const normalizedHome = path.resolve(home).toLowerCase();
|
|
81
|
+
if (!normalizedReal.startsWith(normalizedHome + path.sep) &&
|
|
82
|
+
normalizedReal !== normalizedHome) {
|
|
83
|
+
if (debug) process.stderr.write(`[chisle] safeWriteFlag: symlink target outside home dir\n`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
realFlagDir = flagDir;
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const realFlagPath = path.join(realFlagDir, path.basename(flagPath));
|
|
95
|
+
try {
|
|
96
|
+
if (fs.lstatSync(realFlagPath).isSymbolicLink()) return;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e.code !== 'ENOENT') return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tempPath = path.join(realFlagDir, `.chisle-active.${process.pid}.${Date.now()}`);
|
|
102
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
103
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
|
|
104
|
+
let fd;
|
|
105
|
+
try {
|
|
106
|
+
fd = fs.openSync(tempPath, flags, 0o600);
|
|
107
|
+
fs.writeSync(fd, String(content));
|
|
108
|
+
try { fs.fchmodSync(fd, 0o600); } catch (e) {}
|
|
109
|
+
} finally {
|
|
110
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
fs.renameSync(tempPath, realFlagPath);
|
|
113
|
+
} catch (e) {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Symlink-safe, size-capped, whitelist-validated flag read.
|
|
117
|
+
// Refuses symlinks, caps at 64 bytes, rejects unknown modes.
|
|
118
|
+
const MAX_FLAG_BYTES = 64;
|
|
119
|
+
|
|
120
|
+
function readFlag(flagPath) {
|
|
121
|
+
try {
|
|
122
|
+
let st;
|
|
123
|
+
try {
|
|
124
|
+
st = fs.lstatSync(flagPath);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (st.isSymbolicLink() || !st.isFile()) return null;
|
|
129
|
+
if (st.size > MAX_FLAG_BYTES) return null;
|
|
130
|
+
|
|
131
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
132
|
+
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
|
|
133
|
+
let fd;
|
|
134
|
+
let out;
|
|
135
|
+
try {
|
|
136
|
+
fd = fs.openSync(flagPath, flags);
|
|
137
|
+
const buf = Buffer.alloc(MAX_FLAG_BYTES);
|
|
138
|
+
const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
|
|
139
|
+
out = buf.slice(0, n).toString('utf8');
|
|
140
|
+
} finally {
|
|
141
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const raw = out.trim().toLowerCase();
|
|
145
|
+
if (!VALID_MODES.includes(raw)) return null;
|
|
146
|
+
return raw;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { getDefaultMode, getClaudeDir, VALID_MODES, safeWriteFlag, readFlag };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chisle — UserPromptSubmit hook
|
|
3
|
+
// Handles /chisle commands, natural language activation/deactivation, and
|
|
4
|
+
// per-turn reinforcement.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { getDefaultMode, getClaudeDir, VALID_MODES, safeWriteFlag, readFlag } = require('./chisle-config');
|
|
9
|
+
|
|
10
|
+
const claudeDir = getClaudeDir();
|
|
11
|
+
const flagPath = path.join(claudeDir, '.chisle-active');
|
|
12
|
+
|
|
13
|
+
let input = '';
|
|
14
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
15
|
+
process.stdin.on('end', () => {
|
|
16
|
+
try {
|
|
17
|
+
const data = JSON.parse(input.replace(/^/, ''));
|
|
18
|
+
const prompt = (data.prompt || '').trim();
|
|
19
|
+
const promptLower = prompt.toLowerCase();
|
|
20
|
+
|
|
21
|
+
// Natural language activation
|
|
22
|
+
if (/\b(activate|enable|turn on|start|use)\b.*\bchisle\b/i.test(promptLower) ||
|
|
23
|
+
/\bchisle\b.*\b(mode|activate|enable|on)\b/i.test(promptLower) ||
|
|
24
|
+
/\bchislif(y|ier)\b/i.test(promptLower)) {
|
|
25
|
+
if (!/\b(stop|disable|turn off|deactivate|off)\b/i.test(promptLower)) {
|
|
26
|
+
const mode = getDefaultMode();
|
|
27
|
+
if (mode !== 'off') safeWriteFlag(flagPath, mode);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// /chisle slash commands
|
|
32
|
+
if (/^\/chisle(\b|:chisle\b)/.test(promptLower)) {
|
|
33
|
+
const parts = promptLower.split(/\s+/);
|
|
34
|
+
const cmd = parts[0];
|
|
35
|
+
const arg = parts[1] || '';
|
|
36
|
+
|
|
37
|
+
let mode = null;
|
|
38
|
+
|
|
39
|
+
if (cmd === '/chisle' || cmd === '/chisle:chisle') {
|
|
40
|
+
if (!arg) {
|
|
41
|
+
mode = getDefaultMode();
|
|
42
|
+
} else if (arg === 'off' || arg === 'stop' || arg === 'disable') {
|
|
43
|
+
mode = 'off';
|
|
44
|
+
} else if (VALID_MODES.includes(arg)) {
|
|
45
|
+
mode = arg;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (mode && mode !== 'off') {
|
|
50
|
+
safeWriteFlag(flagPath, mode);
|
|
51
|
+
} else if (mode === 'off') {
|
|
52
|
+
try { fs.unlinkSync(flagPath); } catch (e) {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Natural language deactivation.
|
|
57
|
+
// Only fire when the off-verb actually targets chisle — NOT when chisle merely
|
|
58
|
+
// appears in a sentence that also mentions turning something else off.
|
|
59
|
+
// ("use chisle to turn off the logger" must NOT deactivate.)
|
|
60
|
+
if (/\b(turn off|disable|deactivate|stop|kill|exit)\s+chisle\b/i.test(promptLower) ||
|
|
61
|
+
/\bchisle\s+(mode\s+)?(off|stop|disable|deactivate)\b/i.test(promptLower) ||
|
|
62
|
+
/\bnormal mode\b/i.test(promptLower)) {
|
|
63
|
+
try { fs.unlinkSync(flagPath); } catch (e) {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Per-turn reinforcement
|
|
67
|
+
const activeMode = readFlag(flagPath);
|
|
68
|
+
if (activeMode && activeMode !== 'off') {
|
|
69
|
+
// Inject compact reminder — keeps chisle visible across context compression
|
|
70
|
+
const ladderHint = activeMode === 'ultra'
|
|
71
|
+
? 'YAGNI extremist: delete before add, challenge req in same breath.'
|
|
72
|
+
: 'Code: YAGNI ladder first (stdlib → native → dep → one line → min code).';
|
|
73
|
+
|
|
74
|
+
process.stdout.write(JSON.stringify({
|
|
75
|
+
hookSpecificOutput: {
|
|
76
|
+
hookEventName: 'UserPromptSubmit',
|
|
77
|
+
additionalContext:
|
|
78
|
+
'CHISLE MODE ACTIVE (' + activeMode + '). ' +
|
|
79
|
+
'Prose: drop articles/filler/pleasantries/hedging. Fragments OK. ' +
|
|
80
|
+
ladderHint + ' ' +
|
|
81
|
+
'Code/commits/security: write normal.'
|
|
82
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
// Silent fail
|
|
87
|
+
}
|
|
88
|
+
});
|