codehere 2.0.0-beta.0 → 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/README.md CHANGED
@@ -23,12 +23,14 @@ Codehere is built to remove work, not add it. You keep using Claude Code as usua
23
23
  ```bash
24
24
  npm install -g codehere@next # the 2.0 beta; codehere@latest is still 1.x
25
25
  cd your-project
26
- codehere init # finds your test command, adds hooks to .claude/settings.json
26
+ codehere init # finds your test command, adds hooks to .claude/settings.json, asks you to approve the command
27
27
  git add .codehere .claude/settings.json .gitignore
28
28
  git commit -m "Add Codehere" && git push
29
29
  ```
30
30
 
31
- Each teammate installs the CLI once (`npm install -g codehere@next`). The hooks and the brain arrive through git.
31
+ Each teammate installs the CLI once (`npm install -g codehere@next`) and runs `codehere approve` once in the repo. Until they do, their agent tells them at session start that the test check is off. The hooks and the brain arrive through git.
32
+
33
+ The test command is in `.codehere/config.json`, which anyone with push access can change. So Codehere runs it only after you approve that exact command on your machine, at your terminal. If a pull or the agent changes it, the finish check skips it and tells you, until you approve it again. Approvals are kept in `~/.config/codehere/approvals.json`, outside the repo, so nothing that arrives through git can approve a command.
32
34
 
33
35
  ## The team loop
34
36
 
@@ -45,7 +47,17 @@ codehere reply <id> "Use price * qty; same bug was in checkout.js" --solved
45
47
  git add .codehere && git commit -m "answer" && git push
46
48
  ```
47
49
 
48
- `--solved` turns the answer into a **team lesson**. From then on, any teammate's agent that hits a similar error gets that fix automatically. When an agent solves a repeated error by itself, Codehere asks it to write a short lesson too.
50
+ `--solved` turns the answer into a **team lesson**. From then on, any teammate's agent that hits a similar error gets that fix automatically. When an agent solves a repeated error by itself, Codehere saves a lesson with the proof from that test run (command, exit code, commit, the files the fix changed) and asks the agent to fill in the cause and the fix.
51
+
52
+ You see one line per event:
53
+
54
+ ```
55
+ codehere › Ana's lesson given to the agent · verified on Ana's machine · 21 Oct
56
+ codehere ✕ npm test · 2 failed · agent sent back · 1 of 3
57
+ codehere ✓ npm test · 48 passed · re-run here · 6.2 s
58
+ ```
59
+
60
+ ✓ marks only a test run Codehere just made on your machine. A teammate's "verified" is plain text: it was true on their machine, and anyone with push access can edit a lesson file. The agent is told whether the files the fix changed have changed since.
49
61
 
50
62
  Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or Slack instead.
51
63
 
@@ -53,6 +65,7 @@ Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or
53
65
 
54
66
  ```
55
67
  codehere init [--test "npm test"] set up (safe to run again)
68
+ codehere approve let the finish check run the test command on this machine
56
69
  codehere rule ["text"] add or list team rules
57
70
  codehere lesson ["problem" --fix "..."] add or list lessons
58
71
  codehere brief [--gemma] restart brief for a fresh session
@@ -73,7 +86,7 @@ codehere stats turns to green, loops stopped, context s
73
86
  help/ help cards
74
87
  briefs/ restart briefs
75
88
  config.json test command, loop threshold
