codehere 2.0.0-beta.1 → 2.0.0-beta.3

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, Codex CLI or Copilot 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,21 @@ 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,copilot # finds your test command, adds the hooks, asks you to approve the command
27
+ git add .codehere .claude/settings.json .codex/hooks.json .github/hooks/codehere.json .gitignore
28
28
  git commit -m "Add Codehere" && git push
29
29
  ```
30
30
 
31
+ Name only the agents your team uses, such as `--agent claude` or `--agent codex,copilot`; with no `--agent`, `init` sets up Claude Code. The hooks go in `.claude/settings.json`, `.codex/hooks.json` and `.github/hooks/codehere.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
+ Copilot runs a repo's hooks only in a folder you trust: answer yes when Copilot asks on its first start in the repo. Copilot also runs the hooks in `.claude/settings.json`. Where `.github/hooks/codehere.json` runs Codehere, those stay quiet, so nothing runs twice; without it, they give Copilot the same checks.
38
+
39
+ `codehere uninstall` removes the hooks, and `.codehere/` too when it holds no rules, lessons, help cards or briefs.
40
+
33
41
  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
42
 
35
43
  ## The team loop
@@ -61,11 +69,30 @@ codehere ✓ npm test · 48 passed · re-run here · 6.2 s
61
69
 
62
70
  Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or Slack instead.
63
71
 
72
+ ## Which agents
73
+
74
+ | | Claude Code | Codex CLI | Copilot CLI |
75
+ |---|---|---|---|
76
+ | Team rules at session start | yes | yes | yes |
77
+ | A teammate's lesson after a failed command | yes | yes | yes |
78
+ | "Same error 3 times: stop and find the root cause" | yes | yes, given next to the error output | yes, given next to the error output |
79
+ | Context added to a short prompt such as "still broken" | yes | yes | no: by Copilot's docs it drops a prompt hook's output |
80
+ | Finish check: your tests re-run, the agent sent back | yes | yes | yes |
81
+ | Your one line (✓ ✕ ⊘ ›) | yes | in the interactive screen, as `↳ Hook · codehere ✓ …`; not in `codex exec` | no; when the agent is sent back, you see the full reason as the next prompt |
82
+ | Restart-brief suggestion in long sessions | yes | no | no |
83
+ | Before it works | nothing | each developer trusts the hooks in `/hooks` | each developer trusts the folder when Copilot asks |
84
+ | MCP tools, instruction file | no | no | no |
85
+ | 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) | 1.0.105 with a local model (Qwen 3.5 9B through Ollama): [result](docs/conformance/2026-10-29-copilot-1.0.105-qwen3.5-9b-q4-K-M.md), and with only the Claude Code hooks installed: [result](docs/conformance/2026-10-29-copilot-1.0.105-qwen3.5-9b-q4-K-M-claude-hooks-only.md) |
86
+
87
+ 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.
88
+
64
89
  ## Commands
65
90
 
66
91
  ```
67
- codehere init [--test "npm test"] set up (safe to run again)
92
+ codehere init [--agent claude,codex,copilot] [--test "npm test"]
93
+ set up (safe to run again)
68
94
  codehere approve let the finish check run the test command on this machine
95
+ codehere uninstall remove Codehere from this repo (asks first)
69
96
  codehere rule ["text"] add or list team rules
70
97
  codehere lesson ["problem" --fix "..."] add or list lessons
71
98
  codehere brief [--gemma] restart brief for a fresh session
@@ -110,15 +137,19 @@ node demo/demo.mjs --step # press Enter between scenes (good for a live talk)
110
137
  npm test # end-to-end checks
111
138
  ```
112
139
 
113
- ## Known limits (2.0.0, not released yet)
140
+ ## Known limits (2.0 beta)
114
141
 
115
- - Claude Code only for now. Gemini CLI and Codex hooks are next.
142
+ - Claude Code, Codex CLI and Copilot CLI. OpenCode is next. Copilot in VS Code reads the same hook files but has not been checked, and Copilot's cloud agent is not supported. See [Which agents](#which-agents) for what works on each.
116
143
  - 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
144
  - Redaction catches common keys, tokens and emails. Always read a help card before sharing it.
118
145
  - The "done" check needs a test command. Without one, only the tampering check runs.
