codehere 2.0.0-beta.2 → 2.0.0
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 +28 -24
- package/bin/codehere.js +2 -2
- package/package.json +1 -1
- package/src/commands.js +45 -18
- package/src/hooks.js +64 -12
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 or
|
|
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
|
|
|
@@ -21,19 +21,21 @@ Codehere is built to remove work, not add it. You keep using Claude Code or Code
|
|
|
21
21
|
## Install (2 minutes)
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm install -g codehere
|
|
24
|
+
npm install -g codehere
|
|
25
25
|
cd your-project
|
|
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
|
|
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
|
-
|
|
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
32
|
|
|
33
|
-
Each teammate installs the CLI once (`npm install -g codehere
|
|
33
|
+
Each teammate installs the CLI once (`npm install -g codehere`) and runs `codehere approve` once in the repo. Until they do, their agent tells them at session start that the test check is off. The hooks and the brain arrive through git.
|
|
34
34
|
|
|
35
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
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
|
+
|
|
37
39
|
`codehere uninstall` removes the hooks, and `.codehere/` too when it holds no rules, lessons, help cards or briefs.
|
|
38
40
|
|
|
39
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.
|
|
@@ -69,25 +71,25 @@ Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or
|
|
|
69
71
|
|
|
70
72
|
## Which agents
|
|
71
73
|
|
|
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) |
|
|
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), 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), and with a GitHub-hosted model (`auto`, which picked gpt-6-luna): [result](docs/conformance/2026-10-29-copilot-1.0.105-auto.md) |
|
|
84
86
|
|
|
85
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.
|
|
86
88
|
|
|
87
89
|
## Commands
|
|
88
90
|
|
|
89
91
|
```
|
|
90
|
-
codehere init [--agent claude,codex] [--test "npm test"]
|
|
92
|
+
codehere init [--agent claude,codex,copilot] [--test "npm test"]
|
|
91
93
|
set up (safe to run again)
|
|
92
94
|
codehere approve let the finish check run the test command on this machine
|
|
93
95
|
codehere uninstall remove Codehere from this repo (asks first)
|
|
@@ -135,17 +137,19 @@ node demo/demo.mjs --step # press Enter between scenes (good for a live talk)
|
|
|
135
137
|
npm test # end-to-end checks
|
|
136
138
|
```
|
|
137
139
|
|
|
138
|
-
## Known limits
|
|
140
|
+
## Known limits
|
|
139
141
|
|
|
140
|
-
- Claude Code and
|
|
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.
|
|
141
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.
|
|
142
144
|
- Redaction catches common keys, tokens and emails. Always read a help card before sharing it.
|
|
143
145
|
- The "done" check needs a test command. Without one, only the tampering check runs.
|
|
144
146
|
- Approvals are per folder. Each git worktree or extra clone needs its own `codehere approve`.
|
|
145
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.
|
|
146
|
-
-
|
|
147
|
-
-
|
|
148
|
-
-
|
|
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.
|
|
149
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.
|
|
150
154
|
- Sharing is through git. There is no server, so teammates see updates after `git pull`.
|
|
151
155
|
- Prompt checks are plain word lists (English and Indonesian). They can miss a short prompt in another language.
|
package/bin/codehere.js
CHANGED
|
@@ -25,7 +25,7 @@ const [command, ...rest] = pos;
|
|
|
25
25
|
const USAGE = `codehere · a git-native team brain for vibe coders
|
|
26
26
|
|
|
27
27
|
Setup
|
|
28
|
-
codehere init [--agent claude,codex] [--test "npm test"]
|
|
28
|
+
codehere init [--agent claude,codex,copilot] [--test "npm test"]
|
|
29
29
|
set up .codehere/ and the agents' hooks (default: Claude Code)
|
|
30
30
|
codehere approve let the finish check run the test command on this machine
|
|
31
31
|
codehere uninstall remove Codehere's hooks, and .codehere/ if it holds no team content
|
|
@@ -48,7 +48,7 @@ When you're stuck
|
|
|
48
48
|
codehere stats turns to green, loops stopped, session size
|
|
49
49
|
|
|
50
50
|
Hooks (called by the agent, not by you)
|
|
51
|
-
codehere hook session-start | prompt | post-tool | stop [--agent codex]
|
|
51
|
+
codehere hook session-start | prompt | post-tool | stop [--agent codex|copilot]
|
|
52
52
|
`;
|
|
53
53
|
|
|
54
54
|
const run = async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehere",
|
|
3
|
-
"version": "2.0.0
|
|
3
|
+
"version": "2.0.0",
|
|
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/commands.js
CHANGED
|
@@ -28,12 +28,24 @@ const CODEX_HOOK_EVENTS = {
|
|
|
28
28
|
Stop: { cmd: 'codehere hook stop --agent codex', timeout: 180 },
|
|
29
29
|
};
|
|
30
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
|
+
|
|
31
40
|
const AGENTS = {
|
|
32
41
|
claude: { name: 'Claude Code', file: '.claude/settings.json', events: HOOK_EVENTS },
|
|
33
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 },
|
|
34
44
|
};
|
|
35
45
|
|
|
36
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]);
|
|
37
49
|
|
|
38
50
|
function requireRoot() {
|
|
39
51
|
const root = findRoot();
|
|
@@ -57,7 +69,6 @@ function detectTestCommand(root) {
|
|
|
57
69
|
return null;
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
// Claude Code's .claude/settings.json and Codex's .codex/hooks.json share this shape.
|
|
61
72
|
function mergeHooks(root, agent) {
|
|
62
73
|
const file = path.join(root, agent.file);
|
|
63
74
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -65,12 +76,19 @@ function mergeHooks(root, agent) {
|
|
|
65
76
|
try {
|
|
66
77
|
settings = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
67
78
|
} catch {}
|
|
79
|
+
if (agent.flat) settings = { version: 1, ...settings };
|
|
68
80
|
settings.hooks = settings.hooks || {};
|
|
69
81
|
for (const [event, spec] of Object.entries(agent.events)) {
|
|
70
82
|
const list = (settings.hooks[event] = settings.hooks[event] || []);
|
|
71
|
-
const already = list.some((g) => (g
|
|
83
|
+
const already = list.some((g) => hooksOf(g).some((h) => h?.command === spec.cmd));
|
|
72
84
|
if (already) continue;
|
|
73
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
|
+
}
|
|
74
92
|
if (spec.timeout) hook.timeout = spec.timeout;
|
|
75
93
|
const group = { hooks: [hook] };
|
|
76
94
|
if (spec.matcher) group.matcher = spec.matcher;
|
|
@@ -80,7 +98,7 @@ function mergeHooks(root, agent) {
|
|
|
80
98
|
}
|
|
81
99
|
|
|
82
100
|
// 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
|
|
101
|
+
// A file left with nothing in it is removed, and its folders up to the repo if they are then empty.
|
|
84
102
|
function removeHooks(root, agent) {
|
|
85
103
|
const file = path.join(root, agent.file);
|
|
86
104
|
let settings;
|
|
@@ -95,24 +113,27 @@ function removeHooks(root, agent) {
|
|
|
95
113
|
if (!Array.isArray(hooks[event])) continue;
|
|
96
114
|
hooks[event] = hooks[event]
|
|
97
115
|
.filter((g) => g && typeof g === 'object')
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
const kept =
|
|
101
|
-
if (kept.length !==
|
|
102
|
-
return { ...g, hooks: kept };
|
|
103
|
-
})
|
|
104
|
-
.filter((g) => g.hooks.length);
|
|
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
|
+
});
|
|
105
122
|
if (!hooks[event].length) delete hooks[event];
|
|
106
123
|
}
|
|
107
124
|
if (!found) return false;
|
|
108
125
|
if (!Object.keys(hooks).length) delete settings.hooks;
|
|
109
|
-
if (Object.keys(settings).
|
|
126
|
+
if (Object.keys(settings).some((k) => !(agent.flat && k === 'version'))) {
|
|
110
127
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
111
128
|
} else {
|
|
112
129
|
fs.rmSync(file);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
|
116
137
|
}
|
|
117
138
|
return true;
|
|
118
139
|
}
|
|
@@ -121,7 +142,7 @@ function installedAgents(root) {
|
|
|
121
142
|
return Object.values(AGENTS).filter((a) => {
|
|
122
143
|
try {
|
|
123
144
|
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) =>
|
|
145
|
+
return Object.values(hooks).some((gs) => Array.isArray(gs) && gs.some((g) => hooksOf(g).some(ours)));
|
|
125
146
|
} catch {
|
|
126
147
|
return false;
|
|
127
148
|
}
|
|
@@ -149,14 +170,16 @@ const PS = fs.existsSync('/bin/ps') ? '/bin/ps' : '/usr/bin/ps';
|
|
|
149
170
|
function underAgent() {
|
|
150
171
|
let pid = process.ppid;
|
|
151
172
|
for (let i = 0; i < 100 && pid > 1; i++) {
|
|
152
|
-
let line;
|
|
173
|
+
let line, args;
|
|
153
174
|
try {
|
|
154
175
|
line = execFileSync(PS, ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
176
|
+
args = execFileSync(PS, ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
155
177
|
} catch {
|
|
156
178
|
return true;
|
|
157
179
|
}
|
|
158
180
|
const m = /^(\d+)\s+(.+)$/.exec(line);
|
|
159
|
-
|
|
181
|
+
// on Linux, comm can be a thread name and not the program (seen with Node 24), so argv[0] counts too
|
|
182
|
+
if (!m || [m[2], args.split(/\s/, 1)[0]].some((n) => AGENT_PROCESSES.has(path.basename(n).toLowerCase()))) return true;
|
|
160
183
|
pid = Number(m[1]);
|
|
161
184
|
}
|
|
162
185
|
return pid > 1;
|
|
@@ -217,7 +240,7 @@ const README_MD = [
|
|
|
217
240
|
export async function init(flags) {
|
|
218
241
|
const names = flags.agent === undefined ? ['claude'] : String(flags.agent === true ? '' : flags.agent).split(',').map((x) => x.trim().toLowerCase());
|
|
219
242
|
if (!names.every((n) => Object.hasOwn(AGENTS, n))) {
|
|
220
|
-
console.error(`Unknown agent "${printable(flags.agent === true ? '' : flags.agent)}". Use --agent claude,
|
|
243
|
+
console.error(`Unknown agent "${printable(flags.agent === true ? '' : flags.agent)}". Use --agent with claude, codex or copilot, or several: --agent claude,codex,copilot.`);
|
|
221
244
|
process.exit(1);
|
|
222
245
|
}
|
|
223
246
|
const agents = [...new Set(names)].map((n) => AGENTS[n]);
|
|
@@ -253,6 +276,10 @@ export async function init(flags) {
|
|
|
253
276
|
console.log('then type /hooks and trust the four Codehere hooks. Until then Codex skips them, and `codex exec` does not warn.');
|
|
254
277
|
console.log('');
|
|
255
278
|
}
|
|
279
|
+
if (agents.includes(AGENTS.copilot)) {
|
|
280
|
+
console.log("Copilot runs a repo's hooks only in a folder you trust. Answer yes when Copilot asks on its first start here.");
|
|
281
|
+
console.log('');
|
|
282
|
+
}
|
|
256
283
|
console.log(`Next: commit .codehere/ and ${agents.map((a) => a.file).join(' and ')} so your team gets it too.`);
|
|
257
284
|
console.log(`Then just use ${agents.map((a) => a.name).join(' or ')} as usual. Codehere stays quiet unless it helps.`);
|
|
258
285
|
}
|
package/src/hooks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Hook handlers for Claude Code
|
|
2
|
-
// anything goes wrong, it exits 0 and the agent carries on as if
|
|
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';
|
|
@@ -35,10 +36,44 @@ function toAgent(message) {
|
|
|
35
36
|
|
|
36
37
|
const quiet = () => process.exit(0);
|
|
37
38
|
|
|
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
|
+
|
|
38
65
|
export async function runHook(event, agent) {
|
|
39
|
-
|
|
66
|
+
let input = readStdin();
|
|
40
67
|
const start = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
41
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);
|
|
42
77
|
if (!root) {
|
|
43
78
|
// uninstall ran while this session was open: its hooks still run, .codehere/ is gone
|
|
44
79
|
const at = event === 'stop' && removedAt(start);
|
|
@@ -46,9 +81,9 @@ export async function runHook(event, agent) {
|
|
|
46
81
|
quiet();
|
|
47
82
|
}
|
|
48
83
|
try {
|
|
49
|
-
if (event === 'session-start') return sessionStart(root, input);
|
|
84
|
+
if (event === 'session-start') return sessionStart(root, input, agent);
|
|
50
85
|
if (event === 'prompt') return promptSubmit(root, input);
|
|
51
|
-
if (event === 'post-tool') return postTool(root, input, agent === 'codex'
|
|
86
|
+
if (event === 'post-tool') return postTool(root, input, agent === 'codex' || agent === 'copilot' ? agent : 'claude');
|
|
52
87
|
if (event === 'stop') return stop(root, input);
|
|
53
88
|
} catch (err) {
|
|
54
89
|
if (process.env.CODEHERE_DEBUG) console.error(err);
|
|
@@ -58,7 +93,7 @@ export async function runHook(event, agent) {
|
|
|
58
93
|
|
|
59
94
|
// ---------- SessionStart: load only human-confirmed context ----------
|
|
60
95
|
|
|
61
|
-
function sessionStart(root, input) {
|
|
96
|
+
function sessionStart(root, input, agent) {
|
|
62
97
|
const state = readState(root);
|
|
63
98
|
const s = session(state, input.session_id);
|
|
64
99
|
s.transcript = input.transcript_path || s.transcript;
|
|
@@ -110,6 +145,8 @@ function sessionStart(root, input) {
|
|
|
110
145
|
}
|
|
111
146
|
|
|
112
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') });
|
|
113
150
|
if (out.length) process.stdout.write(out.join('\n') + '\n');
|
|
114
151
|
quiet();
|
|
115
152
|
}
|
|
@@ -125,7 +162,7 @@ function briefFile(root, file) {
|
|
|
125
162
|
|
|
126
163
|
// ---------- what changed in THIS session ----------
|
|
127
164
|
|
|
128
|
-
const IGNORE = (f) =>
|
|
165
|
+
const IGNORE = (f) => ['.codehere/', '.claude/', '.codex/', '.github/copilot/', '.github/hooks/'].some((d) => f.startsWith(d));
|
|
129
166
|
|
|
130
167
|
export function dirtySnapshot(root) {
|
|
131
168
|
const files = statusFiles(root).filter((f) => !IGNORE(f));
|
|
@@ -155,21 +192,33 @@ function changedThisSession(root, s) {
|
|
|
155
192
|
|
|
156
193
|
// Files that decide what Codehere runs and tells every agent, and the agents' own hook
|
|
157
194
|
// settings. Claude Code and Codex load hooks at session start, so a hook removed mid-session
|
|
158
|
-
// still runs at this session's finish check and can report it.
|
|
159
|
-
|
|
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
|
+
}
|
|
160
208
|
|
|
161
209
|
function settingsSnapshot(root) {
|
|
162
210
|
const snap = {};
|
|
163
211
|
for (const f of PROTECTED) {
|
|
164
212
|
let text = null;
|
|
165
213
|
try {
|
|
166
|
-
text =
|
|
214
|
+
text = readProtected(root, f);
|
|
167
215
|
} catch {}
|
|
168
216
|
// In .claude/ only the hook and env settings count: Claude Code also saves permission
|
|
169
217
|
// rules there when the developer picks "don't ask again". env can move HOME or
|
|
170
218
|
// XDG_CONFIG_HOME, where approvals are kept. A missing file has none of them.
|
|
171
219
|
// Codex keeps what it saves in the user's own config, so .codex/ files count whole.
|
|
172
|
-
|
|
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/')) {
|
|
173
222
|
try {
|
|
174
223
|
const j = JSON.parse(text ?? '{}');
|
|
175
224
|
text = JSON.stringify([j.hooks ?? null, j.disableAllHooks ?? null, j.env ?? null]);
|
|
@@ -351,7 +400,9 @@ function postTool(root, input, agent) {
|
|
|
351
400
|
);
|
|
352
401
|
}
|
|
353
402
|
// 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.
|
|
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') });
|
|
355
406
|
if (agent === 'codex') {
|
|
356
407
|
emitJson({
|
|
357
408
|
...(lesson ? { systemMessage: lessonLine(lesson) } : {}),
|
|
@@ -363,6 +414,7 @@ function postTool(root, input, agent) {
|
|
|
363
414
|
toAgent(msg.join('\n') + '\n');
|
|
364
415
|
}
|
|
365
416
|
|
|
417
|
+
if (lesson && agent === 'copilot') emitJson({ additionalContext: lessonText(root, lesson) });
|
|
366
418
|
if (lesson) {
|
|
367
419
|
emitJson({
|
|
368
420
|
systemMessage: lessonLine(lesson),
|