76
- local/ this machine only, git-ignored
89
+ local/ this machine only, git-ignored (session state)
77
90
  ```
78
91
 
79
92
  Everything is plain markdown, so you can read, edit, review and delete it like any other file.
@@ -100,9 +113,13 @@ npm test # end-to-end checks
100
113
  ## Known limits (2.0.0, not released yet)
101
114
 
102
115
  - Claude Code only for now. Gemini CLI and Codex hooks are next.
103
- - Lessons match by error text. Very generic errors can bring up a lesson that doesn't fit, which is why the agent is told it "may apply".
116
+ - Lessons match by error text. Very generic errors can bring up a lesson that doesn't fit, which is why the agent gets it as reference, not as an instruction.
104
117
  - Redaction catches common keys, tokens and emails. Always read a help card before sharing it.
105
118
  - The "done" check needs a test command. Without one, only the tampering check runs.
119
+ - Approvals are per folder. Each git worktree or extra clone needs its own `codehere approve`.
120
+ - Approving `npm test` approves that command, not the scripts behind it in `package.json`. And Claude Code runs any hook a teammate adds to the committed `.claude/settings.json`. Both are your repo's code, with the same trust as running it yourself.
121
+ - When the agent changes `.codehere/config.json`, `.codehere/rules.md` or the hook and env settings in `.claude/settings*.json` during a session, the finish check tells you. It cannot tell you if the session ends without a finish check, for example when you close it mid-turn. An agent that sets out to fake your terminal or rewrite Codehere's local files can also get around this.
122
+ - Only the test command needs approval. A teammate who sets `verifyOnStop` to `false` in `.codehere/config.json` turns the finish check off for everyone; `codehere status` shows it as off.
106
123
  - Sharing is through git. There is no server, so teammates see updates after `git pull`.
107
124
  - Prompt checks are plain word lists (English and Indonesian). They can miss a short prompt in another language.
108
125
  - The weakened-test scan catches skipped tests, deleted test files and removed assertions. It does not yet catch an edited expected value; see `docs/overlap-with-check.md`.
package/bin/codehere.js CHANGED
@@ -24,6 +24,7 @@ const USAGE = `codehere · a git-native team brain for vibe coders
24
24
 
25
25
  Setup
26
26
  codehere init [--test "npm test"] set up .codehere/ and Claude Code hooks
27
+ codehere approve let the finish check run the test command on this machine
27
28
 
28
29
  Team brain
29
30
  codehere rule ["text"] add a team rule (or list rules)
