@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/bin/aimhooman.mjs CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
6
7
  import { newEngineWithDiagnostics } from '../src/scan.mjs';
7
8
  import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
8
9
  import {
@@ -19,7 +20,7 @@ import {
19
20
  } from '../src/gitx.mjs';
20
21
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
21
22
  import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
22
- import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
+ import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
24
  import { ArgumentError, parseArguments } from '../src/args.mjs';
24
25
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
25
26
  import { resolvePolicy } from '../src/policy-resolver.mjs';
@@ -37,8 +38,7 @@ const LIFECYCLE_LOCK_OPTIONS = { retries: 1000 };
37
38
  function tryRepo() {
38
39
  try {
39
40
  return openRepo();
40
- } catch (error) {
41
- if (error?.name === 'StateMigrationError') throw error;
41
+ } catch {
42
42
  return null;
43
43
  }
44
44
  }
@@ -48,6 +48,7 @@ function currentRepositoryIsBare() {
48
48
  return execFileSync('git', ['rev-parse', '--is-bare-repository'], {
49
49
  encoding: 'utf8',
50
50
  stdio: ['ignore', 'pipe', 'ignore'],
51
+ timeout: GIT_TIMEOUT_MS,
51
52
  }).trim() === 'true';
52
53
  } catch {
53
54
  return false;
@@ -58,6 +59,42 @@ function tone() {
58
59
  return process.env.AIMHOOMAN_TONE === 'professional' || process.env.CI ? 'professional' : 'playful';
59
60
  }
60
61
 
62
+ // The per-scan budget sizes the `cat-file --batch` read buffer, so a value that
63
+ // travelled with a clone would let a hostile repository drive the verifier out
64
+ // of memory instead of being rejected by it. These stay in the environment for
65
+ // that reason, never in .aimhooman.json, and a raise is capped for the same one.
66
+ // Lowering needs no ceiling: a smaller budget only skips more, and a skip is
67
+ // already an incomplete scan, which fails closed at 31.
68
+ const MAX_SCAN_LIMIT_BYTES = 1 << 30;
69
+
70
+ // Crossing a scan budget marks the scan incomplete on every profile, and an
71
+ // incomplete scan is a ref update the reference-transaction hook refuses — the
72
+ // one boundary --no-verify does not skip. A repository tracking a lockfile, an
73
+ // image or a vendored bundle over the default therefore could not commit at all,
74
+ // and "reduce the target or limits and retry" named something no flag, schema
75
+ // field or config key could do. This is that knob.
76
+ function scanLimits() {
77
+ const limits = {};
78
+ for (const [key, name] of [
79
+ ['maxFileBytes', 'AIMHOOMAN_MAX_FILE_BYTES'],
80
+ ['maxTotalBytes', 'AIMHOOMAN_MAX_TOTAL_BYTES'],
81
+ ]) {
82
+ const raw = process.env[name];
83
+ if (raw === undefined || raw === '') continue;
84
+ if (!/^\d+$/.test(raw)) {
85
+ throw new ArgumentError(`${name} must be a whole number of bytes; got "${visible(raw)}"`);
86
+ }
87
+ const value = Number(raw);
88
+ if (value < 1 || value > MAX_SCAN_LIMIT_BYTES) {
89
+ throw new ArgumentError(
90
+ `${name} must be between 1 and ${MAX_SCAN_LIMIT_BYTES} bytes; got ${raw}`
91
+ );
92
+ }
93
+ limits[key] = value;
94
+ }
95
+ return limits;
96
+ }
97
+
61
98
  function configuredEngine(profile, repo) {
62
99
  const { engine, errors } = newEngineWithDiagnostics(profile, repo?.stateDir);
63
100
  if (errors.length && profile === 'strict') {
@@ -159,10 +196,23 @@ function emitDiagnostics(diagnostics = []) {
159
196
  }
160
197
 
161
198
  function incompleteMessage(scan) {
162
- const skipped = Object.entries(scan.stats?.skipped || {})
199
+ const reasons = scan.stats?.skipped || {};
200
+ const skipped = Object.entries(reasons)
163
201
  .map(([reason, count]) => `${reason}=${count}`)
164
202
  .join(', ');
165
- return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; reduce the target or limits and retry\n`;
203
+ // Every other reason is a size or budget the caller can shrink. A pack that
204
+ // will not compile is not, and the warning above already names the file and
205
+ // the error, so point at that instead of misdirecting to the limits. When a
206
+ // byte budget is what stopped the scan, name the budget: the caller whose own
207
+ // tree outgrew it needs to raise one, and "reduce the limits" sends them the
208
+ // wrong way down a road they cannot leave.
209
+ const budgeted = reasons['size-limit'] || reasons['total-byte-limit'];
210
+ const hint = reasons['local-pack-error']
211
+ ? 'fix the reported rule pack and retry'
212
+ : budgeted
213
+ ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
214
+ : 'reduce the target or limits and retry';
215
+ return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
166
216
  }
167
217
 
168
218
  function snapshotFile(path) {
@@ -193,6 +243,7 @@ function gitConfigAtScope(root, scope, key) {
193
243
  cwd: root,
194
244
  encoding: 'utf8',
195
245
  stdio: ['ignore', 'pipe', 'ignore'],
246
+ timeout: GIT_TIMEOUT_MS,
196
247
  }).trim();
197
248
  } catch {
198
249
  return '';
@@ -200,7 +251,7 @@ function gitConfigAtScope(root, scope, key) {
200
251
  }
201
252
 
202
253
  function gitVersion() {
203
- try { return execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(); }
254
+ try { return execFileSync('git', ['--version'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim(); }
204
255
  catch { return 'Git unavailable'; }
205
256
  }
206
257
 
@@ -247,9 +298,10 @@ function cmdPrecommit(args) {
247
298
  parseNoArguments(args);
248
299
  const repo = tryRepo();
249
300
  if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
301
+ const limits = scanLimits();
250
302
  let scan;
251
303
  try {
252
- scan = scanGitTarget(repo, { kind: 'staged' });
304
+ scan = scanGitTarget(repo, { kind: 'staged', limits });
253
305
  } catch (e) {
254
306
  console.error(`aimhooman: cannot scan staged content: ${e.message}`);
255
307
  return expectedErrorCode(e);
@@ -275,6 +327,7 @@ function cmdPrecommit(args) {
275
327
  return 10;
276
328
  }
277
329
  const paths = [...new Set(blocks.map((f) => f.path).filter(Boolean))];
330
+ let emptied = false;
278
331
  try {
279
332
  const unstageTargets = new Set(paths);
280
333
  for (const finding of blocks) {
@@ -283,15 +336,15 @@ function cmdPrecommit(args) {
283
336
  }
284
337
  }
285
338
  for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
286
- // The empty-commit hint is derived from the staged paths captured before
287
- // repair, not from a second git read after `git restore --staged`. That
288
- // post-repair read followed an index write and could transiently report
289
- // the wrong state under heavy CI load, flaking the repair tests.
290
- // unstagePaths is atomic (it throws on failure), so when every staged
291
- // path is a repair target the index matches HEAD afterward. The capture
292
- // is best-effort and runs before the repair: if this read fails the
293
- // unstage still runs and the hint is omitted (empty stays false) rather
294
- // than skipping the repair or blocking the commit.
339
+ // Whether the repair empties the index is derived from the staged paths
340
+ // captured before repair, not from a second git read after
341
+ // `git restore --staged`. That post-repair read followed an index write
342
+ // and could transiently report the wrong state under heavy CI load,
343
+ // flaking the repair tests. unstagePaths is atomic (it throws on
344
+ // failure), so when every staged path is a repair target the index
345
+ // matches HEAD afterward. The capture is best-effort and runs before the
346
+ // repair: if this read fails the unstage still runs and the commit is
347
+ // left to proceed rather than blocked on a state we could not read.
295
348
  let stagedBefore;
296
349
  try {
297
350
  stagedBefore = stagedPaths(repo);
@@ -316,10 +369,10 @@ function cmdPrecommit(args) {
316
369
  if (!pending.length) break;
317
370
  unstagePaths(repo, pending);
318
371
  }
319
- const empty = stagedBefore !== null
372
+ emptied = stagedBefore !== null
320
373
  && stagedBefore.every((path) => unstageTargets.has(path));
321
374
  process.stderr.write(
322
- `aimhooman: unstaged ${paths.length} AI artifact(s) from this commit: ${paths.map(visible).join(', ')}${empty ? ' (nothing else staged the commit will be empty)' : ''}\n`
375
+ `aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')}${emptied ? ' nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
323
376
  );
324
377
  } catch (e) {
325
378
  process.stderr.write(
@@ -328,8 +381,20 @@ function cmdPrecommit(args) {
328
381
  );
329
382
  return 10;
330
383
  }
331
- if (reviews.length) process.stderr.write(human(reviews, tone()));
384
+ // Seven of the rules that can reach the summary above are secret rules, so it
385
+ // cannot name a cause without guessing wrong for a private key or an AWS
386
+ // credential — and it used to be the only thing printed for a block. Let the
387
+ // findings speak instead: human() already carries the rule id, the path, the
388
+ // reason and the remediation, and already redacts secret text. It labels each
389
+ // one BLOCK, which is the decision that unstaged the path, not a stopped
390
+ // commit; the summary above says what actually happened to them.
391
+ process.stderr.write(human([...blocks, ...reviews], tone()));
332
392
  if (!scan.complete) process.stderr.write(incompleteMessage(scan));
393
+ // Git refuses a commit with nothing staged; repair runs after git has already
394
+ // decided otherwise, so carrying on here mints the empty commit git would
395
+ // not. The request was to commit the artifact, not to stamp the history with
396
+ // its message and no content.
397
+ if (emptied) return 10;
333
398
  return scan.complete ? 0 : 31;
334
399
  }
335
400
 
@@ -371,12 +436,13 @@ function cmdCommitmsg(args) {
371
436
  }
372
437
  const text = messageBytes.toString('utf8');
373
438
  const validUtf8 = Buffer.from(text, 'utf8').equals(messageBytes);
439
+ const limits = scanLimits();
374
440
  let scan;
375
441
  let treeScan = null;
376
442
  try {
377
443
  const checked = againstWouldBeTree(() => ({
378
444
  message: scanMessage(repo, text, { target: 'staged' }),
379
- tree: options.tree ? scanGitTarget(repo, { kind: 'staged' }) : null,
445
+ tree: options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null,
380
446
  }));
381
447
  scan = checked.message;
382
448
  treeScan = checked.tree;
@@ -452,18 +518,12 @@ function cmdRefcheck(args) {
452
518
  if (phase === 'committed' || phase === 'aborted') return 0;
453
519
 
454
520
  const repo = tryRepo();
455
- if (!repo) {
456
- // Global hooks also run in bare repositories. Bare repositories have no
457
- // worktree/index policy boundary and are intentionally unsupported, so
458
- // a global dispatcher must remain transparent there instead of making
459
- // every receive-pack or update-ref fail merely because --show-toplevel
460
- // is unavailable.
461
- if (currentRepositoryIsBare()) return 0;
462
- console.error('aimhooman: not a git repository');
463
- return 30;
464
- }
465
521
  if (phase === 'preparing') {
466
- return dispatchHooksChanged(repo, 'clean') ? 20 : 0;
522
+ // This phase never scans, and without a repository there is no
523
+ // dispatcher directory to inspect either. Git fires it while `git init`
524
+ // is still building the repository, so refusing here would only stop
525
+ // repository creation on behalf of a phase with nothing to say.
526
+ return repo && dispatchHooksChanged(repo, 'clean') ? 20 : 0;
467
527
  }
468
528
  let input;
469
529
  try { input = readFileSync(0, 'utf8'); }
@@ -487,7 +547,32 @@ function cmdRefcheck(args) {
487
547
  return 30;
488
548
  }
489
549
  if (/^0+$/.test(newObjectId)) continue;
490
- updates.push({ oldObjectId, newObjectId });
550
+ updates.push({ oldObjectId, newObjectId, ref });
551
+ }
552
+
553
+ if (!repo) {
554
+ // Git writes the initial HEAD inside a reference transaction and fires
555
+ // this hook while the repository is still being built: GIT_DIR is
556
+ // exported but nothing can be queried yet, so every rev-parse fails,
557
+ // --is-bare-repository included. The bare carve-out below therefore
558
+ // cannot speak for `git init` or `git init --bare`, and reading the
559
+ // silence as "not a git repository" aborted the transaction and left a
560
+ // half-built .git behind that a second `git init` could not repair.
561
+ // That payload is `0000..0000 ref:refs/heads/<name> HEAD`, which the
562
+ // symref filter above already dropped, so no policy-relevant update
563
+ // survives and there is nothing to scan. The scope is "the payload
564
+ // carries nothing", never "git failed to answer": a transaction holding
565
+ // a real object still demands a repository, so no one can disable the
566
+ // last veto by arranging for openRepo to fail.
567
+ if (!updates.length) return 0;
568
+ // Global hooks also run in bare repositories. Bare repositories have no
569
+ // worktree/index policy boundary and are intentionally unsupported, so
570
+ // a global dispatcher must remain transparent there instead of making
571
+ // every receive-pack or update-ref fail merely because --show-toplevel
572
+ // is unavailable.
573
+ if (currentRepositoryIsBare()) return 0;
574
+ console.error('aimhooman: not a git repository');
575
+ return 30;
491
576
  }
492
577
 
493
578
  let commits;
@@ -524,6 +609,7 @@ function cmdRefcheck(args) {
524
609
  console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
525
610
  return 30;
526
611
  }
612
+ const limits = scanLimits();
527
613
  for (const [revision, reviewContexts] of commits) {
528
614
  let scan;
529
615
  try {
@@ -532,6 +618,7 @@ function cmdRefcheck(args) {
532
618
  revision,
533
619
  reviewContexts,
534
620
  policyMigrationContexts: reviewContexts,
621
+ limits,
535
622
  });
536
623
  }
537
624
  catch (error) {
@@ -584,6 +671,7 @@ function cmdCheck(args) {
584
671
  return 30;
585
672
  }
586
673
  }
674
+ const limits = scanLimits();
587
675
  let scan;
588
676
  try {
589
677
  if (o.message && !o.staged && !o.tracked) {
@@ -597,6 +685,7 @@ function cmdCheck(args) {
597
685
  revision: o.commit,
598
686
  range: o.range,
599
687
  explicitProfile: o.profile,
688
+ limits,
600
689
  ...(o.message ? { messageText } : {}),
601
690
  });
602
691
  }
@@ -650,7 +739,7 @@ function cmdInit(args) {
650
739
  const aimDir = globalHooksDir();
651
740
  let existing = '';
652
741
  try {
653
- existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
742
+ existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
654
743
  } catch { /* unset */ }
655
744
  if (existing && existing !== aimDir) {
656
745
  console.error(
@@ -663,7 +752,7 @@ function cmdInit(args) {
663
752
  // could silently disable a system-wide hook manager, so refuse here too.
664
753
  let systemHooksPath = '';
665
754
  try {
666
- systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
755
+ systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
667
756
  } catch { /* unset or no system config */ }
668
757
  if (systemHooksPath) {
669
758
  console.error(
@@ -687,7 +776,7 @@ function cmdInit(args) {
687
776
  console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
688
777
  return 20;
689
778
  }
690
- execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir]);
779
+ execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
691
780
  } catch (error) {
692
781
  const rollbackFailures = [];
693
782
  for (const snapshot of hookSnapshots.reverse()) {
@@ -697,8 +786,8 @@ function cmdInit(args) {
697
786
  }
698
787
  }
699
788
  try {
700
- if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing]);
701
- else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
789
+ if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
790
+ else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
702
791
  } catch (rollbackError) {
703
792
  rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
704
793
  }
@@ -759,7 +848,13 @@ function cmdInit(args) {
759
848
  rep = installHooks(repo, CLI_PATH);
760
849
  const activeHooks = installedHooks(repo);
761
850
  if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
762
- throw new Error('hook installation incomplete; repository guard is not active');
851
+ // installHooks declines rather than throws when the hooks directory is
852
+ // not ours, and its warnings are the only record of why. They are
853
+ // printed on the success path only, so carry them into the failure or
854
+ // the user is told nothing but "incomplete". The prefix is load-bearing:
855
+ // the exit-code branch below matches on it.
856
+ const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
857
+ throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
763
858
  }
764
859
  saveConfig(repo.stateDir, { profile });
765
860
  applyExclude(repo.excludeFile, patternsForRules(eng.rules));
@@ -847,7 +942,6 @@ function cmdStatus(args) {
847
942
  console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
848
943
  console.log(`excludes: ${excludeError ? `unknown (malformed markers: ${excludeError}; run: aimhooman init)` : excludes.current ? 'current' : excludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init)'}`);
849
944
  for (const error of errors) console.log(`warning: ${error.message}`);
850
- for (const diagnostic of repo.stateDiagnostics || []) console.log(`${diagnostic.level || 'info'}: ${diagnostic.message}`);
851
945
  const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
852
946
  const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
853
947
  console.log(`hooks path: local=${localHooks || 'unset'}, global=${globalHooks || 'unset'}`);
@@ -1414,10 +1508,6 @@ function cmdDoctor(args) {
1414
1508
  } else {
1415
1509
  console.log(`ok host adapters present (${adapters.present}/${adapters.total})`);
1416
1510
  }
1417
- for (const diagnostic of repo.stateDiagnostics || []) {
1418
- console.log(`${diagnostic.level === 'error' ? 'x' : '!'} state: ${diagnostic.message}`);
1419
- if (diagnostic.level === 'error') ok = false;
1420
- }
1421
1511
  const gitRuntime = supportedGitVersion();
1422
1512
  if (gitRuntime.supported) {
1423
1513
  console.log(`ok runtime Node ${process.versions.node}; ${gitRuntime.display}`);
@@ -1485,7 +1575,7 @@ function cmdUninstall(args) {
1485
1575
  const aimDir = globalHooksDir();
1486
1576
  const readGlobalHooksPath = () => {
1487
1577
  try {
1488
- return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
1578
+ return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1489
1579
  } catch { return ''; }
1490
1580
  };
1491
1581
  const current = readGlobalHooksPath();
@@ -1493,7 +1583,7 @@ function cmdUninstall(args) {
1493
1583
  if (current === aimDir) {
1494
1584
  unsetAttempted = true;
1495
1585
  try {
1496
- execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'] });
1586
+ execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1497
1587
  } catch { /* may have failed; re-verify below */ }
1498
1588
  // A read-only or locked ~/.gitconfig can make --unset fail silently.
1499
1589
  // Do not remove the dispatchers while core.hooksPath still resolves to
@@ -1527,12 +1617,33 @@ function cmdUninstall(args) {
1527
1617
  }
1528
1618
  return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
1529
1619
  const rep = uninstallHooks(repo);
1530
- removeExclude(repo.excludeFile);
1531
- console.log('aimhooman: uninstalled');
1620
+ // The irreversible work is already done above, and the report is still
1621
+ // below. A throw from here unwound past all of it, so a damaged marker was
1622
+ // the only thing the user heard while four dispatchers kept guarding every
1623
+ // commit. Report the failure beside the removal report instead of in place
1624
+ // of it. Nothing is swallowed: this is also where the symlink and permission
1625
+ // guards surface, and their messages still reach the user and still exit 30.
1626
+ let excludeFailure = '';
1627
+ try {
1628
+ removeExclude(repo.excludeFile);
1629
+ } catch (error) {
1630
+ excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1631
+ }
1632
+ // Trust the directory, not the report. Every refusal below leaves a working
1633
+ // dispatcher behind, and one printed under "uninstalled" reads as done.
1634
+ const remaining = remainingDispatchers(repo);
1635
+ if (remaining.length) {
1636
+ console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1637
+ for (const path of remaining) console.error(` ${path}`);
1638
+ console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1639
+ } else {
1640
+ console.log('aimhooman: uninstalled');
1641
+ }
1532
1642
  if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1533
1643
  if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1534
1644
  for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1535
- for (const f of rep.failures || []) console.log(` warning: ${f}`);
1645
+ for (const f of rep.failures || []) console.error(` failure: ${f}`);
1646
+ if (excludeFailure) console.error(` failure: ${excludeFailure}`);
1536
1647
  const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1537
1648
  if (purge) {
1538
1649
  // Never wipe stateDir while a predecessor hook backup is still on disk:
@@ -1560,6 +1671,7 @@ function cmdUninstall(args) {
1560
1671
  try {
1561
1672
  globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1562
1673
  encoding: 'utf8',
1674
+ timeout: GIT_TIMEOUT_MS,
1563
1675
  }).trim();
1564
1676
  } catch { /* unset */ }
1565
1677
  if (globalHooksPath === aimDir) {
@@ -1567,7 +1679,9 @@ function cmdUninstall(args) {
1567
1679
  console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1568
1680
  console.log(' run `aimhooman uninstall --global` to remove it.');
1569
1681
  }
1570
- return rep.failures?.length || unrestored.length ? 30 : 0;
1682
+ // The managed block is still in the exclude file and still ignoring paths, so
1683
+ // the uninstall is genuinely incomplete and 30 is the honest answer.
1684
+ return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
1571
1685
  }, LIFECYCLE_LOCK_OPTIONS);
1572
1686
  }
1573
1687
 
@@ -65,6 +65,30 @@ Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
65
65
  hook checks dispatcher integrity there without scanning unresolved references,
66
66
  then keeps the full-scan veto at `prepared`, after Git has locked them.
67
67
 
68
+ ### Frictionless agent guard (the everyday commands run)
69
+
70
+ The layer-2 PreToolUse guard reports paths and refuses unprovable protected Git
71
+ mutations. It does not stand between a developer and the commands they run to read a
72
+ repository. A read-only Git subcommand piped to a filter is a source: `git log | head`,
73
+ `git status | grep modified`, `git diff | cat`, and the `cd repo && git log | head`
74
+ prefix produce stdout for the downstream filter and cannot hide or feed a commit, so
75
+ they run with no refusal. Build and test toolchains (`npm`, `cargo`, `make`, `jest`,
76
+ `eslint`, `tsc`, `pytest`, ...) are allowed as a source for the same reason. The listing
77
+ forms of branch/tag/remote/stash/notes move no ref and run; their mutating forms
78
+ (`git branch -D`, `git tag v1`) still reach the reference-transaction guard. A commit
79
+ made of already-safe halves runs as a whole: `build && git add . && git commit` is
80
+ allowed, because an unmodelled prefix is not a bypass and pre-commit still scans the real
81
+ index at commit time.
82
+
83
+ What stays refused is what can actually get past the scan. Git as a pipe sink
84
+ (`cat patch | git apply`) reads stdin, which can drive `apply`/`am`/`hash-object`.
85
+ Pipe-to-shell, subshells, `eval`, command substitution, and readers that execute their
86
+ own input stay opaque. `git commit --no-verify`, an explicit `core.hooksPath` override,
87
+ and `git push` refuse on the tiers that own them. The boundary that decides whether a
88
+ commit is scanned is the pre-commit and reference-transaction hook, never a denial of the
89
+ shell line that reads the repo. Refusing the read taught agents to drop the `&&` gate
90
+ rather than run the command on its own, which is the friction this tier exists to avoid.
91
+
68
92
  ### Components
69
93
 
70
94
  1. **Rule catalog** — `rules/paths.json`, `rules/attribution.json`,
@@ -118,6 +142,10 @@ means local rules only add restrictions and can never override a core block.
118
142
  transparent in unsupported bare repositories.
119
143
  - **HEAD-safe unstage.** `git restore --staged` needs HEAD; on a repository's
120
144
  initial commit it falls back to `git rm --cached --ignore-unmatch`.
145
+ - **Repair never mints an empty commit.** When the pre-commit repair unstages the last
146
+ staged path (stage only a `.env`, then `git commit`), it exits 10 so Git stops, the
147
+ same outcome as committing with nothing staged. Carrying on would create a commit Git
148
+ itself would have refused and leave the developer a junk commit to `git reset --hard`.
121
149
  - **Anchored attribution rules.** The AI-noreply rule is anchored to trailer
122
150
  lines (`*-by:`) so it never strips a prose line that merely mentions the email.
123
151
  - **`compliance` keeps disclosure.** AI-attribution rules resolve to `allow`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
@@ -1,14 +1,14 @@
1
1
  [
2
2
  {
3
3
  "id": "attribution.claude-coauthor",
4
- "version": 1,
4
+ "version": 2,
5
5
  "provider": "claude-code",
6
6
  "category": "ai-attribution",
7
7
  "confidence": "high",
8
8
  "kind": "message",
9
9
  "match": {
10
10
  "content": [
11
- "(?i)^\\s*co-authored-by:\\s*(?:claude|claude code)\\s*<noreply@anthropic\\.com>\\s*$"
11
+ "(?i)^\\s*co-authored-by:[^<>]+<noreply@anthropic\\.com>\\s*$"
12
12
  ]
13
13
  },
14
14
  "actions": {
@@ -49,14 +49,14 @@
49
49
  },
50
50
  {
51
51
  "id": "attribution.codex-coauthor",
52
- "version": 1,
52
+ "version": 2,
53
53
  "provider": "codex",
54
54
  "category": "ai-attribution",
55
55
  "confidence": "high",
56
56
  "kind": "message",
57
57
  "match": {
58
58
  "content": [
59
- "(?i)^\\s*co-authored-by:\\s*(?:openai\\s+)?codex\\s*<noreply@openai\\.com>\\s*$"
59
+ "(?i)^\\s*co-authored-by:[^<>]+<noreply@openai\\.com>\\s*$"
60
60
  ]
61
61
  },
62
62
  "actions": {
@@ -95,15 +95,15 @@
95
95
  },
96
96
  {
97
97
  "id": "attribution.generated-with",
98
- "version": 1,
98
+ "version": 3,
99
99
  "provider": "generic",
100
100
  "category": "ai-attribution",
101
101
  "confidence": "high",
102
102
  "kind": "message",
103
103
  "match": {
104
104
  "content": [
105
- "(?i)^\\s*generated\\s+(?:with|by)\\s+(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)[.!]?\\s*$",
106
- "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[claude code\\]\\(https://claude\\.ai/code\\)\\s*$"
105
+ "(?i)^\\s*(?:🤖\\s*)?generated\\s+(?:with|by)\\s+(?:claude|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)(?:\\s+[A-Za-z0-9.]+){0,3}[.!]?\\s*(?:🤖\\s*)?$",
106
+ "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)(?:\\s+[A-Za-z0-9.]+){0,3}\\]\\([^)]*\\)\\s*(?:🤖\\s*)?$"
107
107
  ]
108
108
  },
109
109
  "actions": {
@@ -126,7 +126,7 @@
126
126
  "kind": "message",
127
127
  "match": {
128
128
  "content": [
129
- "(?i)^\\s*[a-z-]+-by:\\s*[^<>]+\\s*<noreply@(anthropic|openai)\\.com>\\s*$"
129
+ "(?i)^\\s*[a-z-]+-by:[^<>]+<noreply@(anthropic|openai)\\.com>\\s*$"
130
130
  ]
131
131
  },
132
132
  "actions": {
@@ -1,7 +1,7 @@
1
1
  [
2
2
  {
3
3
  "id": "marker.corner-cut",
4
- "version": 2,
4
+ "version": 3,
5
5
  "provider": "generic",
6
6
  "category": "ai-marker",
7
7
  "confidence": "medium",
@@ -14,19 +14,32 @@
14
14
  "**/docs/**",
15
15
  "**/test/**",
16
16
  "**/tests/**",
17
+ "**/__tests__/**",
18
+ "**/testdata/**",
17
19
  "**/fixtures/**",
18
20
  "**/__fixtures__/**",
19
21
  "**/vendor/**",
22
+ "**/vendored/**",
20
23
  "**/third_party/**",
21
24
  "**/node_modules/**",
22
25
  "**/dist/**",
23
26
  "**/build/**",
27
+ "out/**",
28
+ "target/**",
29
+ "**/.next/**",
24
30
  "**/coverage/**",
25
31
  "**/generated/**",
26
32
  "**/*.generated.*",
27
33
  "**/*.min.js",
28
34
  "**/*.map",
29
- "**/*.md"
35
+ "**/*.md",
36
+ "**/*.mdx",
37
+ "**/*.markdown",
38
+ "**/*.rst",
39
+ "**/*.test.*",
40
+ "**/*.spec.*",
41
+ "**/*_test.*",
42
+ "**/*_spec.*"
30
43
  ],
31
44
  "content": [
32
45
  "(?i)\\b(ponytail|caveman|yagni-oneliner):"
@@ -44,7 +57,7 @@
44
57
  },
45
58
  {
46
59
  "id": "marker.ai-authored",
47
- "version": 1,
60
+ "version": 2,
48
61
  "provider": "generic",
49
62
  "category": "ai-marker",
50
63
  "confidence": "low",
@@ -57,22 +70,35 @@
57
70
  "**/docs/**",
58
71
  "**/test/**",
59
72
  "**/tests/**",
73
+ "**/__tests__/**",
74
+ "**/testdata/**",
60
75
  "**/fixtures/**",
61
76
  "**/__fixtures__/**",
62
77
  "**/vendor/**",
78
+ "**/vendored/**",
63
79
  "**/third_party/**",
64
80
  "**/node_modules/**",
65
81
  "**/dist/**",
66
82
  "**/build/**",
83
+ "out/**",
84
+ "target/**",
85
+ "**/.next/**",
67
86
  "**/coverage/**",
68
87
  "**/generated/**",
69
88
  "**/*.generated.*",
70
89
  "**/*.min.js",
71
90
  "**/*.map",
72
- "**/*.md"
91
+ "**/*.md",
92
+ "**/*.mdx",
93
+ "**/*.markdown",
94
+ "**/*.rst",
95
+ "**/*.test.*",
96
+ "**/*.spec.*",
97
+ "**/*_test.*",
98
+ "**/*_spec.*"
73
99
  ],
74
100
  "content": [
75
- "(?i)\\b(ai[- ]?generated|generated by (ai|copilot|claude|chatgpt|codex))\\b"
101
+ "(?i)\\b(generated by (ai|copilot|claude|chatgpt|codex))\\b"
76
102
  ]
77
103
  },
78
104
  "actions": {