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 +22 -5
- package/bin/codehere.js +3 -0
- package/package.json +1 -1
- package/src/brain.js +134 -9
- package/src/commands.js +53 -4
- package/src/hooks.js +265 -73
- package/src/signals.js +39 -0
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
125
|
-
.split('\
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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:
|
|
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,
|
|
286
|
-
.split('\
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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 \`${
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
'
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
+
}
|