@@ -52,6 +53,8 @@ const run = async () => {
52
53
  return runHook(rest[0]);
53
54
  case 'init':
54
55
  return cmd.init(flags);
56
+ case 'approve':
57
+ return cmd.approve();
55
58
  case 'rule':
56
59
  case 'rules':
57
60
  return cmd.rule(rest.join(' '));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "2.0.0-beta.0",
3
+ "version": "2.0.0-beta.1",
4
4
  "description": "A git-native team brain for vibe coders. Silent fixes for AI coding agents: breaks doom loops, verifies 'done', blocks test tampering, and shares what your team learned.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/brain.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // The team brain: plain markdown files in .codehere/, committed to git.
2
2
  // Local, per-machine state lives in .codehere/local/ (git-ignored).
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
- import { execSync } from 'node:child_process';
6
+ import { execSync, execFileSync } from 'node:child_process';
6
7
 
7
8
  export function findRoot(start = process.cwd()) {
8
9
  let dir = path.resolve(start);
@@ -62,10 +63,11 @@ export function writeConfig(root, cfg) {
62
63
 
63
64
  export function readState(root) {
64
65
  try {
65
- return JSON.parse(fs.readFileSync(paths(root).state, 'utf8'));
66
- } catch {
67
- return { sessions: {}, lastSession: null, lastGreen: null, pendingBrief: null };
68
- }
66
+ const state = JSON.parse(fs.readFileSync(paths(root).state, 'utf8'));
67
+ // state.json can arrive through git: a file of the wrong shape counts as empty
68
+ if (state && state.sessions && typeof state.sessions === 'object') return state;
69
+ } catch {}
70
+ return { sessions: {}, lastSession: null, lastGreen: null, pendingBrief: null };
69
71
  }
70
72
 
71
73
  export function writeState(root, state) {
@@ -165,18 +167,141 @@ export function readRules(root) {
165
167
  .map((l) => l.slice(2).trim());
166
168
  }
167
169
 
170
+ // ---------- approved test commands (this machine only) ----------
171
+ // config.json travels through git, so a teammate's commit can change the test
172
+ // command. The finish check runs only a command approved on this machine. The
173
+ // approvals live outside the repo: any file in .codehere/, even a git-ignored one
174
+ // (git add -f), can arrive through a pull.
175
+
176
+ // The real path of p, for a p that may not exist yet.
177
+ function realish(p) {
178
+ try {
179
+ return fs.realpathSync(p);
180
+ } catch {
181
+ const parent = path.dirname(p);
182
+ return parent === p ? p : path.join(realish(parent), path.basename(p));
183
+ }
184
+ }
185
+
186
+ function repoKey(root) {
187
+ return realish(path.resolve(root));
188
+ }
189
+
190
+ // null when the file would be inside the repo: settings committed to the repo can set
191
+ // XDG_CONFIG_HOME or HOME for the agent's processes, and so for these hooks.
192
+ function approvalsFile(root) {
193
+ const xdg = process.env.XDG_CONFIG_HOME;
194
+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.config');
195
+ const file = path.join(base, 'codehere', 'approvals.json');
196
+ return realish(file).startsWith(repoKey(root) + path.sep) ? null : file;
197
+ }
198
+
199
+ function readAllApprovals(root) {
200
+ try {
201
+ const all = JSON.parse(fs.readFileSync(approvalsFile(root), 'utf8'));
202
+ return all && all.repos && typeof all.repos === 'object' ? all : { repos: {} };
203
+ } catch {
204
+ return { repos: {} };
205
+ }
206
+ }
207
+
208
+ export function readApprovals(root) {
209
+ const list = readAllApprovals(root).repos[repoKey(root)]?.commands;
210
+ return Array.isArray(list) ? list : [];
211
+ }
212
+
213
+ export function isApproved(root, command) {
214
+ return readApprovals(root).some((a) => a.command === command);
215
+ }
216
+
217
+ // false when the approvals file would be inside the repo
218
+ export function addApproval(root, command) {
219
+ const file = approvalsFile(root);
220
+ if (!file) return false;
221
+ const all = readAllApprovals(root);
222
+ const list = readApprovals(root).filter((a) => a.command !== command);
223
+ list.push({ command, by: me(root), at: new Date().toISOString() });
224
+ all.repos[repoKey(root)] = { commands: list };
225
+ fs.mkdirSync(path.dirname(file), { recursive: true });
226
+ fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n');
227
+ return true;
228
+ }
229
+
230
+ // Control, format (bidi, zero-width), filler and variation characters escaped, so text from the repo cannot print a fake
231
+ // line, move the cursor or hide part of a command.
232
+ const ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
233
+ export function printable(text) {
234
+ return String(text).replace(
235
+ /[\p{Cc}\p{Cf}\u034f\u115f\u1160\u2028\u2029\u3164\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu,
236
+ (c) => {
237
+ const n = c.codePointAt(0).toString(16);
238
+ return ESCAPES[c] || (n.length > 4 ? `\\u{${n}}` : `\\u${n.padStart(4, '0')}`);
239
+ }
240
+ );
241
+ }
242
+
168
243
  // ---------- git helpers (all fail-safe) ----------
169
244
 
170
- export function git(root, args) {
245
+ // Pass an array whenever an argument comes from the repo or the state file: it never
246
+ // goes through a shell, so a file name such as a$(cmd).js stays a file name.
247
+ // raw keeps the output as git wrote it: -z lists, and porcelain lines whose first character is a space.
248
+ export function git(root, args, raw = false) {
171
249
  try {
172
- return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] })
173
- .toString()
174
- .trim();
250
+ const opts = { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] };
251
+ const out = (Array.isArray(args) ? execFileSync('git', ['--literal-pathspecs', ...args], opts) : execSync(`git ${args}`, opts)).toString();
252
+ return raw ? out : out.trim();
175
253
  } catch {
176
254
  return '';
177
255
  }
178
256
  }
179
257
 
258
+ // Files with uncommitted changes, relative to root, which may be a subfolder of the repo.
259
+ export function statusFiles(root) {
260
+ const prefix = git(root, ['rev-parse', '--show-prefix']);
261
+ const entries = git(root, ['status', '--porcelain', '-z', '--untracked-files=all', '--', '.'], true).split('\0');
262
+ const files = [];
263
+ for (let i = 0; i < entries.length; i++) {
264
+ const e = entries[i];
265
+ if (e.length < 4) continue;
266
+ if (e.slice(3).startsWith(prefix)) files.push(e.slice(3 + prefix.length));
267
+ if (e[0] === 'R' || e[0] === 'C') i++; // the old name of a rename or copy
268
+ }
269
+ return files;
270
+ }
271
+
272
+ // What `git hash-object` gives for each file (git's filters applied), 'deleted' for a missing
273
+ // file, null when git cannot say. One git call for all of them; absolute paths, because
274
+ // --stdin-paths ignores the subfolder git was started in.
275
+ export function gitHashes(root, files) {
276
+ const present = files.filter((f) => !f.includes('\n') && fs.existsSync(path.join(root, f)));
277
+ let out = [];
278
+ try {
279
+ if (present.length) {
280
+ out = execFileSync('git', ['hash-object', '--stdin-paths'], { cwd: root, input: present.map((f) => path.resolve(root, f)).join('\n') + '\n', stdio: ['pipe', 'pipe', 'ignore'] })
281
+ .toString()
282
+ .trim()
283
+ .split('\n');
284
+ }
285
+ } catch {}
286
+ return files.map((f) => {
287
+ const i = present.indexOf(f);
288
+ if (i >= 0) return out[i] || null;
289
+ return f.includes('\n') ? null : 'deleted';
290
+ });
291
+ }
292
+
293
+ // A tick is Codehere's mark for a run it made on this machine (DESIGN.md §1). Text from the
294
+ // repo or from a teammate never brings its own.
295
+ export function noTick(text) {
296
+ return String(text).replace(/[✓✔☑✅]/g, '?');
297
+ }
298
+
299
+ // A commit id read back from state.json, or null. The state file can arrive through git,
300
+ // so anything else (such as --output=<file>) must never reach git as an argument.
301
+ export function commitId(x) {
302
+ return typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x) ? x : null;
303
+ }
304
+
180
305
  export function isGitRepo(root) {
181
306
  return git(root, 'rev-parse --is-inside-work-tree') === 'true';
182
307
  }
