codehere 1.0.3 → 2.0.0-beta.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/src/hooks.js ADDED
@@ -0,0 +1,442 @@
1
+ // Claude Code hook handlers. Every handler fails open: if anything goes wrong,
2
+ // it exits 0 and the agent carries on as if Codehere were not installed.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import {
7
+ findRoot, paths, readConfig, readState, writeState, session, readRules,
8
+ listDocs, git, isGitRepo, me, slug, today,
9
+ } from './brain.js';
10
+ import {
11
+ responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature,
12
+ } from './signals.js';
13
+
14
+ function readStdin() {
15
+ try {
16
+ const raw = fs.readFileSync(0, 'utf8');
17
+ return raw.trim() ? JSON.parse(raw) : {};
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function emitJson(obj) {
24
+ process.stdout.write(JSON.stringify(obj));
25
+ process.exit(0);
26
+ }
27
+
28
+ function toAgent(message) {
29
+ // exit 2: stderr is shown to the agent (PostToolUse) / blocks stopping (Stop)
30
+ process.stderr.write(message);
31
+ process.exit(2);
32
+ }
33
+
34
+ const quiet = () => process.exit(0);
35
+
36
+ export async function runHook(event) {
37
+ const input = readStdin();
38
+ const root = findRoot(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd());
39
+ if (!root) quiet();
40
+ try {
41
+ if (event === 'session-start') return sessionStart(root, input);
42
+ if (event === 'prompt') return promptSubmit(root, input);
43
+ if (event === 'post-tool') return postTool(root, input);
44
+ if (event === 'stop') return stop(root, input);
45
+ } catch (err) {
46
+ if (process.env.CODEHERE_DEBUG) console.error(err);
47
+ }
48
+ quiet();
49
+ }
50
+
51
+ // ---------- SessionStart: load only human-confirmed context ----------
52
+
53
+ function sessionStart(root, input) {
54
+ const state = readState(root);
55
+ const s = session(state, input.session_id);
56
+ s.transcript = input.transcript_path || s.transcript;
57
+ if (isGitRepo(root) && !s.baseline) {
58
+ // Remember what was already changed before this session, so we only
59
+ // ever react to what the agent changes now.
60
+ s.startHead = git(root, 'rev-parse HEAD') || null;
61
+ s.baseline = dirtySnapshot(root);
62
+ }
63
+
64
+ const out = [];
65
+ const rules = readRules(root);
66
+ if (rules.length) {
67
+ out.push('Team rules from Codehere (written by your teammates, follow them):');
68
+ rules.slice(0, 20).forEach((r) => out.push(`- ${r}`));
69
+ }
70
+
71
+ if (state.pendingBrief && fs.existsSync(state.pendingBrief)) {
72
+ out.push('', 'Restart brief from the previous session. Treat it as the full, current spec:');
73
+ out.push(fs.readFileSync(state.pendingBrief, 'utf8').slice(0, 4000));
74
+ state.pendingBrief = null;
75
+ }
76
+
77
+ const mine = me(root);
78
+ const open = listDocs(paths(root).help).filter((d) => d.meta.status === 'open' && d.meta.author !== mine);
79
+ if (open.length) {
80
+ out.push(
81
+ '',
82
+ `Note for the user: ${open.length} teammate help request(s) are open (${open
83
+ .slice(0, 3)
84
+ .map((d) => d.meta.author)
85
+ .join(', ')}). Mention this once, briefly: they can run \`codehere inbox\`.`
86
+ );
87
+ }
88
+
89
+ writeState(root, state);
90
+ if (out.length) process.stdout.write(out.join('\n') + '\n');
91
+ quiet();
92
+ }
93
+
94
+ // ---------- what changed in THIS session ----------
95
+
96
+ const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/');
97
+
98
+ export function dirtySnapshot(root) {
99
+ const files = git(root, 'status --porcelain --untracked-files=all')
100
+ .split('\n')
101
+ .filter(Boolean)
102
+ // "XY path"; the first line may have lost its leading space to trim()
103
+ .map((l) => l.replace(/^[ MADRCU?!T]{1,2}\s+/, '').replace(/^"|"$/g, '').split(' -> ').pop())
104
+ .filter((f) => f && !IGNORE(f));
105
+ const snap = {};
106
+ for (const f of files) {
107
+ snap[f] = fs.existsSync(path.join(root, f)) ? git(root, `hash-object -- "${f}"`) || 'x' : 'deleted';
108
+ }
109
+ return snap;
110
+ }
111
+
112
+ function changedThisSession(root, s) {
113
+ const now = dirtySnapshot(root);
114
+ const before = s.baseline || null;
115
+ const changed = new Set();
116
+ if (!before) {
117
+ Object.keys(now).forEach((f) => changed.add(f));
118
+ } else {
119
+ for (const f of new Set([...Object.keys(now), ...Object.keys(before)])) {
120
+ if (now[f] !== before[f]) changed.add(f);
121
+ }
122
+ }
123
+ if (s.startHead) {
124
+ git(root, `diff ${s.startHead} HEAD --name-only`)
125
+ .split('\n')
126
+ .filter((f) => f && !IGNORE(f))
127
+ .forEach((f) => changed.add(f));
128
+ }
129
+ return [...changed];
130
+ }
131
+
132
+ // ---------- PostToolUse (Bash): loop breaker + just-in-time team lessons ----------
133
+
134
+ // Commands that only read files. Their output may contain the word "error"
135
+ // (source code, logs) without anything having failed. A chain such as
136
+ // `cd x && cat a; ls b` is read-only only if every part is. Quoted text is
137
+ // emptied first so `grep "error|fail"` stays one part, and a part that runs
138
+ // another command ($(...), backticks, <(...)) is never read-only.
139
+ const READ_ONLY = /^\s*(cd|pwd|echo|cat|head|tail|less|more|grep|rg|ag|sed\s+-n|awk|ls|find|tree|wc|git\s+(diff|show|log|blame|grep|status))\b/;
140
+ export function readOnly(command) {
141
+ const runsSomething = /\$\(|`|<\(/;
142
+ if (runsSomething.test(command)) return false;
143
+ const bare = command.replace(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, "''");
144
+ return bare.split(/&&|\|\||\|&|[;|\n]/).every((part) => !part.trim() || READ_ONLY.test(part));
145
+ }
146
+
147
+ function matchLesson(root, sig, shown) {
148
+ let best = null;
149
+ for (const doc of listDocs(paths(root).lessons)) {
150
+ if (shown.includes(doc.id)) continue;
151
+ const score = Math.max(
152
+ similarity(sig, doc.meta.signature || ''),
153
+ similarity(sig, doc.meta.title || '') * 0.8
154
+ );
155
+ if (score >= 0.45 && (!best || score > best.score)) best = { doc, score };
156
+ }
157
+ return best?.doc || null;
158
+ }
159
+
160
+ function lessonText(doc) {
161
+ const fix = (doc.body.match(/## Fix\n([\s\S]*?)(\n## |$)/) || [null, doc.body])[1].trim();
162
+ return `Team lesson "${doc.meta.title}" (from ${doc.meta.author || 'a teammate'}, ${doc.meta.date || ''}):\n${fix.slice(0, 700)}`;
163
+ }
164
+
165
+ function postTool(root, input) {
166
+ if (input.tool_name && input.tool_name !== 'Bash') quiet();
167
+ const command = (input.tool_input && input.tool_input.command) || '';
168
+ if (readOnly(command)) quiet();
169
+ const text = responseText(input.tool_response) + '\n' + (input.error || '');
170
+ const sig = signature(text);
171
+ if (!sig) {
172
+ // the command that failed last now runs clean: that failure is over
173
+ const state = readState(root);
174
+ const s = state.sessions[input.session_id || 'default'];
175
+ if (s && s.lastFailure && s.lastFailure.command === command) {
176
+ s.lastFailure = null;
177
+ writeState(root, state);
178
+ }
179
+ quiet();
180
+ }
181
+
182
+ const cfg = readConfig(root);
183
+ const state = readState(root);
184
+ const s = session(state, input.session_id);
185
+ s.errors[sig] = (s.errors[sig] || 0) + 1;
186
+ s.rawErrors[sig] = errorLines(text, 8);
187
+ s.lastFailure = { sig, command, test: failingTest(text), lines: errorLines(text, 3), prompt: s.prompts || 0 };
188
+ const count = s.errors[sig];
189
+
190
+ const lesson = matchLesson(root, sig, s.shownLessons);
191
+ if (lesson) s.shownLessons.push(lesson.id);
192
+ writeState(root, state);
193
+
194
+ const threshold = cfg.loopThreshold;
195
+ if (count === threshold || count === threshold + 2) {
196
+ s.loopsStopped = (s.loopsStopped || 0) + 1;
197
+ writeState(root, state);
198
+ const msg = [
199
+ `Codehere: the same error has come back ${count} times this session:`,
200
+ ` ${sig}`,
201
+ 'Stop patching. Do this instead:',
202
+ '1. Write one sentence with your root-cause hypothesis.',
203
+ '2. Check it with one small command before editing any file.',
204
+ '3. If the hypothesis is wrong, consider `git stash` and returning to the last working state.',
205
+ ];
206
+ if (lesson) msg.push('', lessonText(lesson));
207
+ if (count > threshold) {
208
+ msg.push(
209
+ '',
210
+ 'You are still stuck. Stop now. Tell the user in one short line that they can run',
211
+ '`codehere help "<what you are stuck on>"` to send a ready-made help card to a teammate,',
212
+ 'or `codehere brief` to restart in a fresh session. Then wait for the user.'
213
+ );
214
+ }
215
+ toAgent(msg.join('\n') + '\n');
216
+ }
217
+
218
+ if (lesson) {
219
+ emitJson({
220
+ hookSpecificOutput: {
221
+ hookEventName: input.hook_event_name || 'PostToolUse',
222
+ additionalContext: `Codehere: a teammate solved a similar error before. It may apply here. ${lessonText(lesson)}\nCheck whether it fits before trying something new.`,
223
+ },
224
+ });
225
+ }
226
+ quiet();
227
+ }
228
+
229
+ // ---------- UserPromptSubmit: restore what a short prompt leaves out ----------
230
+ // One message at most, only about this session, under 300 tokens. Silent on normal turns.
231
+
232
+ function promptSubmit(root, input) {
233
+ if (!input.session_id) quiet(); // without it, sessions cannot be told apart
234
+ const prompt = String(input.prompt || '');
235
+ const cfg = readConfig(root);
236
+ const state = readState(root);
237
+ const s = session(state, input.session_id);
238
+ s.transcript = input.transcript_path || s.transcript;
239
+ s.prompts = (s.prompts || 0) + 1;
240
+
241
+ // Only a failure from the agent's last turn counts: an older one may be fixed or forgotten.
242
+ const f = s.lastFailure && (s.lastFailure.prompt || 0) === s.prompts - 1 ? s.lastFailure : null;
243
+ const repeats = f ? s.errors[f.sig] || 1 : 0;
244
+ const low = !!f && lowInfoPrompt(prompt);
245
+ if (f && !s.episode) s.episode = { kind: low ? 'still-broken' : 'with-detail', start: s.prompts };
246
+
247
+ let msg = null;
248
+ if (low) {
249
+ const changed = isGitRepo(root) ? quickChanged(root, s) : [];
250
+ msg = [
251
+ "Codehere context from this session's tool output (the user did not type this):",
252
+ "The user's short message most likely means the last failure is still there.",
253
+ `- Error (seen ${repeats}x): ${((f.lines && f.lines[0]) || f.sig).slice(0, 200)}`,
254
+ ...(f.test ? [`- Failing test: ${f.test}`] : []),
255
+ `- Command: ${f.command.slice(0, 120)}`,
256
+ ...(changed.length ? [`- Files changed this session: ${changed.join(', ')}`] : []),
257
+ 'Find the root cause before editing again. If a fix already failed, try a different hypothesis.',
258
+ ].join('\n');
259
+ } else if (f && !s.briefSuggested && repeats >= cfg.loopThreshold && transcriptTokens(s) >= cfg.longSessionTokens) {
260
+ s.briefSuggested = true;
261
+ msg = [
262
+ `Codehere: this session is long (about ${Math.round(transcriptTokens(s) / 1000)}k tokens) and the same error came back ${repeats} times.`,
263
+ 'Fresh sessions with one clear spec fix bugs better than long ones.',
264
+ 'In one line, suggest the user run `codehere brief` and start a new session (it loads the brief). Then continue with their request.',
265
+ ].join('\n');
266
+ } else if (!f && !s.askedHowToCheck && vagueFeature(prompt)) {
267
+ s.askedHowToCheck = true;
268
+ msg = [
269
+ 'Codehere: this feature request has no way to check it yet.',
270
+ 'Before writing code, ask the user only this one question: "How will you know it works?"',
271
+ 'You may offer 2 to 4 checkable examples they can accept. Then wait for the answer.',
272
+ 'Turn the answer into failing tests first, then build in small steps and run the tests after each one.',
273
+ ].join('\n');
274
+ }
275
+
276
+ writeState(root, state);
277
+ if (msg) {
278
+ emitJson({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: msg.slice(0, 1100) } });
279
+ }
280
+ quiet();
281
+ }
282
+
283
+ // Fast list for a prompt (no hashing): tracked files changed since the session started.
284
+ function quickChanged(root, s) {
285
+ return git(root, `diff --name-only ${s.startHead || 'HEAD'}`)
286
+ .split('\n')
287
+ .filter((x) => x && !IGNORE(x))
288
+ .slice(0, 5);
289
+ }
290
+
291
+ // How big the agent's context is now. Claude Code records each reply's token usage in the
292
+ // transcript; the file itself is no guide, as it also stores full request bodies.
293
+ // 0 means unknown (no usage recorded).
294
+ export function transcriptTokens(s) {
295
+ try {
296
+ const size = fs.statSync(s.transcript).size;
297
+ const fd = fs.openSync(s.transcript, 'r');
298
+ const len = Math.min(size, 1024 * 1024);
299
+ const buf = Buffer.alloc(len);
300
+ fs.readSync(fd, buf, 0, len, size - len);
301
+ fs.closeSync(fd);
302
+ const lines = buf.toString('utf8').split('\n').reverse();
303
+ for (const line of lines) {
304
+ if (!line.includes('"usage"')) continue;
305
+ try {
306
+ const u = JSON.parse(line).message?.usage;
307
+ const n = u ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0;
308
+ if (n > 0) return n;
309
+ } catch {}
310
+ }
311
+ return 0;
312
+ } catch {
313
+ return 0;
314
+ }
315
+ }
316
+
317
+ // The session reached a verified green: close the open fix attempt, if any.
318
+ function recordGreen(state, s) {
319
+ if (s.firstGreenAt == null) s.firstGreenAt = s.prompts || 0;
320
+ if (s.episode) {
321
+ state.episodes = state.episodes || [];
322
+ state.episodes.push({ kind: s.episode.kind, turns: Math.max(1, (s.prompts || 0) - s.episode.start + 1) });
323
+ state.episodes = state.episodes.slice(-200);
324
+ s.episode = null;
325
+ }
326
+ // A green test run ends a test failure, not an unrelated one such as a failed build.
327
+ const f = s.lastFailure;
328
+ if (f && (f.test || /\b(test|tests|pytest|jest|vitest|mocha|spec)\b/.test(f.command || ''))) s.lastFailure = null;
329
+ }
330
+
331
+ // ---------- Stop: verify "done" outside the agent ----------
332
+
333
+ function runTests(root, cfg) {
334
+ const r = spawnSync(cfg.testCommand, {
335
+ cwd: root,
336
+ shell: true,
337
+ encoding: 'utf8',
338
+ timeout: cfg.testTimeoutSeconds * 1000,
339
+ env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
340
+ });
341
+ const output = `${r.stdout || ''}\n${r.stderr || ''}`.trim();
342
+ const timedOut = r.error && r.error.code === 'ETIMEDOUT';
343
+ return { ok: r.status === 0 && !timedOut, output, timedOut };
344
+ }
345
+
346
+ function block(reason) {
347
+ emitJson({ decision: 'block', reason });
348
+ }
349
+
350
+ function stop(root, input) {
351
+ const cfg = readConfig(root);
352
+ const state = readState(root);
353
+ const s = session(state, input.session_id);
354
+ const mayBlock = (s.stopBlocks || 0) < 3; // never trap the agent: after 3 blocks, only observe
355
+
356
+ if (!isGitRepo(root)) quiet();
357
+ const base = s.startHead || 'HEAD';
358
+ const changed = changedThisSession(root, s);
359
+ if (!changed.length) quiet(); // the agent changed nothing this session: stay silent
360
+
361
+ // 1. tampering with tests or checks (only in files changed this session)
362
+ const files = changed.map((f) => `"${f}"`).join(' ');
363
+ const nameStatus = git(root, `diff ${base} --name-status -- ${files}`);
364
+ const findings = tamperFindings(nameStatus, git(root, `diff ${base} -U0 -- ${files}`));
365
+ const key = findings.join('|');
366
+ if (findings.length && s.tamperReported !== key && mayBlock) {
367
+ s.tamperReported = key;
368
+ s.stopBlocks++;
369
+ writeState(root, state);
370
+ block(
371
+ [
372
+ 'Codehere found changes that weaken tests or checks:',
373
+ ...findings.map((f) => `- ${f}`),
374
+ 'Restore them and fix the code instead.',
375
+ 'If the change is truly intended, tell the user in one line why, then finish.',
376
+ ].join('\n')
377
+ );
378
+ }
379
+
380
+ // 2. run the tests ourselves
381
+ let verified = null;
382
+ if (cfg.verifyOnStop && cfg.testCommand) {
383
+ const t = runTests(root, cfg);
384
+ if (!t.ok && !mayBlock) {
385
+ writeState(root, state);
386
+ quiet();
387
+ }
388
+ if (!t.ok) {
389
+ s.stopBlocks++;
390
+ writeState(root, state);
391
+ const tail = t.output.split('\n').slice(-25).join('\n');
392
+ block(
393
+ [
394
+ `Codehere ran \`${cfg.testCommand}\` and it ${t.timedOut ? 'timed out' : 'failed'}. The task is not done yet.`,
395
+ 'Last lines of output:',
396
+ tail,
397
+ 'Fix the cause, then finish. Do not change or skip tests to make them pass.',
398
+ ].join('\n')
399
+ );
400
+ }
401
+ verified = cfg.testCommand;
402
+ if (!findings.length) {
403
+ state.lastGreen = { commit: git(root, 'rev-parse HEAD'), at: new Date().toISOString() };
404
+ recordGreen(state, s);
405
+ }
406
+ }
407
+
408
+ // 3. turn a solved repeated error into a team lesson (asked once per session)
409
+ const solved = Object.entries(s.errors).filter(([, n]) => n >= 2);
410
+ if (solved.length && !s.lessonAsked && mayBlock) {
411
+ s.lessonAsked = true;
412
+ s.stopBlocks++;
413
+ writeState(root, state);
414
+ const [sig] = solved.sort((a, b) => b[1] - a[1])[0];
415
+ const file = path.join('.codehere', 'lessons', `${today()}-${slug(sig, 30)}.md`);
416
+ block(
417
+ [
418
+ `Before you finish: you hit and solved a repeated error ("${sig}").`,
419
+ `Save it for the team. Create ${file} with exactly this format:`,
420
+ '---',
421
+ 'title: <short name of the problem>',
422
+ `signature: ${sig}`,
423
+ `author: ${me(root)}`,
424
+ `date: ${today()}`,
425
+ '---',
426
+ '## Fix',
427
+ '- Cause: <one line>',
428
+ '- Fix: <one line>',
429
+ '- Avoid next time: <one line>',
430
+ 'Then finish your reply as normal. Do not mention this step unless asked.',
431
+ ].join('\n')
432
+ );
433
+ }
434
+
435
+ writeState(root, state);
436
+ // Green only for intact evidence (DESIGN.md §1): a pass with a weakened test is not verified.
437
+ if (verified && findings.length) {
438
+ emitJson({ systemMessage: `Codehere ran \`${verified}\`. It passed, but a file changed this session has a weakened check: ${findings.join('; ')}.` });
439
+ }
440
+ if (verified) emitJson({ systemMessage: `Codehere verified: \`${verified}\` passed.` });
441
+ quiet();
442
+ }
package/src/signals.js ADDED
@@ -0,0 +1,186 @@
1
+ // Deterministic signals. No AI in the decision path.
2
+
3
+ const ERROR_LINE =
4
+ /(error|exception|traceback|failed|failure|fatal|cannot|can't|not found|undefined is not|is not defined|is not a function|segmentation fault|ERR!|panic:|denied|refused|unexpected token|syntaxerror|typeerror|referenceerror|modulenotfound|no such file)/i;
5
+
6
+ const NOISE = /^(\s*at\s|\s*\^+\s*$|\s*$)/;
7
+ // A passing test whose name mentions an error ("✔ returns an error for bad input"),
8
+ // or a summary that says zero failed ("ℹ fail 0"), is not a failure.
9
+ const PASSING = /^\s*(✔|✓|√|ok \d+\b|PASS\b)|^\s*(ℹ\s*)?(fail|failed|failures|errors)\s*[:=]?\s*0\s*$/i;
10
+
11
+ export function stripAnsi(s) {
12
+ return String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
13
+ }
14
+
15
+ // Pull text out of whatever shape the tool response has.
16
+ export function responseText(resp) {
17
+ if (resp == null) return '';
18
+ if (typeof resp === 'string') return resp;
19
+ const parts = [];
20
+ for (const key of ['stderr', 'stdout', 'output', 'error', 'content', 'result']) {
21
+ const v = resp[key];
22
+ if (typeof v === 'string') parts.push(v);
23
+ else if (Array.isArray(v)) parts.push(v.map((x) => (typeof x === 'string' ? x : x?.text || '')).join('\n'));
24
+ }
25
+ return parts.length ? parts.join('\n') : JSON.stringify(resp);
26
+ }
27
+
28
+ export function errorLines(text, max = 12) {
29
+ const lines = stripAnsi(text).split('\n');
30
+ const hits = [];
31
+ for (let i = 0; i < lines.length && hits.length < max; i++) {
32
+ if (ERROR_LINE.test(lines[i]) && !NOISE.test(lines[i]) && !PASSING.test(lines[i])) hits.push(lines[i].trim());
33
+ }
34
+ return hits;
35
+ }
36
+
37
+ // A stable fingerprint for "the same error again".
38
+ export function normalize(line) {
39
+ return stripAnsi(line)
40
+ .replace(/(['"`]).*?\1/g, "'…'")
41
+ .replace(/(?:[A-Za-z]:)?(?:[\w.-]*[\\/])+([\w.-]+)/g, '$1')
42
+ .replace(/0x[0-9a-f]+/gi, 'N')
43
+ .replace(/-?\d+(\.\d+)?/g, 'N')
44
+ .replace(/\s+/g, ' ')
45
+ .trim()
46
+ .slice(0, 160);
47
+ }
48
+
49
+ export function signature(text) {
50
+ const lines = errorLines(text, 3);
51
+ if (!lines.length) return null;
52
+ return normalize(lines[0]);
53
+ }
54
+
55
+ // The name of the failing test, from node --test / TAP, jest, pytest or go test output.
56
+ export function failingTest(text) {
57
+ const t = stripAnsi(text);
58
+ const m =
59
+ t.match(/^\s*(?:✖|×|✗|not ok \d+ -)\s+(?!failing tests)(.+?)(?:\s+\(?\d[\d.]*\s?m?s\)?)?\s*$/m) ||
60
+ t.match(/^\s*●\s+(.+?)\s*$/m) ||
61
+ t.match(/^FAILED\s+(\S+)/m) ||
62
+ t.match(/^--- FAIL:\s+(\S+)/m);
63
+ return m ? m[1].trim().slice(0, 120) : null;
64
+ }
65
+
66
+ // ---------- prompts (UserPromptSubmit) ----------
67
+
68
+ const words = (p) => String(p || '').trim().split(/\s+/).filter(Boolean);
69
+ // A file path, inline code, a pasted error line or a number: the prompt carries its own
70
+ // information. The bare word "error" does not count ("still error", "masih error").
71
+ const SPECIFIC = /`|[\w-]+\/[\w./-]+|\.\w{1,5}\b|\w*(error|exception)\s*[:[(]|\b(traceback|line \d+|expected|got)\b|\d{3,}/i;
72
+ // English and Indonesian words that ask for a repair. Thanks and "works now" never count.
73
+ const REPAIR =
74
+ /\b(still|again|broken|fix|fail|fails|failing|failed|wrong|same|bug|error|errors|wtf|ugh|stuck|nope|masih|lagi|rusak|gagal|eror|salah|belum|tolong|perbaiki|benerin|betulin)\b|\b(not|doesn'?t|didn'?t|isn'?t|won'?t|can'?t|gak|nggak|ngga|ga|tidak|blm)\s+(work|working|run|running|pass|passing|load|loading|jalan|bisa|berhasil)\b/i;
75
+ const POSITIVE = /\b(thanks|thank you|thx|works now|working now|great|nice|good|perfect|awesome|mantap|makasih|terima kasih|sip|berhasil|sudah bisa|udah bisa)\b/i;
76
+
77
+ // "still broken", "fix it", "masih error": short, no specifics, asks for a repair.
78
+ export function lowInfoPrompt(p) {
79
+ const w = words(p);
80
+ if (!w.length || w.length > 8 || SPECIFIC.test(p) || POSITIVE.test(p)) return false;
81
+ return REPAIR.test(p);
82
+ }
83
+
84
+ const BUILD_START =
85
+ /^\s*(please\s+|tolong\s+|can you\s+|could you\s+)?(build|create|make|add|implement|develop|design|set up|setup|buat|buatkan|bikin|bikinin|tambah|tambahkan|bangun)\b/i;
86
+ const BIG = /\b(app|application|feature|system|platform|website|site|dashboard|service|bot|game|aplikasi|fitur|sistem|situs|web)\b/i;
87
+ const SMALL = /\b(make sure|pastikan|instead|still|again|fix|bug|error|rename|change|ubah|ganti|refactor|button|color|colour|font|style)\b/i;
88
+ const CHECKABLE =
89
+ /\b(tests?|spec|expect|assert|should (return|show|be|print)|when i|given|acceptance|example|input|output|returns?|tes|uji|contoh|harus|kalau|jika)\b/i;
90
+
91
+ // "build me a todo app with login": a new feature with nothing that says how to check it.
92
+ // Questions and small edits ("make the button blue") do not count.
93
+ export function vagueFeature(p) {
94
+ return (
95
+ words(p).length >= 6 &&
96
+ BUILD_START.test(p) &&
97
+ BIG.test(p) &&
98
+ !SMALL.test(p) &&
99
+ !CHECKABLE.test(p) &&
100
+ !SPECIFIC.test(p) &&
101
+ !/\?\s*$/.test(p)
102
+ );
103
+ }
104
+
105
+ function tokens(s) {
106
+ return new Set(
107
+ String(s)
108
+ .toLowerCase()
109
+ .split(/[^a-z0-9_]+/)
110
+ .filter((w) => w.length > 2 && w !== 'n')
111
+ );
112
+ }
113
+
114
+ export function similarity(a, b) {
115
+ const A = tokens(a);
116
+ const B = tokens(b);
117
+ if (!A.size || !B.size) return 0;
118
+ let inter = 0;
119
+ for (const t of A) if (B.has(t)) inter++;
120
+ return inter / (A.size + B.size - inter);
121
+ }
122
+
123
+ // ---------- test tampering ----------
124
+
125
+ const TEST_FILE = /(^|\/)(test|tests|__tests__|spec)\/|\.(test|spec)\.[a-z]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/i;
126
+
127
+ const TAMPER_ADDED = [
128
+ { re: /\b(it|test|describe)\.skip\s*\(/, why: 'a test was skipped (.skip)' },
129
+ { re: /\b(xit|xdescribe|xtest)\s*\(/, why: 'a test was disabled (xit/xdescribe)' },
130
+ { re: /@pytest\.mark\.skip|unittest\.skip/, why: 'a Python test was skipped' },
131
+ { re: /@ts-nocheck/, why: 'type checking was turned off (@ts-nocheck)' },
132
+ { re: /--no-verify/, why: 'git hooks were bypassed (--no-verify)' },
133
+ { re: /expect\([^)]*\)\.(toBe|toEqual)\(\s*(true|1)\s*\)\s*;?\s*\/\/\s*(todo|temp|hack)/i, why: 'an assertion was replaced with a placeholder' },
134
+ ];
135
+
136
+ export function tamperFindings(nameStatus, diff) {
137
+ const found = [];
138
+ for (const line of nameStatus.split('\n')) {
139
+ const [status, file] = line.split(/\s+/);
140
+ if (status === 'D' && file && TEST_FILE.test(file)) found.push(`test file deleted: ${file}`);
141
+ }
142
+ let current = '';
143
+ let removedAsserts = 0;
144
+ let addedAsserts = 0;
145
+ for (const line of diff.split('\n')) {
146
+ if (line.startsWith('+++ ')) {
147
+ current = line.replace(/^\+\+\+ (b\/)?/, '');
148
+ continue;
149
+ }
150
+ if (line.startsWith('--- ')) continue;
151
+ if (line.startsWith('+')) {
152
+ for (const t of TAMPER_ADDED) if (t.re.test(line)) found.push(`${t.why} in ${current}`);
153
+ if (TEST_FILE.test(current) && /\b(expect|assert)\w*\s*\(/.test(line)) addedAsserts++;
154
+ } else if (line.startsWith('-')) {
155
+ if (TEST_FILE.test(current) && /\b(expect|assert)\w*\s*\(/.test(line)) removedAsserts++;
156
+ }
157
+ }
158
+ if (removedAsserts - addedAsserts >= 2) {
159
+ found.push(`${removedAsserts - addedAsserts} assertions removed from test files`);
160
+ }
161
+ return [...new Set(found)];
162
+ }
163
+
164
+ // ---------- redaction for anything that leaves the machine ----------
165
+
166
+ const SECRETS = [
167
+ /sk-[A-Za-z0-9_-]{16,}/g,
168
+ /sk-ant-[A-Za-z0-9_-]{16,}/g,
169
+ /gh[pousr]_[A-Za-z0-9]{20,}/g,
170
+ /AKIA[0-9A-Z]{16}/g,
171
+ /xox[abprs]-[A-Za-z0-9-]{10,}/g,
172
+ /AIza[0-9A-Za-z_-]{30,}/g,
173
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
174
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
175
+ /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g,
176
+ ];
177
+
178
+ export function redact(text) {
179
+ let out = String(text);
180
+ for (const re of SECRETS) out = out.replace(re, '[redacted]');
181
+ out = out.replace(
182
+ /\b([A-Z0-9_]*(SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*[:=]\s*\S+/gi,
183
+ '$1=[redacted]'
184
+ );
185
+ return out;
186
+ }