codehere 2.0.0-beta.1 → 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 +35 -8
- package/bin/codehere.js +11 -5
- package/package.json +1 -1
- package/src/brain.js +31 -0
- package/src/commands.js +199 -37
- package/src/hooks.js +34 -13
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,13 +23,19 @@ 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
|
+
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
|
+
|
|
31
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.
|
|
32
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
|
+
|
|
33
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.
|
|
34
40
|
|
|
35
41
|
## The team loop
|
|
@@ -61,11 +67,30 @@ codehere ✓ npm test · 48 passed · re-run here · 6.2 s
|
|
|
61
67
|
|
|
62
68
|
Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or Slack instead.
|
|
63
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
|
+
|
|
64
87
|
## Commands
|
|
65
88
|
|
|
66
89
|
```
|
|
67
|
-
codehere init [--test "npm test"]
|
|
90
|
+
codehere init [--agent claude,codex] [--test "npm test"]
|
|
91
|
+
set up (safe to run again)
|
|
68
92
|
codehere approve let the finish check run the test command on this machine
|
|
93
|
+
codehere uninstall remove Codehere from this repo (asks first)
|
|
69
94
|
codehere rule ["text"] add or list team rules
|
|
70
95
|
codehere lesson ["problem" --fix "..."] add or list lessons
|
|
71
96
|
codehere brief [--gemma] restart brief for a fresh session
|
|
@@ -110,15 +135,17 @@ node demo/demo.mjs --step # press Enter between scenes (good for a live talk)
|
|
|
110
135
|
npm test # end-to-end checks
|
|
111
136
|
```
|
|
112
137
|
|
|
113
|
-
## Known limits (2.0
|
|
138
|
+
## Known limits (2.0 beta)
|
|
114
139
|
|
|
115
|
-
- Claude Code
|
|
140
|
+
- Claude Code and Codex CLI. Copilot and OpenCode are next. See [Which agents](#which-agents) for what works on each.
|
|
116
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.
|
|
117
142
|
- Redaction catches common keys, tokens and emails. Always read a help card before sharing it.
|
|
118
143
|
- The "done" check needs a test command. Without one, only the tampering check runs.
|
|
119
144
|
- Approvals are per folder. Each git worktree or extra clone needs its own `codehere approve`.
|
|
120
|
-
-
|
|
121
|
-
-
|
|
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.
|
|
122
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.
|
|
123
150
|
- Sharing is through git. There is no server, so teammates see updates after `git pull`.
|
|
124
151
|
- Prompt checks are plain word lists (English and Indonesian). They can miss a short prompt in another language.
|
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,8 +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)
|
|
27
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
|
|
28
32
|
|
|
29
33
|
Team brain
|
|
30
34
|
codehere rule ["text"] add a team rule (or list rules)
|
|
@@ -43,18 +47,20 @@ When you're stuck
|
|
|
43
47
|
codehere status what Codehere has seen
|
|
44
48
|
codehere stats turns to green, loops stopped, session size
|
|
45
49
|
|
|
46
|
-
Hooks (called by
|
|
47
|
-
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]
|
|
48
52
|
`;
|
|
49
53
|
|
|
50
54
|
const run = async () => {
|
|
51
55
|
switch (command) {
|
|
52
56
|
case 'hook':
|
|
53
|
-
return runHook(rest[0]);
|
|
57
|
+
return runHook(rest[0], flags.agent);
|
|
54
58
|
case 'init':
|
|
55
59
|
return cmd.init(flags);
|
|
56
60
|
case 'approve':
|
|
57
61
|
return cmd.approve();
|
|
62
|
+
case 'uninstall':
|
|
63
|
+
return cmd.uninstall();
|
|
58
64
|
case 'rule':
|
|
59
65
|
case 'rules':
|
|
60
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
|
@@ -227,6 +227,37 @@ export function addApproval(root, command) {
|
|
|
227
227
|
return true;
|
|
228
228
|
}
|
|
229
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
|
+
|
|
230
261
|
// Control, format (bidi, zero-width), filler and variation characters escaped, so text from the repo cannot print a fake
|
|
231
262
|
// line, move the cursor or hide part of a command.
|
|
232
263
|
const ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
package/src/commands.js
CHANGED
|
@@ -2,10 +2,11 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import {
|
|
6
7
|
findRoot, paths, ensureDirs, readConfig, writeConfig, readState, writeState,
|
|
7
8
|
readRules, listDocs, parseDoc, renderDoc, slug, stamp, today, git, isGitRepo, me,
|
|
8
|
-
readApprovals, isApproved, addApproval, printable, commitId,
|
|
9
|
+
readApprovals, isApproved, addApproval, removeApprovals, markRemoved, printable, commitId,
|
|
9
10
|
} from './brain.js';
|
|
10
11
|
import { redact } from './signals.js';
|
|
11
12
|
import { summarize } from './gemma.js';
|
|
@@ -19,6 +20,21 @@ const HOOK_EVENTS = {
|
|
|
19
20
|
Stop: { cmd: 'codehere hook stop', timeout: 180 },
|
|
20
21
|
};
|
|
21
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
|
+
|
|
22
38
|
function requireRoot() {
|
|
23
39
|
const root = findRoot();
|
|
24
40
|
if (!root) {
|
|
@@ -41,18 +57,18 @@ function detectTestCommand(root) {
|
|
|
41
57
|
return null;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const file = path.join(
|
|
47
|
-
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 });
|
|
48
64
|
let settings = {};
|
|
49
65
|
try {
|
|
50
66
|
settings = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
51
67
|
} catch {}
|
|
52
68
|
settings.hooks = settings.hooks || {};
|
|
53
|
-
for (const [event, spec] of Object.entries(
|
|
69
|
+
for (const [event, spec] of Object.entries(agent.events)) {
|
|
54
70
|
const list = (settings.hooks[event] = settings.hooks[event] || []);
|
|
55
|
-
const already = list.some((g) => (g
|
|
71
|
+
const already = list.some((g) => (g?.hooks || []).some((h) => h?.command === spec.cmd));
|
|
56
72
|
if (already) continue;
|
|
57
73
|
const hook = { type: 'command', command: spec.cmd };
|
|
58
74
|
if (spec.timeout) hook.timeout = spec.timeout;
|
|
@@ -61,7 +77,55 @@ function mergeClaudeSettings(root) {
|
|
|
61
77
|
list.push(group);
|
|
62
78
|
}
|
|
63
79
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
64
|
-
|
|
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
|
+
});
|
|
65
129
|
}
|
|
66
130
|
|
|
67
131
|
function addGitignore(root) {
|
|
@@ -75,9 +139,29 @@ function addGitignore(root) {
|
|
|
75
139
|
|
|
76
140
|
// ---------- approving the test command (this machine only) ----------
|
|
77
141
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
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();
|
|
81
165
|
|
|
82
166
|
async function askApproval(root, command) {
|
|
83
167
|
const last = git(root, 'log -1 --format="%an on %ad (%h)" --date=short -- .codehere/config.json');
|
|
@@ -108,7 +192,7 @@ export async function approve() {
|
|
|
108
192
|
if (!command) return console.log('No test command set. Set one: codehere init --test "npm test"');
|
|
109
193
|
if (isApproved(root, command)) return console.log(`Already approved on this machine: ${printable(command)}`);
|
|
110
194
|
if (!atTerminal()) {
|
|
111
|
-
console.error('codehere approve asks you to confirm
|
|
195
|
+
console.error('codehere approve asks you to confirm in your own terminal, not through an agent. Run it there.');
|
|
112
196
|
process.exit(1);
|
|
113
197
|
}
|
|
114
198
|
await askApproval(root, command);
|
|
@@ -116,7 +200,27 @@ export async function approve() {
|
|
|
116
200
|
|
|
117
201
|
// ---------- init ----------
|
|
118
202
|
|
|
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
|
+
|
|
119
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]);
|
|
120
224
|
const root = process.cwd();
|
|
121
225
|
const p = ensureDirs(root);
|
|
122
226
|
const cfg = readConfig(root);
|
|
@@ -124,37 +228,19 @@ export async function init(flags) {
|
|
|
124
228
|
else if (!cfg.testCommand) cfg.testCommand = detectTestCommand(root);
|
|
125
229
|
writeConfig(root, cfg);
|
|
126
230
|
|
|
127
|
-
if (!fs.existsSync(p.rules))
|
|
128
|
-
|
|
129
|
-
p.rules,
|
|
130
|
-
'# 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'
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
fs.writeFileSync(
|
|
134
|
-
path.join(p.base, 'README.md'),
|
|
135
|
-
[
|
|
136
|
-
'# .codehere — this team\'s brain',
|
|
137
|
-
'',
|
|
138
|
-
'Plain markdown, shared through git. Commit and push it like code.',
|
|
139
|
-
'',
|
|
140
|
-
'- `rules.md`: team rules every agent session loads',
|
|
141
|
-
'- `lessons/`: errors someone already solved; shown to the agent only when the same error appears',
|
|
142
|
-
'- `help/`: help cards; teammates see them with `codehere inbox`',
|
|
143
|
-
'- `briefs/`: restart briefs for fresh sessions',
|
|
144
|
-
'- `local/`: this machine only (git-ignored)',
|
|
145
|
-
'',
|
|
146
|
-
].join('\n')
|
|
147
|
-
);
|
|
231
|
+
if (!fs.existsSync(p.rules)) fs.writeFileSync(p.rules, RULES_MD);
|
|
232
|
+
fs.writeFileSync(path.join(p.base, 'README.md'), README_MD);
|
|
148
233
|
for (const d of [p.lessons, p.help, p.briefs]) {
|
|
149
234
|
const keep = path.join(d, '.gitkeep');
|
|
150
235
|
if (!fs.existsSync(keep)) fs.writeFileSync(keep, '');
|
|
151
236
|
}
|
|
152
237
|
addGitignore(root);
|
|
153
|
-
const
|
|
238
|
+
for (const a of agents) mergeHooks(root, a);
|
|
239
|
+
markRemoved(root, false);
|
|
154
240
|
|
|
155
241
|
console.log('Codehere is set up.');
|
|
156
242
|
console.log(`- Team brain: ${path.relative(root, p.base)}/`);
|
|
157
|
-
console.log(`-
|
|
243
|
+
for (const a of agents) console.log(`- ${a.name} hooks: ${a.file}`);
|
|
158
244
|
console.log(`- Test check on "done": ${cfg.testCommand ? printable(cfg.testCommand) : 'none found (set one with: codehere init --test "npm test")'}`);
|
|
159
245
|
console.log('');
|
|
160
246
|
if (cfg.testCommand && !isApproved(root, cfg.testCommand)) {
|
|
@@ -162,8 +248,83 @@ export async function init(flags) {
|
|
|
162
248
|
else console.log('The test command runs only after you approve it on this machine: codehere approve');
|
|
163
249
|
console.log('');
|
|
164
250
|
}
|
|
165
|
-
|
|
166
|
-
|
|
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.');
|
|
167
328
|
}
|
|
168
329
|
|
|
169
330
|
// ---------- rules ----------
|
|
@@ -445,6 +606,7 @@ export function status() {
|
|
|
445
606
|
const { state, s } = lastSession(root);
|
|
446
607
|
const p = paths(root);
|
|
447
608
|
console.log('Codehere status');
|
|
609
|
+
console.log(`- Agents: ${installedAgents(root).map((a) => a.name).join(', ') || 'none (codehere init --agent claude,codex)'}`);
|
|
448
610
|
console.log(`- Team rules: ${readRules(root).length}`);
|
|
449
611
|
console.log(`- Team lessons: ${listDocs(p.lessons).length}`);
|
|
450
612
|
console.log(`- Open help requests: ${listDocs(p.help).filter((d) => d.meta.status === 'open').length}`);
|
package/src/hooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
5
|
import crypto from 'node:crypto';
|
|
@@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
7
7
|
import {
|
|
8
8
|
findRoot, paths, readConfig, readState, writeState, session, readRules, renderDoc,
|
|
9
9
|
listDocs, git, isGitRepo, me, slug, today, isApproved, readApprovals, printable, commitId, statusFiles, gitHashes, noTick,
|
|
10
|
+
removedAt,
|
|
10
11
|
} from './brain.js';
|
|
11
12
|
import {
|
|
12
13
|
responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature, testCounts,
|
|
@@ -34,14 +35,20 @@ function toAgent(message) {
|
|
|
34
35
|
|
|
35
36
|
const quiet = () => process.exit(0);
|
|
36
37
|
|
|
37
|
-
export async function runHook(event) {
|
|
38
|
+
export async function runHook(event, agent) {
|
|
38
39
|
const input = readStdin();
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
+
}
|
|
41
48
|
try {
|
|
42
49
|
if (event === 'session-start') return sessionStart(root, input);
|
|
43
50
|
if (event === 'prompt') return promptSubmit(root, input);
|
|
44
|
-
if (event === 'post-tool') return postTool(root, input);
|
|
51
|
+
if (event === 'post-tool') return postTool(root, input, agent === 'codex' ? 'codex' : 'claude');
|
|
45
52
|
if (event === 'stop') return stop(root, input);
|
|
46
53
|
} catch (err) {
|
|
47
54
|
if (process.env.CODEHERE_DEBUG) console.error(err);
|
|
@@ -118,7 +125,7 @@ function briefFile(root, file) {
|
|
|
118
125
|
|
|
119
126
|
// ---------- what changed in THIS session ----------
|
|
120
127
|
|
|
121
|
-
const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/');
|
|
128
|
+
const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/') || f.startsWith('.codex/');
|
|
122
129
|
|
|
123
130
|
export function dirtySnapshot(root) {
|
|
124
131
|
const files = statusFiles(root).filter((f) => !IGNORE(f));
|
|
@@ -146,10 +153,10 @@ function changedThisSession(root, s) {
|
|
|
146
153
|
return [...changed];
|
|
147
154
|
}
|
|
148
155
|
|
|
149
|
-
// Files that decide what Codehere runs and tells every agent, and the
|
|
150
|
-
// settings. Claude Code
|
|
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
|
|
151
158
|
// 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'];
|
|
159
|
+
const PROTECTED = ['.codehere/config.json', '.codehere/rules.md', '.claude/settings.json', '.claude/settings.local.json', '.codex/hooks.json', '.codex/config.toml'];
|
|
153
160
|
|
|
154
161
|
function settingsSnapshot(root) {
|
|
155
162
|
const snap = {};
|
|
@@ -161,6 +168,7 @@ function settingsSnapshot(root) {
|
|
|
161
168
|
// In .claude/ only the hook and env settings count: Claude Code also saves permission
|
|
162
169
|
// rules there when the developer picks "don't ask again". env can move HOME or
|
|
163
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.
|
|
164
172
|
if (f.startsWith('.claude/')) {
|
|
165
173
|
try {
|
|
166
174
|
const j = JSON.parse(text ?? '{}');
|
|
@@ -175,7 +183,8 @@ function settingsSnapshot(root) {
|
|
|
175
183
|
function settingsChanged(root, s) {
|
|
176
184
|
if (!s.settings) return []; // no snapshot from session start: cannot tell
|
|
177
185
|
const now = settingsSnapshot(root);
|
|
178
|
-
|
|
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]);
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
// ---------- PostToolUse (Bash): loop breaker + just-in-time team lessons ----------
|
|
@@ -291,7 +300,7 @@ function writeLesson(root, sig, proofCommand, changed) {
|
|
|
291
300
|
return path.relative(root, file);
|
|
292
301
|
}
|
|
293
302
|
|
|
294
|
-
function postTool(root, input) {
|
|
303
|
+
function postTool(root, input, agent) {
|
|
295
304
|
if (input.tool_name && input.tool_name !== 'Bash') quiet();
|
|
296
305
|
const command = (input.tool_input && input.tool_input.command) || '';
|
|
297
306
|
if (readOnly(command)) quiet();
|
|
@@ -341,6 +350,14 @@ function postTool(root, input) {
|
|
|
341
350
|
'or `codehere brief` to restart in a fresh session. Then wait for the user.'
|
|
342
351
|
);
|
|
343
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
|
+
}
|
|
344
361
|
// with a lesson, JSON instead of exit 2, so the developer also gets its line
|
|
345
362
|
if (lesson) emitJson({ decision: 'block', reason: msg.join('\n'), systemMessage: lessonLine(lesson) });
|
|
346
363
|
toAgent(msg.join('\n') + '\n');
|
|
@@ -485,6 +502,8 @@ export function runResult(t) {
|
|
|
485
502
|
return c && c.failed ? `${c.failed} failed` : `exit ${t.code}`;
|
|
486
503
|
}
|
|
487
504
|
|
|
505
|
+
const removedLine = (at) => `codehere ✕ codehere uninstall ran at ${new Date(at).toTimeString().slice(0, 5)} · this repo's checks are removed`;
|
|
506
|
+
|
|
488
507
|
// message: the developer's lines, already escaped
|
|
489
508
|
function block(reason, message) {
|
|
490
509
|
emitJson(message ? { decision: 'block', reason, systemMessage: message } : { decision: 'block', reason });
|
|
@@ -500,7 +519,8 @@ function stop(root, input) {
|
|
|
500
519
|
const base = commitId(s.startHead) || 'HEAD';
|
|
501
520
|
const changed = changedThisSession(root, s);
|
|
502
521
|
const settings = settingsChanged(root, s);
|
|
503
|
-
|
|
522
|
+
const removed = removedAt(root);
|
|
523
|
+
if (!changed.length && !settings.length && !removed) quiet(); // the agent changed nothing this session: stay silent
|
|
504
524
|
|
|
505
525
|
// Lines for the developer. Each is escaped as a whole, so text from the repo cannot fake a
|
|
506
526
|
// line, and text from the repo never carries a tick (noTick).
|
|
@@ -511,6 +531,7 @@ function stop(root, input) {
|
|
|
511
531
|
if (a.at > (s.startedAt || '')) all.push(`codehere › approved on this machine during this session: ${noTick(a.command)}`);
|
|
512
532
|
}
|
|
513
533
|
if (settings.length) all.push(`codehere ✕ settings changed during this session · ${settings.join(' · ')}`);
|
|
534
|
+
if (removed) all.push(removedLine(removed));
|
|
514
535
|
return all.map(printable).join('\n');
|
|
515
536
|
}
|
|
516
537
|
function report() {
|