@rmyndharis/aimhooman 0.1.1 → 0.1.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/rules/paths.json CHANGED
@@ -1,12 +1,13 @@
1
1
  [
2
2
  {
3
3
  "id": "claude.local-settings",
4
- "version": 1,
4
+ "version": 2,
5
5
  "provider": "claude-code",
6
6
  "category": "local-settings",
7
7
  "confidence": "high",
8
8
  "kind": "path",
9
9
  "match": {
10
+ "path_case": "insensitive",
10
11
  "paths": [
11
12
  ".claude/settings.local.json",
12
13
  "**/.claude/settings.local.json"
@@ -24,17 +25,16 @@
24
25
  },
25
26
  {
26
27
  "id": "claude.session-state",
27
- "version": 1,
28
+ "version": 2,
28
29
  "provider": "claude-code",
29
30
  "category": "ephemeral-state",
30
31
  "confidence": "high",
31
32
  "kind": "path",
32
33
  "match": {
34
+ "path_case": "insensitive",
33
35
  "paths": [
34
36
  ".claude.json",
35
37
  "**/.claude.json",
36
- ".claude/.credentials.json",
37
- "**/.claude/.credentials.json",
38
38
  ".claude/session*.json",
39
39
  "**/.claude/session*.json",
40
40
  ".claude/history*",
@@ -64,12 +64,13 @@
64
64
  },
65
65
  {
66
66
  "id": "codex.session-state",
67
- "version": 1,
67
+ "version": 2,
68
68
  "provider": "codex",
69
69
  "category": "ephemeral-state",
70
70
  "confidence": "high",
71
71
  "kind": "path",
72
72
  "match": {
73
+ "path_case": "insensitive",
73
74
  "paths": [
74
75
  ".codex/sessions/**",
75
76
  "**/.codex/sessions/**",
@@ -93,12 +94,13 @@
93
94
  },
94
95
  {
95
96
  "id": "copilot.session-state",
96
- "version": 2,
97
+ "version": 3,
97
98
  "provider": "copilot",
98
99
  "category": "ephemeral-state",
99
100
  "confidence": "high",
100
101
  "kind": "path",
101
102
  "match": {
103
+ "path_case": "insensitive",
102
104
  "paths": [
103
105
  ".copilot/**",
104
106
  "**/.copilot/**"
@@ -116,12 +118,13 @@
116
118
  },
117
119
  {
118
120
  "id": "cursor.session-state",
119
- "version": 1,
121
+ "version": 2,
120
122
  "provider": "cursor",
121
123
  "category": "ephemeral-state",
122
124
  "confidence": "medium",
123
125
  "kind": "path",
124
126
  "match": {
127
+ "path_case": "insensitive",
125
128
  "paths": [
126
129
  ".cursor/session*",
127
130
  "**/.cursor/session*",
@@ -143,12 +146,13 @@
143
146
  },
144
147
  {
145
148
  "id": "aider.history",
146
- "version": 2,
149
+ "version": 3,
147
150
  "provider": "aider",
148
151
  "category": "ephemeral-state",
149
152
  "confidence": "high",
150
153
  "kind": "path",
151
154
  "match": {
155
+ "path_case": "insensitive",
152
156
  "paths": [
153
157
  ".aider.*",
154
158
  "**/.aider.*"
@@ -166,12 +170,13 @@
166
170
  },
167
171
  {
168
172
  "id": "specstory.history",
169
- "version": 1,
173
+ "version": 2,
170
174
  "provider": "specstory",
171
175
  "category": "ephemeral-state",
172
176
  "confidence": "medium",
173
177
  "kind": "path",
174
178
  "match": {
179
+ "path_case": "insensitive",
175
180
  "paths": [
176
181
  ".specstory/**",
177
182
  "**/.specstory/**"
@@ -189,12 +194,13 @@
189
194
  },
190
195
  {
191
196
  "id": "continue.sessions",
192
- "version": 1,
197
+ "version": 2,
193
198
  "provider": "continue",
194
199
  "category": "ephemeral-state",
195
200
  "confidence": "medium",
196
201
  "kind": "path",
197
202
  "match": {
203
+ "path_case": "insensitive",
198
204
  "paths": [
199
205
  ".continue/sessions/**",
200
206
  "**/.continue/sessions/**"
@@ -212,7 +218,7 @@
212
218
  },
213
219
  {
214
220
  "id": "secret.dotenv",
215
- "version": 2,
221
+ "version": 3,
216
222
  "provider": "generic",
217
223
  "category": "secret",
218
224
  "confidence": "high",
@@ -226,16 +232,11 @@
226
232
  "**/.env.*"
227
233
  ],
228
234
  "except": [
229
- ".env.example",
230
- "**/.env.example",
231
- ".env.sample",
232
- "**/.env.sample",
233
- ".env.template",
234
- "**/.env.template",
235
- ".env.dist",
236
- "**/.env.dist",
237
- ".env.defaults",
238
- "**/.env.defaults"
235
+ "**/*.example",
236
+ "**/*.sample",
237
+ "**/*.template",
238
+ "**/*.dist",
239
+ "**/*.defaults"
239
240
  ]
240
241
  },
241
242
  "actions": {
@@ -303,8 +304,33 @@
303
304
  ]
304
305
  },
305
306
  {
306
- "id": "generic.agent-instructions",
307
+ "id": "secret.claude-credentials",
307
308
  "version": 1,
309
+ "provider": "claude-code",
310
+ "category": "secret",
311
+ "confidence": "high",
312
+ "kind": "path",
313
+ "match": {
314
+ "path_case": "insensitive",
315
+ "paths": [
316
+ ".claude/.credentials.json",
317
+ "**/.claude/.credentials.json"
318
+ ]
319
+ },
320
+ "actions": {
321
+ "clean": "block",
322
+ "strict": "block",
323
+ "compliance": "block"
324
+ },
325
+ "reason": "This file holds an OAuth access token and must never be committed.",
326
+ "remediation": [
327
+ "git restore --staged <path>",
328
+ "Revoke the token if it already reached a commit."
329
+ ]
330
+ },
331
+ {
332
+ "id": "generic.agent-instructions",
333
+ "version": 2,
308
334
  "provider": "generic",
309
335
  "category": "ambiguous-instructions",
310
336
  "confidence": "medium",
@@ -328,18 +354,19 @@
328
354
  },
329
355
  "reason": "Agent instruction files may be intentional team config. Review before committing.",
330
356
  "remediation": [
331
- "aimhooman allow <path> --reason \"shared team config\"",
357
+ "aimhooman review <path> --head <commit> --reason \"shared team config\"",
332
358
  "or unstage if it is personal"
333
359
  ]
334
360
  },
335
361
  {
336
362
  "id": "generic.project-policy",
337
- "version": 1,
363
+ "version": 2,
338
364
  "provider": "aimhooman",
339
365
  "category": "policy-config",
340
366
  "confidence": "high",
341
367
  "kind": "path",
342
368
  "match": {
369
+ "path_case": "insensitive",
343
370
  "paths": [
344
371
  ".aimhooman.json",
345
372
  "**/.aimhooman.json"
@@ -352,54 +379,54 @@
352
379
  },
353
380
  "reason": "Versioned enforcement policy changes require explicit human review.",
354
381
  "remediation": [
355
- "aimhooman allow .aimhooman.json --reason \"reviewed team policy change\"",
382
+ "aimhooman review .aimhooman.json --head <commit> --reason \"reviewed team policy change\"",
356
383
  "or unstage the policy change"
357
384
  ]
358
385
  },
359
386
  {
360
387
  "id": "playwright-mcp.state",
361
- "version": 1,
388
+ "version": 2,
362
389
  "provider": "playwright-mcp",
363
390
  "category": "ephemeral-state",
364
391
  "confidence": "high",
365
392
  "kind": "path",
366
- "match": { "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
393
+ "match": { "path_case": "insensitive", "paths": [".playwright-mcp/**", "**/.playwright-mcp/**"] },
367
394
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
368
395
  "reason": "Playwright MCP session artifacts are local, not repository content.",
369
396
  "remediation": ["git restore --staged <path>"]
370
397
  },
371
398
  {
372
399
  "id": "remember.state",
373
- "version": 1,
400
+ "version": 2,
374
401
  "provider": "remember",
375
402
  "category": "ephemeral-state",
376
403
  "confidence": "high",
377
404
  "kind": "path",
378
- "match": { "paths": [".remember/**", "**/.remember/**"] },
405
+ "match": { "path_case": "insensitive", "paths": [".remember/**", "**/.remember/**"] },
379
406
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
380
407
  "reason": "Remember second-brain data is local, not repository content.",
381
408
  "remediation": ["git restore --staged <path>"]
382
409
  },
383
410
  {
384
411
  "id": "superpowers.state",
385
- "version": 1,
412
+ "version": 2,
386
413
  "provider": "superpowers",
387
414
  "category": "ephemeral-state",
388
415
  "confidence": "high",
389
416
  "kind": "path",
390
- "match": { "paths": [".superpowers/**", "**/.superpowers/**"] },
417
+ "match": { "path_case": "insensitive", "paths": [".superpowers/**", "**/.superpowers/**"] },
391
418
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
392
419
  "reason": "Superpowers plugin state is local, not repository content.",
393
420
  "remediation": ["git restore --staged <path>"]
394
421
  },
395
422
  {
396
423
  "id": "agent.state",
397
- "version": 2,
424
+ "version": 3,
398
425
  "provider": "generic",
399
426
  "category": "ephemeral-state",
400
427
  "confidence": "high",
401
428
  "kind": "path",
402
- "match": { "paths": [".agent/**", "**/.agent/**"] },
429
+ "match": { "path_case": "insensitive", "paths": [".agent/**", "**/.agent/**"] },
403
430
  "actions": { "clean": "block", "strict": "block", "compliance": "block" },
404
431
  "reason": "Generic agent state is local, not repository content.",
405
432
  "remediation": ["git restore --staged <path>"]
@@ -74,7 +74,8 @@
74
74
  "kind": "code",
75
75
  "match": {
76
76
  "content": [
77
- "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,255}|glpat-[A-Za-z0-9_-]{20,255}|npm_[A-Za-z0-9]{36,255}|xox[baprs]-[A-Za-z0-9-]{20,255})\\b"
77
+ "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,255}|glpat-[A-Za-z0-9_-]{20,255}|npm_[A-Za-z0-9]{36,255}|xox[baprs]-[A-Za-z0-9-]{20,255})\\b",
78
+ "\\b(?:sk-ant-api[0-9]{2}-[A-Za-z0-9_-]{80,255}|sk-proj-[A-Za-z0-9_-]{40,255}|AIza[A-Za-z0-9_-]{35}|(?:sk|rk)_live_[A-Za-z0-9]{20,255}|hf_[A-Za-z0-9]{34}|SG\\.[A-Za-z0-9_-]{20,255}\\.[A-Za-z0-9_-]{40,255})\\b"
78
79
  ]
79
80
  },
80
81
  "actions": {
@@ -17,7 +17,7 @@ This repository uses aimhooman to keep AI tooling artifacts out of Git history.
17
17
  Its policy:
18
18
 
19
19
  - Never stage or commit local AI session/state files. Examples include
20
- `.claude.json`, `.claude/.credentials.json`, `.claude/session*.json`,
20
+ `.claude.json`, `.claude/session*.json`,
21
21
  `.claude/history*`, `.claude/todos/*`, `.claude/shell-snapshots/*`,
22
22
  `.claude/statsig/*`, `.claude/projects/*`, `.claude/logs/*`,
23
23
  `.codex/sessions/*`, `.codex/history*`, `.codex/log/*`, `.codex/logs/*`,
@@ -27,8 +27,10 @@ Its policy:
27
27
  exhaustive; the packaged `rules/paths.json` is the detection source of truth.
28
28
  If one is staged, unstage it and keep it out of Git instead.
29
29
  - Never commit secrets: a real `.env` (not `.env.example`), private keys
30
- (`id_rsa` and files containing a private-key header), `.aws/credentials`, or
31
- service-account keys. Public certificates are allowed.
30
+ (`id_rsa` and files containing a private-key header), `.aws/credentials`,
31
+ `.claude/.credentials.json`, service-account keys, or a provider API key
32
+ (GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
33
+ SendGrid). Public certificates are allowed.
32
34
  - Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
33
35
  an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
34
36
  noreply emails. A commit message reads as if a human wrote it.
@@ -111,6 +111,7 @@ function syncDirectory(directory, operations) {
111
111
  }
112
112
 
113
113
  const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
114
+ const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
114
115
 
115
116
  function waitForLock(milliseconds) {
116
117
  if (milliseconds > 0) Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, milliseconds);
@@ -136,6 +137,12 @@ function processIdentity(pid) {
136
137
  const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
137
138
  encoding: 'utf8',
138
139
  stdio: ['ignore', 'pipe', 'ignore'],
140
+ // This is the only spawn on the lock path, and it runs on macOS
141
+ // and BSD alone: Linux reads /proc and Windows returns early.
142
+ // The retry loop below budgets milliseconds per attempt, so a
143
+ // probe that stalls must lose rather than stretch the loop. A
144
+ // timeout throws into the catch below, which fails safe.
145
+ timeout: IDENTITY_PROBE_TIMEOUT_MS,
139
146
  }).trim();
140
147
  return started ? `ps:${started}` : null;
141
148
  }
@@ -283,7 +290,17 @@ export function withLock(lockPath, fn, options = {}) {
283
290
  if (attempt + 1 < retries) waitForLock(retryDelayMs);
284
291
  }
285
292
  if (!held) {
286
- throw new Error(`cannot acquire state lock "${lockPath}" after ${retries} attempts`);
293
+ // Name the queue, not just the lock. This scheme writes no file at lockPath;
294
+ // only the legacy holder checked above would, so a reader of this error
295
+ // usually goes looking for a path that is not there. What blocks is a
296
+ // candidate in the queue directory, retained because its owner cannot be
297
+ // disproved, and a holder killed outright never reaches the finally below
298
+ // that would have removed its own.
299
+ throw new Error(
300
+ `cannot acquire state lock "${lockPath}" after ${retries} attempts. Contenders `
301
+ + `queue in "${queueDir}"; if no other aimhooman command is running, a candidate `
302
+ + 'there outlived the process that published it, and removing that directory clears it',
303
+ );
287
304
  }
288
305
  return fn();
289
306
  } catch (error) {
@@ -3,3 +3,13 @@
3
3
  export function gitEnvironment(environment = process.env) {
4
4
  return { ...environment, GIT_NO_REPLACE_OBJECTS: '1' };
5
5
  }
6
+
7
+ // execFileSync has no default timeout, so a git child that never exits blocks
8
+ // the caller forever. One did: it held a CI runner until the platform's 6-hour
9
+ // ceiling killed the job, with no error to read afterwards.
10
+ //
11
+ // Real calls here finish in milliseconds. This bound exists to end a stuck one,
12
+ // not to pace a slow one, so it sits far above any legitimate call: a huge
13
+ // repository on a slow disk stays well inside it, and a hang now raises an
14
+ // error naming the command instead of going quiet.
15
+ export const GIT_TIMEOUT_MS = 120_000;
package/src/githooks.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
+ import { GIT_TIMEOUT_MS } from './git-environment.mjs';
3
4
  import {
4
5
  accessSync,
5
6
  chmodSync,
@@ -38,13 +39,19 @@ function hooksDir(repo) {
38
39
 
39
40
  const scope = configScope(repo, 'core.hooksPath');
40
41
  const localScope = ['local', 'worktree'].includes(scope);
41
- const repositoryOwned = localScope && repositoryOwnsPath(repo, dir);
42
+ const inside = localScope && repositoryOwnsPath(repo, dir);
43
+ // Inside the worktree is not the same as ours. A hooks directory Git tracks
44
+ // (husky, the vanilla .githooks pattern) is repository content: a dispatcher
45
+ // written there stages this machine's absolute CLI, Node, and PATH for
46
+ // everyone who clones.
47
+ const tracked = inside && trackedPath(repo, dir);
48
+ const repositoryOwned = inside && !tracked;
42
49
  const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
43
50
  if (shared) {
44
51
  const where = scope ? `${scope} scope` : 'a non-local scope';
45
- const reason = localScope && !repositoryOwned
46
- ? `${where}, outside this repository`
47
- : where;
52
+ let reason = where;
53
+ if (tracked) reason = `${where}, tracked by this repository`;
54
+ else if (localScope && !inside) reason = `${where}, outside this repository`;
48
55
  return {
49
56
  dir,
50
57
  shared: true,
@@ -60,16 +67,43 @@ function hooksDir(repo) {
60
67
  };
61
68
  }
62
69
 
70
+ function pathContains(root, candidate) {
71
+ const rel = relative(root, candidate);
72
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
73
+ }
74
+
63
75
  function repositoryOwnsPath(repo, path) {
64
76
  const candidate = canonicalPath(path);
65
77
  return [repo.root, repo.commonDir, repo.gitDir]
66
78
  .filter(Boolean)
67
79
  .map(canonicalPath)
68
- .some((root) => {
69
- const rel = relative(root, candidate);
70
- return rel === '' || (!rel.startsWith(`..${sep}`)
71
- && rel !== '..' && !isAbsolute(rel));
72
- });
80
+ .some((root) => pathContains(root, candidate));
81
+ }
82
+
83
+ // insideGitDir is the part of repositoryOwnsPath that cannot be shared. A .git
84
+ // belongs to exactly one repository, while a directory in the worktree can be
85
+ // the core.hooksPath of a second one.
86
+ function insideGitDir(repo, path) {
87
+ const candidate = canonicalPath(path);
88
+ return [repo.commonDir, repo.gitDir]
89
+ .filter(Boolean)
90
+ .map(canonicalPath)
91
+ .some((root) => pathContains(root, candidate));
92
+ }
93
+
94
+ // trackedPath reports whether Git tracks anything under path. A Git that cannot
95
+ // answer counts as tracked: refusing to install costs a warning, while guessing
96
+ // wrong commits this machine's absolute paths into a shared repository.
97
+ function trackedPath(repo, path) {
98
+ try {
99
+ return execFileSync(
100
+ 'git',
101
+ ['--literal-pathspecs', 'ls-files', '-z', '--', String(path)],
102
+ { cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
103
+ ).length > 0;
104
+ } catch {
105
+ return true;
106
+ }
73
107
  }
74
108
 
75
109
  function canonicalPath(path) {
@@ -95,13 +129,14 @@ function effectiveHooksDir(repo) {
95
129
  return execFileSync(
96
130
  'git',
97
131
  ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
98
- { cwd: repo.root, encoding: 'utf8' }
132
+ { cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
99
133
  ).trim();
100
134
  } catch {
101
135
  try {
102
136
  const path = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
103
137
  cwd: repo.root,
104
138
  encoding: 'utf8',
139
+ timeout: GIT_TIMEOUT_MS,
105
140
  }).trim();
106
141
  return isAbsolute(path) ? path : resolve(repo.root, path);
107
142
  } catch {
@@ -115,6 +150,7 @@ function configScope(repo, key) {
115
150
  const line = execFileSync('git', ['config', '--show-scope', '--get', key], {
116
151
  cwd: repo.root,
117
152
  encoding: 'utf8',
153
+ timeout: GIT_TIMEOUT_MS,
118
154
  }).trim();
119
155
  return line.split(/\s/, 1)[0];
120
156
  } catch {
@@ -125,6 +161,7 @@ function configScope(repo, key) {
125
161
  execFileSync('git', ['config', '--local', '--get', key], {
126
162
  cwd: repo.root,
127
163
  stdio: ['ignore', 'ignore', 'ignore'],
164
+ timeout: GIT_TIMEOUT_MS,
128
165
  });
129
166
  return 'local';
130
167
  } catch {
@@ -307,7 +344,7 @@ function installHooksLocked(repo, cliPath, options, dir, warnings) {
307
344
  continue;
308
345
  }
309
346
  if (current && ownedHook(readFileSync(dest, 'utf8'), name)
310
- && !ownedForChain(readFileSync(dest, 'utf8'), name, chainedPath)) {
347
+ && !ownedByRepo(repo, dir, readFileSync(dest, 'utf8'), name, chainedPath)) {
311
348
  warnings.push(`${name} is managed for another repository; refusing to overwrite it`);
312
349
  unsafe = true;
313
350
  }
@@ -381,13 +418,18 @@ function uninstallHooksLocked(repo, dir, warnings) {
381
418
  const chained = join(chainedDir, name);
382
419
  const content = readFileSync(dest, 'utf8');
383
420
  if (!ownedHook(content, name)) continue;
384
- if (!ownedForChain(content, name, chained)) {
385
- warnings.push(`${name} is managed for another repository; refusing to remove it`);
421
+ if (!ownedByRepo(repo, dir, content, name, chained)) {
422
+ failures.push(`${name}: managed for another repository; left in place at ${dest}`);
386
423
  continue;
387
424
  }
388
425
  const predecessor = entry(chained);
389
426
  if (predecessor?.isSymbolicLink()) {
390
- warnings.push(`${name} chained backup is a symlink; refusing to restore it`);
427
+ // Never read or copy through the symlink, but do let go of the
428
+ // dispatcher: holding the repository hostage over a backup we
429
+ // refuse to touch helps nobody.
430
+ warnings.push(`${name} chained backup is a symlink; ${chained} left in place and your original hook was not restored`);
431
+ unlinkSync(dest);
432
+ removed.push(name);
391
433
  continue;
392
434
  }
393
435
  if (predecessor) {
@@ -407,6 +449,34 @@ function uninstallHooksLocked(repo, dir, warnings) {
407
449
  return { removed: removed.sort(), restored: restored.sort(), warnings, failures };
408
450
  }
409
451
 
452
+ // remainingDispatchers lists aimhooman dispatchers a local uninstall was meant
453
+ // to remove and did not. uninstall checks the directory rather than trusting its
454
+ // own report: a refusal that only produced a warning would otherwise be printed
455
+ // under a success headline, and "uninstalled" while four dispatchers still block
456
+ // every commit is the one lie a guard must never tell.
457
+ //
458
+ // A shared hooks directory is skipped because uninstallHooks does not touch one
459
+ // either — the global directory has `uninstall --global`, and a foreign or
460
+ // tracked one is reported by its own warning. This asks hooksDir rather than
461
+ // comparing paths: two spellings of one directory differ on Windows, and
462
+ // deciding ownership by string is the bug this change exists to remove.
463
+ export function remainingDispatchers(repo) {
464
+ const { dir, shared } = hooksDir(repo);
465
+ if (shared) return [];
466
+ return Object.keys(MANAGED).sort().flatMap((name) => {
467
+ const path = join(dir, name);
468
+ try {
469
+ const stat = entry(path);
470
+ if (!stat || stat.isSymbolicLink()) return [];
471
+ return ownedHook(readFileSync(path, 'utf8'), name) ? [path] : [];
472
+ } catch {
473
+ // Unreadable: cannot prove it is gone, so report it rather than
474
+ // claim a clean removal.
475
+ return [path];
476
+ }
477
+ });
478
+ }
479
+
410
480
  // hookDiagnostics reports both dispatcher integrity and whether its configured
411
481
  // command can be reached from the current environment.
412
482
  export function hookDiagnostics(repo) {
@@ -414,8 +484,12 @@ export function hookDiagnostics(repo) {
414
484
  // Global hooks (installGlobalHooks) keep chained backups in the hooks dir
415
485
  // itself; local hooks keep them in repo state via chainDir. Diagnose the
416
486
  // path the dispatcher actually embeds, not the repo-relative default.
487
+ // canonicalPath, not resolve: a HOME behind a symlink (Fedora Silverblue
488
+ // ships /home -> /var/home; NFS and autofs homes are everywhere) makes the
489
+ // two spellings differ, and every global dispatcher would then be diagnosed
490
+ // as belonging to another repository.
417
491
  const globalDir = globalHooksDir();
418
- const chained = resolve(dir) === resolve(globalDir)
492
+ const chained = canonicalPath(dir) === canonicalPath(globalDir)
419
493
  ? join(globalDir, 'chained')
420
494
  : chainDir(repo);
421
495
  return Object.keys(MANAGED).sort().map((name) => {
@@ -458,7 +532,7 @@ export function hookDiagnostics(repo) {
458
532
  reason: inspected.reason,
459
533
  };
460
534
  }
461
- if (!ownedForChain(content, name, base.chainedPath)) {
535
+ if (!ownedByRepo(repo, dir, content, name, base.chainedPath)) {
462
536
  return {
463
537
  ...base,
464
538
  managed: false,
@@ -580,8 +654,20 @@ AIMHOOMAN_CLI=${shq(resolvedCliPath)}
580
654
  AIMHOOMAN_NODE=${shq(nodePath)}
581
655
  AIMHOOMAN_PATH=${shq(shellPathValue)}
582
656
  ${captureTree}${captureTransaction}run_aimhooman() {
583
- if [ ! -f "$AIMHOOMAN_CLI" ] || [ ! -f "$AIMHOOMAN_NODE" ]; then
584
- echo "aimhooman: guard unavailable (aimhooman CLI or Node is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
657
+ if [ ! -f "$AIMHOOMAN_CLI" ]; then
658
+ echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
659
+ return 0
660
+ fi
661
+ if [ ! -f "$AIMHOOMAN_NODE" ]; then
662
+ # Both remedies below are Node programs, so stopping is only fair when a Node
663
+ # exists to run them with. Without one the CLI file is inert: 'init' and
664
+ # 'uninstall' cannot run, and refusing would leave the repository unusable
665
+ # with no way to remove these hooks except deleting them by hand.
666
+ if command -v node >/dev/null 2>&1; then
667
+ echo "aimhooman: the pinned Node interpreter is missing (\$AIMHOOMAN_NODE); the operation was stopped. Run 'aimhooman init' to re-pin it, or 'aimhooman uninstall' to remove the guard." >&2
668
+ return 20
669
+ fi
670
+ echo "aimhooman: guard unavailable (no Node interpreter found); allowing this operation without protection. Install Node and run 'aimhooman init' to restore the guard." >&2
585
671
  return 0
586
672
  fi
587
673
  (
@@ -698,8 +784,16 @@ function ownedHook(content, name) {
698
784
  return inspected.valid || legacyHook(content, name);
699
785
  }
700
786
 
701
- function ownedForChain(content, name, chainedPath) {
787
+ // ownedByRepo decides whether this repository may rewrite or remove a dispatcher.
788
+ // Inside our own .git the fingerprint settles it: we wrote the file, and no
789
+ // second repository can own anything there. The baked chained path only earns a
790
+ // vote where the hooks directory can genuinely be shared — two repositories may
791
+ // point core.hooksPath at one directory, and then it is the only way to tell the
792
+ // installs apart. Comparing paths everywhere means a renamed repository stops
793
+ // recognising the guard it installed itself, with no way to re-pin or remove it.
794
+ function ownedByRepo(repo, dir, content, name, chainedPath) {
702
795
  if (!ownedHook(content, name)) return false;
796
+ if (insideGitDir(repo, dir)) return true;
703
797
  return assignmentValue(content, 'CHAINED') === String(chainedPath);
704
798
  }
705
799