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 +39 -8
- package/bin/codehere.js +11 -5
- package/package.json +1 -1
- package/src/brain.js +31 -0
- package/src/commands.js +224 -37
- package/src/hooks.js +92 -19
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
|
|
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"]
|
|
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
|
|
140
|
+
## Known limits (2.0 beta)
|
|
114
141
|
|
|
115
|
-
- Claude Code
|
|
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
|
-
-
|
|
121
|
-
-
|
|
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"]
|
|
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
|
|
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.
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(`-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
2
|
-
// 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';
|
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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) =>
|
|
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
|
|
150
|
-
// settings. Claude Code
|
|
151
|
-
// still runs at this session's finish check and can report it.
|
|
152
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|