119
146
  - 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.
147
+ - 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.
148
+ - Copilot shows you none of Codehere's lines either, so on Copilot you do not see the ✓, the ⊘, or the notes about an approval, an uninstall or a settings change during a session. The agent still gets every message, and when the finish check sends it back you see the reason as the next prompt.
149
+ - Copilot's cloud agent also runs the committed `.github/hooks/codehere.json`. `codehere` is not installed there, so by GitHub's docs each hook fails and the job carries on without Codehere.
150
+ - `codehere approve` and `codehere uninstall` ask only in your own terminal. They refuse when Claude Code, Codex or Copilot 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 (not on Copilot, which shows none of these lines).
151
+ - 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`, as Copilot does in a trusted folder for `.github/hooks/` and `.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.
152
+ - When the agent changes `.codehere/config.json`, `.codehere/rules.md`, the hook and env settings in `.claude/settings*.json` or `.github/copilot/settings*.json`, or `.codex/hooks.json`, `.codex/config.toml` or `.github/hooks/codehere.json` during a session, the finish check tells you. It cannot tell you if the session ends without a finish check, for example when you close it mid-turn. An agent that sets out to fake your terminal or rewrite Codehere's local files can also get around this.
122
153
  - 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
154
  - Sharing is through git. There is no server, so teammates see updates after `git pull`.
