chisle 3.4.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chisle",
3
- "version": "3.4.0",
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": [
package/README.md CHANGED
@@ -448,6 +448,41 @@ export CHISLE_DEFAULT_MODE=off
448
448
 
449
449
  Resolution: env var → config file → `on`. Valid: `off`, `on`.
450
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
+
451
486
  ---
452
487
 
453
488
  ## Prior art & what stacks with it
@@ -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' + skillContent.replace(/^---[\s\S]*?---\s*/, '');
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
- output =
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
@@ -92,6 +92,166 @@ function getDefaultMode() {
92
92
  return 'on';
93
93
  }
94
94
 
95
+ // ── Section suppression ─────────────────────────────────────────────────────
96
+ // Optional config key letting a user drop Chisle's prose rules or code rules
97
+ // independently, while keeping the other. Config file only — no env override,
98
+ // unlike defaultMode. Absent, malformed, or non-boolean always falls back to
99
+ // enabled (true); never throws.
100
+ // { "sections": { "prose": false } } // suppress prose rules, keep code
101
+
102
+ // Headings from skills/chisle/SKILL.md, grouped by section.
103
+ const PROSE_HEADINGS = [
104
+ 'Prose: Maximum Signal Per Token',
105
+ 'Output Format',
106
+ 'What it sounds like',
107
+ ];
108
+ const CODE_HEADINGS = [
109
+ 'Code: The Efficiency Ladder',
110
+ 'Code Rules',
111
+ 'Context Diet: Read Less Into the Window',
112
+ ];
113
+
114
+ function readSectionsFromConfigFile(configPath) {
115
+ try {
116
+ const raw = fs.readFileSync(configPath, 'utf8');
117
+ const config = JSON.parse(raw);
118
+ const s = config && typeof config === 'object' ? config.sections : null;
119
+ return {
120
+ prose: !!(s && typeof s === 'object' && typeof s.prose === 'boolean' ? s.prose : true),
121
+ code: !!(s && typeof s === 'object' && typeof s.code === 'boolean' ? s.code : true),
122
+ };
123
+ } catch (e) {
124
+ return { prose: true, code: true };
125
+ }
126
+ }
127
+
128
+ function getSections() {
129
+ return readSectionsFromConfigFile(path.join(getConfigDir(), 'config.json'));
130
+ }
131
+
132
+ // Drops whole `## Heading` sections (heading line through the line before the
133
+ // next `## ` heading) whose heading is in an excluded group. Byte-identical
134
+ // to `content` when nothing is excluded. Never throws — falls back to the
135
+ // unfiltered content. Assumes no `## ` line appears inside a fenced code
136
+ // block (true for SKILL.md and the fallback ruleset at 07c603b).
137
+ function filterSections(content, sections, proseHeadings, codeHeadings) {
138
+ try {
139
+ if (typeof content !== 'string' || !content) return content;
140
+ const prose = sections ? sections.prose !== false : true;
141
+ const code = sections ? sections.code !== false : true;
142
+ if (prose && code) return content;
143
+
144
+ const dropProse = proseHeadings || PROSE_HEADINGS;
145
+ const dropCode = codeHeadings || CODE_HEADINGS;
146
+
147
+ const chunks = content.split(/(?=^## .+$)/m);
148
+ const kept = chunks.filter((chunk) => {
149
+ const m = /^## (.+)$/m.exec(chunk);
150
+ if (!m) return true; // preamble chunk (no heading)
151
+ const heading = m[1].trim();
152
+ if (!prose && dropProse.includes(heading)) return false;
153
+ if (!code && dropCode.includes(heading)) return false;
154
+ return true;
155
+ });
156
+ return kept.join('');
157
+ } catch (e) {
158
+ return content;
159
+ }
160
+ }
161
+
162
+ // ── Output-style detection ──────────────────────────────────────────────────
163
+ // Claude Code's output-style mechanism also dictates prose structure, so an
164
+ // active style and Chisle's prose section issue contradicting instructions with
165
+ // no precedence between them (#15). `outputStyle` is a documented settings key
166
+ // (schemastore: claude-code-settings.json), and `/output-style` persists the
167
+ // selection to .claude/settings.local.json at the local project level, so the
168
+ // active style IS readable — it just isn't in the SessionStart payload.
169
+ //
170
+ // Kept dependency-free on purpose: this file is copied standalone into
171
+ // ~/.config/opencode/plugins/chisle-hooks/, so it must not require anything
172
+ // outside itself.
173
+
174
+ // Claude Code parses settings.json with strict JSON, but users do leave
175
+ // comments in it. Tolerate that rather than silently reading no style.
176
+ function parseJsonLoose(raw) {
177
+ try { return JSON.parse(raw.replace(/^\uFEFF/, '')); } catch (e) {}
178
+ try {
179
+ const stripped = raw
180
+ .replace(/^\uFEFF/, '')
181
+ .replace(/"(?:[^"\\]|\\.)*"|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
182
+ (m) => (m[0] === '"' ? m : ' '));
183
+ return JSON.parse(stripped);
184
+ } catch (e) { return null; }
185
+ }
186
+
187
+ function readJsonFile(p) {
188
+ try {
189
+ const st = fs.lstatSync(p);
190
+ if (st.isSymbolicLink() || !st.isFile()) return null;
191
+ return parseJsonLoose(fs.readFileSync(p, 'utf8'));
192
+ } catch (e) { return null; }
193
+ }
194
+
195
+ // Claude Code's own precedence, most specific first. A style set for this
196
+ // project must beat a different one set globally.
197
+ function outputStyleSources(opts) {
198
+ const o = opts || {};
199
+ const cwd = o.cwd || process.cwd();
200
+ const claudeDir = o.claudeDir || getClaudeDir();
201
+ return [
202
+ path.join(cwd, '.claude', 'settings.local.json'),
203
+ path.join(cwd, '.claude', 'settings.json'),
204
+ path.join(claudeDir, 'settings.json'),
205
+ ];
206
+ }
207
+
208
+ // The active output style, or null when none is. "default" is the built-in
209
+ // no-op style, so it counts as none.
210
+ function getActiveOutputStyle(opts) {
211
+ try {
212
+ for (const file of outputStyleSources(opts)) {
213
+ const json = readJsonFile(file);
214
+ if (!json || typeof json !== 'object') continue;
215
+ const style = json.outputStyle;
216
+ if (typeof style !== 'string') continue;
217
+ const trimmed = style.trim();
218
+ if (!trimmed) continue;
219
+ return trimmed.toLowerCase() === 'default' ? null : trimmed;
220
+ }
221
+ return null;
222
+ } catch (e) { return null; }
223
+ }
224
+
225
+ // What the user wrote, distinguishing "absent" from "explicitly true". Only an
226
+ // absent value may be overridden by output-style detection.
227
+ function explicitSections() {
228
+ try {
229
+ const json = readJsonFile(path.join(getConfigDir(), 'config.json'));
230
+ const s = json && typeof json === 'object' ? json.sections : null;
231
+ if (!s || typeof s !== 'object') return {};
232
+ const out = {};
233
+ if (typeof s.prose === 'boolean') out.prose = s.prose;
234
+ if (typeof s.code === 'boolean') out.code = s.code;
235
+ return out;
236
+ } catch (e) { return {}; }
237
+ }
238
+
239
+ // Final prose/code decision for the Claude Code hooks. Explicit config always
240
+ // wins — a user who wrote `sections.prose: true` has said they want the prose
241
+ // rules even alongside a style, and this must not overrule that. Only an unset
242
+ // prose key yields to an active style. `code` is never touched: output styles
243
+ // govern prose structure, not the efficiency ladder.
244
+ //
245
+ // Pi has no output-style mechanism, so pi-extension keeps calling getSections().
246
+ function resolveSections(opts) {
247
+ const explicit = explicitSections();
248
+ const base = getSections();
249
+ if (explicit.prose !== undefined) return { ...base, outputStyle: null };
250
+ const style = getActiveOutputStyle(opts);
251
+ if (!style) return { ...base, outputStyle: null };
252
+ return { prose: false, code: base.code, outputStyle: style };
253
+ }
254
+
95
255
  // Symlink-safe, atomic flag write with 0600 perms.
