@hncode/hncode 0.3.1 → 0.3.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/src/git.js ADDED
@@ -0,0 +1,332 @@
1
+ // Git workflow helpers — structured access to the operations a coding agent
2
+ // performs constantly, so the model (and the /git, /commit, /branch commands)
3
+ // do not have to hand-write shell pipelines for them.
4
+ //
5
+ // Everything here shells out to the user's own `git`. Nothing is reimplemented:
6
+ // the point is a typed, testable wrapper that reports clean errors and never
7
+ // runs a destructive command implicitly. `isRepo()` gates every other call so a
8
+ // non-repo directory yields a clear message instead of a raw git error.
9
+ //
10
+ // Destructive operations (reset --hard, checkout --, branch -D) are NOT exposed
11
+ // here. The agent can still run them through Bash if the user asks; a helper
12
+ // module should not make them easy to reach by accident.
13
+
14
+ import cp from 'node:child_process';
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+
18
+ // Force UTF-8 for git's OWN output regardless of the console codepage, and stop
19
+ // it octal-escaping non-ASCII filenames. Without these, on a Windows console
20
+ // using a non-UTF-8 codepage (GBK/CP936, etc.) git prints paths and messages in
21
+ // that codepage and Node's utf8 decode mangles them — and even in UTF-8 locales
22
+ // `git status` quotes any non-ASCII path as `"\350\257\264\346\230\216.md"`,
23
+ // which the model then cannot match to a real file.
24
+ // i18n.logOutputEncoding=utf-8 : commit messages on stdout
25
+ // core.quotepath=false : raw (unquoted) non-ASCII paths
26
+ // i18n.commitEncoding=utf-8 : how we write messages back
27
+ // Passed with -c so nothing is written to the user's git config.
28
+ const GIT_ENV = [
29
+ '-c', 'core.quotepath=false',
30
+ '-c', 'i18n.logOutputEncoding=utf-8',
31
+ '-c', 'i18n.commitEncoding=utf-8',
32
+ ];
33
+
34
+ // Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
35
+ // Never throws: a missing git binary or a non-repo is data, not an exception.
36
+ export function runGit(args, cwd, opts = {}) {
37
+ const timeout = opts.timeoutMs || 30_000;
38
+ try {
39
+ const r = cp.spawnSync('git', [...GIT_ENV, ...args], {
40
+ cwd: cwd || process.cwd(),
41
+ // Decode git's bytes as UTF-8 explicitly. `encoding: 'utf8'` is what we
42
+ // want, but the environment is also pinned below so a console codepage
43
+ // cannot override the byte stream before it reaches us.
44
+ encoding: 'utf8',
45
+ maxBuffer: 16 * 1024 * 1024,
46
+ timeout,
47
+ windowsHide: true,
48
+ env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' },
49
+ });
50
+ if (r.error) return { ok: false, stdout: '', stderr: '', code: null, error: r.error.message };
51
+ return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '', code: r.status, error: null };
52
+ } catch (e) {
53
+ return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
54
+ }
55
+ }
56
+
57
+ export function isRepo(cwd) {
58
+ const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
59
+ return r.ok && r.stdout.trim() === 'true';
60
+ }
61
+
62
+ // "{branch}" or a detached-HEAD short sha. Empty when not a repo.
63
+ export function currentBranch(cwd) {
64
+ const r = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
65
+ if (!r.ok) return '';
66
+ const b = r.stdout.trim();
67
+ if (b && b !== 'HEAD') return b;
68
+ // Detached: report the short sha so the status line is still meaningful.
69
+ const s = runGit(['rev-parse', '--short', 'HEAD'], cwd);
70
+ return s.ok ? `(detached ${s.stdout.trim()})` : '';
71
+ }
72
+
73
+ export function headSha(cwd, short = true) {
74
+ const r = runGit(short ? ['rev-parse', '--short', 'HEAD'] : ['rev-parse', 'HEAD'], cwd);
75
+ return r.ok ? r.stdout.trim() : '';
76
+ }
77
+
78
+ // Parsed `git status --porcelain=v1`. Each entry:
79
+ // { x, y, path, origPath } — x = index status, y = worktree status
80
+ // `??` is untracked, `!!` is ignored (not reported by default here).
81
+ export function status(cwd) {
82
+ const r = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all'], cwd);
83
+ if (!r.ok) return { ok: false, error: r.error || r.stderr.trim() || 'git status failed', entries: [] };
84
+ const parts = r.stdout.split('\0');
85
+ const entries = [];
86
+ for (let i = 0; i < parts.length; i++) {
87
+ const e = parts[i];
88
+ if (!e) continue;
89
+ const x = e[0], y = e[1];
90
+ const p = e.slice(3);
91
+ // A rename/copy is followed by the ORIGINAL path in the next NUL field.
92
+ if (x === 'R' || x === 'C') {
93
+ const orig = parts[++i] || '';
94
+ entries.push({ x, y, path: p, origPath: orig });
95
+ } else {
96
+ entries.push({ x, y, path: p });
97
+ }
98
+ }
99
+ return { ok: true, entries, error: null };
100
+ }
101
+
102
+ // A compact human summary: "M file.js", "?? new.txt", …
103
+ export function statusLines(cwd) {
104
+ const s = status(cwd);
105
+ if (!s.ok) return [`git status failed: ${s.error}`];
106
+ if (!s.entries.length) return ['Working tree clean.'];
107
+ return s.entries.map((e) => `${(e.x + e.y).trim() || '??'} ${e.path}${e.origPath ? ` (from ${e.origPath})` : ''}`);
108
+ }
109
+
110
+ // Stage paths (default: everything, `git add -A`). Returns { ok, error }.
111
+ export function stage(paths, cwd) {
112
+ // `-A` is a FLAG, not a pathspec: it must not follow `--`. With explicit
113
+ // paths, `--` separates them from a path that looks like a flag.
114
+ const real = (paths && paths.length) ? ['add', '--', ...paths] : ['add', '-A'];
115
+ const r = runGit(real, cwd);
116
+ return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git add failed') };
117
+ }
118
+
119
+ export function unstage(paths, cwd) {
120
+ const real = (paths && paths.length) ? ['reset', 'HEAD', '--', ...paths] : ['reset', 'HEAD'];
121
+ const r = runGit(real, cwd);
122
+ return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git reset failed') };
123
+ }
124
+
125
+ // Files changed in the index (staged), for a commit preview.
126
+ export function stagedFiles(cwd) {
127
+ const r = runGit(['diff', '--cached', '--name-only'], cwd);
128
+ return r.ok ? r.stdout.split('\n').filter(Boolean) : [];
129
+ }
130
+
131
+ // Is the index empty (nothing staged)?
132
+ export function hasStagedChanges(cwd) {
133
+ const r = runGit(['diff', '--cached', '--quiet'], cwd);
134
+ // exit 1 means there ARE differences.
135
+ return r.code === 1;
136
+ }
137
+
138
+ // Commit the staged index. `message` may be multi-line; it is passed with -m
139
+ // repeated so newlines survive every platform's arg handling.
140
+ export function commit(message, cwd, opts = {}) {
141
+ const msg = String(message == null ? '' : message).trim();
142
+ if (!msg) return { ok: false, error: 'commit message must not be empty' };
143
+ const args = ['commit'];
144
+ if (opts.amend) args.push('--amend');
145
+ if (opts.noVerify) args.push('--no-verify');
146
+ for (const line of msg.split('\n')) args.push('-m', line);
147
+ const r = runGit(args, cwd, { timeoutMs: 120_000 });
148
+ if (!r.ok) return { ok: false, error: r.stderr.trim() || r.error || 'git commit failed', stdout: r.stdout };
149
+ const sha = headSha(cwd);
150
+ return { ok: true, sha, stdout: r.stdout.trim() };
151
+ }
152
+
153
+ export function listBranches(cwd) {
154
+ const r = runGit(['branch', '--format=%(refname:short)'], cwd);
155
+ return r.ok ? r.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
156
+ }
157
+
158
+ // Create and switch to a branch. `from` optionally bases it on another ref.
159
+ export function createBranch(name, cwd, from) {
160
+ const n = String(name || '').trim();
161
+ if (!n) return { ok: false, error: 'branch name must not be empty' };
162
+ const args = ['switch', '-c', n];
163
+ if (from) args.push(from);
164
+ let r = runGit(args, cwd);
165
+ // Older git (< 2.23) has no `switch`.
166
+ if (!r.ok && /switch/.test(r.stderr) && /unknown|not a git command/i.test(r.stderr)) {
167
+ r = runGit(from ? ['checkout', '-b', n, from] : ['checkout', '-b', n], cwd);
168
+ }
169
+ return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git switch failed') };
170
+ }
171
+
172
+ export function switchBranch(name, cwd) {
173
+ const n = String(name || '').trim();
174
+ if (!n) return { ok: false, error: 'branch name must not be empty' };
175
+ let r = runGit(['switch', n], cwd);
176
+ if (!r.ok && /unknown|not a git command/i.test(r.stderr)) r = runGit(['checkout', n], cwd);
177
+ return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error || 'git switch failed') };
178
+ }
179
+
180
+ export function remoteUrl(cwd) {
181
+ const r = runGit(['remote', 'get-url', 'origin'], cwd);
182
+ return r.ok ? r.stdout.trim() : '';
183
+ }
184
+
185
+ // Parse a git remote URL into { host, owner, repo }. Handles https and ssh
186
+ // (scp-like) forms. Returns null when it does not look like one.
187
+ export function parseRemote(url) {
188
+ const s = String(url || '').trim();
189
+ if (!s) return null;
190
+ let m = /^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(s);
191
+ if (m) {
192
+ const parts = m[2].split('/').filter(Boolean);
193
+ if (parts.length >= 2) return { host: m[1], owner: parts[0], repo: parts[parts.length - 1] };
194
+ }
195
+ m = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?\/?$/.exec(s);
196
+ if (m) {
197
+ const parts = m[2].split('/').filter(Boolean);
198
+ if (parts.length >= 2) return { host: m[1], owner: parts[0], repo: parts[parts.length - 1] };
199
+ }
200
+ return null;
201
+ }
202
+
203
+ // A "create a PR" URL for the current branch, or '' when the remote is unknown.
204
+ // This does NOT open a browser or call an API: it hands the user a link, which
205
+ // is the portable behaviour across hosts (GitHub/GitLab/Bitbucket all accept a
206
+ // compare URL for the common hosts; unknown hosts get the repo page).
207
+ export function prUrl(cwd, base = 'main') {
208
+ const info = parseRemote(remoteUrl(cwd));
209
+ if (!info) return '';
210
+ const branch = currentBranch(cwd);
211
+ if (!branch || branch.startsWith('(')) return '';
212
+ const { host, owner, repo } = info;
213
+ if (host.includes('github.com')) {
214
+ return `https://${host}/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`;
215
+ }
216
+ if (host.includes('gitlab')) {
217
+ return `https://${host}/${owner}/${repo}/-/merge_requests/new?merge_request[source_branch]=${encodeURIComponent(branch)}`;
218
+ }
219
+ if (host.includes('bitbucket')) {
220
+ return `https://${host}/${owner}/${repo}/pull-requests/new?source=${encodeURIComponent(branch)}`;
221
+ }
222
+ return `https://${host}/${owner}/${repo}`;
223
+ }
224
+
225
+ // --- worktree support (parallel sessions that cannot collide) ----------------
226
+
227
+ export function listWorktrees(cwd) {
228
+ const r = runGit(['worktree', 'list', '--porcelain'], cwd);
229
+ if (!r.ok) return [];
230
+ const out = [];
231
+ let cur = null;
232
+ for (const line of r.stdout.split('\n')) {
233
+ if (line.startsWith('worktree ')) { if (cur) out.push(cur); cur = { path: line.slice(9).trim() }; }
234
+ else if (!cur) continue;
235
+ else if (line.startsWith('branch ')) cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
236
+ else if (line.startsWith('HEAD ')) cur.head = line.slice(5).trim();
237
+ else if (line === 'bare') cur.bare = true;
238
+ else if (line === 'detached') cur.detached = true;
239
+ }
240
+ if (cur) out.push(cur);
241
+ return out;
242
+ }
243
+
244
+ // Normalize a path for comparison. Two problems make a raw string compare fail:
245
+ // * git reports worktree paths with FORWARD slashes even on Windows, while
246
+ // path.resolve() yields backslashes;
247
+ // * on Windows a path can be spelled with an 8.3 short name (`ADMINI~1`) or
248
+ // the long name (`Administrator`) — they are the same directory.
249
+ // realpathSync resolves both; it also lower-cases via lowercasing after resolve.
250
+ function samePath(a, b) {
251
+ const norm = (p) => {
252
+ let r;
253
+ const abs = path.resolve(String(p || ''));
254
+ try { r = fs.realpathSync.native ? fs.realpathSync.native(abs) : fs.realpathSync(abs); }
255
+ catch { r = abs; } // may not exist yet: fall back
256
+ return process.platform === 'win32' ? r.toLowerCase() : r;
257
+ };
258
+ return norm(a) === norm(b);
259
+ }
260
+
261
+ // Create (or reuse) a worktree for `branch` at `dir`.
262
+ export function addWorktree(dir, branch, cwd, opts = {}) {
263
+ const target = path.resolve(dir);
264
+ if (fs.existsSync(target)) {
265
+ const existing = listWorktrees(cwd).find((w) => samePath(w.path, target));
266
+ if (existing) return { ok: true, path: target, reused: true, branch: existing.branch };
267
+ return { ok: false, error: `path already exists and is not a worktree: ${target}` };
268
+ }
269
+ const branches = listBranches(cwd);
270
+ const args = ['worktree', 'add'];
271
+ if (opts.detach) args.push('--detach');
272
+ args.push(target);
273
+ if (branch) args.push(branches.includes(branch) ? branch : '-b', ...(branches.includes(branch) ? [] : [branch]));
274
+ const r = runGit(args, cwd);
275
+ return r.ok ? { ok: true, path: target, reused: false, branch } : { ok: false, error: r.stderr.trim() || r.error };
276
+ }
277
+
278
+ export function removeWorktree(dir, cwd, opts = {}) {
279
+ const args = ['worktree', 'remove', path.resolve(dir)];
280
+ if (opts.force) args.push('--force');
281
+ const r = runGit(args, cwd);
282
+ return { ok: r.ok, error: r.ok ? null : (r.stderr.trim() || r.error) };
283
+ }
284
+
285
+ // A short context block for the system prompt / status line: branch, dirty
286
+ // count, and the remote. Empty when not a repo.
287
+ export function summary(cwd) {
288
+ if (!isRepo(cwd)) return '';
289
+ const branch = currentBranch(cwd);
290
+ const s = status(cwd);
291
+ const dirty = s.ok ? s.entries.length : 0;
292
+ const info = parseRemote(remoteUrl(cwd));
293
+ const remote = info ? `${info.owner}/${info.repo}` : '';
294
+ return [
295
+ `branch: ${branch || '(unknown)'}`,
296
+ `changes: ${dirty} file(s)${dirty ? ' — uncommitted' : ' — clean'}`,
297
+ remote ? `remote: ${remote}` : '',
298
+ ].filter(Boolean).join('\n');
299
+ }
300
+
301
+ // Compact statusline info: the branch plus the working-tree diff totals.
302
+ // Returns null when not a repo (the caller then renders nothing).
303
+ //
304
+ // The counts come from `git diff --shortstat` run twice (unstaged + staged) so
305
+ // the numbers describe the UNCOMMITTED work — which is what a "+12 -3" badge on
306
+ // the status line is for.
307
+ export function statuslineInfo(cwd) {
308
+ if (!isRepo(cwd)) return null;
309
+ const branch = currentBranch(cwd);
310
+ const stat = (args) => {
311
+ const r = runGit(['diff', ...args, '--shortstat'], cwd);
312
+ if (!r.ok) return { files: 0, insertions: 0, deletions: 0 };
313
+ // e.g. " 3 files changed, 12 insertions(+), 4 deletions(-)"
314
+ const s = r.stdout.trim();
315
+ const files = /(\d+) files? changed/.exec(s);
316
+ const ins = /(\d+) insertions?\(\+\)/.exec(s);
317
+ const del = /(\d+) deletions?\(-\)/.exec(s);
318
+ return {
319
+ files: files ? Number(files[1]) : 0,
320
+ insertions: ins ? Number(ins[1]) : 0,
321
+ deletions: del ? Number(del[1]) : 0,
322
+ };
323
+ };
324
+ const unstaged = stat([]);
325
+ const staged = stat(['--cached']);
326
+ return {
327
+ branch: branch || '',
328
+ insertions: unstaged.insertions + staged.insertions,
329
+ deletions: unstaged.deletions + staged.deletions,
330
+ changed: unstaged.files + staged.files,
331
+ };
332
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,247 @@
1
+ // Shell-command hooks — user-configured commands run at lifecycle events,
2
+ // mirroring Claude Code's `hooks` feature.
3
+ //
4
+ // Config lives in ~/.hncode/hooks.json (and an optional per-project
5
+ // <workspace>/.hncode/hooks.json that is merged on top):
6
+ //
7
+ // {
8
+ // "hooks": {
9
+ // "PreToolUse": [{ "matcher": "Edit|Write", "command": "npm run lint" }],
10
+ // "PostToolUse": [{ "matcher": "Edit", "command": "npx prettier --write $FILE" }],
11
+ // "UserPromptSubmit": [{ "command": "echo submitting" }],
12
+ // "SessionStart": [{ "command": "git fetch --quiet" }],
13
+ // "Stop": [{ "command": "echo turn done >> ~/turns.log" }]
14
+ // }
15
+ // }
16
+ //
17
+ // Each entry runs the command with the shell (bash on POSIX, pwsh on Windows).
18
+ // The event payload is passed as JSON on stdin and, for convenience, projected
19
+ // into environment variables the command can read directly:
20
+ //
21
+ // HNCODE_EVENT event name (PreToolUse, …)
22
+ // HNCODE_TOOL_NAME tool name (tool events only)
23
+ // HNCODE_TOOL_ARGS JSON of the tool args (tool events only)
24
+ // HNCODE_FILE best-effort path argument (tool events only)
25
+ // HNCODE_PROMPT the user's prompt (UserPromptSubmit)
26
+ // HNCODE_CWD working directory the command runs in
27
+ //
28
+ // Exit codes: 0 = continue. For PreToolUse, a NON-zero exit BLOCKS the tool and
29
+ // its stderr (or stdout) becomes the message the model sees. For every other
30
+ // event a failure is only logged, never fatal — a broken hook must not stop a
31
+ // turn. Hooks are best-effort and time-bounded (see HOOK_TIMEOUT_MS).
32
+
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import cp from 'node:child_process';
37
+
38
+ // Events the core emits. Kept explicit so a typo in config is reported instead
39
+ // of silently never firing.
40
+ export const HOOK_EVENTS = [
41
+ 'SessionStart',
42
+ 'UserPromptSubmit',
43
+ 'PreToolUse',
44
+ 'PostToolUse',
45
+ 'Stop',
46
+ 'PreCompact',
47
+ ];
48
+
49
+ // A hook that hangs must not hang the agent. 60s is generous for a formatter or
50
+ // a lint pass and still bounded.
51
+ export const HOOK_TIMEOUT_MS = 60 * 1000;
52
+
53
+ export function globalHooksFile() {
54
+ return process.env.HNCODE_HOOKS_FILE || path.join(os.homedir(), '.hncode', 'hooks.json');
55
+ }
56
+ export function projectHooksFile(workspace) {
57
+ return path.join(workspace || process.cwd(), '.hncode', 'hooks.json');
58
+ }
59
+
60
+ // Parse one hooks file. Returns { hooks: {event: [entry]}, errors: [string] }.
61
+ // A malformed file yields empty hooks and a recorded error, never a throw.
62
+ function readHooksFile(file) {
63
+ const out = { hooks: {}, errors: [] };
64
+ let text;
65
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
66
+ let doc;
67
+ try { doc = JSON.parse(text); } catch (e) {
68
+ out.errors.push(`${file}: invalid JSON (${e.message})`);
69
+ return out;
70
+ }
71
+ const raw = (doc && doc.hooks) || {};
72
+ for (const [event, list] of Object.entries(raw)) {
73
+ if (!HOOK_EVENTS.includes(event)) { out.errors.push(`${file}: unknown event "${event}"`); continue; }
74
+ const entries = Array.isArray(list) ? list : [list];
75
+ const clean = [];
76
+ for (const e of entries) {
77
+ if (!e || typeof e !== 'object' || typeof e.command !== 'string' || !e.command.trim()) {
78
+ out.errors.push(`${file}: ${event} entry needs a non-empty "command"`);
79
+ continue;
80
+ }
81
+ clean.push({
82
+ command: e.command,
83
+ matcher: typeof e.matcher === 'string' ? e.matcher : '',
84
+ timeoutMs: Number.isFinite(e.timeout) ? Math.max(1, e.timeout) * 1000 : HOOK_TIMEOUT_MS,
85
+ source: file,
86
+ });
87
+ }
88
+ if (clean.length) out.hooks[event] = clean;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Merge global + project hooks. Project entries run AFTER global ones so a
94
+ // project can add to (not replace) the user's setup.
95
+ export function loadHooks(workspace) {
96
+ const merged = {};
97
+ const errors = [];
98
+ const files = workspace
99
+ ? [globalHooksFile(), projectHooksFile(workspace)]
100
+ : [globalHooksFile()];
101
+ const seen = new Set();
102
+ for (const f of files) {
103
+ const abs = path.resolve(f);
104
+ if (seen.has(abs)) continue; // global === project (workspace is ~)
105
+ seen.add(abs);
106
+ const { hooks, errors: errs } = readHooksFile(abs);
107
+ errors.push(...errs);
108
+ for (const [ev, list] of Object.entries(hooks)) {
109
+ merged[ev] = (merged[ev] || []).concat(list);
110
+ }
111
+ }
112
+ return { hooks: merged, errors };
113
+ }
114
+
115
+ // Does this entry apply to `toolName`? An empty matcher matches everything; a
116
+ // non-empty one is a case-insensitive alternation of exact names (Claude Code's
117
+ // `Edit|Write` form).
118
+ export function matcherApplies(matcher, toolName) {
119
+ if (!matcher) return true;
120
+ if (!toolName) return true;
121
+ const name = String(toolName);
122
+ return matcher.split('|').map((s) => s.trim()).filter(Boolean)
123
+ .some((m) => m.toLowerCase() === name.toLowerCase());
124
+ }
125
+
126
+ // The path-ish argument of a tool call, for HNCODE_FILE. Best-effort: it only
127
+ // needs to help a formatter/linter, not to be authoritative.
128
+ function fileArgFor(args) {
129
+ if (!args || typeof args !== 'object') return '';
130
+ for (const k of ['path', 'file_path', 'filePath', 'file']) {
131
+ if (typeof args[k] === 'string' && args[k]) return args[k];
132
+ }
133
+ return '';
134
+ }
135
+
136
+ // Build the environment for a hook invocation from the event payload.
137
+ export function hookEnv(event, payload, cwd) {
138
+ const p = payload || {};
139
+ const env = { ...process.env, HNCODE_EVENT: event, HNCODE_CWD: cwd || process.cwd() };
140
+ if (p.toolName) env.HNCODE_TOOL_NAME = String(p.toolName);
141
+ if (p.toolArgs !== undefined) env.HNCODE_TOOL_ARGS = JSON.stringify(p.toolArgs);
142
+ const f = fileArgFor(p.toolArgs);
143
+ if (f) env.HNCODE_FILE = f;
144
+ if (p.prompt !== undefined) env.HNCODE_PROMPT = String(p.prompt);
145
+ return env;
146
+ }
147
+
148
+ function shellCommand() {
149
+ if (process.platform === 'win32') return { bin: 'pwsh', flag: '-Command' };
150
+ return { bin: '/bin/sh', flag: '-c' };
151
+ }
152
+
153
+ // Run ONE hook entry. Resolves to
154
+ // { ok: true, stdout, stderr, exitCode } — command exited 0
155
+ // { ok: false, blocked: bool, message, ... } — non-zero / timeout
156
+ // Never rejects: a hook failure is data, not an exception.
157
+ export function runHookCommand(entry, event, payload, cwd) {
158
+ return new Promise((resolve) => {
159
+ const { bin, flag } = shellCommand();
160
+ let child;
161
+ try {
162
+ child = cp.spawn(bin, [flag, entry.command], {
163
+ cwd: cwd || process.cwd(),
164
+ env: hookEnv(event, payload, cwd),
165
+ stdio: ['pipe', 'pipe', 'pipe'],
166
+ });
167
+ } catch (e) {
168
+ resolve({ ok: false, blocked: false, message: `could not start: ${e.message}`, exitCode: null });
169
+ return;
170
+ }
171
+ let out = '', err = '';
172
+ let done = false;
173
+ const finish = (res) => { if (!done) { done = true; resolve(res); } };
174
+ const timer = setTimeout(() => {
175
+ try { child.kill(); } catch {}
176
+ finish({ ok: false, blocked: event === 'PreToolUse', message: `timed out after ${entry.timeoutMs / 1000}s`, exitCode: null });
177
+ }, entry.timeoutMs);
178
+
179
+ if (child.stdout) child.stdout.on('data', (d) => { out += d.toString(); });
180
+ if (child.stderr) child.stderr.on('data', (d) => { err += d.toString(); });
181
+ child.on('error', (e) => { clearTimeout(timer); finish({ ok: false, blocked: false, message: e.message, exitCode: null }); });
182
+ child.on('close', (code) => {
183
+ clearTimeout(timer);
184
+ if (code === 0) finish({ ok: true, stdout: out, stderr: err, exitCode: 0 });
185
+ else finish({
186
+ ok: false,
187
+ blocked: event === 'PreToolUse',
188
+ message: (code === null ? 'terminated' : `exit ${code}`),
189
+ stdout: out, stderr: err, exitCode: code,
190
+ });
191
+ });
192
+ // Feed the payload as JSON on stdin (some hooks prefer parsing it).
193
+ try {
194
+ if (child.stdin) {
195
+ child.stdin.end(JSON.stringify({ event, ...(payload || {}) }));
196
+ }
197
+ } catch { /* stdin already gone: the env vars are still available */ }
198
+ });
199
+ }
200
+
201
+ // Run every hook for `event`. Returns
202
+ // { blocked, reason, results: [...] }
203
+ // `blocked` is true only when a PreToolUse hook failed — that is the one event
204
+ // whose exit code changes control flow (the tool must not run).
205
+ export async function runShellHooks(config, event, payload, cwd) {
206
+ const list = (config && config.hooks && config.hooks[event]) || [];
207
+ if (!list.length) return { blocked: false, reason: '', results: [] };
208
+ // Tool events respect their matcher: a `matcher: "Edit"` hook must fire only
209
+ // for Edit, for BOTH PreToolUse and PostToolUse. Applying it to PreToolUse
210
+ // alone made a PostToolUse hook run on every tool — a "format after edit" hook
211
+ // ran after reads and searches too.
212
+ const isToolEvent = event === 'PreToolUse' || event === 'PostToolUse';
213
+ const results = [];
214
+ for (const entry of list) {
215
+ if (isToolEvent && !matcherApplies(entry.matcher, payload && payload.toolName)) continue;
216
+ const r = await runHookCommand(entry, event, payload, cwd);
217
+ results.push({ entry, ...r });
218
+ if (!r.ok) {
219
+ // The message the model sees: prefer stderr, fall back to stdout.
220
+ const msg = (r.stderr || r.stdout || r.message || 'hook failed').trim();
221
+ if (event === 'PreToolUse') {
222
+ return { blocked: true, reason: msg || 'blocked by a PreToolUse hook', results };
223
+ }
224
+ // Other events: log only, keep going.
225
+ try { console.error(`[hncode-hook] ${event} (${entry.source}): ${msg}`); } catch {}
226
+ }
227
+ }
228
+ return { blocked: false, reason: '', results };
229
+ }
230
+
231
+ // Human-readable summary for /hooks.
232
+ export function describeHooks(config) {
233
+ const hooks = (config && config.hooks) || {};
234
+ const lines = [];
235
+ const total = Object.values(hooks).reduce((a, l) => a + l.length, 0);
236
+ if (!total) return ['No hooks configured.', '', `Global file: ${globalHooksFile()}`, `Project file: ${projectHooksFile(process.cwd())}`];
237
+ for (const ev of HOOK_EVENTS) {
238
+ const list = hooks[ev];
239
+ if (!list || !list.length) continue;
240
+ lines.push(`${ev}:`);
241
+ for (const e of list) {
242
+ lines.push(` ${e.matcher ? `[${e.matcher}] ` : ''}${e.command}`);
243
+ lines.push(` (${e.source})`);
244
+ }
245
+ }
246
+ return lines;
247
+ }