codehere 1.0.3 → 2.0.0-beta.1

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,634 @@
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 crypto from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
7
+ import {
8
+ findRoot, paths, readConfig, readState, writeState, session, readRules, renderDoc,
9
+ listDocs, git, isGitRepo, me, slug, today, isApproved, readApprovals, printable, commitId, statusFiles, gitHashes, noTick,
10
+ } from './brain.js';
11
+ import {
12
+ responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature, testCounts,
13
+ } from './signals.js';
14
+
15
+ function readStdin() {
16
+ try {
17
+ const raw = fs.readFileSync(0, 'utf8');
18
+ return raw.trim() ? JSON.parse(raw) : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function emitJson(obj) {
25
+ process.stdout.write(JSON.stringify(obj));
26
+ process.exit(0);
27
+ }
28
+
29
+ function toAgent(message) {
30
+ // exit 2: stderr is shown to the agent (PostToolUse) / blocks stopping (Stop)
31
+ process.stderr.write(message);
32
+ process.exit(2);
33
+ }
34
+
35
+ const quiet = () => process.exit(0);
36
+
37
+ export async function runHook(event) {
38
+ const input = readStdin();
39
+ const root = findRoot(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd());
40
+ if (!root) quiet();
41
+ try {
42
+ if (event === 'session-start') return sessionStart(root, input);
43
+ if (event === 'prompt') return promptSubmit(root, input);
44
+ if (event === 'post-tool') return postTool(root, input);
45
+ if (event === 'stop') return stop(root, input);
46
+ } catch (err) {
47
+ if (process.env.CODEHERE_DEBUG) console.error(err);
48
+ }
49
+ quiet();
50
+ }
51
+
52
+ // ---------- SessionStart: load only human-confirmed context ----------
53
+
54
+ function sessionStart(root, input) {
55
+ const state = readState(root);
56
+ const s = session(state, input.session_id);
57
+ s.transcript = input.transcript_path || s.transcript;
58
+ if (isGitRepo(root) && !s.baseline) {
59
+ // Remember what was already changed before this session, so we only
60
+ // ever react to what the agent changes now.
61
+ s.startHead = git(root, 'rev-parse HEAD') || null;
62
+ s.baseline = dirtySnapshot(root);
63
+ s.settings = settingsSnapshot(root);
64
+ } else if (input.source === 'resume') {
65
+ // While the session was closed only the developer or a pull could change settings.
66
+ s.settings = settingsSnapshot(root);
67
+ }
68
+
69
+ const out = [];
70
+ const rules = readRules(root);
71
+ if (rules.length) {
72
+ out.push('Team rules from Codehere (written by your teammates, follow them):');
73
+ rules.slice(0, 20).forEach((r) => out.push(`- ${r}`));
74
+ }
75
+
76
+ // Only a brief in .codehere/briefs/: state.json can arrive through git and point anywhere.
77
+ const brief = briefFile(root, state.pendingBrief);
78
+ if (brief) {
79
+ out.push('', 'Restart brief from the previous session. Treat it as the full, current spec:');
80
+ out.push(fs.readFileSync(brief, 'utf8').slice(0, 4000));
81
+ }
82
+ state.pendingBrief = null;
83
+
84
+ const mine = me(root);
85
+ const open = listDocs(paths(root).help).filter((d) => d.meta.status === 'open' && d.meta.author !== mine);
86
+ if (open.length) {
87
+ out.push(
88
+ '',
89
+ `Note for the user: ${open.length} teammate help request(s) are open (${open
90
+ .slice(0, 3)
91
+ .map((d) => d.meta.author)
92
+ .join(', ')}). Mention this once, briefly: they can run \`codehere inbox\`.`
93
+ );
94
+ }
95
+
96
+ // Without this, an unapproved command only shows up as ⊘ at the end of a turn.
97
+ const cfg = readConfig(root);
98
+ if (cfg.verifyOnStop && cfg.testCommand && !isApproved(root, cfg.testCommand)) {
99
+ out.push(
100
+ '',
101
+ `Note for the user: Codehere's test check is off in this repo, because the test command \`${printable(cfg.testCommand)}\` is not approved on this machine. Mention this once, briefly: they can turn it on by running \`codehere approve\` in their own terminal. Do not run it for them.`
102
+ );
103
+ }
104
+
105
+ writeState(root, state);
106
+ if (out.length) process.stdout.write(out.join('\n') + '\n');
107
+ quiet();
108
+ }
109
+
110
+ function briefFile(root, file) {
111
+ try {
112
+ const real = fs.realpathSync(path.resolve(root, file));
113
+ return real.startsWith(fs.realpathSync(paths(root).briefs) + path.sep) ? real : null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ // ---------- what changed in THIS session ----------
120
+
121
+ const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/');
122
+
123
+ export function dirtySnapshot(root) {
124
+ const files = statusFiles(root).filter((f) => !IGNORE(f));
125
+ const hashes = gitHashes(root, files);
126
+ return Object.fromEntries(files.map((f, i) => [f, hashes[i] || 'x']));
127
+ }
128
+
129
+ function changedThisSession(root, s) {
130
+ const now = dirtySnapshot(root);
131
+ const before = s.baseline || null;
132
+ const changed = new Set();
133
+ if (!before) {
134
+ Object.keys(now).forEach((f) => changed.add(f));
135
+ } else {
136
+ for (const f of new Set([...Object.keys(now), ...Object.keys(before)])) {
137
+ if (now[f] !== before[f]) changed.add(f);
138
+ }
139
+ }
140
+ if (commitId(s.startHead)) {
141
+ git(root, ['diff', s.startHead, 'HEAD', '--name-only', '--relative', '-z'], true)
142
+ .split('\0')
143
+ .filter((f) => f && !IGNORE(f))
144
+ .forEach((f) => changed.add(f));
145
+ }
146
+ return [...changed];
147
+ }
148
+
149
+ // Files that decide what Codehere runs and tells every agent, and the agent's own hook
150
+ // settings. Claude Code loads hooks at session start, so a hook removed mid-session
151
+ // still runs at this session's finish check and can report it.
152
+ const PROTECTED = ['.codehere/config.json', '.codehere/rules.md', '.claude/settings.json', '.claude/settings.local.json'];
153
+
154
+ function settingsSnapshot(root) {
155
+ const snap = {};
156
+ for (const f of PROTECTED) {
157
+ let text = null;
158
+ try {
159
+ text = fs.readFileSync(path.join(root, f), 'utf8');
160
+ } catch {}
161
+ // In .claude/ only the hook and env settings count: Claude Code also saves permission
162
+ // rules there when the developer picks "don't ask again". env can move HOME or
163
+ // XDG_CONFIG_HOME, where approvals are kept. A missing file has none of them.
164
+ if (f.startsWith('.claude/')) {
165
+ try {
166
+ const j = JSON.parse(text ?? '{}');
167
+ text = JSON.stringify([j.hooks ?? null, j.disableAllHooks ?? null, j.env ?? null]);
168
+ } catch {}
169
+ }
170
+ snap[f] = text == null ? null : crypto.createHash('sha256').update(text).digest('hex');
171
+ }
172
+ return snap;
173
+ }
174
+
175
+ function settingsChanged(root, s) {
176
+ if (!s.settings) return []; // no snapshot from session start: cannot tell
177
+ const now = settingsSnapshot(root);
178
+ return PROTECTED.filter((f) => now[f] !== s.settings[f]);
179
+ }
180
+
181
+ // ---------- PostToolUse (Bash): loop breaker + just-in-time team lessons ----------
182
+
183
+ // Commands that only read files. Their output may contain the word "error"
184
+ // (source code, logs) without anything having failed. A chain such as
185
+ // `cd x && cat a; ls b` is read-only only if every part is. Quoted text is
186
+ // emptied first so `grep "error|fail"` stays one part, and a part that runs
187
+ // another command ($(...), backticks, <(...)) is never read-only.
188
+ 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/;
189
+ export function readOnly(command) {
190
+ const runsSomething = /\$\(|`|<\(/;
191
+ if (runsSomething.test(command)) return false;
192
+ const bare = command.replace(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, "''");
193
+ return bare.split(/&&|\|\||\|&|[;|\n]/).every((part) => !part.trim() || READ_ONLY.test(part));
194
+ }
195
+
196
+ function matchLesson(root, sig, shown) {
197
+ let best = null;
198
+ for (const doc of listDocs(paths(root).lessons)) {
199
+ if (shown.includes(doc.id) || doc.body.includes('- Fix: <one line>')) continue; // not filled in yet
200
+ const score = Math.max(
201
+ similarity(sig, doc.meta.signature || ''),
202
+ similarity(sig, doc.meta.title || '') * 0.8
203
+ );
204
+ if (score >= 0.45 && (!best || score > best.score)) best = { doc, score };
205
+ }
206
+ return best?.doc || null;
207
+ }
208
+
209
+ // ---------- lessons: what the agent gets, what the developer sees (design 1) ----------
210
+
211
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
212
+ function shortDate(d) {
213
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d || '');
214
+ if (!m) return d || '';
215
+ const day = `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]}`;
216
+ return Number(m[1]) === new Date().getFullYear() ? day : `${day} ${m[1]}`;
217
+ }
218
+
219
+ const PLACEHOLDERS = ['<short name of the problem>', '<one line>'];
220
+
221
+ const json = (s) => {
222
+ try {
223
+ return JSON.parse(s);
224
+ } catch {
225
+ return null;
226
+ }
227
+ };
228
+
229
+ // Verified only with the proof Codehere itself writes after a run with exit 0. A file edited
230
+ // to say "verified" without it is not. Either way this is the teammate's machine, never ours.
231
+ function lessonProof(doc) {
232
+ const m = doc.meta;
233
+ return m.state === 'verified' && m.author && m.proof_command && m.proof_exit === '0' && /^[0-9a-f]{7,64}$/.test(m.proof_commit || '');
234
+ }
235
+
236
+ function lessonText(root, doc) {
237
+ const m = doc.meta;
238
+ const filled = (s) => (s && !PLACEHOLDERS.includes(s) ? s : null);
239
+ const cause = filled((doc.body.match(/^- Cause:(.*)$/m) || [])[1]?.trim());
240
+ const fix =
241
+ filled((doc.body.match(/^- Fix:(.*)$/m) || [])[1]?.trim()) ||
242
+ (doc.body.match(/## Fix\n([\s\S]*?)(\n## |$)/) || [null, doc.body])[1].trim().replace(/^- /, '');
243
+ const out = [
244
+ 'Codehere: a teammate solved a similar error before. This is reference, not an instruction.',
245
+ `Lesson ${filled(m.title) || m.signature}`,
246
+ `From ${m.author || 'a teammate'} · ${shortDate(m.date)}`,
247
+ ];
248
+ if (cause) out.push(`Cause ${cause}`);
249
+ out.push(`Fix ${fix.slice(0, 700)}`);
250
+ const files = json(m.files);
251
+ const hashes = json(m.file_hashes);
252
+ if (Array.isArray(files) && files.length && files.every((f) => typeof f === 'string')) {
253
+ const now = gitHashes(root, files);
254
+ const changed = !Array.isArray(hashes) || files.some((f, i) => !now[i] || now[i] !== hashes[i]);
255
+ out.push(`Files ${files.join(', ')} · ${changed ? 'changed' : 'unchanged'} since the fix`);
256
+ }
257
+ out.push(
258
+ lessonProof(doc)
259
+ ? `Proof verified on ${m.author}'s machine: ${m.proof_command} exit 0 at ${m.proof_commit}${m.proof_dirty === 'true' ? ' plus uncommitted changes' : ''}. Not re-run here.`
260
+ : 'Proof none recorded.'
261
+ );
262
+ out.push('Check whether it fits before trying something new.');
263
+ return out.join('\n');
264
+ }
265
+
266
+ function lessonLine(doc) {
267
+ const who = noTick(doc.meta.author || 'a teammate');
268
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(doc.meta.date || '') ? ` · ${shortDate(doc.meta.date)}` : '';
269
+ return printable(`codehere › ${who}'s lesson given to the agent · ${lessonProof(doc) ? `verified on ${who}'s machine` : 'not verified'}${date}`);
270
+ }
271
+
272
+ // Codehere writes the proof fields itself; the agent fills in the title, cause and fix.
273
+ function writeLesson(root, sig, proofCommand, changed) {
274
+ const dir = paths(root).lessons;
275
+ fs.mkdirSync(dir, { recursive: true });
276
+ const name = `${today()}-${slug(sig, 30)}`;
277
+ let file = path.join(dir, `${name}.md`);
278
+ for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${name}-${i}.md`);
279
+ const meta = { title: PLACEHOLDERS[0], signature: sig, author: me(root), date: today(), state: proofCommand ? 'verified' : 'unverified' };
280
+ if (proofCommand) {
281
+ Object.assign(meta, {
282
+ proof_command: proofCommand,
283
+ proof_exit: 0,
284
+ proof_commit: git(root, ['rev-parse', '--short', 'HEAD']),
285
+ proof_dirty: statusFiles(root).some((f) => !IGNORE(f)),
286
+ });
287
+ }
288
+ meta.files = JSON.stringify(changed);
289
+ meta.file_hashes = JSON.stringify(gitHashes(root, changed));
290
+ fs.writeFileSync(file, renderDoc(meta, `## Fix\n- Cause: ${PLACEHOLDERS[1]}\n- Fix: ${PLACEHOLDERS[1]}\n`));
291
+ return path.relative(root, file);
292
+ }
293
+
294
+ function postTool(root, input) {
295
+ if (input.tool_name && input.tool_name !== 'Bash') quiet();
296
+ const command = (input.tool_input && input.tool_input.command) || '';
297
+ if (readOnly(command)) quiet();
298
+ const text = responseText(input.tool_response) + '\n' + (input.error || '');
299
+ const sig = signature(text);
300
+ if (!sig) {
301
+ // the command that failed last now runs clean: that failure is over
302
+ const state = readState(root);
303
+ const s = state.sessions[input.session_id || 'default'];
304
+ if (s && s.lastFailure && s.lastFailure.command === command) {
305
+ s.lastFailure = null;
306
+ writeState(root, state);
307
+ }
308
+ quiet();
309
+ }
310
+
311
+ const cfg = readConfig(root);
312
+ const state = readState(root);
313
+ const s = session(state, input.session_id);
314
+ s.errors[sig] = (s.errors[sig] || 0) + 1;
315
+ s.rawErrors[sig] = errorLines(text, 8);
316
+ s.lastFailure = { sig, command, test: failingTest(text), lines: errorLines(text, 3), prompt: s.prompts || 0 };
317
+ const count = s.errors[sig];
318
+
319
+ const lesson = matchLesson(root, sig, s.shownLessons);
320
+ if (lesson) s.shownLessons.push(lesson.id);
321
+ writeState(root, state);
322
+
323
+ const threshold = cfg.loopThreshold;
324
+ if (count === threshold || count === threshold + 2) {
325
+ s.loopsStopped = (s.loopsStopped || 0) + 1;
326
+ writeState(root, state);
327
+ const msg = [
328
+ `Codehere: the same error has come back ${count} times this session:`,
329
+ ` ${sig}`,
330
+ 'Stop patching. Do this instead:',
331
+ '1. Write one sentence with your root-cause hypothesis.',
332
+ '2. Check it with one small command before editing any file.',
333
+ '3. If the hypothesis is wrong, consider `git stash` and returning to the last working state.',
334
+ ];
335
+ if (lesson) msg.push('', lessonText(root, lesson));
336
+ if (count > threshold) {
337
+ msg.push(
338
+ '',
339
+ 'You are still stuck. Stop now. Tell the user in one short line that they can run',
340
+ '`codehere help "<what you are stuck on>"` to send a ready-made help card to a teammate,',
341
+ 'or `codehere brief` to restart in a fresh session. Then wait for the user.'
342
+ );
343
+ }
344
+ // with a lesson, JSON instead of exit 2, so the developer also gets its line
345
+ if (lesson) emitJson({ decision: 'block', reason: msg.join('\n'), systemMessage: lessonLine(lesson) });
346
+ toAgent(msg.join('\n') + '\n');
347
+ }
348
+
349
+ if (lesson) {
350
+ emitJson({
351
+ systemMessage: lessonLine(lesson),
352
+ hookSpecificOutput: {
353
+ hookEventName: input.hook_event_name || 'PostToolUse',
354
+ additionalContext: lessonText(root, lesson),
355
+ },
356
+ });
357
+ }
358
+ quiet();
359
+ }
360
+
361
+ // ---------- UserPromptSubmit: restore what a short prompt leaves out ----------
362
+ // One message at most, only about this session, under 300 tokens. Silent on normal turns.
363
+
364
+ function promptSubmit(root, input) {
365
+ if (!input.session_id) quiet(); // without it, sessions cannot be told apart
366
+ const prompt = String(input.prompt || '');
367
+ const cfg = readConfig(root);
368
+ const state = readState(root);
369
+ const s = session(state, input.session_id);
370
+ s.transcript = input.transcript_path || s.transcript;
371
+ s.prompts = (s.prompts || 0) + 1;
372
+ if (!s.settings) s.settings = settingsSnapshot(root); // session start did not run for this session
373
+
374
+ // Only a failure from the agent's last turn counts: an older one may be fixed or forgotten.
375
+ const f = s.lastFailure && (s.lastFailure.prompt || 0) === s.prompts - 1 ? s.lastFailure : null;
376
+ const repeats = f ? s.errors[f.sig] || 1 : 0;
377
+ const low = !!f && lowInfoPrompt(prompt);
378
+ if (f && !s.episode) s.episode = { kind: low ? 'still-broken' : 'with-detail', start: s.prompts };
379
+
380
+ let msg = null;
381
+ if (low) {
382
+ const changed = isGitRepo(root) ? quickChanged(root, s) : [];
383
+ msg = [
384
+ "Codehere context from this session's tool output (the user did not type this):",
385
+ "The user's short message most likely means the last failure is still there.",
386
+ `- Error (seen ${repeats}x): ${((f.lines && f.lines[0]) || f.sig).slice(0, 200)}`,
387
+ ...(f.test ? [`- Failing test: ${f.test}`] : []),
388
+ `- Command: ${f.command.slice(0, 120)}`,
389
+ ...(changed.length ? [`- Files changed this session: ${changed.join(', ')}`] : []),
390
+ 'Find the root cause before editing again. If a fix already failed, try a different hypothesis.',
391
+ ].join('\n');
392
+ } else if (f && !s.briefSuggested && repeats >= cfg.loopThreshold && transcriptTokens(s) >= cfg.longSessionTokens) {
393
+ s.briefSuggested = true;
394
+ msg = [
395
+ `Codehere: this session is long (about ${Math.round(transcriptTokens(s) / 1000)}k tokens) and the same error came back ${repeats} times.`,
396
+ 'Fresh sessions with one clear spec fix bugs better than long ones.',
397
+ 'In one line, suggest the user run `codehere brief` and start a new session (it loads the brief). Then continue with their request.',
398
+ ].join('\n');
399
+ } else if (!f && !s.askedHowToCheck && vagueFeature(prompt)) {
400
+ s.askedHowToCheck = true;
401
+ msg = [
402
+ 'Codehere: this feature request has no way to check it yet.',
403
+ 'Before writing code, ask the user only this one question: "How will you know it works?"',
404
+ 'You may offer 2 to 4 checkable examples they can accept. Then wait for the answer.',
405
+ 'Turn the answer into failing tests first, then build in small steps and run the tests after each one.',
406
+ ].join('\n');
407
+ }
408
+
409
+ writeState(root, state);
410
+ if (msg) {
411
+ emitJson({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: msg.slice(0, 1100) } });
412
+ }
413
+ quiet();
414
+ }
415
+
416
+ // Fast list for a prompt (no hashing): tracked files changed since the session started.
417
+ function quickChanged(root, s) {
418
+ return git(root, ['diff', '--name-only', '--relative', '-z', commitId(s.startHead) || 'HEAD'], true)
419
+ .split('\0')
420
+ .filter((x) => x && !IGNORE(x))
421
+ .slice(0, 5);
422
+ }
423
+
424
+ // How big the agent's context is now. Claude Code records each reply's token usage in the
425
+ // transcript; the file itself is no guide, as it also stores full request bodies.
426
+ // 0 means unknown (no usage recorded).
427
+ export function transcriptTokens(s) {
428
+ try {
429
+ const size = fs.statSync(s.transcript).size;
430
+ const fd = fs.openSync(s.transcript, 'r');
431
+ const len = Math.min(size, 1024 * 1024);
432
+ const buf = Buffer.alloc(len);
433
+ fs.readSync(fd, buf, 0, len, size - len);
434
+ fs.closeSync(fd);
435
+ const lines = buf.toString('utf8').split('\n').reverse();
436
+ for (const line of lines) {
437
+ if (!line.includes('"usage"')) continue;
438
+ try {
439
+ const u = JSON.parse(line).message?.usage;
440
+ const n = u ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0;
441
+ if (n > 0) return n;
442
+ } catch {}
443
+ }
444
+ return 0;
445
+ } catch {
446
+ return 0;
447
+ }
448
+ }
449
+
450
+ // The session reached a verified green: close the open fix attempt, if any.
451
+ function recordGreen(state, s) {
452
+ if (s.firstGreenAt == null) s.firstGreenAt = s.prompts || 0;
453
+ if (s.episode) {
454
+ state.episodes = state.episodes || [];
455
+ state.episodes.push({ kind: s.episode.kind, turns: Math.max(1, (s.prompts || 0) - s.episode.start + 1) });
456
+ state.episodes = state.episodes.slice(-200);
457
+ s.episode = null;
458
+ }
459
+ // A green test run ends a test failure, not an unrelated one such as a failed build.
460
+ const f = s.lastFailure;
461
+ if (f && (f.test || /\b(test|tests|pytest|jest|vitest|mocha|spec)\b/.test(f.command || ''))) s.lastFailure = null;
462
+ }
463
+
464
+ // ---------- Stop: verify "done" outside the agent ----------
465
+
466
+ function runTests(root, cfg) {
467
+ const started = Date.now();
468
+ const r = spawnSync(cfg.testCommand, {
469
+ cwd: root,
470
+ shell: true,
471
+ encoding: 'utf8',
472
+ timeout: Math.max(1, Number(cfg.testTimeoutSeconds) || 120) * 1000, // 0 would mean no limit
473
+ env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
474
+ });
475
+ const output = `${r.stdout || ''}\n${r.stderr || ''}`.trim();
476
+ const timedOut = r.error && r.error.code === 'ETIMEDOUT';
477
+ return { ok: r.status === 0 && !timedOut, output, timedOut, code: r.status, seconds: (Date.now() - started) / 1000 };
478
+ }
479
+
480
+ // "48 passed" or "2 failed" when the runner's summary can be read, else the exit code
481
+ export function runResult(t) {
482
+ if (t.timedOut) return 'timed out';
483
+ const c = testCounts(t.output);
484
+ if (t.ok) return c && !c.failed ? `${c.passed} passed` : 'exit 0'; // a summary with failures but exit 0: say only what is sure
485
+ return c && c.failed ? `${c.failed} failed` : `exit ${t.code}`;
486
+ }
487
+
488
+ // message: the developer's lines, already escaped
489
+ function block(reason, message) {
490
+ emitJson(message ? { decision: 'block', reason, systemMessage: message } : { decision: 'block', reason });
491
+ }
492
+
493
+ function stop(root, input) {
494
+ const cfg = readConfig(root);
495
+ const state = readState(root);
496
+ const s = session(state, input.session_id);
497
+ const mayBlock = (s.stopBlocks || 0) < 3; // never trap the agent: after 3 blocks, only observe
498
+
499
+ if (!isGitRepo(root)) quiet();
500
+ const base = commitId(s.startHead) || 'HEAD';
501
+ const changed = changedThisSession(root, s);
502
+ const settings = settingsChanged(root, s);
503
+ if (!changed.length && !settings.length) quiet(); // the agent changed nothing this session: stay silent
504
+
505
+ // Lines for the developer. Each is escaped as a whole, so text from the repo cannot fake a
506
+ // line, and text from the repo never carries a tick (noTick).
507
+ const lines = [];
508
+ function message() {
509
+ const all = [...lines];
510
+ for (const a of readApprovals(root)) {
511
+ if (a.at > (s.startedAt || '')) all.push(`codehere › approved on this machine during this session: ${noTick(a.command)}`);
512
+ }
513
+ if (settings.length) all.push(`codehere ✕ settings changed during this session · ${settings.join(' · ')}`);
514
+ return all.map(printable).join('\n');
515
+ }
516
+ function report() {
517
+ const m = message();
518
+ if (m) emitJson({ systemMessage: m });
519
+ quiet();
520
+ }
521
+
522
+ // 1. tampering with tests or checks (only in files changed this session)
523
+ let findings = [];
524
+ if (changed.length) {
525
+ const nameStatus = git(root, ['diff', base, '--relative', '--name-status', '--', ...changed]);
526
+ findings = tamperFindings(nameStatus, git(root, ['diff', base, '--relative', '-U0', '--', ...changed]));
527
+ }
528
+ const key = findings.join('|');
529
+ if (findings.length && s.tamperReported !== key && mayBlock) {
530
+ s.tamperReported = key;
531
+ s.stopBlocks++;
532
+ writeState(root, state);
533
+ block(
534
+ [
535
+ 'Codehere found changes that weaken tests or checks:',
536
+ ...findings.map((f) => `- ${f}`),
537
+ 'Restore them and fix the code instead.',
538
+ 'If the change is truly intended, tell the user in one line why, then finish.',
539
+ ].join('\n')
540
+ );
541
+ }
542
+
543
+ // 2. Codehere's settings or the agent's hook settings changed during the session.
544
+ // The agent is sent back only for the ones that can game or switch off the check;
545
+ // a rules.md change is shown to the developer alone.
546
+ const settingsKey = settings.join('|');
547
+ const sendBack = settings.some((f) => f !== '.codehere/rules.md');
548
+ if (sendBack && s.settingsReported !== settingsKey && mayBlock) {
549
+ s.settingsReported = settingsKey;
550
+ s.stopBlocks++;
551
+ writeState(root, state);
552
+ block(
553
+ [
554
+ 'Codehere settings or hook settings changed during this session:',
555
+ ...settings.map((f) => `- ${f}`),
556
+ 'If you changed them and the user did not ask for it, undo your change.',
557
+ 'Then tell the user in one line what changed and why, and finish.',
558
+ ].join('\n')
559
+ );
560
+ }
561
+
562
+ // 3. run the tests ourselves, but only a command approved on this machine:
563
+ // a teammate's commit or the agent may have changed it
564
+ const approved = cfg.testCommand && isApproved(root, cfg.testCommand);
565
+ if (cfg.verifyOnStop && cfg.testCommand && !approved) {
566
+ const last = readApprovals(root).at(-1);
567
+ lines.push(
568
+ `codehere ⊘ ${noTick(cfg.testCommand)} · not run · not approved on this machine${last ? ` · last approved: ${noTick(last.command)}` : ''} › run codehere approve in your terminal`
569
+ );
570
+ }
571
+ const configChanged = settings.includes('.codehere/config.json');
572
+ const cmd = noTick(cfg.testCommand || '');
573
+ let passLine = null;
574
+ let proven = false; // passed here, now, with intact tests and an unchanged config
575
+ if (cfg.verifyOnStop && approved) {
576
+ const t = runTests(root, cfg);
577
+ if (!t.ok && !mayBlock) {
578
+ lines.push(`codehere ✕ ${cmd} · ${runResult(t)} · not sent back, limit of 3 reached`);
579
+ writeState(root, state);
580
+ report();
581
+ }
582
+ if (!t.ok) {
583
+ s.stopBlocks++;
584
+ writeState(root, state);
585
+ const tail = t.output.split('\n').slice(-25).join('\n');
586
+ block(
587
+ [
588
+ `Codehere ran \`${cmd}\` and it ${t.timedOut ? 'timed out' : 'failed'}. The task is not done yet.`,
589
+ 'Last lines of output:',
590
+ tail,
591
+ 'Fix the cause, then finish. Do not change or skip tests to make them pass.',
592
+ ].join('\n'),
593
+ (lines.push(`codehere ✕ ${cmd} · ${runResult(t)} · agent sent back · ${s.stopBlocks} of 3`), message())
594
+ );
595
+ }
596
+ // ✓ only for intact evidence (DESIGN.md §1): not after a weakened test, nor after the
597
+ // config that names the test command changed.
598
+ proven = !findings.length && !configChanged;
599
+ if (findings.length) passLine = `codehere ✕ ${cmd} passed, but a check was weakened · ${noTick(findings.join(' · '))}`;
600
+ else if (configChanged) passLine = `codehere ✕ ${cmd} passed, but .codehere/config.json changed during this session`;
601
+ else passLine = `codehere ✓ ${cmd} · ${runResult(t)} · re-run here · ${t.seconds.toFixed(1)} s`;
602
+ if (proven) {
603
+ state.lastGreen = { commit: git(root, 'rev-parse HEAD'), at: new Date().toISOString() };
604
+ recordGreen(state, s);
605
+ }
606
+ lines.unshift(passLine);
607
+ }
608
+
609
+ // 4. turn a solved repeated error into a team lesson (once per session). After the 3-block
610
+ // cap the agent is not asked to fill it in; the developer is told instead.
611
+ const solved = Object.entries(s.errors).filter(([, n]) => n >= 2);
612
+ if (solved.length && !s.lessonAsked) {
613
+ s.lessonAsked = true;
614
+ const [sig] = solved.sort((a, b) => b[1] - a[1])[0];
615
+ const file = writeLesson(root, sig, proven ? cfg.testCommand : null, changed);
616
+ if (mayBlock) {
617
+ s.stopBlocks++;
618
+ writeState(root, state);
619
+ block(
620
+ [
621
+ `Before you finish: you hit and solved a repeated error ("${sig}").`,
622
+ `Codehere saved it for the team in ${file}${proven ? ', with this test run as proof' : ''}.`,
623
+ 'In that file, replace the three <...> placeholders (title, Cause, Fix) with one line each.',
624
+ 'Do not change the other fields. Then finish your reply as normal. Do not mention this step unless asked.',
625
+ ].join('\n'),
626
+ message()
627
+ );
628
+ }
629
+ lines.push(`codehere › lesson saved: ${file} · fill in its title, cause and fix`);
630
+ }
631
+
632
+ writeState(root, state);
633
+ report();
634
+ }