package/src/commands.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import readline from 'node:readline/promises';
4
5
  import {
5
6
  findRoot, paths, ensureDirs, readConfig, writeConfig, readState, writeState,
6
7
  readRules, listDocs, parseDoc, renderDoc, slug, stamp, today, git, isGitRepo, me,
8
+ readApprovals, isApproved, addApproval, printable, commitId,
7
9
  } from './brain.js';
8
10
  import { redact } from './signals.js';
9
11
  import { summarize } from './gemma.js';
@@ -71,9 +73,50 @@ function addGitignore(root) {
71
73
  }
72
74
  }
73
75
 
76
+ // ---------- approving the test command (this machine only) ----------
77
+
78
+ // Approval needs a terminal. An agent's shell tool has none, though an agent that fakes one
79
+ // can answer, so an approval made during a session is reported at the finish check.
80
+ const atTerminal = () => !!(process.stdin.isTTY && process.stdout.isTTY);
81
+
82
+ async function askApproval(root, command) {
83
+ const last = git(root, 'log -1 --format="%an on %ad (%h)" --date=short -- .codehere/config.json');
84
+ const uncommitted = git(root, 'status --porcelain -- .codehere/config.json');
85
+ const prev = readApprovals(root).at(-1);
86
+ console.log('When the agent says it is done, Codehere runs this command on this machine:');
87
+ console.log(` ${printable(command)}`);
88
+ if (last) console.log(`Last changed in git by ${printable(last)}.`); // a teammate picks their own author name
89
+ if (uncommitted) console.log('.codehere/config.json also has uncommitted changes on this machine.');
90
+ if (prev) console.log(`Last approved here: ${printable(prev.command)}`);
91
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
92
+ const answer = await rl.question('Approve it? [y/N] ');
93
+ rl.close();
94
+ if (!/^y(es)?$/i.test(answer.trim())) {
95
+ console.log('Not approved. The finish check will not run it.');
96
+ return;
97
+ }
98
+ if (!addApproval(root, command)) {
99
+ console.error('Not approved: HOME or XDG_CONFIG_HOME points inside this repo, where a teammate could write approvals.');
100
+ process.exit(1);
101
+ }
102
+ console.log('Approved on this machine.');
103
+ }
104
+
105
+ export async function approve() {
106
+ const root = requireRoot();
107
+ const command = readConfig(root).testCommand;
108
+ if (!command) return console.log('No test command set. Set one: codehere init --test "npm test"');
109
+ if (isApproved(root, command)) return console.log(`Already approved on this machine: ${printable(command)}`);
110
+ if (!atTerminal()) {
111
+ console.error('codehere approve asks you to confirm at a terminal. Run it in your own terminal.');
112
+ process.exit(1);
113
+ }
114
+ await askApproval(root, command);
115
+ }
116
+
74
117
  // ---------- init ----------