124
155
  - 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,copilot] [--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|copilot]
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.3",
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,33 @@ 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
+ // Copilot's own format: camelCase events, hooks listed directly under each event, timeout as
32
+ // timeoutSec. Its prompt output is dropped, so that hook only counts the turn.
33
+ const COPILOT_HOOK_EVENTS = {
34
+ sessionStart: { cmd: 'codehere hook session-start --agent copilot' },
35
+ userPromptSubmitted: { cmd: 'codehere hook prompt --agent copilot', timeout: 10 },
36
+ postToolUse: { cmd: 'codehere hook post-tool --agent copilot', matcher: 'bash' },
37
+ agentStop: { cmd: 'codehere hook stop --agent copilot', timeout: 180 },
38
+ };
39
+
40
+ const AGENTS = {
41
+ claude: { name: 'Claude Code', file: '.claude/settings.json', events: HOOK_EVENTS },
42
+ codex: { name: 'Codex CLI', file: '.codex/hooks.json', events: CODEX_HOOK_EVENTS },
43
+ copilot: { name: 'Copilot CLI', file: '.github/hooks/codehere.json', events: COPILOT_HOOK_EVENTS, flat: true },
44
+ };
45
+
46
+ const ours = (h) => typeof h?.command === 'string' && h.command.startsWith('codehere hook ');
47
+ // Claude Code and Codex wrap hooks in groups; Copilot lists each hook directly.
48
+ const hooksOf = (g) => (Array.isArray(g?.hooks) ? g.hooks : [g]);
49
+
22
50
  function requireRoot() {
23
51
  const root = findRoot();
24
52
  if (!root) {
@@ -41,27 +69,84 @@ function detectTestCommand(root) {
41
69
  return null;
42
70
  }
43
71
 
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 });
72
+ function mergeHooks(root, agent) {
73
+ const file = path.join(root, agent.file);
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
75
  let settings = {};
49
76
  try {
50
77
  settings = JSON.parse(fs.readFileSync(file, 'utf8'));
51
78
  } catch {}
79
+ if (agent.flat) settings = { version: 1, ...settings };
52
80
  settings.hooks = settings.hooks || {};
53
- for (const [event, spec] of Object.entries(HOOK_EVENTS)) {
81
+ for (const [event, spec] of Object.entries(agent.events)) {
54
82
  const list = (settings.hooks[event] = settings.hooks[event] || []);
55
- const already = list.some((g) => (g.hooks || []).some((h) => h.command === spec.cmd));
83
+ const already = list.some((g) => hooksOf(g).some((h) => h?.command === spec.cmd));
56
84
  if (already) continue;
57
85
  const hook = { type: 'command', command: spec.cmd };
86
+ if (agent.flat) {
87
+ if (spec.matcher) hook.matcher = spec.matcher;
88
+ if (spec.timeout) hook.timeoutSec = spec.timeout;
89
+ list.push(hook);
90
+ continue;
91
+ }
58
92
  if (spec.timeout) hook.timeout = spec.timeout;
59
93
  const group = { hooks: [hook] };
60
94
  if (spec.matcher) group.matcher = spec.matcher;
61
95
  list.push(group);
62
96
  }
63
97
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
64
- return file;
98
+ }
99
+
100
+ // Takes Codehere's hooks out of the agent's settings file, keeping everything else.
101
+ // A file left with nothing in it is removed, and its folders up to the repo if they are then empty.
102
+ function removeHooks(root, agent) {
103
+ const file = path.join(root, agent.file);
104
+ let settings;
105
+ try {
106
+ settings = JSON.parse(fs.readFileSync(file, 'utf8'));
107
+ } catch {
108
+ return false;
109
+ }
110
+ const hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
111
+ let found = false;
112
+ for (const event of Object.keys(hooks)) {
113
+ if (!Array.isArray(hooks[event])) continue;
114
+ hooks[event] = hooks[event]
115
+ .filter((g) => g && typeof g === 'object')
116
+ .flatMap((g) => {
117
+ if (!Array.isArray(g.hooks)) return ours(g) ? ((found = true), []) : [g];
118
+ const kept = g.hooks.filter((h) => !ours(h));
119
+ if (kept.length !== g.hooks.length) found = true;
120
+ return kept.length ? [{ ...g, hooks: kept }] : [];
121
+ });
122
+ if (!hooks[event].length) delete hooks[event];
123
+ }
124
+ if (!found) return false;
125
+ if (!Object.keys(hooks).length) delete settings.hooks;
126
+ if (Object.keys(settings).some((k) => !(agent.flat && k === 'version'))) {
127
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
128
+ } else {
129
+ fs.rmSync(file);
130
+ for (let d = path.dirname(file); d !== root; d = path.dirname(d)) {
131
+ try {
132
+ fs.rmdirSync(d);
133
+ } catch {
134
+ break; // not empty: something else lives there
135
+ }
136
+ }
137
+ }
138
+ return true;
139
+ }
140
+
141
+ function installedAgents(root) {
142
+ return Object.values(AGENTS).filter((a) => {
143
+ try {
144
+ const hooks = JSON.parse(fs.readFileSync(path.join(root, a.file), 'utf8')).hooks || {};
145
+ return Object.values(hooks).some((gs) => Array.isArray(gs) && gs.some((g) => hooksOf(g).some(ours)));
146
+ } catch {
147
+ return false;
148
+ }
149
+ });
65
150
  }
66
151
 
67
152
  function addGitignore(root) {
@@ -75,9 +160,29 @@ function addGitignore(root) {
75
160
 
76
161
  // ---------- approving the test command (this machine only) ----------
77
162
 
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);
163
+ // Approving and uninstalling need the developer's own terminal: a terminal on stdin and stdout,
164
+ // and no agent among the processes above this one. An agent's commands run under its process,
165
+ // even in a terminal it opens (Codex can). Inside Codex's sandbox ps is not allowed, so the
166
+ // parents cannot be read, which counts as under an agent. An agent that detaches its terminal
167
+ // on purpose can still answer, so the finish check reports an approval or an uninstall.
168
+ const AGENT_PROCESSES = new Set(['claude', 'codex', 'copilot', 'opencode']);
169
+ const PS = fs.existsSync('/bin/ps') ? '/bin/ps' : '/usr/bin/ps';
170
+ function underAgent() {
171
+ let pid = process.ppid;
172
+ for (let i = 0; i < 100 && pid > 1; i++) {
173
+ let line;
174
+ try {
175
+ line = execFileSync(PS, ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
176
+ } catch {
177
+ return true;
178
+ }
179
+ const m = /^(\d+)\s+(.+)$/.exec(line);
180
+ if (!m || AGENT_PROCESSES.has(path.basename(m[2]).toLowerCase())) return true;
181
+ pid = Number(m[1]);
182
+ }
183
+ return pid > 1;
184
+ }
185
+ const atTerminal = () => !!(process.stdin.isTTY && process.stdout.isTTY) && !underAgent();
81
186
 
82
187
  async function askApproval(root, command) {
83
188
  const last = git(root, 'log -1 --format="%an on %ad (%h)" --date=short -- .codehere/config.json');
@@ -108,7 +213,7 @@ export async function approve() {
108
213
  if (!command) return console.log('No test command set. Set one: codehere init --test "npm test"');
109
214
  if (isApproved(root, command)) return console.log(`Already approved on this machine: ${printable(command)}`);
110
215
  if (!atTerminal()) {
111
- console.error('codehere approve asks you to confirm at a terminal. Run it in your own terminal.');
216
+ console.error('codehere approve asks you to confirm in your own terminal, not through an agent. Run it there.');
112
217
  process.exit(1);
113
218
  }
114
219
  await askApproval(root, command);
@@ -116,7 +221,27 @@ export async function approve() {
116
221
 
117
222
  // ---------- init ----------
118
223
 
224
+ 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';
225
+ const README_MD = [
226
+ '# .codehere — this team\'s brain',
227
+ '',
228
+ 'Plain markdown, shared through git. Commit and push it like code.',
229
+ '',
230
+ '- `rules.md`: team rules every agent session loads',
231
+ '- `lessons/`: errors someone already solved; shown to the agent only when the same error appears',
232
+ '- `help/`: help cards; teammates see them with `codehere inbox`',
233
+ '- `briefs/`: restart briefs for fresh sessions',
234
+ '- `local/`: this machine only (git-ignored)',
235
+ '',
236
+ ].join('\n');
237
+
119
238
  export async function init(flags) {
239
+ const names = flags.agent === undefined ? ['claude'] : String(flags.agent === true ? '' : flags.agent).split(',').map((x) => x.trim().toLowerCase());
240
+ if (!names.every((n) => Object.hasOwn(AGENTS, n))) {
241
+ console.error(`Unknown agent "${printable(flags.agent === true ? '' : flags.agent)}". Use --agent with claude, codex or copilot, or several: --agent claude,codex,copilot.`);
242
+ process.exit(1);
243
+ }
244
+ const agents = [...new Set(names)].map((n) => AGENTS[n]);
120
245
  const root = process.cwd();
121
246
  const p = ensureDirs(root);
122
247
  const cfg = readConfig(root);
@@ -124,37 +249,19 @@ export async function init(flags) {
124
249
  else if (!cfg.testCommand) cfg.testCommand = detectTestCommand(root);
125
250
  writeConfig(root, cfg);
126
251
 
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
- );
252
+ if (!fs.existsSync(p.rules)) fs.writeFileSync(p.rules, RULES_MD);
253
+ fs.writeFileSync(path.join(p.base, 'README.md'), README_MD);
148
254
  for (const d of [p.lessons, p.help, p.briefs]) {
149
255
  const keep = path.join(d, '.gitkeep');
150
256
  if (!fs.existsSync(keep)) fs.writeFileSync(keep, '');
151
257
  }
152
258
  addGitignore(root);
153
- const settingsFile = mergeClaudeSettings(root);
259
+ for (const a of agents) mergeHooks(root, a);
260
+ markRemoved(root, false);
154
261
 
155
262
  console.log('Codehere is set up.');
156
263
  console.log(`- Team brain: ${path.relative(root, p.base)}/`);
157
- console.log(`- Claude Code hooks: ${path.relative(root, settingsFile)}`);
264
+ for (const a of agents) console.log(`- ${a.name} hooks: ${a.file}`);
158
265
  console.log(`- Test check on "done": ${cfg.testCommand ? printable(cfg.testCommand) : 'none found (set one with: codehere init --test "npm test")'}`);
159
266
  console.log('');
160
267
  if (cfg.testCommand && !isApproved(root, cfg.testCommand)) {
@@ -162,8 +269,87 @@ export async function init(flags) {
162
269
  else console.log('The test command runs only after you approve it on this machine: codehere approve');
163
270
  console.log('');
164
271
  }
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.');
272
+ if (agents.includes(AGENTS.codex)) {
273
+ console.log("Codex runs a repo's hooks only after each developer trusts them. Open Codex here, trust the project if asked,");
274
+ console.log('then type /hooks and trust the four Codehere hooks. Until then Codex skips them, and `codex exec` does not warn.');
275
+ console.log('');
276
+ }
277
+ if (agents.includes(AGENTS.copilot)) {
278
+ console.log("Copilot runs a repo's hooks only in a folder you trust. Answer yes when Copilot asks on its first start here.");
279
+ console.log('');
280
+ }
281
+ console.log(`Next: commit .codehere/ and ${agents.map((a) => a.file).join(' and ')} so your team gets it too.`);
282
+ console.log(`Then just use ${agents.map((a) => a.name).join(' or ')} as usual. Codehere stays quiet unless it helps.`);
283
+ }
284
+
285
+ // ---------- uninstall ----------
286
+
287
+ function removeGitignoreLine(root) {
288
+ const file = path.join(root, '.gitignore');
289
+ if (!fs.existsSync(file)) return;
290
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
291
+ const kept = lines.filter((l) => l !== '.codehere/local/');
292
+ if (kept.length === lines.length) return;
293
+ if (kept.join('\n').trim()) fs.writeFileSync(file, kept.join('\n'));
294
+ else fs.rmSync(file);
295
+ }
296
+
297
+ function filesIn(dir) {
298
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
299
+ const f = path.join(dir, e.name);
300
+ return e.isDirectory() ? filesIn(f) : [f];
301
+ });
302
+ }
303
+
304
+ const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
305
+
306
+ // Like approve, it needs the developer's own terminal, so the agent cannot switch Codehere off.
307
+ // .codehere/ goes only when it holds nothing but what init wrote.
308
+ export async function uninstall() {
309
+ const root = findRoot();
310
+ if (!root) {
311
+ console.error('No .codehere folder here. Nothing to remove.');
312
+ process.exit(1);
313
+ }
314
+ // findRoot walks up: from a folder with no .codehere/ it can reach the home folder
315
+ const home = fs.realpathSync(root) === fs.realpathSync(os.homedir());
316
+ if (home || !isGitRepo(root)) {
317
+ console.error(`The nearest .codehere/ is in ${printable(root)}, ${home ? 'your home folder' : 'which is not a git repo'}. Nothing removed.`);
318
+ process.exit(1);
319
+ }
320
+ if (!atTerminal()) {
321
+ console.error('codehere uninstall asks you to confirm in your own terminal, not through an agent. Run it there.');
322
+ process.exit(1);
323
+ }
324
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
325
+ const answer = await rl.question(`Remove Codehere from ${printable(root)}? [y/N] `);
326
+ rl.close();
327
+ if (!/^y(es)?$/i.test(answer.trim())) return console.log('Nothing removed.');
328
+
329
+ for (const a of Object.values(AGENTS)) {
330
+ if (removeHooks(root, a)) console.log(`- Removed Codehere's hooks from ${a.file}`);
331
+ }
332
+ removeApprovals(root);
333
+ markRemoved(root);
334
+ const p = paths(root);
335
+ fs.rmSync(p.local, { recursive: true, force: true });
336
+ const readme = path.join(p.base, 'README.md');
337
+ const written = [p.config, p.rules, readme, ...[p.lessons, p.help, p.briefs].map((d) => path.join(d, '.gitkeep'))];
338
+ const rules = readRules(root).length;
339
+ const docs = [p.lessons, p.help, p.briefs].map((d) => listDocs(d).length);
340
+ const other = filesIn(p.base).filter((f) => !written.includes(f)).length - docs.reduce((a, b) => a + b, 0);
341
+ 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));
342
+ 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));
343
+ if (!rules) held.push(...edited.map((f) => `an edited ${f}`));
344
+ else if (edited.includes('README.md')) held.push('an edited README.md');
345
+ if (!held.length) {
346
+ fs.rmSync(p.base, { recursive: true, force: true });
347
+ removeGitignoreLine(root); // kept otherwise: a session still open would write local/ again
348
+ console.log('- Removed .codehere/');
349
+ } else {
350
+ console.log(`- Kept .codehere/: it holds ${held.join(', ')}. Delete it yourself if the team no longer needs it.`);
351
+ }
352
+ console.log('Codehere is removed from this repo on this machine. Commit the changes so your team gets them too.');
167
353
  }
168
354
 
169
355
  // ---------- rules ----------
@@ -445,6 +631,7 @@ export function status() {
445
631
  const { state, s } = lastSession(root);
446
632
  const p = paths(root);
447
633
  console.log('Codehere status');
634
+ console.log(`- Agents: ${installedAgents(root).map((a) => a.name).join(', ') || 'none (codehere init --agent claude,codex)'}`);
448
635
  console.log(`- Team rules: ${readRules(root).length}`);
449
636
  console.log(`- Team lessons: ${listDocs(p.lessons).length}`);
450
637
  console.log(`- Open help requests: ${listDocs(p.help).filter((d) => d.meta.status === 'open').length}`);
package/src/hooks.js CHANGED
@@ -1,5 +1,6 @@
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, Codex CLI (--agent codex) and Copilot CLI (--agent copilot).
2
+ // Every handler fails open: if anything goes wrong, it exits 0 and the agent carries on as if
3
+ // Codehere were not installed.
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import crypto from 'node:crypto';
@@ -7,6 +8,7 @@ import { spawnSync } from 'node:child_process';
7
8
  import {
8
9
  findRoot, paths, readConfig, readState, writeState, session, readRules, renderDoc,
9
10
  listDocs, git, isGitRepo, me, slug, today, isApproved, readApprovals, printable, commitId, statusFiles, gitHashes, noTick,
11
+ removedAt,
10
12
  } from './brain.js';
11
13
  import {
12
14
  responseText, signature, errorLines, similarity, tamperFindings, failingTest, lowInfoPrompt, vagueFeature, testCounts,
@@ -34,14 +36,54 @@ function toAgent(message) {
34
36
 
35
37
  const quiet = () => process.exit(0);
36
38
 
37
- export async function runHook(event) {
38
- const input = readStdin();
39
- const root = findRoot(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd());
40
- if (!root) quiet();
39
+ // Copilot's inputs: camelCase from its own hook file, snake_case from .claude/settings.json.
40
+ // Map them to the names the handlers read. A failed command arrives as a successful tool
41
+ // call; only its output shows the failure.
42
+ function fromCopilot(i) {
43
+ const tool = i.toolName ?? i.tool_name;
44
+ return {
45
+ ...i,
46
+ session_id: i.sessionId ?? i.session_id,
47
+ tool_name: tool === 'bash' ? 'Bash' : tool,
48
+ tool_input: i.toolArgs ?? i.tool_input,
49
+ tool_response: i.toolResult?.textResultForLlm ?? i.tool_result?.text_result_for_llm,
50
+ };
51
+ }
52
+
53
+ const COPILOT_EVENT = { 'session-start': 'sessionStart', prompt: 'userPromptSubmitted', 'post-tool': 'postToolUse', stop: 'agentStop' };
54
+
55
+ // Whether Copilot's own hook file runs Codehere for this event.
56
+ function copilotFileRuns(root, event) {
57
+ try {
58
+ const hooks = JSON.parse(fs.readFileSync(path.join(root, '.github/hooks/codehere.json'), 'utf8')).hooks;
59
+ return (hooks?.[COPILOT_EVENT[event]] || []).some((h) => h?.command === `codehere hook ${event} --agent copilot`);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ export async function runHook(event, agent) {
66
+ let input = readStdin();
67
+ const start = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
68
+ const root = findRoot(start);
69
+ // Copilot also runs the hooks in .claude/settings.json, adding a `timestamp` Claude Code does
70
+ // not send. When Copilot's own file runs Codehere for this event, these stay quiet so nothing
71
+ // runs twice. Otherwise they are all Copilot has, and answer in its format.
72
+ if (agent !== 'copilot' && process.env.COPILOT_CLI && 'timestamp' in input) {
73
+ if (root && copilotFileRuns(root, event)) quiet();
74
+ agent = 'copilot';
75
+ }
76
+ if (agent === 'copilot') input = fromCopilot(input);
77
+ if (!root) {
78
+ // uninstall ran while this session was open: its hooks still run, .codehere/ is gone
79
+ const at = event === 'stop' && removedAt(start);
80
+ if (at) emitJson({ systemMessage: removedLine(at) });
81
+ quiet();
82
+ }
41
83
  try {
42
- if (event === 'session-start') return sessionStart(root, input);
84
+ if (event === 'session-start') return sessionStart(root, input, agent);
43
85
  if (event === 'prompt') return promptSubmit(root, input);
44
- if (event === 'post-tool') return postTool(root, input);
86
+ if (event === 'post-tool') return postTool(root, input, agent === 'codex' || agent === 'copilot' ? agent : 'claude');
45
87
  if (event === 'stop') return stop(root, input);
46
88
  } catch (err) {
47
89
  if (process.env.CODEHERE_DEBUG) console.error(err);
@@ -51,7 +93,7 @@ export async function runHook(event) {
51
93
 
52
94
  // ---------- SessionStart: load only human-confirmed context ----------
53
95
 
54
- function sessionStart(root, input) {
96
+ function sessionStart(root, input, agent) {
55
97
  const state = readState(root);
56
98
  const s = session(state, input.session_id);
57
99
  s.transcript = input.transcript_path || s.transcript;
@@ -103,6 +145,8 @@ function sessionStart(root, input) {
103
145
  }
104
146
 
105
147
  writeState(root, state);
148
+ // Copilot reads only JSON here; Claude Code and Codex take the plain text
149
+ if (out.length && agent === 'copilot') emitJson({ additionalContext: out.join('\n') });
106
150
  if (out.length) process.stdout.write(out.join('\n') + '\n');
107
151
  quiet();
108
152
  }
@@ -118,7 +162,7 @@ function briefFile(root, file) {
118
162
 
119
163
  // ---------- what changed in THIS session ----------
120
164
 
121
- const IGNORE = (f) => f.startsWith('.codehere/') || f.startsWith('.claude/');
165
+ const IGNORE = (f) => ['.codehere/', '.claude/', '.codex/', '.github/copilot/', '.github/hooks/'].some((d) => f.startsWith(d));
122
166
 
123
167
  export function dirtySnapshot(root) {
124
168
  const files = statusFiles(root).filter((f) => !IGNORE(f));
@@ -146,22 +190,35 @@ function changedThisSession(root, s) {
146
190
  return [...changed];
147
191
  }
148
192
 
149
- // Files that decide what Codehere runs and tells every agent, and the agent's own hook
150
- // settings. Claude Code loads hooks at session start, so a hook removed mid-session
151
- // still runs at this session's finish check and can report it.
152
- const PROTECTED = ['.codehere/config.json', '.codehere/rules.md', '.claude/settings.json', '.claude/settings.local.json'];
193
+ // Files that decide what Codehere runs and tells every agent, and the agents' own hook
194
+ // settings. Claude Code and Codex load hooks at session start, so a hook removed mid-session
195
+ // still runs at this session's finish check and can report it. Copilot runs every file in
196
+ // .github/hooks/, so that folder counts as one.
197
+ const PROTECTED = [
198
+ '.codehere/config.json', '.codehere/rules.md', '.claude/settings.json', '.claude/settings.local.json', '.codex/hooks.json', '.codex/config.toml',
199
+ '.github/hooks/', '.github/copilot/settings.json', '.github/copilot/settings.local.json',
200
+ ];
201
+
202
+ function readProtected(root, f) {
203
+ if (!f.endsWith('/')) return fs.readFileSync(path.join(root, f), 'utf8');
204
+ const files = fs.readdirSync(path.join(root, f)).filter((n) => n.endsWith('.json')).sort();
205
+ if (!files.length) return null;
206
+ return files.map((n) => `${n}\0${fs.readFileSync(path.join(root, f, n), 'utf8')}`).join('\0');
207
+ }
153
208
 
154
209
  function settingsSnapshot(root) {
155
210
  const snap = {};
156
211
  for (const f of PROTECTED) {
157
212
  let text = null;
158
213
  try {
159
- text = fs.readFileSync(path.join(root, f), 'utf8');
214
+ text = readProtected(root, f);
160
215
  } catch {}
161
216
  // In .claude/ only the hook and env settings count: Claude Code also saves permission
162
217
  // rules there when the developer picks "don't ask again". env can move HOME or
163
218
  // XDG_CONFIG_HOME, where approvals are kept. A missing file has none of them.
164
- if (f.startsWith('.claude/')) {
219
+ // Codex keeps what it saves in the user's own config, so .codex/ files count whole.
220
+ // Copilot's settings files in .github/copilot/ count like .claude/; its hooks file is ours.
221
+ if (f.startsWith('.claude/') || f.startsWith('.github/copilot/')) {
165
222
  try {
166
223
  const j = JSON.parse(text ?? '{}');
167
224
  text = JSON.stringify([j.hooks ?? null, j.disableAllHooks ?? null, j.env ?? null]);
@@ -175,7 +232,8 @@ function settingsSnapshot(root) {
175
232
  function settingsChanged(root, s) {
176
233
  if (!s.settings) return []; // no snapshot from session start: cannot tell
177
234
  const now = settingsSnapshot(root);
178
- return PROTECTED.filter((f) => now[f] !== s.settings[f]);
235
+ // a session that started before a file was protected has no hash for it
236
+ return PROTECTED.filter((f) => f in s.settings && now[f] !== s.settings[f]);
179
237
  }
180
238
 
181
239
  // ---------- PostToolUse (Bash): loop breaker + just-in-time team lessons ----------
@@ -291,7 +349,7 @@ function writeLesson(root, sig, proofCommand, changed) {
291
349
  return path.relative(root, file);
292
350
  }
293
351
 
294
- function postTool(root, input) {
352
+ function postTool(root, input, agent) {
295
353
  if (input.tool_name && input.tool_name !== 'Bash') quiet();
296
354
  const command = (input.tool_input && input.tool_input.command) || '';
297
355
  if (readOnly(command)) quiet();
@@ -341,11 +399,22 @@ function postTool(root, input) {
341
399
  'or `codehere brief` to restart in a fresh session. Then wait for the user.'
342
400
  );
343
401
  }
402
+ // Codex replaces the command's output with a block's text (or exit 2's), so the agent
403
+ // would lose the error. There it goes in as context next to the output. Copilot shows
404
+ // exit 2 to the developer, not the agent, and takes context only in its own format.
405
+ if (agent === 'copilot') emitJson({ additionalContext: msg.join('\n') });
406
+ if (agent === 'codex') {
407
+ emitJson({
408
+ ...(lesson ? { systemMessage: lessonLine(lesson) } : {}),
409
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: msg.join('\n') },
410
+ });
411
+ }
344
412
  // with a lesson, JSON instead of exit 2, so the developer also gets its line
345
413
  if (lesson) emitJson({ decision: 'block', reason: msg.join('\n'), systemMessage: lessonLine(lesson) });
346
414
  toAgent(msg.join('\n') + '\n');
347
415
  }
348
416
 
417
+ if (lesson && agent === 'copilot') emitJson({ additionalContext: lessonText(root, lesson) });
349
418
  if (lesson) {
350
419
  emitJson({
351
420
  systemMessage: lessonLine(lesson),
@@ -485,6 +554,8 @@ export function runResult(t) {
485
554
  return c && c.failed ? `${c.failed} failed` : `exit ${t.code}`;
486
555
  }
487
556
 
557
+ const removedLine = (at) => `codehere ✕ codehere uninstall ran at ${new Date(at).toTimeString().slice(0, 5)} · this repo's checks are removed`;
558
+
488
559
  // message: the developer's lines, already escaped
489
560
  function block(reason, message) {
490
561
  emitJson(message ? { decision: 'block', reason, systemMessage: message } : { decision: 'block', reason });
@@ -500,7 +571,8 @@ function stop(root, input) {
500
571
  const base = commitId(s.startHead) || 'HEAD';
501
572
  const changed = changedThisSession(root, s);
502
573
  const settings = settingsChanged(root, s);
503
- if (!changed.length && !settings.length) quiet(); // the agent changed nothing this session: stay silent
574
+ const removed = removedAt(root);
575
+ if (!changed.length && !settings.length && !removed) quiet(); // the agent changed nothing this session: stay silent
504
576
 
505
577
  // Lines for the developer. Each is escaped as a whole, so text from the repo cannot fake a
506
578
  // line, and text from the repo never carries a tick (noTick).
@@ -511,6 +583,7 @@ function stop(root, input) {
511
583
  if (a.at > (s.startedAt || '')) all.push(`codehere › approved on this machine during this session: ${noTick(a.command)}`);
512
584
  }
513
585
  if (settings.length) all.push(`codehere ✕ settings changed during this session · ${settings.join(' · ')}`);
586
+ if (removed) all.push(removedLine(removed));
514
587
  return all.map(printable).join('\n');
515
588
  }
516
589
  function report() {