codehere 2.0.0-beta.0 → 2.0.0-beta.2
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 +54 -10
- package/bin/codehere.js +14 -5
- package/package.json +1 -1
- package/src/brain.js +165 -9
- package/src/commands.js +247 -36
- package/src/hooks.js +294 -81
- package/src/signals.js +39 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**A team brain for vibe coders.** It stops your AI coding agent from going in circles, checks "done" for real, and shares what your team learns. It works through plain files in git.
|
|
4
4
|
|
|
5
|
-
Codehere is built to remove work, not add it. You keep using Claude Code as usual. Codehere talks to the agent, not to you, and stays silent unless it helps.
|
|
5
|
+
Codehere is built to remove work, not add it. You keep using Claude Code or Codex CLI as usual. Codehere talks to the agent, not to you, and stays silent unless it helps.
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -23,12 +23,20 @@ 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
|
|
27
|
-
git add .codehere .claude/settings.json .gitignore
|
|
26
|
+
codehere init --agent claude,codex # finds your test command, adds the hooks, asks you to approve the command
|
|
27
|
+
git add .codehere .claude/settings.json .codex/hooks.json .gitignore
|
|
28
28
|
git commit -m "Add Codehere" && git push
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Use `--agent claude` or `--agent codex` for one agent; with no `--agent`, `init` sets up Claude Code. The hooks go in `.claude/settings.json` and `.codex/hooks.json`.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
Codex runs a repo's hooks only after each developer trusts them. Open Codex in the repo, trust the project if asked, then type `/hooks` and trust the four Codehere hooks. Do it once per clone, and again after an update that changes the hooks. Until then Codex skips them, and `codex exec` does not warn.
|
|
36
|
+
|
|
37
|
+
`codehere uninstall` removes the hooks, and `.codehere/` too when it holds no rules, lessons, help cards or briefs.
|
|
38
|
+
|
|
39
|
+
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
40
|
|
|
33
41
|
## The team loop
|
|
34
42
|
|
|
@@ -45,14 +53,44 @@ codehere reply <id> "Use price * qty; same bug was in checkout.js" --solved
|
|
|
45
53
|
git add .codehere && git commit -m "answer" && git push
|
|
46
54
|
```
|
|
47
55
|
|
|
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
|
|
56
|
+
`--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.
|
|
57
|
+
|
|
58
|
+
You see one line per event:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
codehere › Ana's lesson given to the agent · verified on Ana's machine · 21 Oct
|
|
62
|
+
codehere ✕ npm test · 2 failed · agent sent back · 1 of 3
|
|
63
|
+
codehere ✓ npm test · 48 passed · re-run here · 6.2 s
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
✓ 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
67
|
|
|
50
68
|
Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or Slack instead.
|
|
51
69
|
|
|
70
|
+
## Which agents
|
|
71
|
+
|
|
72
|
+
| | Claude Code | Codex CLI |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| Team rules at session start | yes | yes |
|
|
75
|
+
| A teammate's lesson after a failed command | yes | yes |
|
|
76
|
+
| "Same error 3 times: stop and find the root cause" | yes | yes, given next to the error output |
|
|
77
|
+
| Context added to a short prompt such as "still broken" | yes | yes |
|
|
78
|
+
| Finish check: your tests re-run, the agent sent back | yes | yes |
|
|
79
|
+
| Your one line (✓ ✕ ⊘ ›) | yes | in the interactive screen, as `↳ Hook · codehere ✓ …`; not in `codex exec` |
|
|
80
|
+
| Restart-brief suggestion in long sessions | yes | no |
|
|
81
|
+
| Before it works | nothing | each developer trusts the hooks in `/hooks` |
|
|
82
|
+
| MCP tools, instruction file | no | no |
|
|
83
|
+
| Tested on | 2.1.311 with haiku: [result](docs/conformance/2026-10-27-claude-2.1.311-haiku.md) | 0.164.0 with a local model (Qwen 3.5 9B through Ollama): [result](docs/conformance/2026-10-27-codex-0.164.0-qwen3.5-9b-q4-K-M.md) |
|
|
84
|
+
|
|
85
|
+
The rows for rules, lessons and the finish check are checked on the real agent by `node scripts/conformance.mjs`. The other rows are checked by `npm test` on hook inputs recorded from each agent.
|
|
86
|
+
|
|
52
87
|
## Commands
|
|
53
88
|
|
|
54
89
|
```
|
|
55
|
-
codehere init [--test "npm test"]
|
|
90
|
+
codehere init [--agent claude,codex] [--test "npm test"]
|
|
91
|
+
set up (safe to run again)
|
|
92
|
+
codehere approve let the finish check run the test command on this machine
|
|
93
|
+
codehere uninstall remove Codehere from this repo (asks first)
|
|
56
94
|
codehere rule ["text"] add or list team rules
|
|
57
95
|
codehere lesson ["problem" --fix "..."] add or list lessons
|
|
58
96
|
codehere brief [--gemma] restart brief for a fresh session
|
|
@@ -73,7 +111,7 @@ codehere stats turns to green, loops stopped, context s
|
|
|
73
111
|
help/ help cards
|
|
74
112
|
briefs/ restart briefs
|
|
75
113
|
config.json test command, loop threshold
|
|
76
|
-
local/ this machine only, git-ignored
|
|
114
|
+
local/ this machine only, git-ignored (session state)
|
|
77
115
|
```
|
|
78
116
|
|
|
79
117
|
Everything is plain markdown, so you can read, edit, review and delete it like any other file.
|
|
@@ -97,12 +135,18 @@ node demo/demo.mjs --step # press Enter between scenes (good for a live talk)
|
|
|
97
135
|
npm test # end-to-end checks
|
|
98
136
|
```
|
|
99
137
|
|
|
100
|
-
## Known limits (2.0
|
|
138
|
+
## Known limits (2.0 beta)
|
|
101
139
|
|
|
102
|
-
- Claude Code
|
|
103
|
-
- Lessons match by error text. Very generic errors can bring up a lesson that doesn't fit, which is why the agent
|
|
140
|
+
- Claude Code and Codex CLI. Copilot and OpenCode are next. See [Which agents](#which-agents) for what works on each.
|
|
141
|
+
- 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
142
|
- Redaction catches common keys, tokens and emails. Always read a help card before sharing it.
|
|
105
143
|
- The "done" check needs a test command. Without one, only the tampering check runs.
|
|
144
|
+
- Approvals are per folder. Each git worktree or extra clone needs its own `codehere approve`.
|
|
145
|
+
- In `codex exec`, Codex shows you none of Codehere's lines. The agent still gets every message, and the finish check still sends it back.
|
|
146
|
+
- `codehere approve` and `codehere uninstall` ask only in your own terminal. They refuse when Claude Code or Codex is among the processes above them, and inside Codex's sandbox. An agent that detaches its terminal on purpose can still answer, so the finish check of a session that was open tells you when an approval or an uninstall happened.
|
|
147
|
+
- 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`; Codex asks each developer to trust a new or changed hook first. Both are your repo's code, with the same trust as running it yourself.
|
|
148
|
+
- When the agent changes `.codehere/config.json`, `.codehere/rules.md`, the hook and env settings in `.claude/settings*.json`, or `.codex/hooks.json` or `.codex/config.toml` 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.
|
|
149
|
+
- 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
150
|
- Sharing is through git. There is no server, so teammates see updates after `git pull`.
|
|
107
151
|
- Prompt checks are plain word lists (English and Indonesian). They can miss a short prompt in another language.
|
|
108
152
|
- 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
|
@@ -8,7 +8,9 @@ const flags = {};
|
|
|
8
8
|
const pos = [];
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const a = argv[i];
|
|
11
|
-
if (a.startsWith('--')) {
|
|
11
|
+
if (a.startsWith('--') && a.indexOf('=') > 2) {
|
|
12
|
+
flags[a.slice(2, a.indexOf('='))] = a.slice(a.indexOf('=') + 1);
|
|
13
|
+
} else if (a.startsWith('--')) {
|
|
12
14
|
const key = a.slice(2);
|
|
13
15
|
const next = argv[i + 1];
|
|
14
16
|
if (!BOOLEAN.has(key) && next !== undefined && !next.startsWith('--')) {
|
|
@@ -23,7 +25,10 @@ const [command, ...rest] = pos;
|
|
|
23
25
|
const USAGE = `codehere · a git-native team brain for vibe coders
|
|
24
26
|
|
|
25
27
|
Setup
|
|
26
|
-
codehere init [--test "npm test"]
|
|
28
|
+
codehere init [--agent claude,codex] [--test "npm test"]
|
|
29
|
+
set up .codehere/ and the agents' hooks (default: Claude Code)
|
|
30
|
+
codehere approve let the finish check run the test command on this machine
|
|
31
|
+
codehere uninstall remove Codehere's hooks, and .codehere/ if it holds no team content
|
|
27
32
|
|
|
28
33
|
Team brain
|
|
29
34
|
codehere rule ["text"] add a team rule (or list rules)
|
|
@@ -42,16 +47,20 @@ When you're stuck
|
|
|
42
47
|
codehere status what Codehere has seen
|
|
43
48
|
codehere stats turns to green, loops stopped, session size
|
|
44
49
|
|
|
45
|
-
Hooks (called by
|
|
46
|
-
codehere hook session-start | prompt | post-tool | stop
|
|
50
|
+
Hooks (called by the agent, not by you)
|
|
51
|
+
codehere hook session-start | prompt | post-tool | stop [--agent codex]
|
|
47
52
|
`;
|
|
48
53
|
|
|
49
54
|
const run = async () => {
|
|
50
55
|
switch (command) {
|
|
51
56
|
case 'hook':
|
|
52
|
-
return runHook(rest[0]);
|
|
57
|
+
return runHook(rest[0], flags.agent);
|
|
53
58
|
case 'init':
|
|
54
59
|
return cmd.init(flags);
|
|
60
|
+
case 'approve':
|
|
61
|
+
return cmd.approve();
|
|
62
|
+
case 'uninstall':
|
|
63
|
+
return cmd.uninstall();
|
|
55
64
|
case 'rule':
|
|
56
65
|
case 'rules':
|
|
57
66
|
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.2",
|
|
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,172 @@ 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
|
+
export function removeApprovals(root) {
|
|
231
|
+
const file = approvalsFile(root);
|
|
232
|
+
const all = readAllApprovals(root);
|
|
233
|
+
if (!file || !(repoKey(root) in all.repos)) return;
|
|
234
|
+
delete all.repos[repoKey(root)];
|
|
235
|
+
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// uninstall leaves a note here, outside the repo. A session that was open when it ran still has
|
|
239
|
+
// its hooks loaded, but its state and maybe .codehere/ are gone: its finish check reads the note.
|
|
240
|
+
export function markRemoved(root, removed = true) {
|
|
241
|
+
const file = approvalsFile(root);
|
|
242
|
+
const all = readAllApprovals(root);
|
|
243
|
+
const notes = all.removed && typeof all.removed === 'object' ? all.removed : {};
|
|
244
|
+
if (!file || (!removed && !(repoKey(root) in notes))) return;
|
|
245
|
+
if (removed) notes[repoKey(root)] = new Date().toISOString();
|
|
246
|
+
else delete notes[repoKey(root)];
|
|
247
|
+
all.removed = notes;
|
|
248
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
249
|
+
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// When uninstall ran for dir or a folder above it, else null.
|
|
253
|
+
export function removedAt(dir) {
|
|
254
|
+
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
255
|
+
const at = readAllApprovals(d).removed?.[repoKey(d)];
|
|
256
|
+
if (typeof at === 'string') return at;
|
|
257
|
+
if (path.dirname(d) === d) return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Control, format (bidi, zero-width), filler and variation characters escaped, so text from the repo cannot print a fake
|
|
262
|
+
// line, move the cursor or hide part of a command.
|
|
263
|
+
const ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
|
264
|
+
export function printable(text) {
|
|
265
|
+
return String(text).replace(
|
|
266
|
+
/[\p{Cc}\p{Cf}\u034f\u115f\u1160\u2028\u2029\u3164\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu,
|
|
267
|
+
(c) => {
|
|
268
|
+
const n = c.codePointAt(0).toString(16);
|
|
269
|
+
return ESCAPES[c] || (n.length > 4 ? `\\u{${n}}` : `\\u${n.padStart(4, '0')}`);
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
168
274
|
// ---------- git helpers (all fail-safe) ----------
|
|
169
275
|
|
|
170
|
-
|
|
276
|
+
// Pass an array whenever an argument comes from the repo or the state file: it never
|
|
277
|
+
// goes through a shell, so a file name such as a$(cmd).js stays a file name.
|
|
278
|
+
// raw keeps the output as git wrote it: -z lists, and porcelain lines whose first character is a space.
|
|
279
|
+
export function git(root, args, raw = false) {
|
|
171
280
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
281
|
+
const opts = { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] };
|
|
282
|
+
const out = (Array.isArray(args) ? execFileSync('git', ['--literal-pathspecs', ...args], opts) : execSync(`git ${args}`, opts)).toString();
|
|
283
|
+
return raw ? out : out.trim();
|
|
175
284
|
} catch {
|
|
176
285
|
return '';
|
|
177
286
|
}
|
|
178
287
|
}
|
|
179
288
|
|
|
289
|
+
// Files with uncommitted changes, relative to root, which may be a subfolder of the repo.
|
|
290
|
+
export function statusFiles(root) {
|
|
291
|
+
const prefix = git(root, ['rev-parse', '--show-prefix']);
|
|
292
|
+
const entries = git(root, ['status', '--porcelain', '-z', '--untracked-files=all', '--', '.'], true).split('\0');
|
|
293
|
+
const files = [];
|
|
294
|
+
for (let i = 0; i < entries.length; i++) {
|
|
295
|
+
const e = entries[i];
|
|
296
|
+
if (e.length < 4) continue;
|
|
297
|
+
if (e.slice(3).startsWith(prefix)) files.push(e.slice(3 + prefix.length));
|
|
298
|
+
if (e[0] === 'R' || e[0] === 'C') i++; // the old name of a rename or copy
|
|
299
|
+
}
|
|
300
|
+
return files;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// What `git hash-object` gives for each file (git's filters applied), 'deleted' for a missing
|
|
304
|
+
// file, null when git cannot say. One git call for all of them; absolute paths, because
|
|
305
|
+
// --stdin-paths ignores the subfolder git was started in.
|
|
306
|
+
export function gitHashes(root, files) {
|
|
307
|
+
const present = files.filter((f) => !f.includes('\n') && fs.existsSync(path.join(root, f)));
|
|
308
|
+
let out = [];
|
|
309
|
+
try {
|
|
310
|
+
if (present.length) {
|
|
311
|
+
out = execFileSync('git', ['hash-object', '--stdin-paths'], { cwd: root, input: present.map((f) => path.resolve(root, f)).join('\n') + '\n', stdio: ['pipe', 'pipe', 'ignore'] })
|
|
312
|
+
.toString()
|
|
313
|
+
.trim()
|
|
314
|
+
.split('\n');
|
|
315
|
+
}
|
|
316
|
+
} catch {}
|
|
317
|
+
return files.map((f) => {
|
|
318
|
+
const i = present.indexOf(f);
|
|
319
|
+
if (i >= 0) return out[i] || null;
|
|
320
|
+
return f.includes('\n') ? null : 'deleted';
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A tick is Codehere's mark for a run it made on this machine (DESIGN.md §1). Text from the
|
|
325
|
+
// repo or from a teammate never brings its own.
|
|
326
|
+
export function noTick(text) {
|
|
327
|
+
return String(text).replace(/[✓✔☑✅]/g, '?');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A commit id read back from state.json, or null. The state file can arrive through git,
|
|
331
|
+
// so anything else (such as --output=<file>) must never reach git as an argument.
|
|
332
|
+
export function commitId(x) {
|
|
333
|
+
return typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x) ? x : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
180
336
|
export function isGitRepo(root) {
|
|
181
337
|
return git(root, 'rev-parse --is-inside-work-tree') === 'true';
|
|
182
338
|
}
|
package/src/commands.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
4
6
|
import {
|
|
5
7
|
findRoot, paths, ensureDirs, readConfig, writeConfig, readState, writeState,
|
|
6
8
|
readRules, listDocs, parseDoc, renderDoc, slug, stamp, today, git, isGitRepo, me,
|
|
9
|
+
readApprovals, isApproved, addApproval, removeApprovals, markRemoved, printable, commitId,
|
|
7
10
|
} from './brain.js';
|
|
8
11
|
import { redact } from './signals.js';
|
|
9
12
|
import { summarize } from './gemma.js';
|
|
@@ -17,6 +20,21 @@ const HOOK_EVENTS = {
|
|
|
17
20
|
Stop: { cmd: 'codehere hook stop', timeout: 180 },
|
|
18
21
|
};
|
|
19
22
|
|
|
23
|
+
// Keep these strings stable: Codex asks every developer to trust a hook again when its definition changes.
|
|
24
|
+
const CODEX_HOOK_EVENTS = {
|
|
25
|
+
SessionStart: { cmd: 'codehere hook session-start --agent codex' },
|
|
26
|
+
UserPromptSubmit: { cmd: 'codehere hook prompt --agent codex', timeout: 10 },
|
|
27
|
+
PostToolUse: { cmd: 'codehere hook post-tool --agent codex', matcher: 'Bash' },
|
|
28
|
+
Stop: { cmd: 'codehere hook stop --agent codex', timeout: 180 },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const AGENTS = {
|
|
32
|
+
claude: { name: 'Claude Code', file: '.claude/settings.json', events: HOOK_EVENTS },
|
|
33
|
+
codex: { name: 'Codex CLI', file: '.codex/hooks.json', events: CODEX_HOOK_EVENTS },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ours = (h) => typeof h?.command === 'string' && h.command.startsWith('codehere hook ');
|
|
37
|
+
|
|
20
38
|
function requireRoot() {
|
|
21
39
|
const root = findRoot();
|
|
22
40
|
if (!root) {
|
|
@@ -39,18 +57,18 @@ function detectTestCommand(root) {
|
|
|
39
57
|
return null;
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const file = path.join(
|
|
45
|
-
fs.mkdirSync(
|
|
60
|
+
// Claude Code's .claude/settings.json and Codex's .codex/hooks.json share this shape.
|
|
61
|
+
function mergeHooks(root, agent) {
|
|
62
|
+
const file = path.join(root, agent.file);
|
|
63
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
46
64
|
let settings = {};
|
|
47
65
|
try {
|
|
48
66
|
settings = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
49
67
|
} catch {}
|
|
50
68
|
settings.hooks = settings.hooks || {};
|
|
51
|
-
for (const [event, spec] of Object.entries(
|
|
69
|
+
for (const [event, spec] of Object.entries(agent.events)) {
|
|
52
70
|
const list = (settings.hooks[event] = settings.hooks[event] || []);
|
|
53
|
-
const already = list.some((g) => (g
|
|
71
|
+
const already = list.some((g) => (g?.hooks || []).some((h) => h?.command === spec.cmd));
|
|
54
72
|
if (already) continue;
|
|
55
73
|
const hook = { type: 'command', command: spec.cmd };
|
|
56
74
|
if (spec.timeout) hook.timeout = spec.timeout;
|
|
@@ -59,7 +77,55 @@ function mergeClaudeSettings(root) {
|
|
|
59
77
|
list.push(group);
|
|
60
78
|
}
|
|
61
79
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
62
|
-
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Takes Codehere's hooks out of the agent's settings file, keeping everything else.
|
|
83
|
+
// A file left with nothing in it is removed, and its folder if that is then empty.
|
|
84
|
+
function removeHooks(root, agent) {
|
|
85
|
+
const file = path.join(root, agent.file);
|
|
86
|
+
let settings;
|
|
87
|
+
try {
|
|
88
|
+
settings = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
|
|
93
|
+
let found = false;
|
|
94
|
+
for (const event of Object.keys(hooks)) {
|
|
95
|
+
if (!Array.isArray(hooks[event])) continue;
|
|
96
|
+
hooks[event] = hooks[event]
|
|
97
|
+
.filter((g) => g && typeof g === 'object')
|
|
98
|
+
.map((g) => {
|
|
99
|
+
const all = Array.isArray(g.hooks) ? g.hooks : [];
|
|
100
|
+
const kept = all.filter((h) => !ours(h));
|
|
101
|
+
if (kept.length !== all.length) found = true;
|
|
102
|
+
return { ...g, hooks: kept };
|
|
103
|
+
})
|
|
104
|
+
.filter((g) => g.hooks.length);
|
|
105
|
+
if (!hooks[event].length) delete hooks[event];
|
|
106
|
+
}
|
|
107
|
+
if (!found) return false;
|
|
108
|
+
if (!Object.keys(hooks).length) delete settings.hooks;
|
|
109
|
+
if (Object.keys(settings).length) {
|
|
110
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
111
|
+
} else {
|
|
112
|
+
fs.rmSync(file);
|
|
113
|
+
try {
|
|
114
|
+
fs.rmdirSync(path.dirname(file));
|
|
115
|
+
} catch {} // not empty: something else lives there
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function installedAgents(root) {
|
|
121
|
+
return Object.values(AGENTS).filter((a) => {
|
|
122
|
+
try {
|
|
123
|
+
const hooks = JSON.parse(fs.readFileSync(path.join(root, a.file), 'utf8')).hooks || {};
|
|
124
|
+
return Object.values(hooks).some((gs) => Array.isArray(gs) && gs.some((g) => Array.isArray(g?.hooks) && g.hooks.some(ours)));
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
63
129
|
}
|
|
64
130
|
|
|
65
131
|
function addGitignore(root) {
|
|
@@ -71,9 +137,90 @@ function addGitignore(root) {
|
|
|
71
137
|
}
|
|
72
138
|
}
|
|
73
139
|
|
|
140
|
+
// ---------- approving the test command (this machine only) ----------
|
|
141
|
+
|
|
142
|
+
// Approving and uninstalling need the developer's own terminal: a terminal on stdin and stdout,
|
|
143
|
+
// and no agent among the processes above this one. An agent's commands run under its process,
|
|
144
|
+
// even in a terminal it opens (Codex can). Inside Codex's sandbox ps is not allowed, so the
|
|
145
|
+
// parents cannot be read, which counts as under an agent. An agent that detaches its terminal
|
|
146
|
+
// on purpose can still answer, so the finish check reports an approval or an uninstall.
|
|
147
|
+
const AGENT_PROCESSES = new Set(['claude', 'codex', 'copilot', 'opencode']);
|
|
148
|
+
const PS = fs.existsSync('/bin/ps') ? '/bin/ps' : '/usr/bin/ps';
|
|
149
|
+
function underAgent() {
|
|
150
|
+
let pid = process.ppid;
|
|
151
|
+
for (let i = 0; i < 100 && pid > 1; i++) {
|
|
152
|
+
let line;
|
|
153
|
+
try {
|
|
154
|
+
line = execFileSync(PS, ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
155
|
+
} catch {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const m = /^(\d+)\s+(.+)$/.exec(line);
|
|
159
|
+
if (!m || AGENT_PROCESSES.has(path.basename(m[2]).toLowerCase())) return true;
|
|
160
|
+
pid = Number(m[1]);
|
|
161
|
+
}
|
|
162
|
+
return pid > 1;
|
|
163
|
+
}
|
|
164
|
+
const atTerminal = () => !!(process.stdin.isTTY && process.stdout.isTTY) && !underAgent();
|
|
165
|
+
|
|
166
|
+
async function askApproval(root, command) {
|
|
167
|
+
const last = git(root, 'log -1 --format="%an on %ad (%h)" --date=short -- .codehere/config.json');
|
|
168
|
+
const uncommitted = git(root, 'status --porcelain -- .codehere/config.json');
|
|
169
|
+
const prev = readApprovals(root).at(-1);
|
|
170
|
+
console.log('When the agent says it is done, Codehere runs this command on this machine:');
|
|
171
|
+
console.log(` ${printable(command)}`);
|
|
172
|
+
if (last) console.log(`Last changed in git by ${printable(last)}.`); // a teammate picks their own author name
|
|
173
|
+
if (uncommitted) console.log('.codehere/config.json also has uncommitted changes on this machine.');
|
|
174
|
+
if (prev) console.log(`Last approved here: ${printable(prev.command)}`);
|
|
175
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
176
|
+
const answer = await rl.question('Approve it? [y/N] ');
|
|
177
|
+
rl.close();
|
|
178
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
179
|
+
console.log('Not approved. The finish check will not run it.');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!addApproval(root, command)) {
|
|
183
|
+
console.error('Not approved: HOME or XDG_CONFIG_HOME points inside this repo, where a teammate could write approvals.');
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
console.log('Approved on this machine.');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function approve() {
|
|
190
|
+
const root = requireRoot();
|
|
191
|
+
const command = readConfig(root).testCommand;
|
|
192
|
+
if (!command) return console.log('No test command set. Set one: codehere init --test "npm test"');
|
|
193
|
+
if (isApproved(root, command)) return console.log(`Already approved on this machine: ${printable(command)}`);
|
|
194
|
+
if (!atTerminal()) {
|
|
195
|
+
console.error('codehere approve asks you to confirm in your own terminal, not through an agent. Run it there.');
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
await askApproval(root, command);
|
|
199
|
+
}
|
|
200
|
+
|
|
74
201
|
// ---------- init ----------
|
|
75
202
|
|
|
76
|
-
|
|
203
|
+
const RULES_MD = '# Team rules\n\nOne rule per line, starting with "- ". Written by people, not by the agent.\nEvery agent session on this repo loads these.\n\n';
|
|
204
|
+
const README_MD = [
|
|
205
|
+
'# .codehere — this team\'s brain',
|
|
206
|
+
'',
|
|
207
|
+
'Plain markdown, shared through git. Commit and push it like code.',
|
|
208
|
+
'',
|
|
209
|
+
'- `rules.md`: team rules every agent session loads',
|
|
210
|
+
'- `lessons/`: errors someone already solved; shown to the agent only when the same error appears',
|
|
211
|
+
'- `help/`: help cards; teammates see them with `codehere inbox`',
|
|
212
|
+
'- `briefs/`: restart briefs for fresh sessions',
|
|
213
|
+
'- `local/`: this machine only (git-ignored)',
|
|
214
|
+
'',
|
|
215
|
+
].join('\n');
|
|
216
|
+
|
|
217
|
+
export async function init(flags) {
|
|
218
|
+
const names = flags.agent === undefined ? ['claude'] : String(flags.agent === true ? '' : flags.agent).split(',').map((x) => x.trim().toLowerCase());
|
|
219
|
+
if (!names.every((n) => Object.hasOwn(AGENTS, n))) {
|
|
220
|
+
console.error(`Unknown agent "${printable(flags.agent === true ? '' : flags.agent)}". Use --agent claude, --agent codex or --agent claude,codex.`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
const agents = [...new Set(names)].map((n) => AGENTS[n]);
|
|
77
224
|
const root = process.cwd();
|
|
78
225
|
const p = ensureDirs(root);
|
|
79
226
|
const cfg = readConfig(root);
|
|
@@ -81,41 +228,103 @@ export function init(flags) {
|
|
|
81
228
|
else if (!cfg.testCommand) cfg.testCommand = detectTestCommand(root);
|
|
82
229
|
writeConfig(root, cfg);
|
|
83
230
|
|
|
84
|
-
if (!fs.existsSync(p.rules))
|
|
85
|
-
|
|
86
|
-
p.rules,
|
|
87
|
-
'# Team rules\n\nOne rule per line, starting with "- ". Written by people, not by the agent.\nEvery agent session on this repo loads these.\n\n'
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
fs.writeFileSync(
|
|
91
|
-
path.join(p.base, 'README.md'),
|
|
92
|
-
[
|
|
93
|
-
'# .codehere — this team\'s brain',
|
|
94
|
-
'',
|
|
95
|
-
'Plain markdown, shared through git. Commit and push it like code.',
|
|
96
|
-
'',
|
|
97
|
-
'- `rules.md`: team rules every agent session loads',
|
|
98
|
-
'- `lessons/`: errors someone already solved; shown to the agent only when the same error appears',
|
|
99
|
-
'- `help/`: help cards; teammates see them with `codehere inbox`',
|
|
100
|
-
'- `briefs/`: restart briefs for fresh sessions',
|
|
101
|
-
'- `local/`: this machine only (git-ignored)',
|
|
102
|
-
'',
|
|
103
|
-
].join('\n')
|
|
104
|
-
);
|
|
231
|
+
if (!fs.existsSync(p.rules)) fs.writeFileSync(p.rules, RULES_MD);
|
|
232
|
+
fs.writeFileSync(path.join(p.base, 'README.md'), README_MD);
|
|
105
233
|
for (const d of [p.lessons, p.help, p.briefs]) {
|
|
106
234
|
const keep = path.join(d, '.gitkeep');
|
|
107
235
|
if (!fs.existsSync(keep)) fs.writeFileSync(keep, '');
|
|
108
236
|
}
|
|
109
237
|
addGitignore(root);
|
|
110
|
-
const
|
|
238
|
+
for (const a of agents) mergeHooks(root, a);
|
|
239
|
+
markRemoved(root, false);
|
|
111
240
|
|
|
112
241
|
console.log('Codehere is set up.');
|
|
113
242
|
console.log(`- Team brain: ${path.relative(root, p.base)}/`);
|
|
114
|
-
console.log(`-
|
|
115
|
-
console.log(`- Test check on "done": ${cfg.testCommand ? cfg.testCommand : 'none found (set one with: codehere init --test "npm test")'}`);
|
|
243
|
+
for (const a of agents) console.log(`- ${a.name} hooks: ${a.file}`);
|
|
244
|
+
console.log(`- Test check on "done": ${cfg.testCommand ? printable(cfg.testCommand) : 'none found (set one with: codehere init --test "npm test")'}`);
|
|
116
245
|
console.log('');
|
|
117
|
-
|
|
118
|
-
|
|
246
|
+
if (cfg.testCommand && !isApproved(root, cfg.testCommand)) {
|
|
247
|
+
if (atTerminal()) await askApproval(root, cfg.testCommand);
|
|
248
|
+
else console.log('The test command runs only after you approve it on this machine: codehere approve');
|
|
249
|
+
console.log('');
|
|
250
|
+
}
|
|
251
|
+
if (agents.includes(AGENTS.codex)) {
|
|
252
|
+
console.log("Codex runs a repo's hooks only after each developer trusts them. Open Codex here, trust the project if asked,");
|
|
253
|
+
console.log('then type /hooks and trust the four Codehere hooks. Until then Codex skips them, and `codex exec` does not warn.');
|
|
254
|
+
console.log('');
|
|
255
|
+
}
|
|
256
|
+
console.log(`Next: commit .codehere/ and ${agents.map((a) => a.file).join(' and ')} so your team gets it too.`);
|
|
257
|
+
console.log(`Then just use ${agents.map((a) => a.name).join(' or ')} as usual. Codehere stays quiet unless it helps.`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------- uninstall ----------
|
|
261
|
+
|
|
262
|
+
function removeGitignoreLine(root) {
|
|
263
|
+
const file = path.join(root, '.gitignore');
|
|
264
|
+
if (!fs.existsSync(file)) return;
|
|
265
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
266
|
+
const kept = lines.filter((l) => l !== '.codehere/local/');
|
|
267
|
+
if (kept.length === lines.length) return;
|
|
268
|
+
if (kept.join('\n').trim()) fs.writeFileSync(file, kept.join('\n'));
|
|
269
|
+
else fs.rmSync(file);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function filesIn(dir) {
|
|
273
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
274
|
+
const f = path.join(dir, e.name);
|
|
275
|
+
return e.isDirectory() ? filesIn(f) : [f];
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
280
|
+
|
|
281
|
+
// Like approve, it needs the developer's own terminal, so the agent cannot switch Codehere off.
|
|
282
|
+
// .codehere/ goes only when it holds nothing but what init wrote.
|
|
283
|
+
export async function uninstall() {
|
|
284
|
+
const root = findRoot();
|
|
285
|
+
if (!root) {
|
|
286
|
+
console.error('No .codehere folder here. Nothing to remove.');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
// findRoot walks up: from a folder with no .codehere/ it can reach the home folder
|
|
290
|
+
const home = fs.realpathSync(root) === fs.realpathSync(os.homedir());
|
|
291
|
+
if (home || !isGitRepo(root)) {
|
|
292
|
+
console.error(`The nearest .codehere/ is in ${printable(root)}, ${home ? 'your home folder' : 'which is not a git repo'}. Nothing removed.`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
if (!atTerminal()) {
|
|
296
|
+
console.error('codehere uninstall asks you to confirm in your own terminal, not through an agent. Run it there.');
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
300
|
+
const answer = await rl.question(`Remove Codehere from ${printable(root)}? [y/N] `);
|
|
301
|
+
rl.close();
|
|
302
|
+
if (!/^y(es)?$/i.test(answer.trim())) return console.log('Nothing removed.');
|
|
303
|
+
|
|
304
|
+
for (const a of Object.values(AGENTS)) {
|
|
305
|
+
if (removeHooks(root, a)) console.log(`- Removed Codehere's hooks from ${a.file}`);
|
|
306
|
+
}
|
|
307
|
+
removeApprovals(root);
|
|
308
|
+
markRemoved(root);
|
|
309
|
+
const p = paths(root);
|
|
310
|
+
fs.rmSync(p.local, { recursive: true, force: true });
|
|
311
|
+
const readme = path.join(p.base, 'README.md');
|
|
312
|
+
const written = [p.config, p.rules, readme, ...[p.lessons, p.help, p.briefs].map((d) => path.join(d, '.gitkeep'))];
|
|
313
|
+
const rules = readRules(root).length;
|
|
314
|
+
const docs = [p.lessons, p.help, p.briefs].map((d) => listDocs(d).length);
|
|
315
|
+
const other = filesIn(p.base).filter((f) => !written.includes(f)).length - docs.reduce((a, b) => a + b, 0);
|
|
316
|
+
const edited = [[p.rules, RULES_MD], [readme, README_MD]].filter(([f, text]) => fs.existsSync(f) && fs.readFileSync(f, 'utf8') !== text).map(([f]) => path.basename(f));
|
|
317
|
+
const held = [[rules, 'rule'], [docs[0], 'lesson'], [docs[1], 'help card'], [docs[2], 'brief'], [other, 'other file']].filter(([n]) => n).map(([n, w]) => count(n, w));
|
|
318
|
+
if (!rules) held.push(...edited.map((f) => `an edited ${f}`));
|
|
319
|
+
else if (edited.includes('README.md')) held.push('an edited README.md');
|
|
320
|
+
if (!held.length) {
|
|
321
|
+
fs.rmSync(p.base, { recursive: true, force: true });
|
|
322
|
+
removeGitignoreLine(root); // kept otherwise: a session still open would write local/ again
|
|
323
|
+
console.log('- Removed .codehere/');
|
|
324
|
+
} else {
|
|
325
|
+
console.log(`- Kept .codehere/: it holds ${held.join(', ')}. Delete it yourself if the team no longer needs it.`);
|
|
326
|
+
}
|
|
327
|
+
console.log('Codehere is removed from this repo on this machine. Commit the changes so your team gets them too.');
|
|
119
328
|
}
|
|
120
329
|
|
|
121
330
|
// ---------- rules ----------
|
|
@@ -212,7 +421,7 @@ function gitSummary(root, base) {
|
|
|
212
421
|
return {
|
|
213
422
|
branch: git(root, 'rev-parse --abbrev-ref HEAD'),
|
|
214
423
|
head: git(root, 'log -1 --format="%h %s"'),
|
|
215
|
-
stat: git(root,
|
|
424
|
+
stat: git(root, ['diff', commitId(base) || 'HEAD', '--stat']).split('\n').slice(-12).join('\n'),
|
|
216
425
|
};
|
|
217
426
|
}
|
|
218
427
|
|
|
@@ -397,10 +606,12 @@ export function status() {
|
|
|
397
606
|
const { state, s } = lastSession(root);
|
|
398
607
|
const p = paths(root);
|
|
399
608
|
console.log('Codehere status');
|
|
609
|
+
console.log(`- Agents: ${installedAgents(root).map((a) => a.name).join(', ') || 'none (codehere init --agent claude,codex)'}`);
|
|
400
610
|
console.log(`- Team rules: ${readRules(root).length}`);
|
|
401
611
|
console.log(`- Team lessons: ${listDocs(p.lessons).length}`);
|
|
402
612
|
console.log(`- Open help requests: ${listDocs(p.help).filter((d) => d.meta.status === 'open').length}`);
|
|
403
|
-
|
|
613
|
+
const approval = isApproved(root, cfg.testCommand) ? 'approved on this machine' : 'not approved on this machine (codehere approve)';
|
|
614
|
+
console.log(`- Test check on "done": ${cfg.testCommand && cfg.verifyOnStop ? `${printable(cfg.testCommand)} · ${approval}` : 'off'}`);
|
|
404
615
|
if (state.lastGreen) console.log(`- Last green: ${state.lastGreen.commit.slice(0, 8)} at ${state.lastGreen.at.slice(0, 16)}`);
|
|
405
616
|
if (s) {
|
|
406
617
|
const errs = repeatedErrors(s);
|
package/src/hooks.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// Claude Code
|
|
2
|
-
// it exits 0 and the agent carries on as if Codehere were not installed.
|
|
1
|
+
// Hook handlers for Claude Code and Codex CLI (--agent codex). Every handler fails open: if
|
|
2
|
+
// anything goes wrong, 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,
|
|
10
|
+
removedAt,
|
|
9
11
|
} from './brain.js';
|
|
10
12
|
import {
|
|
11
|
-
responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature,
|
|
13
|
+
responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature, testCounts,
|
|
12
14
|
} from './signals.js';
|
|
13
15
|
|
|
14
16
|
function readStdin() {
|
|
@@ -33,14 +35,20 @@ function toAgent(message) {
|
|
|
33
35
|
|
|
34
36
|
const quiet = () => process.exit(0);
|
|
35
37
|
|
|
36
|
-
export async function runHook(event) {
|
|
38
|
+
export async function runHook(event, agent) {
|
|
37
39
|
const input = readStdin();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
+
const start = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
41
|
+
const root = findRoot(start);
|
|
42
|
+
if (!root) {
|
|
43
|
+
// uninstall ran while this session was open: its hooks still run, .codehere/ is gone
|
|
44
|
+
const at = event === 'stop' && removedAt(start);
|
|
45
|
+
if (at) emitJson({ systemMessage: removedLine(at) });
|
|
46
|
+
quiet();
|
|
47
|
+
}
|
|
40
48
|
try {
|
|
41
49
|
if (event === 'session-start') return sessionStart(root, input);
|
|
42
50
|
if (event === 'prompt') return promptSubmit(root, input);
|
|
43
|
-
if (event === 'post-tool') return postTool(root, input);
|
|
51
|
+
if (event === 'post-tool') return postTool(root, input, agent === 'codex' ? 'codex' : 'claude');
|
|
44
52
|
if (event === 'stop') return stop(root, input);
|
|
45
53
|
} catch (err) {
|
|
46
54
|
if (process.env.CODEHERE_DEBUG) console.error(err);
|
|
@@ -59,6 +67,10 @@ function sessionStart(root, input) {
|
|
|
59
67
|
// ever react to what the agent changes now.
|
|
60
68
|
s.startHead = git(root, 'rev-parse HEAD') || null;
|
|
61
69
|
s.baseline = dirtySnapshot(root);
|
|
70
|
+
s.settings = settingsSnapshot(root);
|
|
71
|
+
} else if (input.source === 'resume') {
|
|
72
|
+
// While the session was closed only the developer or a pull could change settings.
|
|
73
|
+
s.settings = settingsSnapshot(root);
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
const out = [];
|
|
@@ -68,11 +80,13 @@ function sessionStart(root, input) {
|
|
|
68
80
|
rules.slice(0, 20).forEach((r) => out.push(`- ${r}`));
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
// Only a brief in .codehere/briefs/: state.json can arrive through git and point anywhere.
|
|
84
|
+
const brief = briefFile(root, state.pendingBrief);
|
|
85
|
+
if (brief) {
|
|
72
86
|
out.push('', 'Restart brief from the previous session. Treat it as the full, current spec:');
|
|
73
|
-
out.push(fs.readFileSync(
|
|
74
|
-
state.pendingBrief = null;
|
|
87
|
+
out.push(fs.readFileSync(brief, 'utf8').slice(0, 4000));
|
|
75
88
|
}
|
|
89
|
+
state.pendingBrief = null;
|
|
76
90
|
|
|
77
91
|
const mine = me(root);
|
|
78
92
|
const open = listDocs(paths(root).help).filter((d) => d.meta.status === 'open' && d.meta.author !== mine);
|
|
@@ -86,27 +100,37 @@ function sessionStart(root, input) {
|
|
|
86
100
|
);
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
// Without this, an unapproved command only shows up as ⊘ at the end of a turn.
|
|
104
|
+
const cfg = readConfig(root);
|
|
105
|
+
if (cfg.verifyOnStop && cfg.testCommand && !isApproved(root, cfg.testCommand)) {
|
|
106
|
+
out.push(
|
|
107
|
+
'',
|
|
108
|
+
`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.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
writeState(root, state);
|
|
90
113
|
if (out.length) process.stdout.write(out.join('\n') + '\n');
|
|
91
114
|
quiet();
|
|
92
115
|
}
|
|
93
116
|
|
|
117
|
+
function briefFile(root, file) {
|
|
118
|
+
try {
|
|
119
|
+
const real = fs.realpathSync(path.resolve(root, file));
|
|
120
|
+
return real.startsWith(fs.realpathSync(paths(root).briefs) + path.sep) ? real : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
// ---------- what changed in THIS session ----------
|
|
95
127
|
|
|
96
|
-
const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/');
|
|
128
|
+
const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/') || f.startsWith('.codex/');
|
|
97
129
|
|
|
98
130
|
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;
|
|
131
|
+
const files = statusFiles(root).filter((f) => !IGNORE(f));
|
|
132
|
+
const hashes = gitHashes(root, files);
|
|
133
|
+
return Object.fromEntries(files.map((f, i) => [f, hashes[i] || 'x']));
|
|
110
134
|
}
|
|
111
135
|
|
|
112
136
|
function changedThisSession(root, s) {
|
|
@@ -120,15 +144,49 @@ function changedThisSession(root, s) {
|
|
|
120
144
|
if (now[f] !== before[f]) changed.add(f);
|
|
121
145
|
}
|
|
122
146
|
}
|
|
123
|
-
if (s.startHead) {
|
|
124
|
-
git(root,
|
|
125
|
-
.split('\
|
|
147
|
+
if (commitId(s.startHead)) {
|
|
148
|
+
git(root, ['diff', s.startHead, 'HEAD', '--name-only', '--relative', '-z'], true)
|
|
149
|
+
.split('\0')
|
|
126
150
|
.filter((f) => f && !IGNORE(f))
|
|
127
151
|
.forEach((f) => changed.add(f));
|
|
128
152
|
}
|
|
129
153
|
return [...changed];
|
|
130
154
|
}
|
|
131
155
|
|
|
156
|
+
// Files that decide what Codehere runs and tells every agent, and the agents' own hook
|
|
157
|
+
// settings. Claude Code and Codex load hooks at session start, so a hook removed mid-session
|
|
158
|
+
// still runs at this session's finish check and can report it.
|
|
159
|
+
const PROTECTED = ['.codehere/config.json', '.codehere/rules.md', '.claude/settings.json', '.claude/settings.local.json', '.codex/hooks.json', '.codex/config.toml'];
|
|
160
|
+
|
|
161
|
+
function settingsSnapshot(root) {
|
|
162
|
+
const snap = {};
|
|
163
|
+
for (const f of PROTECTED) {
|
|
164
|
+
let text = null;
|
|
165
|
+
try {
|
|
166
|
+
text = fs.readFileSync(path.join(root, f), 'utf8');
|
|
167
|
+
} catch {}
|
|
168
|
+
// In .claude/ only the hook and env settings count: Claude Code also saves permission
|
|
169
|
+
// rules there when the developer picks "don't ask again". env can move HOME or
|
|
170
|
+
// XDG_CONFIG_HOME, where approvals are kept. A missing file has none of them.
|
|
171
|
+
// Codex keeps what it saves in the user's own config, so .codex/ files count whole.
|
|
172
|
+
if (f.startsWith('.claude/')) {
|
|
173
|
+
try {
|
|
174
|
+
const j = JSON.parse(text ?? '{}');
|
|
175
|
+
text = JSON.stringify([j.hooks ?? null, j.disableAllHooks ?? null, j.env ?? null]);
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
snap[f] = text == null ? null : crypto.createHash('sha256').update(text).digest('hex');
|
|
179
|
+
}
|
|
180
|
+
return snap;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function settingsChanged(root, s) {
|
|
184
|
+
if (!s.settings) return []; // no snapshot from session start: cannot tell
|
|
185
|
+
const now = settingsSnapshot(root);
|
|
186
|
+
// a session that started before a file was protected has no hash for it
|
|
187
|
+
return PROTECTED.filter((f) => f in s.settings && now[f] !== s.settings[f]);
|
|
188
|
+
}
|
|
189
|
+
|
|
132
190
|
// ---------- PostToolUse (Bash): loop breaker + just-in-time team lessons ----------
|
|
133
191
|
|
|
134
192
|
// Commands that only read files. Their output may contain the word "error"
|
|
@@ -147,7 +205,7 @@ export function readOnly(command) {
|
|
|
147
205
|
function matchLesson(root, sig, shown) {
|
|
148
206
|
let best = null;
|
|
149
207
|
for (const doc of listDocs(paths(root).lessons)) {
|
|
150
|
-
if (shown.includes(doc.id)) continue;
|
|
208
|
+
if (shown.includes(doc.id) || doc.body.includes('- Fix: <one line>')) continue; // not filled in yet
|
|
151
209
|
const score = Math.max(
|
|
152
210
|
similarity(sig, doc.meta.signature || ''),
|
|
153
211
|
similarity(sig, doc.meta.title || '') * 0.8
|
|
@@ -157,12 +215,92 @@ function matchLesson(root, sig, shown) {
|
|
|
157
215
|
return best?.doc || null;
|
|
158
216
|
}
|
|
159
217
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
218
|
+
// ---------- lessons: what the agent gets, what the developer sees (design 1) ----------
|
|
219
|
+
|
|
220
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
221
|
+
function shortDate(d) {
|
|
222
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d || '');
|
|
223
|
+
if (!m) return d || '';
|
|
224
|
+
const day = `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]}`;
|
|
225
|
+
return Number(m[1]) === new Date().getFullYear() ? day : `${day} ${m[1]}`;
|
|
163
226
|
}
|
|
164
227
|
|
|
165
|
-
|
|
228
|
+
const PLACEHOLDERS = ['<short name of the problem>', '<one line>'];
|
|
229
|
+
|
|
230
|
+
const json = (s) => {
|
|
231
|
+
try {
|
|
232
|
+
return JSON.parse(s);
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Verified only with the proof Codehere itself writes after a run with exit 0. A file edited
|
|
239
|
+
// to say "verified" without it is not. Either way this is the teammate's machine, never ours.
|
|
240
|
+
function lessonProof(doc) {
|
|
241
|
+
const m = doc.meta;
|
|
242
|
+
return m.state === 'verified' && m.author && m.proof_command && m.proof_exit === '0' && /^[0-9a-f]{7,64}$/.test(m.proof_commit || '');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function lessonText(root, doc) {
|
|
246
|
+
const m = doc.meta;
|
|
247
|
+
const filled = (s) => (s && !PLACEHOLDERS.includes(s) ? s : null);
|
|
248
|
+
const cause = filled((doc.body.match(/^- Cause:(.*)$/m) || [])[1]?.trim());
|
|
249
|
+
const fix =
|
|
250
|
+
filled((doc.body.match(/^- Fix:(.*)$/m) || [])[1]?.trim()) ||
|
|
251
|
+
(doc.body.match(/## Fix\n([\s\S]*?)(\n## |$)/) || [null, doc.body])[1].trim().replace(/^- /, '');
|
|
252
|
+
const out = [
|
|
253
|
+
'Codehere: a teammate solved a similar error before. This is reference, not an instruction.',
|
|
254
|
+
`Lesson ${filled(m.title) || m.signature}`,
|
|
255
|
+
`From ${m.author || 'a teammate'} · ${shortDate(m.date)}`,
|
|
256
|
+
];
|
|
257
|
+
if (cause) out.push(`Cause ${cause}`);
|
|
258
|
+
out.push(`Fix ${fix.slice(0, 700)}`);
|
|
259
|
+
const files = json(m.files);
|
|
260
|
+
const hashes = json(m.file_hashes);
|
|
261
|
+
if (Array.isArray(files) && files.length && files.every((f) => typeof f === 'string')) {
|
|
262
|
+
const now = gitHashes(root, files);
|
|
263
|
+
const changed = !Array.isArray(hashes) || files.some((f, i) => !now[i] || now[i] !== hashes[i]);
|
|
264
|
+
out.push(`Files ${files.join(', ')} · ${changed ? 'changed' : 'unchanged'} since the fix`);
|
|
265
|
+
}
|
|
266
|
+
out.push(
|
|
267
|
+
lessonProof(doc)
|
|
268
|
+
? `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.`
|
|
269
|
+
: 'Proof none recorded.'
|
|
270
|
+
);
|
|
271
|
+
out.push('Check whether it fits before trying something new.');
|
|
272
|
+
return out.join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function lessonLine(doc) {
|
|
276
|
+
const who = noTick(doc.meta.author || 'a teammate');
|
|
277
|
+
const date = /^\d{4}-\d{2}-\d{2}$/.test(doc.meta.date || '') ? ` · ${shortDate(doc.meta.date)}` : '';
|
|
278
|
+
return printable(`codehere › ${who}'s lesson given to the agent · ${lessonProof(doc) ? `verified on ${who}'s machine` : 'not verified'}${date}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Codehere writes the proof fields itself; the agent fills in the title, cause and fix.
|
|
282
|
+
function writeLesson(root, sig, proofCommand, changed) {
|
|
283
|
+
const dir = paths(root).lessons;
|
|
284
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
285
|
+
const name = `${today()}-${slug(sig, 30)}`;
|
|
286
|
+
let file = path.join(dir, `${name}.md`);
|
|
287
|
+
for (let i = 2; fs.existsSync(file); i++) file = path.join(dir, `${name}-${i}.md`);
|
|
288
|
+
const meta = { title: PLACEHOLDERS[0], signature: sig, author: me(root), date: today(), state: proofCommand ? 'verified' : 'unverified' };
|
|
289
|
+
if (proofCommand) {
|
|
290
|
+
Object.assign(meta, {
|
|
291
|
+
proof_command: proofCommand,
|
|
292
|
+
proof_exit: 0,
|
|
293
|
+
proof_commit: git(root, ['rev-parse', '--short', 'HEAD']),
|
|
294
|
+
proof_dirty: statusFiles(root).some((f) => !IGNORE(f)),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
meta.files = JSON.stringify(changed);
|
|
298
|
+
meta.file_hashes = JSON.stringify(gitHashes(root, changed));
|
|
299
|
+
fs.writeFileSync(file, renderDoc(meta, `## Fix\n- Cause: ${PLACEHOLDERS[1]}\n- Fix: ${PLACEHOLDERS[1]}\n`));
|
|
300
|
+
return path.relative(root, file);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function postTool(root, input, agent) {
|
|
166
304
|
if (input.tool_name && input.tool_name !== 'Bash') quiet();
|
|
167
305
|
const command = (input.tool_input && input.tool_input.command) || '';
|
|
168
306
|
if (readOnly(command)) quiet();
|
|
@@ -203,7 +341,7 @@ function postTool(root, input) {
|
|
|
203
341
|
'2. Check it with one small command before editing any file.',
|
|
204
342
|
'3. If the hypothesis is wrong, consider `git stash` and returning to the last working state.',
|
|
205
343
|
];
|
|
206
|
-
if (lesson) msg.push('', lessonText(lesson));
|
|
344
|
+
if (lesson) msg.push('', lessonText(root, lesson));
|
|
207
345
|
if (count > threshold) {
|
|
208
346
|
msg.push(
|
|
209
347
|
'',
|
|
@@ -212,14 +350,25 @@ function postTool(root, input) {
|
|
|
212
350
|
'or `codehere brief` to restart in a fresh session. Then wait for the user.'
|
|
213
351
|
);
|
|
214
352
|
}
|
|
353
|
+
// Codex replaces the command's output with a block's text (or exit 2's), so the agent
|
|
354
|
+
// would lose the error. There it goes in as context next to the output.
|
|
355
|
+
if (agent === 'codex') {
|
|
356
|
+
emitJson({
|
|
357
|
+
...(lesson ? { systemMessage: lessonLine(lesson) } : {}),
|
|
358
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: msg.join('\n') },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
// with a lesson, JSON instead of exit 2, so the developer also gets its line
|
|
362
|
+
if (lesson) emitJson({ decision: 'block', reason: msg.join('\n'), systemMessage: lessonLine(lesson) });
|
|
215
363
|
toAgent(msg.join('\n') + '\n');
|
|
216
364
|
}
|
|
217
365
|
|
|
218
366
|
if (lesson) {
|
|
219
367
|
emitJson({
|
|
368
|
+
systemMessage: lessonLine(lesson),
|
|
220
369
|
hookSpecificOutput: {
|
|
221
370
|
hookEventName: input.hook_event_name || 'PostToolUse',
|
|
222
|
-
additionalContext:
|
|
371
|
+
additionalContext: lessonText(root, lesson),
|
|
223
372
|
},
|
|
224
373
|
});
|
|
225
374
|
}
|
|
@@ -237,6 +386,7 @@ function promptSubmit(root, input) {
|
|
|
237
386
|
const s = session(state, input.session_id);
|
|
238
387
|
s.transcript = input.transcript_path || s.transcript;
|
|
239
388
|
s.prompts = (s.prompts || 0) + 1;
|
|
389
|
+
if (!s.settings) s.settings = settingsSnapshot(root); // session start did not run for this session
|
|
240
390
|
|
|
241
391
|
// Only a failure from the agent's last turn counts: an older one may be fixed or forgotten.
|
|
242
392
|
const f = s.lastFailure && (s.lastFailure.prompt || 0) === s.prompts - 1 ? s.lastFailure : null;
|
|
@@ -282,8 +432,8 @@ function promptSubmit(root, input) {
|
|
|
282
432
|
|
|
283
433
|
// Fast list for a prompt (no hashing): tracked files changed since the session started.
|
|
284
434
|
function quickChanged(root, s) {
|
|
285
|
-
return git(root,
|
|
286
|
-
.split('\
|
|
435
|
+
return git(root, ['diff', '--name-only', '--relative', '-z', commitId(s.startHead) || 'HEAD'], true)
|
|
436
|
+
.split('\0')
|
|
287
437
|
.filter((x) => x && !IGNORE(x))
|
|
288
438
|
.slice(0, 5);
|
|
289
439
|
}
|
|
@@ -331,20 +481,32 @@ function recordGreen(state, s) {
|
|
|
331
481
|
// ---------- Stop: verify "done" outside the agent ----------
|
|
332
482
|
|
|
333
483
|
function runTests(root, cfg) {
|
|
484
|
+
const started = Date.now();
|
|
334
485
|
const r = spawnSync(cfg.testCommand, {
|
|
335
486
|
cwd: root,
|
|
336
487
|
shell: true,
|
|
337
488
|
encoding: 'utf8',
|
|
338
|
-
timeout: cfg.testTimeoutSeconds * 1000,
|
|
489
|
+
timeout: Math.max(1, Number(cfg.testTimeoutSeconds) || 120) * 1000, // 0 would mean no limit
|
|
339
490
|
env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
|
|
340
491
|
});
|
|
341
492
|
const output = `${r.stdout || ''}\n${r.stderr || ''}`.trim();
|
|
342
493
|
const timedOut = r.error && r.error.code === 'ETIMEDOUT';
|
|
343
|
-
return { ok: r.status === 0 && !timedOut, output, timedOut };
|
|
494
|
+
return { ok: r.status === 0 && !timedOut, output, timedOut, code: r.status, seconds: (Date.now() - started) / 1000 };
|
|
344
495
|
}
|
|
345
496
|
|
|
346
|
-
|
|
347
|
-
|
|
497
|
+
// "48 passed" or "2 failed" when the runner's summary can be read, else the exit code
|
|
498
|
+
export function runResult(t) {
|
|
499
|
+
if (t.timedOut) return 'timed out';
|
|
500
|
+
const c = testCounts(t.output);
|
|
501
|
+
if (t.ok) return c && !c.failed ? `${c.passed} passed` : 'exit 0'; // a summary with failures but exit 0: say only what is sure
|
|
502
|
+
return c && c.failed ? `${c.failed} failed` : `exit ${t.code}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const removedLine = (at) => `codehere ✕ codehere uninstall ran at ${new Date(at).toTimeString().slice(0, 5)} · this repo's checks are removed`;
|
|
506
|
+
|
|
507
|
+
// message: the developer's lines, already escaped
|
|
508
|
+
function block(reason, message) {
|
|
509
|
+
emitJson(message ? { decision: 'block', reason, systemMessage: message } : { decision: 'block', reason });
|
|
348
510
|
}
|
|
349
511
|
|
|
350
512
|
function stop(root, input) {
|
|
@@ -354,14 +516,36 @@ function stop(root, input) {
|
|
|
354
516
|
const mayBlock = (s.stopBlocks || 0) < 3; // never trap the agent: after 3 blocks, only observe
|
|
355
517
|
|
|
356
518
|
if (!isGitRepo(root)) quiet();
|
|
357
|
-
const base = s.startHead || 'HEAD';
|
|
519
|
+
const base = commitId(s.startHead) || 'HEAD';
|
|
358
520
|
const changed = changedThisSession(root, s);
|
|
359
|
-
|
|
521
|
+
const settings = settingsChanged(root, s);
|
|
522
|
+
const removed = removedAt(root);
|
|
523
|
+
if (!changed.length && !settings.length && !removed) quiet(); // the agent changed nothing this session: stay silent
|
|
524
|
+
|
|
525
|
+
// Lines for the developer. Each is escaped as a whole, so text from the repo cannot fake a
|
|
526
|
+
// line, and text from the repo never carries a tick (noTick).
|
|
527
|
+
const lines = [];
|
|
528
|
+
function message() {
|
|
529
|
+
const all = [...lines];
|
|
530
|
+
for (const a of readApprovals(root)) {
|
|
531
|
+
if (a.at > (s.startedAt || '')) all.push(`codehere › approved on this machine during this session: ${noTick(a.command)}`);
|
|
532
|
+
}
|
|
533
|
+
if (settings.length) all.push(`codehere ✕ settings changed during this session · ${settings.join(' · ')}`);
|
|
534
|
+
if (removed) all.push(removedLine(removed));
|
|
535
|
+
return all.map(printable).join('\n');
|
|
536
|
+
}
|
|
537
|
+
function report() {
|
|
538
|
+
const m = message();
|
|
539
|
+
if (m) emitJson({ systemMessage: m });
|
|
540
|
+
quiet();
|
|
541
|
+
}
|
|
360
542
|
|
|
361
543
|
// 1. tampering with tests or checks (only in files changed this session)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
544
|
+
let findings = [];
|
|
545
|
+
if (changed.length) {
|
|
546
|
+
const nameStatus = git(root, ['diff', base, '--relative', '--name-status', '--', ...changed]);
|
|
547
|
+
findings = tamperFindings(nameStatus, git(root, ['diff', base, '--relative', '-U0', '--', ...changed]));
|
|
548
|
+
}
|
|
365
549
|
const key = findings.join('|');
|
|
366
550
|
if (findings.length && s.tamperReported !== key && mayBlock) {
|
|
367
551
|
s.tamperReported = key;
|
|
@@ -377,13 +561,44 @@ function stop(root, input) {
|
|
|
377
561
|
);
|
|
378
562
|
}
|
|
379
563
|
|
|
380
|
-
// 2.
|
|
381
|
-
|
|
382
|
-
|
|
564
|
+
// 2. Codehere's settings or the agent's hook settings changed during the session.
|
|
565
|
+
// The agent is sent back only for the ones that can game or switch off the check;
|
|
566
|
+
// a rules.md change is shown to the developer alone.
|
|
567
|
+
const settingsKey = settings.join('|');
|
|
568
|
+
const sendBack = settings.some((f) => f !== '.codehere/rules.md');
|
|
569
|
+
if (sendBack && s.settingsReported !== settingsKey && mayBlock) {
|
|
570
|
+
s.settingsReported = settingsKey;
|
|
571
|
+
s.stopBlocks++;
|
|
572
|
+
writeState(root, state);
|
|
573
|
+
block(
|
|
574
|
+
[
|
|
575
|
+
'Codehere settings or hook settings changed during this session:',
|
|
576
|
+
...settings.map((f) => `- ${f}`),
|
|
577
|
+
'If you changed them and the user did not ask for it, undo your change.',
|
|
578
|
+
'Then tell the user in one line what changed and why, and finish.',
|
|
579
|
+
].join('\n')
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 3. run the tests ourselves, but only a command approved on this machine:
|
|
584
|
+
// a teammate's commit or the agent may have changed it
|
|
585
|
+
const approved = cfg.testCommand && isApproved(root, cfg.testCommand);
|
|
586
|
+
if (cfg.verifyOnStop && cfg.testCommand && !approved) {
|
|
587
|
+
const last = readApprovals(root).at(-1);
|
|
588
|
+
lines.push(
|
|
589
|
+
`codehere ⊘ ${noTick(cfg.testCommand)} · not run · not approved on this machine${last ? ` · last approved: ${noTick(last.command)}` : ''} › run codehere approve in your terminal`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
const configChanged = settings.includes('.codehere/config.json');
|
|
593
|
+
const cmd = noTick(cfg.testCommand || '');
|
|
594
|
+
let passLine = null;
|
|
595
|
+
let proven = false; // passed here, now, with intact tests and an unchanged config
|
|
596
|
+
if (cfg.verifyOnStop && approved) {
|
|
383
597
|
const t = runTests(root, cfg);
|
|
384
598
|
if (!t.ok && !mayBlock) {
|
|
599
|
+
lines.push(`codehere ✕ ${cmd} · ${runResult(t)} · not sent back, limit of 3 reached`);
|
|
385
600
|
writeState(root, state);
|
|
386
|
-
|
|
601
|
+
report();
|
|
387
602
|
}
|
|
388
603
|
if (!t.ok) {
|
|
389
604
|
s.stopBlocks++;
|
|
@@ -391,52 +606,50 @@ function stop(root, input) {
|
|
|
391
606
|
const tail = t.output.split('\n').slice(-25).join('\n');
|
|
392
607
|
block(
|
|
393
608
|
[
|
|
394
|
-
`Codehere ran \`${
|
|
609
|
+
`Codehere ran \`${cmd}\` and it ${t.timedOut ? 'timed out' : 'failed'}. The task is not done yet.`,
|
|
395
610
|
'Last lines of output:',
|
|
396
611
|
tail,
|
|
397
612
|
'Fix the cause, then finish. Do not change or skip tests to make them pass.',
|
|
398
|
-
].join('\n')
|
|
613
|
+
].join('\n'),
|
|
614
|
+
(lines.push(`codehere ✕ ${cmd} · ${runResult(t)} · agent sent back · ${s.stopBlocks} of 3`), message())
|
|
399
615
|
);
|
|
400
616
|
}
|
|
401
|
-
|
|
402
|
-
|
|
617
|
+
// ✓ only for intact evidence (DESIGN.md §1): not after a weakened test, nor after the
|
|
618
|
+
// config that names the test command changed.
|
|
619
|
+
proven = !findings.length && !configChanged;
|
|
620
|
+
if (findings.length) passLine = `codehere ✕ ${cmd} passed, but a check was weakened · ${noTick(findings.join(' · '))}`;
|
|
621
|
+
else if (configChanged) passLine = `codehere ✕ ${cmd} passed, but .codehere/config.json changed during this session`;
|
|
622
|
+
else passLine = `codehere ✓ ${cmd} · ${runResult(t)} · re-run here · ${t.seconds.toFixed(1)} s`;
|
|
623
|
+
if (proven) {
|
|
403
624
|
state.lastGreen = { commit: git(root, 'rev-parse HEAD'), at: new Date().toISOString() };
|
|
404
625
|
recordGreen(state, s);
|
|
405
626
|
}
|
|
627
|
+
lines.unshift(passLine);
|
|
406
628
|
}
|
|
407
629
|
|
|
408
|
-
//
|
|
630
|
+
// 4. turn a solved repeated error into a team lesson (once per session). After the 3-block
|
|
631
|
+
// cap the agent is not asked to fill it in; the developer is told instead.
|
|
409
632
|
const solved = Object.entries(s.errors).filter(([, n]) => n >= 2);
|
|
410
|
-
if (solved.length && !s.lessonAsked
|
|
633
|
+
if (solved.length && !s.lessonAsked) {
|
|
411
634
|
s.lessonAsked = true;
|
|
412
|
-
s.stopBlocks++;
|
|
413
|
-
writeState(root, state);
|
|
414
635
|
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
|
-
);
|
|
636
|
+
const file = writeLesson(root, sig, proven ? cfg.testCommand : null, changed);
|
|
637
|
+
if (mayBlock) {
|
|
638
|
+
s.stopBlocks++;
|
|
639
|
+
writeState(root, state);
|
|
640
|
+
block(
|
|
641
|
+
[
|
|
642
|
+
`Before you finish: you hit and solved a repeated error ("${sig}").`,
|
|
643
|
+
`Codehere saved it for the team in ${file}${proven ? ', with this test run as proof' : ''}.`,
|
|
644
|
+
'In that file, replace the three <...> placeholders (title, Cause, Fix) with one line each.',
|
|
645
|
+
'Do not change the other fields. Then finish your reply as normal. Do not mention this step unless asked.',
|
|
646
|
+
].join('\n'),
|
|
647
|
+
message()
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
lines.push(`codehere › lesson saved: ${file} · fill in its title, cause and fix`);
|
|
433
651
|
}
|
|
434
652
|
|
|
435
653
|
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();
|
|
654
|
+
report();
|
|
442
655
|
}
|
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
|
+
}
|