96
256
  // Defends against local attacker replacing predictable path with symlink to clobber other files.
97
257
  function safeWriteFlag(flagPath, content) {
@@ -193,4 +353,6 @@ function readFlag(flagPath) {
193
353
  module.exports = {
194
354
  LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir,
195
355
  VALID_MODES, safeWriteFlag, readFlag,
356
+ PROSE_HEADINGS, CODE_HEADINGS, getSections, filterSections,
357
+ getActiveOutputStyle, explicitSections, resolveSections,
196
358
  };
@@ -5,9 +5,27 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
- const { getClaudeDir, safeWriteFlag, readFlag } = require('./chisle-config');
8
+ const { getClaudeDir, safeWriteFlag, readFlag, resolveSections } = require('./chisle-config');
9
9
  const { requestedMode } = require('./chisle-mode');
10
10
 
11
+ // Per-turn reinforcement clauses. `Code/commits/security: write normal.` is
12
+ // the Boundaries carve-out (always-on in SKILL.md) and ships regardless of
13
+ // `sections`; the prose/code clauses honor it the same way the SessionStart
14
+ // ruleset does.
15
+ const PROSE_CLAUSE = 'Prose: drop articles/filler/pleasantries/hedging. Fragments OK.';
16
+ const CODE_CLAUSE = 'Code: YAGNI ladder first (reuse → stdlib → native → dep → one line → min code).';
17
+ const BOUNDARIES_CLAUSE = 'Code/commits/security: write normal.';
18
+
19
+ function reinforcementLine(sections) {
20
+ const prose = sections ? sections.prose !== false : true;
21
+ const code = sections ? sections.code !== false : true;
22
+ const parts = ['CHISLE ACTIVE.'];
23
+ if (prose) parts.push(PROSE_CLAUSE);
24
+ if (code) parts.push(CODE_CLAUSE);
25
+ parts.push(BOUNDARIES_CLAUSE);
26
+ return parts.join(' ');
27
+ }
28
+
11
29
  const claudeDir = getClaudeDir();
12
30
  const flagPath = path.join(claudeDir, '.chisle-active');
13
31
 
@@ -32,11 +50,7 @@ process.stdin.on('end', () => {
32
50
  process.stdout.write(JSON.stringify({
33
51
  hookSpecificOutput: {
34
52
  hookEventName: 'UserPromptSubmit',
35
- additionalContext:
36
- 'CHISLE ACTIVE. ' +
37
- 'Prose: drop articles/filler/pleasantries/hedging. Fragments OK. ' +
38
- 'Code: YAGNI ladder first (reuse → stdlib → native → dep → one line → min code). ' +
39
- 'Code/commits/security: write normal.'
53
+ additionalContext: reinforcementLine(resolveSections({ cwd: process.cwd(), claudeDir }))
40
54
  }
41
55
  }));
42
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chisle",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Maximum-efficiency dev mode for AI coding agents. Zero-fluff prose, YAGNI-first code, and safe tool-output compression.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -3,7 +3,7 @@
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
- const { getDefaultMode } = require('../hooks/chisle-config');
6
+ const { getDefaultMode, getSections, filterSections } = require('../hooks/chisle-config');
7
7
  const { duplicateMarker, limitsFor, transform } = require('../hooks/chisle-compress-output');
8
8
  const { requestedMode } = require('../hooks/chisle-mode');
9
9
 
@@ -12,20 +12,52 @@ const MODE_TYPE = 'chisle-mode';
12
12
  const SAFE_TOOLS = new Set(['bash', 'powershell', 'grep', 'find', 'ls']);
13
13
  const NEVER_COMPRESS = new Set(['read', 'edit', 'write']);
14
14
  const DEDUP_MIN = 2048;
15
- const FALLBACK_RULES = `CHISLE ACTIVE
15
+ // Sectioned so the `sections` config applies here too. Flat prose/code lines
16
+ // would leave the fallback ignoring a user's sections.prose:false the moment
17
+ // SKILL.md became unreadable — chisle-activate.js's fallback is shaped the
18
+ // same way for the same reason. The safety line stays outside both groups.
19
+ const FALLBACK_BODY = `Chisle: maximum-efficiency dev mode. Zero-fluff prose. YAGNI-first code.
16
20
 
17
- Chisle: maximum-efficiency dev mode. Zero-fluff prose. YAGNI-first code.
21
+ ## Prose
18
22
 
19
23
  Drop articles, filler, pleasantries, and hedging. Fragments OK; technical terms exact.
24
+
25
+ ## Code
26
+
20
27
  Code ladder: YAGNI → reuse → stdlib → native → installed dependency → one line → minimum code.
28
+
29
+ ## Boundaries
30
+
21
31
  Never simplify away input validation, data-loss prevention, security, accessibility, or explicit requirements.`;
22
32
 
23
33
  function loadRules() {
24
34
  try {
25
35
  const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'chisle', 'SKILL.md'), 'utf8');
26
- return 'CHISLE ACTIVE\n\n' + skill.replace(/^---[\s\S]*?---\s*/, '');
36
+ // Shares the SKILL.md emission path with chisle-activate.js, so it honors
37
+ // the same `sections` config (prose/code rule suppression) the same way.
38
+ return 'CHISLE ACTIVE\n\n' +
39
+ filterSections(skill.replace(/^---[\s\S]*?---\s*/, ''), getSections());
40
+ } catch (_) {
41
+ return 'CHISLE ACTIVE\n\n' +
42
+ filterSections(FALLBACK_BODY, getSections(), ['Prose'], ['Code']);
43
+ }
44
+ }
45
+
46
+ // OMP (oh-my-pi) ships a subset of Pi's sessionManager: buildContextEntries and
47
+ // getBranch are absent there, so calling one throws inside session_start and
48
+ // takes the whole extension down —
49
+ // "ctx.sessionManager.buildContextEntries is not a function" (issue #14).
50
+ // Treat both as optional readers. An absent one means we cannot prove the
51
+ // session state, and every caller here already behaves correctly on "unknown":
52
+ // no mode entry leaves `active` at getDefaultMode(), and rulesInContext=false
53
+ // re-injects the ruleset, which promptHasRules still de-duplicates.
54
+ function sessionEntries(ctx, method) {
55
+ try {
56
+ const sm = ctx && ctx.sessionManager;
57
+ if (!sm || typeof sm[method] !== 'function') return [];
58
+ return sm[method]() || [];
27
59
  } catch (_) {
28
- return FALLBACK_RULES;
60
+ return [];
29
61
  }
30
62
  }
31
63
 
@@ -77,12 +109,12 @@ function chisleExtension(pi) {
77
109
  lastByToolBlock = new Map();
78
110
  dedupBaseline = new Map();
79
111
 
80
- const modeEntry = ctx.sessionManager.getBranch()
112
+ const modeEntry = sessionEntries(ctx, 'getBranch')
81
113
  .filter(entry => entry.type === 'custom' && entry.customType === MODE_TYPE).pop();
82
114
  if (modeEntry && modeEntry.data && ['on', 'off'].includes(modeEntry.data.mode)) {
83
115
  active = modeEntry.data.mode === 'on';
84
116
  }
85
- rulesInContext = hasRulesEntry(ctx.sessionManager.buildContextEntries());
117
+ rulesInContext = hasRulesEntry(sessionEntries(ctx, 'buildContextEntries'));
86
118
  updateStatus(ctx);
87
119
  }
88
120
 
@@ -116,7 +148,7 @@ function chisleExtension(pi) {
116
148
  pi.on('session_compact', async (_event, ctx) => {
117
149
  lastByToolBlock.clear();
118
150
  dedupBaseline.clear();
119
- rulesInContext = hasRulesEntry(ctx.sessionManager.buildContextEntries());
151
+ rulesInContext = hasRulesEntry(sessionEntries(ctx, 'buildContextEntries'));
120
152
  });
121
153
 
122
154
  pi.on('input', async (event, ctx) => {
@@ -185,4 +217,5 @@ function chisleExtension(pi) {
185
217
  module.exports = chisleExtension;
186
218
  module.exports.loadRules = loadRules;
187
219
  module.exports.hasRulesEntry = hasRulesEntry;
220
+ module.exports.sessionEntries = sessionEntries;
188
221
  module.exports.piToolAllowed = piToolAllowed;