75
118
 
76
- export function init(flags) {
119
+ export async function init(flags) {
77
120
  const root = process.cwd();
78
121
  const p = ensureDirs(root);
79
122
  const cfg = readConfig(root);
@@ -112,8 +155,13 @@ export function init(flags) {
112
155
  console.log('Codehere is set up.');
113
156
  console.log(`- Team brain: ${path.relative(root, p.base)}/`);
114
157
  console.log(`- Claude Code hooks: ${path.relative(root, settingsFile)}`);
115
- console.log(`- Test check on "done": ${cfg.testCommand ? cfg.testCommand : 'none found (set one with: codehere init --test "npm test")'}`);
158
+ console.log(`- Test check on "done": ${cfg.testCommand ? printable(cfg.testCommand) : 'none found (set one with: codehere init --test "npm test")'}`);
116
159
  console.log('');
160
+ if (cfg.testCommand && !isApproved(root, cfg.testCommand)) {
161
+ if (atTerminal()) await askApproval(root, cfg.testCommand);
162
+ else console.log('The test command runs only after you approve it on this machine: codehere approve');
163
+ console.log('');
164
+ }
117
165
  console.log('Next: commit .codehere/ and .claude/settings.json so your team gets it too.');
118
166
  console.log('Then just use Claude Code as usual. Codehere stays quiet unless it helps.');
119
167
  }
@@ -212,7 +260,7 @@ function gitSummary(root, base) {
212
260
  return {
213
261
  branch: git(root, 'rev-parse --abbrev-ref HEAD'),
214
262
  head: git(root, 'log -1 --format="%h %s"'),
215
- stat: git(root, `diff ${base || 'HEAD'} --stat`).split('\n').slice(-12).join('\n'),
263
+ stat: git(root, ['diff', commitId(base) || 'HEAD', '--stat']).split('\n').slice(-12).join('\n'),
216
264
  };
217
265
  }
218
266
 
@@ -400,7 +448,8 @@ export function status() {
400
448
  console.log(`- Team rules: ${readRules(root).length}`);
401
449
  console.log(`- Team lessons: ${listDocs(p.lessons).length}`);
402
450
  console.log(`- Open help requests: ${listDocs(p.help).filter((d) => d.meta.status === 'open').length}`);
403
- console.log(`- Test check on "done": ${cfg.testCommand || 'off'}`);
451
+ const approval = isApproved(root, cfg.testCommand) ? 'approved on this machine' : 'not approved on this machine (codehere approve)';
452
+ console.log(`- Test check on "done": ${cfg.testCommand && cfg.verifyOnStop ? `${printable(cfg.testCommand)} · ${approval}` : 'off'}`);
404
453
  if (state.lastGreen) console.log(`- Last green: ${state.lastGreen.commit.slice(0, 8)} at ${state.lastGreen.at.slice(0, 16)}`);
405
454
  if (s) {
406
455
  const errs = repeatedErrors(s);
package/src/hooks.js CHANGED
@@ -2,13 +2,14 @@
2
2
  // it exits 0 and the agent carries on as if Codehere were not installed.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import crypto from 'node:crypto';
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import {
7
- findRoot, paths, readConfig, readState, writeState, session, readRules,
8
- listDocs, git, isGitRepo, me, slug, today,
8
+ findRoot, paths, readConfig, readState, writeState, session, readRules, renderDoc,
9
+ listDocs, git, isGitRepo, me, slug, today, isApproved, readApprovals, printable, commitId, statusFiles, gitHashes, noTick,
9
10
  } from './brain.js';
10
11
  import {
11
- responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature,
12
+ responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature, testCounts,
12
13
  } from './signals.js';
13
14
 
14
15
  function readStdin() {
@@ -59,6 +60,10 @@ function sessionStart(root, input) {
59
60
  // ever react to what the agent changes now.
60
61
  s.startHead = git(root, 'rev-parse HEAD') || null;
61
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);
62
67
  }
63
68
 
64
69
  const out = [];
@@ -68,11 +73,13 @@ function sessionStart(root, input) {
68
73
  rules.slice(0, 20).forEach((r) => out.push(`- ${r}`));
69
74
  }
70
75
 
71
- if (state.pendingBrief && fs.existsSync(state.pendingBrief)) {
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) {
72
79
  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;
80
+ out.push(fs.readFileSync(brief, 'utf8').slice(0, 4000));
75
81
  }
82
+ state.pendingBrief = null;
76
83
 
77
84
  const mine = me(root);
78
85
  const open = listDocs(paths(root).help).filter((d) => d.meta.status === 'open' && d.meta.author !== mine);
@@ -86,27 +93,37 @@ function sessionStart(root, input) {
86
93
  );
87
94
  }
88
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
+
89
105
  writeState(root, state);
90
106
  if (out.length) process.stdout.write(out.join('\n') + '\n');
91
107
  quiet();
92
108
  }
93
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
+
94
119
  // ---------- what changed in THIS session ----------
95
120
 
96
121
  const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/');
97
122
 
98
123
  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;
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']));
110
127
  }
111
128
 
112
129
  function changedThisSession(root, s) {
@@ -120,15 +137,47 @@ function changedThisSession(root, s) {
120
137
  if (now[f] !== before[f]) changed.add(f);
121
138
  }
122
139
  }
123
- if (s.startHead) {
124
- git(root, `diff ${s.startHead} HEAD --name-only`)
125
- .split('\n')
140
+ if (commitId(s.startHead)) {
141
+ git(root, ['diff', s.startHead, 'HEAD', '--name-only', '--relative', '-z'], true)
142
+ .split('\0')
126
143
  .filter((f) => f && !IGNORE(f))
127
144
  .forEach((f) => changed.add(f));
128
145
  }
129
146
  return [...changed];
130
147
  }
131
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
+
132
181
  // ---------- PostToolUse (Bash): loop breaker + just-in-time team lessons ----------
133
182
 
134
183
  // Commands that only read files. Their output may contain the word "error"
@@ -147,7 +196,7 @@ export function readOnly(command) {
147
196
  function matchLesson(root, sig, shown) {
148
197
  let best = null;
149
198
  for (const doc of listDocs(paths(root).lessons)) {
150
- if (shown.includes(doc.id)) continue;
199
+ if (shown.includes(doc.id) || doc.body.includes('- Fix: <one line>')) continue; // not filled in yet
151
200
  const score = Math.max(
152
201
  similarity(sig, doc.meta.signature || ''),
153
202
  similarity(sig, doc.meta.title || '') * 0.8
@@ -157,9 +206,89 @@ function matchLesson(root, sig, shown) {
157
206
  return best?.doc || null;
158
207
  }
159
208
 
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)}`;
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);
163
292
  }
164
293
 
165
294
  function postTool(root, input) {
@@ -203,7 +332,7 @@ function postTool(root, input) {
203
332
  '2. Check it with one small command before editing any file.',
204
333
  '3. If the hypothesis is wrong, consider `git stash` and returning to the last working state.',
205
334
  ];
206
- if (lesson) msg.push('', lessonText(lesson));
335
+ if (lesson) msg.push('', lessonText(root, lesson));
207
336
  if (count > threshold) {
208
337
  msg.push(
209
338
  '',
@@ -212,14 +341,17 @@ function postTool(root, input) {
212
341
  'or `codehere brief` to restart in a fresh session. Then wait for the user.'
213
342
  );
214
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) });
215
346
  toAgent(msg.join('\n') + '\n');
216
347
  }
217
348
 
218
349
  if (lesson) {
219
350
  emitJson({
351
+ systemMessage: lessonLine(lesson),
220
352
  hookSpecificOutput: {
221
353
  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.`,
354
+ additionalContext: lessonText(root, lesson),
223
355
  },
224
356
  });
