compact-agent 1.24.2 → 1.25.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/bin/ecc-hooks.cjs CHANGED
@@ -1,394 +1,394 @@
1
- #!/usr/bin/env node
2
- /**
3
- * ECC-native hook dispatcher for Crowcoder.
4
- *
5
- * Reads Crowcoder's hook env vars (CROWCODER_EVENT, CROWCODER_TOOL,
6
- * CROWCODER_TOOL_INPUT, CROWCODER_TOOL_OUTPUT) and runs a single named check.
7
- *
8
- * Exit codes:
9
- * 0 — allow (PreToolUse) or success (PostToolUse)
10
- * 2 — block (PreToolUse only; non-zero on a non-blocking hook is just logged)
11
- *
12
- * Usage:
13
- * node ecc-hooks.cjs <check-name> [__ecc__]
14
- *
15
- * The trailing __ecc__ tag is used by the installer to identify ECC-managed
16
- * hook entries so /ecc-install can refresh them without touching user hooks.
17
- */
18
- 'use strict';
19
-
20
- const checkName = process.argv[2] || '';
21
-
22
- let toolInput = {};
23
- try {
24
- toolInput = JSON.parse(process.env.CROWCODER_TOOL_INPUT || '{}');
25
- } catch { /* ignore — leave as {} */ }
26
-
27
- const tool = process.env.CROWCODER_TOOL || '';
28
- const cwd = process.env.CROWCODER_CWD || process.cwd();
29
-
30
- // ── Helpers ─────────────────────────────────────────────
31
- function bashCommand() {
32
- return String(toolInput.command || toolInput.cmd || '');
33
- }
34
-
35
- function filePath() {
36
- return String(toolInput.path || toolInput.file_path || toolInput.file || '');
37
- }
38
-
39
- function fileContent() {
40
- return String(
41
- toolInput.content ||
42
- toolInput.new_string ||
43
- toolInput.text ||
44
- '',
45
- );
46
- }
47
-
48
- function block(message) {
49
- process.stderr.write(`[ECC] BLOCKED: ${message}\n`);
50
- process.exit(2);
51
- }
52
-
53
- function warn(message) {
54
- process.stderr.write(`[ECC] ${message}\n`);
55
- process.exit(0);
56
- }
57
-
58
- function ok() {
59
- process.exit(0);
60
- }
61
-
62
- // ── Checks ──────────────────────────────────────────────
63
- const checks = {
64
- /**
65
- * Block `git commit --no-verify` and friends — these skip pre-commit hooks.
66
- */
67
- 'block-no-verify': () => {
68
- const cmd = bashCommand();
69
- if (!/\bgit\s+/.test(cmd)) return ok();
70
- if (/(^|\s)--no-verify(\s|$)/.test(cmd)) {
71
- return block('`--no-verify` skips git hooks — fix the failure instead.');
72
- }
73
- if (/(^|\s)--no-gpg-sign(\s|$)/.test(cmd)) {
74
- return block('`--no-gpg-sign` bypasses signing — ask the user first.');
75
- }
76
- return ok();
77
- },
78
-
79
- /**
80
- * Remind the user to run dev servers under tmux (non-blocking, POSIX only).
81
- */
82
- 'dev-server-tmux': () => {
83
- if (process.platform === 'win32') return ok();
84
- if (process.env.TMUX) return ok();
85
- const cmd = bashCommand();
86
- const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev|next\s+dev|vite(?:\s+dev)?)\b/;
87
- const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
88
- if (devPattern.test(cmd) && !tmuxLauncher.test(cmd)) {
89
- return warn('Consider running dev servers in tmux for log access: `tmux new-session -d -s dev "<cmd>"`');
90
- }
91
- return ok();
92
- },
93
-
94
- /**
95
- * Warn (don't block) when reading typically-sensitive files.
96
- */
97
- 'sensitive-file': () => {
98
- const path = filePath();
99
- if (!path) return ok();
100
- if (/\.(env|key|pem|p12|pfx)$/i.test(path) || /\b(credentials|secrets|id_rsa)\b/i.test(path)) {
101
- return warn(`Reading sensitive file: ${path}`);
102
- }
103
- return ok();
104
- },
105
-
106
- /**
107
- * Block edits to linter/formatter config files. Without this, an agent
108
- * that hits a lint error tends to "fix" it by relaxing the config rule
109
- * instead of fixing the actual code. Ported from upstream ECC
110
- * scripts/hooks/config-protection.js (140 LOC condensed to ~40).
111
- *
112
- * The full upstream list includes 50+ patterns covering ESLint v8/v9,
113
- * Prettier (all formats), TS/Babel/Webpack/Vite/Rollup configs,
114
- * Python (ruff, pyproject.toml, .flake8, setup.cfg), Go (golangci),
115
- * Ruby (RuboCop), and .editorconfig. We match by basename so paths
116
- * like `apps/foo/.eslintrc.cjs` are still blocked.
117
- */
118
- 'config-protection': () => {
119
- const fp = filePath();
120
- if (!fp) return ok();
121
- const base = require('path').basename(fp);
122
- const protectedFiles = new Set([
123
- '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json',
124
- '.eslintrc.yml', '.eslintrc.yaml', 'eslint.config.js',
125
- 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
126
- 'eslint.config.mts', 'eslint.config.cts',
127
- '.prettierrc', '.prettierrc.js', '.prettierrc.cjs', '.prettierrc.json',
128
- '.prettierrc.yml', '.prettierrc.yaml', '.prettierrc.toml',
129
- 'prettier.config.js', 'prettier.config.mjs', 'prettier.config.cjs',
130
- '.editorconfig', 'tsconfig.json', 'tsconfig.base.json',
131
- 'tsconfig.build.json', 'jsconfig.json',
132
- '.ruff.toml', 'ruff.toml', 'pyproject.toml', 'setup.cfg', '.flake8',
133
- 'mypy.ini', 'pytest.ini',
134
- '.golangci.yml', '.golangci.yaml', '.golangci.toml',
135
- '.rubocop.yml', '.rubocop.yaml',
136
- '.swiftlint.yml', '.clang-format', '.clang-tidy',
137
- ]);
138
- if (protectedFiles.has(base)) {
139
- return block(
140
- `${base} is a linter/formatter config — modifying it to silence ` +
141
- `errors instead of fixing the code is anti-pattern. Fix the source ` +
142
- `file the lint/typecheck error points to. If the rule itself is ` +
143
- `genuinely wrong, ask the user before changing the config.`,
144
- );
145
- }
146
- return ok();
147
- },
148
-
149
- /**
150
- * Simplified GateGuard — force investigation before first Edit/Write per
151
- * file in this session. Upstream's full implementation (878 LOC) tracks
152
- * elaborate session state, destructive-bash detection, and quote-aware
153
- * SQL pattern matching. We port the highest-leverage mechanism: a
154
- * per-session per-file flag that demands investigation on first touch.
155
- *
156
- * State lives at ~/.crowcoder/state/gateguard/<sessionId>.json — a
157
- * JSON array of paths already touched. After the first touch of a file,
158
- * subsequent Edit/Write calls pass through normally.
159
- *
160
- * Auto-cleanup: state files older than 24h are deleted on next run.
161
- *
162
- * Upstream credit: github.com/zunoworks/gateguard (the underlying idea).
163
- */
164
- 'gateguard': () => {
165
- const fs = require('fs');
166
- const pathMod = require('path');
167
- const os = require('os');
168
- const targetPath = filePath();
169
- if (!targetPath) return ok();
170
-
171
- const sessionId = process.env.CROWCODER_SESSION_ID || 'unknown';
172
- const stateDir = pathMod.join(os.homedir(), '.crowcoder', 'state', 'gateguard');
173
- const stateFile = pathMod.join(stateDir, `${sessionId}.json`);
174
-
175
- // GC: drop any state files older than 24h. Best-effort, never throws.
176
- try {
177
- if (fs.existsSync(stateDir)) {
178
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
179
- for (const name of fs.readdirSync(stateDir)) {
180
- const p = pathMod.join(stateDir, name);
181
- try {
182
- const s = fs.statSync(p);
183
- if (s.mtimeMs < cutoff) fs.unlinkSync(p);
184
- } catch { /* noop */ }
185
- }
186
- }
187
- } catch { /* noop */ }
188
-
189
- let touched = new Set();
190
- try {
191
- if (fs.existsSync(stateFile)) {
192
- const raw = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
193
- if (Array.isArray(raw)) touched = new Set(raw);
194
- }
195
- } catch { /* corrupt state: treat as empty */ }
196
-
197
- if (touched.has(targetPath)) return ok(); // already investigated this session
198
-
199
- // First touch — record + block with investigation instruction.
200
- touched.add(targetPath);
201
- try {
202
- fs.mkdirSync(stateDir, { recursive: true });
203
- fs.writeFileSync(stateFile, JSON.stringify([...touched]), 'utf-8');
204
- } catch { /* if we can't persist, still block this one time */ }
205
-
206
- return block(
207
- `First Edit/Write to ${targetPath} this session. Before proceeding, ` +
208
- `investigate: (1) Read the file to understand its current contents. ` +
209
- `(2) Grep for importers / callers / refs so the change doesn't break ` +
210
- `consumers. (3) If it's a schema/type, check existing data usage. ` +
211
- `After investigating, retry the edit — GateGuard tracks per-file and ` +
212
- `will let the retry through. Set CROWCODER_GATEGUARD=off to disable.`,
213
- );
214
- },
215
-
216
- /**
217
- * Quality gate — run formatter/linter against just-edited files and
218
- * report failures (non-blocking warn). Detects toolchain via standard
219
- * config files in cwd. PostToolUse hook, exit 0 always.
220
- *
221
- * Detection: prettier (.prettierrc*, package.json prettier field),
222
- * eslint (eslint config), ruff (pyproject.toml ruff section or
223
- * ruff.toml), golangci (.golangci.*), rubocop (.rubocop.yml).
224
- *
225
- * Only checks the path the hook was given — single-file scope keeps
226
- * the latency bounded. Full repo lint stays on /verify or CI.
227
- *
228
- * Defers actually executing the linter to avoid hard-coupling to any
229
- * one tool's CLI shape. Instead we print a hint with the commands the
230
- * user can run. This is a "remind, don't run" hook for now; later
231
- * iterations can actually invoke + parse the output.
232
- */
233
- 'quality-gate': () => {
234
- const fp = filePath();
235
- if (!fp) return ok();
236
- const ext = fp.split('.').pop()?.toLowerCase() || '';
237
- const fs = require('fs');
238
- const pathMod = require('path');
239
- const findCwd = (start, name) => {
240
- let dir = pathMod.dirname(start);
241
- for (let i = 0; i < 10; i++) {
242
- if (fs.existsSync(pathMod.join(dir, name))) return pathMod.join(dir, name);
243
- const parent = pathMod.dirname(dir);
244
- if (parent === dir) break;
245
- dir = parent;
246
- }
247
- return null;
248
- };
249
- const hints = [];
250
- // JS/TS family
251
- if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
252
- if (findCwd(fp, '.eslintrc.json') || findCwd(fp, '.eslintrc.cjs')
253
- || findCwd(fp, '.eslintrc.js') || findCwd(fp, 'eslint.config.js')
254
- || findCwd(fp, 'eslint.config.mjs') || findCwd(fp, 'eslint.config.cjs')) {
255
- hints.push(`eslint ${fp}`);
256
- }
257
- if (findCwd(fp, '.prettierrc') || findCwd(fp, '.prettierrc.json')
258
- || findCwd(fp, '.prettierrc.js') || findCwd(fp, 'prettier.config.js')) {
259
- hints.push(`prettier --check ${fp}`);
260
- }
261
- }
262
- // Python
263
- else if (ext === 'py') {
264
- if (findCwd(fp, 'pyproject.toml') || findCwd(fp, 'ruff.toml') || findCwd(fp, '.ruff.toml')) {
265
- hints.push(`ruff check ${fp}`);
266
- }
267
- if (findCwd(fp, '.flake8') || findCwd(fp, 'setup.cfg')) {
268
- hints.push(`flake8 ${fp}`);
269
- }
270
- }
271
- // Go
272
- else if (ext === 'go') {
273
- if (findCwd(fp, '.golangci.yml') || findCwd(fp, '.golangci.yaml') || findCwd(fp, '.golangci.toml')) {
274
- hints.push(`golangci-lint run ${fp}`);
275
- }
276
- hints.push(`gofmt -d ${fp}`);
277
- }
278
- // Ruby
279
- else if (ext === 'rb') {
280
- if (findCwd(fp, '.rubocop.yml') || findCwd(fp, '.rubocop.yaml')) {
281
- hints.push(`rubocop ${fp}`);
282
- }
283
- }
284
- if (hints.length > 0) {
285
- return warn(`Quality gate suggestion for ${fp}:\n ${hints.join('\n ')}`);
286
- }
287
- return ok();
288
- },
289
-
290
- /**
291
- * Session-end format/typecheck reminder. PostToolUse for now (since
292
- * compact-agent doesn't have a Stop hook event yet). Fires once per
293
- * tool call but the body batches reminders rather than spam each one.
294
- *
295
- * Goal: surface "you should run `npm run typecheck` (or equivalent)
296
- * before considering this session done" at the end of substantial
297
- * work. Detects the right command via package.json scripts.
298
- *
299
- * Tracks per-session state at ~/.crowcoder/state/quality-hint/<id>.json
300
- * so we only nudge once per session (per project).
301
- */
302
- 'format-typecheck-hint': () => {
303
- const fp = filePath();
304
- if (!fp) return ok();
305
- const ext = fp.split('.').pop()?.toLowerCase() || '';
306
- // Only fires on substantial-edit file extensions
307
- if (!['ts', 'tsx', 'py', 'go', 'rs', 'java', 'kt'].includes(ext)) return ok();
308
-
309
- const fs = require('fs');
310
- const pathMod = require('path');
311
- const os = require('os');
312
- const sessionId = process.env.CROWCODER_SESSION_ID || 'unknown';
313
- const stateDir = pathMod.join(os.homedir(), '.crowcoder', 'state', 'quality-hint');
314
- const stateFile = pathMod.join(stateDir, `${sessionId}.json`);
315
-
316
- // Already nudged this session — silent
317
- try { if (fs.existsSync(stateFile)) return ok(); } catch { /* noop */ }
318
-
319
- // Look up the toolchain command via the nearest package.json (TS/JS)
320
- // or pyproject.toml (Python). Best-effort; no failure on missing.
321
- let hint = '';
322
- const findUp = (start, name) => {
323
- let dir = pathMod.dirname(start);
324
- for (let i = 0; i < 10; i++) {
325
- if (fs.existsSync(pathMod.join(dir, name))) return pathMod.join(dir, name);
326
- const parent = pathMod.dirname(dir);
327
- if (parent === dir) break;
328
- dir = parent;
329
- }
330
- return null;
331
- };
332
-
333
- if (['ts', 'tsx'].includes(ext)) {
334
- const pj = findUp(fp, 'package.json');
335
- if (pj) {
336
- try {
337
- const json = JSON.parse(fs.readFileSync(pj, 'utf-8'));
338
- const scripts = json.scripts || {};
339
- const tc = scripts.typecheck || scripts['type-check'] || (json.devDependencies?.typescript ? 'tsc --noEmit' : '');
340
- if (tc) hint = `Before wrapping up, run typecheck: \`${tc}\``;
341
- } catch { /* noop */ }
342
- }
343
- } else if (ext === 'py') {
344
- const pj = findUp(fp, 'pyproject.toml');
345
- if (pj) hint = `Before wrapping up, consider \`mypy\` or \`pyright\` (per your toolchain) and \`ruff check\`.`;
346
- } else if (ext === 'go') {
347
- hint = `Before wrapping up, run \`go vet ./...\` and \`go test ./...\`.`;
348
- } else if (ext === 'rs') {
349
- hint = `Before wrapping up, run \`cargo check\` and \`cargo test\`.`;
350
- }
351
-
352
- if (!hint) return ok();
353
-
354
- // Record + nudge once
355
- try {
356
- fs.mkdirSync(stateDir, { recursive: true });
357
- fs.writeFileSync(stateFile, JSON.stringify({ session: sessionId, hintedAt: new Date().toISOString() }), 'utf-8');
358
- } catch { /* persist failure is fine */ }
359
- return warn(hint);
360
- },
361
-
362
- /**
363
- * Warn when a file edit/write leaves console.log() / print() statements.
364
- * Looks at the new_string / content payload only — doesn't read disk.
365
- */
366
- 'console-log-warn': () => {
367
- const path = filePath();
368
- const content = fileContent();
369
- if (!content) return ok();
370
- if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(path)) return ok();
371
- const noisy = /\bconsole\.(log|debug|info|warn|error)\s*\(/g;
372
- const matches = content.match(noisy);
373
- if (matches && matches.length > 0) {
374
- return warn(`${matches.length} console statement(s) in ${path}`);
375
- }
376
- return ok();
377
- },
378
- };
379
-
380
- // ── Dispatch ────────────────────────────────────────────
381
- const fn = checks[checkName];
382
- if (!fn) {
383
- // Unknown check — silently pass so an upgrade that removes a check doesn't
384
- // break old hooks.json entries.
385
- process.exit(0);
386
- }
387
-
388
- try {
389
- fn();
390
- } catch (err) {
391
- // Hook bugs must not break the user's flow.
392
- process.stderr.write(`[ECC] hook ${checkName} error: ${err && err.message}\n`);
393
- process.exit(0);
394
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ECC-native hook dispatcher for Crowcoder.
4
+ *
5
+ * Reads Crowcoder's hook env vars (CROWCODER_EVENT, CROWCODER_TOOL,
6
+ * CROWCODER_TOOL_INPUT, CROWCODER_TOOL_OUTPUT) and runs a single named check.
7
+ *
8
+ * Exit codes:
9
+ * 0 — allow (PreToolUse) or success (PostToolUse)
10
+ * 2 — block (PreToolUse only; non-zero on a non-blocking hook is just logged)
11
+ *
12
+ * Usage:
13
+ * node ecc-hooks.cjs <check-name> [__ecc__]
14
+ *
15
+ * The trailing __ecc__ tag is used by the installer to identify ECC-managed
16
+ * hook entries so /ecc-install can refresh them without touching user hooks.
17
+ */
18
+ 'use strict';
19
+
20
+ const checkName = process.argv[2] || '';
21
+
22
+ let toolInput = {};
23
+ try {
24
+ toolInput = JSON.parse(process.env.CROWCODER_TOOL_INPUT || '{}');
25
+ } catch { /* ignore — leave as {} */ }
26
+
27
+ const tool = process.env.CROWCODER_TOOL || '';
28
+ const cwd = process.env.CROWCODER_CWD || process.cwd();
29
+
30
+ // ── Helpers ─────────────────────────────────────────────
31
+ function bashCommand() {
32
+ return String(toolInput.command || toolInput.cmd || '');
33
+ }
34
+
35
+ function filePath() {
36
+ return String(toolInput.path || toolInput.file_path || toolInput.file || '');
37
+ }
38
+
39
+ function fileContent() {
40
+ return String(
41
+ toolInput.content ||
42
+ toolInput.new_string ||
43
+ toolInput.text ||
44
+ '',
45
+ );
46
+ }
47
+
48
+ function block(message) {
49
+ process.stderr.write(`[ECC] BLOCKED: ${message}\n`);
50
+ process.exit(2);
51
+ }
52
+
53
+ function warn(message) {
54
+ process.stderr.write(`[ECC] ${message}\n`);
55
+ process.exit(0);
56
+ }
57
+
58
+ function ok() {
59
+ process.exit(0);
60
+ }
61
+
62
+ // ── Checks ──────────────────────────────────────────────
63
+ const checks = {
64
+ /**
65
+ * Block `git commit --no-verify` and friends — these skip pre-commit hooks.
66
+ */
67
+ 'block-no-verify': () => {
68
+ const cmd = bashCommand();
69
+ if (!/\bgit\s+/.test(cmd)) return ok();
70
+ if (/(^|\s)--no-verify(\s|$)/.test(cmd)) {
71
+ return block('`--no-verify` skips git hooks — fix the failure instead.');
72
+ }
73
+ if (/(^|\s)--no-gpg-sign(\s|$)/.test(cmd)) {
74
+ return block('`--no-gpg-sign` bypasses signing — ask the user first.');
75
+ }
76
+ return ok();
77
+ },
78
+
79
+ /**
80
+ * Remind the user to run dev servers under tmux (non-blocking, POSIX only).
81
+ */
82
+ 'dev-server-tmux': () => {
83
+ if (process.platform === 'win32') return ok();
84
+ if (process.env.TMUX) return ok();
85
+ const cmd = bashCommand();
86
+ const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev|next\s+dev|vite(?:\s+dev)?)\b/;
87
+ const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
88
+ if (devPattern.test(cmd) && !tmuxLauncher.test(cmd)) {
89
+ return warn('Consider running dev servers in tmux for log access: `tmux new-session -d -s dev "<cmd>"`');
90
+ }
91
+ return ok();
92
+ },
93
+
94
+ /**
95
+ * Warn (don't block) when reading typically-sensitive files.
96
+ */
97
+ 'sensitive-file': () => {
98
+ const path = filePath();
99
+ if (!path) return ok();
100
+ if (/\.(env|key|pem|p12|pfx)$/i.test(path) || /\b(credentials|secrets|id_rsa)\b/i.test(path)) {
101
+ return warn(`Reading sensitive file: ${path}`);
102
+ }
103
+ return ok();
104
+ },
105
+
106
+ /**
107
+ * Block edits to linter/formatter config files. Without this, an agent
108
+ * that hits a lint error tends to "fix" it by relaxing the config rule
109
+ * instead of fixing the actual code. Ported from upstream ECC
110
+ * scripts/hooks/config-protection.js (140 LOC condensed to ~40).
111
+ *
112
+ * The full upstream list includes 50+ patterns covering ESLint v8/v9,
113
+ * Prettier (all formats), TS/Babel/Webpack/Vite/Rollup configs,
114
+ * Python (ruff, pyproject.toml, .flake8, setup.cfg), Go (golangci),
115
+ * Ruby (RuboCop), and .editorconfig. We match by basename so paths
116
+ * like `apps/foo/.eslintrc.cjs` are still blocked.
117
+ */
118
+ 'config-protection': () => {
119
+ const fp = filePath();
120
+ if (!fp) return ok();
121
+ const base = require('path').basename(fp);
122
+ const protectedFiles = new Set([
123
+ '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json',
124
+ '.eslintrc.yml', '.eslintrc.yaml', 'eslint.config.js',
125
+ 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
126
+ 'eslint.config.mts', 'eslint.config.cts',
127
+ '.prettierrc', '.prettierrc.js', '.prettierrc.cjs', '.prettierrc.json',
128
+ '.prettierrc.yml', '.prettierrc.yaml', '.prettierrc.toml',
129
+ 'prettier.config.js', 'prettier.config.mjs', 'prettier.config.cjs',
130
+ '.editorconfig', 'tsconfig.json', 'tsconfig.base.json',
131
+ 'tsconfig.build.json', 'jsconfig.json',
132
+ '.ruff.toml', 'ruff.toml', 'pyproject.toml', 'setup.cfg', '.flake8',
133
+ 'mypy.ini', 'pytest.ini',
134
+ '.golangci.yml', '.golangci.yaml', '.golangci.toml',
135
+ '.rubocop.yml', '.rubocop.yaml',
136
+ '.swiftlint.yml', '.clang-format', '.clang-tidy',
137
+ ]);
138
+ if (protectedFiles.has(base)) {
139
+ return block(
140
+ `${base} is a linter/formatter config — modifying it to silence ` +
141
+ `errors instead of fixing the code is anti-pattern. Fix the source ` +
142
+ `file the lint/typecheck error points to. If the rule itself is ` +
143
+ `genuinely wrong, ask the user before changing the config.`,
144
+ );
145
+ }
146
+ return ok();
147
+ },
148
+
149
+ /**
150
+ * Simplified GateGuard — force investigation before first Edit/Write per
151
+ * file in this session. Upstream's full implementation (878 LOC) tracks
152
+ * elaborate session state, destructive-bash detection, and quote-aware
153
+ * SQL pattern matching. We port the highest-leverage mechanism: a
154
+ * per-session per-file flag that demands investigation on first touch.
155
+ *
156
+ * State lives at ~/.compact-agent/state/gateguard/<sessionId>.json — a
157
+ * JSON array of paths already touched. After the first touch of a file,
158
+ * subsequent Edit/Write calls pass through normally.
159
+ *
160
+ * Auto-cleanup: state files older than 24h are deleted on next run.
161
+ *
162
+ * Upstream credit: github.com/zunoworks/gateguard (the underlying idea).
163
+ */
164
+ 'gateguard': () => {
165
+ const fs = require('fs');
166
+ const pathMod = require('path');
167
+ const os = require('os');
168
+ const targetPath = filePath();
169
+ if (!targetPath) return ok();
170
+
171
+ const sessionId = process.env.CROWCODER_SESSION_ID || 'unknown';
172
+ const stateDir = pathMod.join(os.homedir(), '.compact-agent', 'state', 'gateguard');
173
+ const stateFile = pathMod.join(stateDir, `${sessionId}.json`);
174
+
175
+ // GC: drop any state files older than 24h. Best-effort, never throws.
176
+ try {
177
+ if (fs.existsSync(stateDir)) {
178
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
179
+ for (const name of fs.readdirSync(stateDir)) {
180
+ const p = pathMod.join(stateDir, name);
181
+ try {
182
+ const s = fs.statSync(p);
183
+ if (s.mtimeMs < cutoff) fs.unlinkSync(p);
184
+ } catch { /* noop */ }
185
+ }
186
+ }
187
+ } catch { /* noop */ }
188
+
189
+ let touched = new Set();
190
+ try {
191
+ if (fs.existsSync(stateFile)) {
192
+ const raw = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
193
+ if (Array.isArray(raw)) touched = new Set(raw);
194
+ }
195
+ } catch { /* corrupt state: treat as empty */ }
196
+
197
+ if (touched.has(targetPath)) return ok(); // already investigated this session
198
+
199
+ // First touch — record + block with investigation instruction.
200
+ touched.add(targetPath);
201
+ try {
202
+ fs.mkdirSync(stateDir, { recursive: true });
203
+ fs.writeFileSync(stateFile, JSON.stringify([...touched]), 'utf-8');
204
+ } catch { /* if we can't persist, still block this one time */ }
205
+
206
+ return block(
207
+ `First Edit/Write to ${targetPath} this session. Before proceeding, ` +
208
+ `investigate: (1) Read the file to understand its current contents. ` +
209
+ `(2) Grep for importers / callers / refs so the change doesn't break ` +
210
+ `consumers. (3) If it's a schema/type, check existing data usage. ` +
211
+ `After investigating, retry the edit — GateGuard tracks per-file and ` +
212
+ `will let the retry through. Set CROWCODER_GATEGUARD=off to disable.`,
213
+ );
214
+ },
215
+
216
+ /**
217
+ * Quality gate — run formatter/linter against just-edited files and
218
+ * report failures (non-blocking warn). Detects toolchain via standard
219
+ * config files in cwd. PostToolUse hook, exit 0 always.
220
+ *
221
+ * Detection: prettier (.prettierrc*, package.json prettier field),
222
+ * eslint (eslint config), ruff (pyproject.toml ruff section or
223
+ * ruff.toml), golangci (.golangci.*), rubocop (.rubocop.yml).
224
+ *
225
+ * Only checks the path the hook was given — single-file scope keeps
226
+ * the latency bounded. Full repo lint stays on /verify or CI.
227
+ *
228
+ * Defers actually executing the linter to avoid hard-coupling to any
229
+ * one tool's CLI shape. Instead we print a hint with the commands the
230
+ * user can run. This is a "remind, don't run" hook for now; later
231
+ * iterations can actually invoke + parse the output.
232
+ */
233
+ 'quality-gate': () => {
234
+ const fp = filePath();
235
+ if (!fp) return ok();
236
+ const ext = fp.split('.').pop()?.toLowerCase() || '';
237
+ const fs = require('fs');
238
+ const pathMod = require('path');
239
+ const findCwd = (start, name) => {
240
+ let dir = pathMod.dirname(start);
241
+ for (let i = 0; i < 10; i++) {
242
+ if (fs.existsSync(pathMod.join(dir, name))) return pathMod.join(dir, name);
243
+ const parent = pathMod.dirname(dir);
244
+ if (parent === dir) break;
245
+ dir = parent;
246
+ }
247
+ return null;
248
+ };
249
+ const hints = [];
250
+ // JS/TS family
251
+ if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
252
+ if (findCwd(fp, '.eslintrc.json') || findCwd(fp, '.eslintrc.cjs')
253
+ || findCwd(fp, '.eslintrc.js') || findCwd(fp, 'eslint.config.js')
254
+ || findCwd(fp, 'eslint.config.mjs') || findCwd(fp, 'eslint.config.cjs')) {
255
+ hints.push(`eslint ${fp}`);
256
+ }
257
+ if (findCwd(fp, '.prettierrc') || findCwd(fp, '.prettierrc.json')
258
+ || findCwd(fp, '.prettierrc.js') || findCwd(fp, 'prettier.config.js')) {
259
+ hints.push(`prettier --check ${fp}`);
260
+ }
261
+ }
262
+ // Python
263
+ else if (ext === 'py') {
264
+ if (findCwd(fp, 'pyproject.toml') || findCwd(fp, 'ruff.toml') || findCwd(fp, '.ruff.toml')) {
265
+ hints.push(`ruff check ${fp}`);
266
+ }
267
+ if (findCwd(fp, '.flake8') || findCwd(fp, 'setup.cfg')) {
268
+ hints.push(`flake8 ${fp}`);
269
+ }
270
+ }
271
+ // Go
272
+ else if (ext === 'go') {
273
+ if (findCwd(fp, '.golangci.yml') || findCwd(fp, '.golangci.yaml') || findCwd(fp, '.golangci.toml')) {
274
+ hints.push(`golangci-lint run ${fp}`);
275
+ }
276
+ hints.push(`gofmt -d ${fp}`);
277
+ }
278
+ // Ruby
279
+ else if (ext === 'rb') {
280
+ if (findCwd(fp, '.rubocop.yml') || findCwd(fp, '.rubocop.yaml')) {
281
+ hints.push(`rubocop ${fp}`);
282
+ }
283
+ }
284
+ if (hints.length > 0) {
285
+ return warn(`Quality gate suggestion for ${fp}:\n ${hints.join('\n ')}`);
286
+ }
287
+ return ok();
288
+ },
289
+
290
+ /**
291
+ * Session-end format/typecheck reminder. PostToolUse for now (since
292
+ * compact-agent doesn't have a Stop hook event yet). Fires once per
293
+ * tool call but the body batches reminders rather than spam each one.
294
+ *
295
+ * Goal: surface "you should run `npm run typecheck` (or equivalent)
296
+ * before considering this session done" at the end of substantial
297
+ * work. Detects the right command via package.json scripts.
298
+ *
299
+ * Tracks per-session state at ~/.compact-agent/state/quality-hint/<id>.json
300
+ * so we only nudge once per session (per project).
301
+ */
302
+ 'format-typecheck-hint': () => {
303
+ const fp = filePath();
304
+ if (!fp) return ok();
305
+ const ext = fp.split('.').pop()?.toLowerCase() || '';
306
+ // Only fires on substantial-edit file extensions
307
+ if (!['ts', 'tsx', 'py', 'go', 'rs', 'java', 'kt'].includes(ext)) return ok();
308
+
309
+ const fs = require('fs');
310
+ const pathMod = require('path');
311
+ const os = require('os');
312
+ const sessionId = process.env.CROWCODER_SESSION_ID || 'unknown';
313
+ const stateDir = pathMod.join(os.homedir(), '.compact-agent', 'state', 'quality-hint');
314
+ const stateFile = pathMod.join(stateDir, `${sessionId}.json`);
315
+
316
+ // Already nudged this session — silent
317
+ try { if (fs.existsSync(stateFile)) return ok(); } catch { /* noop */ }
318
+
319
+ // Look up the toolchain command via the nearest package.json (TS/JS)
320
+ // or pyproject.toml (Python). Best-effort; no failure on missing.
321
+ let hint = '';
322
+ const findUp = (start, name) => {
323
+ let dir = pathMod.dirname(start);
324
+ for (let i = 0; i < 10; i++) {
325
+ if (fs.existsSync(pathMod.join(dir, name))) return pathMod.join(dir, name);
326
+ const parent = pathMod.dirname(dir);
327
+ if (parent === dir) break;
328
+ dir = parent;
329
+ }
330
+ return null;
331
+ };
332
+
333
+ if (['ts', 'tsx'].includes(ext)) {
334
+ const pj = findUp(fp, 'package.json');
335
+ if (pj) {
336
+ try {
337
+ const json = JSON.parse(fs.readFileSync(pj, 'utf-8'));
338
+ const scripts = json.scripts || {};
339
+ const tc = scripts.typecheck || scripts['type-check'] || (json.devDependencies?.typescript ? 'tsc --noEmit' : '');
340
+ if (tc) hint = `Before wrapping up, run typecheck: \`${tc}\``;
341
+ } catch { /* noop */ }
342
+ }
343
+ } else if (ext === 'py') {
344
+ const pj = findUp(fp, 'pyproject.toml');
345
+ if (pj) hint = `Before wrapping up, consider \`mypy\` or \`pyright\` (per your toolchain) and \`ruff check\`.`;
346
+ } else if (ext === 'go') {
347
+ hint = `Before wrapping up, run \`go vet ./...\` and \`go test ./...\`.`;
348
+ } else if (ext === 'rs') {
349
+ hint = `Before wrapping up, run \`cargo check\` and \`cargo test\`.`;
350
+ }
351
+
352
+ if (!hint) return ok();
353
+
354
+ // Record + nudge once
355
+ try {
356
+ fs.mkdirSync(stateDir, { recursive: true });
357
+ fs.writeFileSync(stateFile, JSON.stringify({ session: sessionId, hintedAt: new Date().toISOString() }), 'utf-8');
358
+ } catch { /* persist failure is fine */ }
359
+ return warn(hint);
360
+ },
361
+
362
+ /**
363
+ * Warn when a file edit/write leaves console.log() / print() statements.
364
+ * Looks at the new_string / content payload only — doesn't read disk.
365
+ */
366
+ 'console-log-warn': () => {
367
+ const path = filePath();
368
+ const content = fileContent();
369
+ if (!content) return ok();
370
+ if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/i.test(path)) return ok();
371
+ const noisy = /\bconsole\.(log|debug|info|warn|error)\s*\(/g;
372
+ const matches = content.match(noisy);
373
+ if (matches && matches.length > 0) {
374
+ return warn(`${matches.length} console statement(s) in ${path}`);
375
+ }
376
+ return ok();
377
+ },
378
+ };
379
+
380
+ // ── Dispatch ────────────────────────────────────────────
381
+ const fn = checks[checkName];
382
+ if (!fn) {
383
+ // Unknown check — silently pass so an upgrade that removes a check doesn't
384
+ // break old hooks.json entries.
385
+ process.exit(0);
386
+ }
387
+
388
+ try {
389
+ fn();
390
+ } catch (err) {
391
+ // Hook bugs must not break the user's flow.
392
+ process.stderr.write(`[ECC] hook ${checkName} error: ${err && err.message}\n`);
393
+ process.exit(0);
394
+ }