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.
@@ -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
+ });