225
357
  }
@@ -237,6 +369,7 @@ function promptSubmit(root, input) {
237
369
  const s = session(state, input.session_id);
238
370
  s.transcript = input.transcript_path || s.transcript;
239
371
  s.prompts = (s.prompts || 0) + 1;
372
+ if (!s.settings) s.settings = settingsSnapshot(root); // session start did not run for this session
240
373
 
241
374
  // Only a failure from the agent's last turn counts: an older one may be fixed or forgotten.
242
375
  const f = s.lastFailure && (s.lastFailure.prompt || 0) === s.prompts - 1 ? s.lastFailure : null;
@@ -282,8 +415,8 @@ function promptSubmit(root, input) {
282
415
 
283
416
  // Fast list for a prompt (no hashing): tracked files changed since the session started.
284
417
  function quickChanged(root, s) {
285
- return git(root, `diff --name-only ${s.startHead || 'HEAD'}`)
286
- .split('\n')
418
+ return git(root, ['diff', '--name-only', '--relative', '-z', commitId(s.startHead) || 'HEAD'], true)
419
+ .split('\0')
287
420
  .filter((x) => x && !IGNORE(x))
288
421
  .slice(0, 5);
289
422
  }
@@ -331,20 +464,30 @@ function recordGreen(state, s) {
331
464
  // ---------- Stop: verify "done" outside the agent ----------
332
465
 
333
466
  function runTests(root, cfg) {
467
+ const started = Date.now();
334
468
  const r = spawnSync(cfg.testCommand, {
335
469
  cwd: root,
336
470
  shell: true,
337
471
  encoding: 'utf8',
338
- timeout: cfg.testTimeoutSeconds * 1000,
472
+ timeout: Math.max(1, Number(cfg.testTimeoutSeconds) || 120) * 1000, // 0 would mean no limit
339
473
  env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
340
474
  });
341
475
  const output = `${r.stdout || ''}\n${r.stderr || ''}`.trim();
342
476
  const timedOut = r.error && r.error.code === 'ETIMEDOUT';
343
- return { ok: r.status === 0 && !timedOut, output, timedOut };
477
+ return { ok: r.status === 0 && !timedOut, output, timedOut, code: r.status, seconds: (Date.now() - started) / 1000 };
344
478
  }
345
479
 
346
- function block(reason) {
347
- emitJson({ decision: 'block', reason });
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 });
348
491
  }
349
492
 
350
493
  function stop(root, input) {
@@ -354,14 +497,34 @@ function stop(root, input) {
354
497
  const mayBlock = (s.stopBlocks || 0) < 3; // never trap the agent: after 3 blocks, only observe
355
498
 
356
499
  if (!isGitRepo(root)) quiet();
357
- const base = s.startHead || 'HEAD';
500
+ const base = commitId(s.startHead) || 'HEAD';
358
501
  const changed = changedThisSession(root, s);
359
- if (!changed.length) quiet(); // the agent changed nothing this session: stay silent
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
+ }
360
521
 
361
522
  // 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}`));
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
+ }
365
528
  const key = findings.join('|');
366
529
  if (findings.length && s.tamperReported !== key && mayBlock) {
367
530
  s.tamperReported = key;
@@ -377,13 +540,44 @@ function stop(root, input) {
377
540
  );
378
541
  }
379
542
 
380
- // 2. run the tests ourselves
381
- let verified = null;
382
- if (cfg.verifyOnStop && cfg.testCommand) {
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) {
383
576
  const t = runTests(root, cfg);
384
577
  if (!t.ok && !mayBlock) {
578
+ lines.push(`codehere ✕ ${cmd} · ${runResult(t)} · not sent back, limit of 3 reached`);
385
579
  writeState(root, state);
386
- quiet();
580
+ report();
387
581
  }
388
582
  if (!t.ok) {
389
583
  s.stopBlocks++;
@@ -391,52 +585,50 @@ function stop(root, input) {
391
585
  const tail = t.output.split('\n').slice(-25).join('\n');
392
586
  block(
393
587
  [
394
- `Codehere ran \`${cfg.testCommand}\` and it ${t.timedOut ? 'timed out' : 'failed'}. The task is not done yet.`,
588
+ `Codehere ran \`${cmd}\` and it ${t.timedOut ? 'timed out' : 'failed'}. The task is not done yet.`,
395
589
  'Last lines of output:',
396
590
  tail,
397
591
  'Fix the cause, then finish. Do not change or skip tests to make them pass.',
398
- ].join('\n')
592
+ ].join('\n'),
593
+ (lines.push(`codehere ✕ ${cmd} · ${runResult(t)} · agent sent back · ${s.stopBlocks} of 3`), message())
399
594
  );
400
595
  }
401
- verified = cfg.testCommand;
402
- if (!findings.length) {
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) {
403
603
  state.lastGreen = { commit: git(root, 'rev-parse HEAD'), at: new Date().toISOString() };
404
604
  recordGreen(state, s);
405
605
  }
606
+ lines.unshift(passLine);
406
607
  }
407
608
 
408
- // 3. turn a solved repeated error into a team lesson (asked once per session)
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.
409
611
  const solved = Object.entries(s.errors).filter(([, n]) => n >= 2);
410
- if (solved.length && !s.lessonAsked && mayBlock) {
612
+ if (solved.length && !s.lessonAsked) {
411
613
  s.lessonAsked = true;
412
- s.stopBlocks++;
413
- writeState(root, state);
414
614
  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
- );
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`);
433
630
  }
434
631
 
435
632
  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();
633
+ report();
442
634
  }
package/src/signals.js CHANGED
@@ -184,3 +184,42 @@ export function redact(text) {
184
184
  );
185
185
  return out;
186
186
  }
187
+
188
+ // Passed and failed counts from a test runner's own summary line, or null. Exactly one
189
+ // summary must be found: with two runners in one command, or a runner that prints one per
190
+ // package, a count would cover only part of the run, so the finish line says "exit 0".
191
+ const num = (s, re) => {
192
+ const m = s.match(re);
193
+ return m ? Number(m[1]) : null;
194
+ };
195
+ const counts = (passed, failed) => (passed == null && failed == null ? null : { passed: passed || 0, failed: failed || 0 });
196
+ const SUMMARIES = [
197
+ // node --test, spec and TAP reporters: "ℹ tests 3", "ℹ pass 2", "ℹ fail 1"
198
+ { re: /^(?:ℹ|#) tests \d+$/gm, read: (t) => counts(num(t, /^(?:ℹ|#) pass (\d+)$/m), num(t, /^(?:ℹ|#) fail (\d+)$/m)) },
199
+ // jest "Tests: 2 failed, 46 passed, 48 total", vitest " Tests 2 failed | 46 passed (48)"
200
+ { re: /^\s*Tests:?\s+.*\d+ (?:passed|failed)\b.*$/gm, read: (t, line) => counts(num(line, /(\d+) passed/), num(line, /(\d+) failed/)) },
201
+ // pytest "===== 1 failed, 1 passed in 0.02s ====="
202
+ {
203
+ re: /^[=\s]*(?:\d+ \w+(?:, )?)+ in [\d.]+s\b.*$/gm,
204
+ read: (t, line) => counts(num(line, /(\d+) passed/), (num(line, /(\d+) failed/) || 0) + (num(line, /(\d+) errors?/) || 0)),
205
+ },
206
+ // mocha "46 passing" and "2 failing"
207
+ { re: /^\s*\d+ passing\b.*$/gm, read: (t) => counts(num(t, /^\s*(\d+) passing\b/m), num(t, /^\s*(\d+) failing\b/m)) },
208
+ // python unittest "Ran 5 tests in 0.001s", then "OK" or "FAILED (failures=2, errors=1)"
209
+ {
210
+ re: /^Ran \d+ tests? in [\d.]+s$/gm,
211
+ read: (t) => {
212
+ if (/expected failures|unexpected successes/.test(t)) return null; // neither passed nor failed
213
+ const failed = (num(t, /failures=(\d+)/) || 0) + (num(t, /errors=(\d+)/) || 0);
214
+ return counts(num(t, /^Ran (\d+) tests?/m) - failed - (num(t, /skipped=(\d+)/) || 0), failed);
215
+ },
216
+ },
217
+ // cargo "test result: ok. 4 passed; 0 failed", one line per test binary
218
+ { re: /^test result: \w+\. \d+ passed; \d+ failed\b.*$/gm, read: (t, line) => counts(num(line, /(\d+) passed/), num(line, /(\d+) failed/)) },
219
+ ];
220
+
221
+ export function testCounts(output) {
222
+ const t = stripAnsi(output);
223
+ const found = SUMMARIES.flatMap((s) => [...t.matchAll(s.re)].map((m) => ({ s, line: m[0] })));
224
+ return found.length === 1 ? found[0].s.read(t, found[0].line) : null;
225
+ }