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 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 # finds your test command, adds hooks to .claude/settings.json, asks you to approve the command
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"] set up (safe to run again)
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.0, not released yet)
138
+ ## Known limits (2.0 beta)
114
139
 
115
- - Claude Code only for now. Gemini CLI and Codex hooks are next.
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
- - Approving `npm test` approves that command, not the scripts behind it in `package.json`. And Claude Code runs any hook a teammate adds to the committed `.claude/settings.json`. Both are your repo's code, with the same trust as running it yourself.
121
- - When the agent changes `.codehere/config.json`, `.codehere/rules.md` or the hook and env settings in `.claude/settings*.json` during a session, the finish check tells you. It cannot tell you if the session ends without a finish check, for example when you close it mid-turn. An agent that sets out to fake your terminal or rewrite Codehere's local files can also get around this.
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"] set up .codehere/ and Claude Code hooks
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 Claude Code, not by you)
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.1",
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
- function mergeClaudeSettings(root) {
45
- const dir = path.join(root, '.claude');
46
- const file = path.join(dir, 'settings.json');
47
- fs.mkdirSync(dir, { recursive: true });
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(HOOK_EVENTS)) {
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.hooks || []).some((h) => h.command === spec.cmd));
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
- return file;
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
- // Approval needs a terminal. An agent's shell tool has none, though an agent that fakes one
79
- // can answer, so an approval made during a session is reported at the finish check.
80
- const atTerminal = () => !!(process.stdin.isTTY && process.stdout.isTTY);
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 at a terminal. Run it in your own terminal.');
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
- fs.writeFileSync(
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 settingsFile = mergeClaudeSettings(root);
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(`- Claude Code hooks: ${path.relative(root, settingsFile)}`);
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
- console.log('Next: commit .codehere/ and .claude/settings.json so your team gets it too.');
166
- console.log('Then just use Claude Code as usual. Codehere stays quiet unless it helps.');
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 hook handlers. Every handler fails open: if anything goes wrong,
2
- // it exits 0 and the agent carries on as if Codehere were not installed.
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 root = findRoot(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd());
40
- if (!root) quiet();
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 agent's own hook
150
- // settings. Claude Code loads hooks at session start, so a hook removed mid-session
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
- return PROTECTED.filter((f) => now[f] !== s.settings[f]);
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
- if (!changed.length && !settings.length) quiet(); // the agent changed nothing this session: stay silent
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() {