@rmyndharis/aimhooman 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/aimhooman.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
- import { chmodSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
@@ -9,20 +10,23 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
9
10
  import {
10
11
  GitRevisionError,
11
12
  gitConfig,
13
+ ignoredByPatterns,
12
14
  introducedCommits,
13
15
  openRepo,
14
16
  readCommitPath,
15
17
  readStagedPath,
16
18
  stagedPaths,
17
19
  stagedRenameSources,
20
+ stagedTreeSha,
18
21
  unstagePaths,
19
22
  withIndexFromTree,
20
23
  } from '../src/gitx.mjs';
21
24
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
22
- import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
25
+ import { applyExclude, inspectExclude, managedPatterns, patternsForRules, removeExclude } from '../src/exclude.mjs';
23
26
  import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
24
27
  import { ArgumentError, parseArguments } from '../src/args.mjs';
25
28
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
29
+ import { DEFAULT_SCAN_LIMITS } from '../src/scan-session.mjs';
26
30
  import { resolvePolicy } from '../src/policy-resolver.mjs';
27
31
  import { atomicWrite, withLock } from '../src/atomic-write.mjs';
28
32
  import { commitParents, resolveCommit } from '../src/history-scan.mjs';
@@ -118,6 +122,22 @@ function configuredEngine(profile, repo) {
118
122
 
119
123
  function main(argv) {
120
124
  const [cmd, ...rest] = argv;
125
+ // Subcommand-level --help: `aimhooman override --help` (or `init -h`, etc.)
126
+ // previously fell into the subcommand's strict argument parser, which rejects
127
+ // --help as an unknown option and exits 20. Recognise a leading help flag on
128
+ // any real subcommand and route to usage() instead, so help works everywhere.
129
+ // The top-level help forms (cmd itself is help/--help/-h) are handled in the
130
+ // switch below.
131
+ const SUBCOMMAND_HELP_FLAGS = new Set(['--help', '-h', 'help']);
132
+ const knownSubcommands = new Set([
133
+ 'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'init',
134
+ 'status', 'explain', 'allow', 'deny', 'override', 'review',
135
+ 'policy-review', 'fix', 'doctor', 'uninstall',
136
+ ]);
137
+ if (knownSubcommands.has(cmd) && rest.length && SUBCOMMAND_HELP_FLAGS.has(rest[0])) {
138
+ usage();
139
+ return 0;
140
+ }
121
141
  switch (cmd) {
122
142
  case 'check':
123
143
  return cmdCheck(rest);
@@ -197,8 +217,11 @@ function emitDiagnostics(diagnostics = []) {
197
217
 
198
218
  function incompleteMessage(scan) {
199
219
  const reasons = scan.stats?.skipped || {};
220
+ // Each count is how many items were skipped, not the limit that fired:
221
+ // "(size-limit=1)" read as a one-byte budget. Name the noun so the number
222
+ // cannot be mistaken for the budget it tripped.
200
223
  const skipped = Object.entries(reasons)
201
- .map(([reason, count]) => `${reason}=${count}`)
224
+ .map(([reason, count]) => `${reason}=${count} ${skipCountNoun(reason, count)}`)
202
225
  .join(', ');
203
226
  // Every other reason is a size or budget the caller can shrink. A pack that
204
227
  // will not compile is not, and the warning above already names the file and
@@ -212,7 +235,7 @@ function incompleteMessage(scan) {
212
235
  : budgeted
213
236
  ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
214
237
  : 'reduce the target or limits and retry';
215
- let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
238
+ let message = `aimhooman: scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
216
239
  const skippedPaths = scan.stats?.skippedPaths || {};
217
240
  const pathLines = [];
218
241
  for (const [reason, entries] of Object.entries(skippedPaths)) {
@@ -225,6 +248,17 @@ function incompleteMessage(scan) {
225
248
  return message + pathLines.join('');
226
249
  }
227
250
 
251
+ // Most skip reasons tally files; the three below tally something else, so the
252
+ // noun travels with the reason instead of reading "local-pack-error=1 file".
253
+ function skipCountNoun(reason, count) {
254
+ const noun = {
255
+ 'finding-limit': 'finding',
256
+ 'local-input-limit': 'input',
257
+ 'local-pack-error': 'pack',
258
+ }[reason] || 'file';
259
+ return `${noun}${count === 1 ? '' : 's'}`;
260
+ }
261
+
228
262
  function formatBytes(bytes) {
229
263
  if (bytes == null || bytes < 0) return '?';
230
264
  if (bytes < 1024) return `${bytes} B`;
@@ -311,6 +345,81 @@ function dispatchHooksChanged(repo, profile) {
311
345
  return true;
312
346
  }
313
347
 
348
+ // W5 pre-commit/commit-msg marker dedup. pre-commit writes a marker after a
349
+ // clean, complete scan of the staged tree; commit-msg reads it and skips its
350
+ // duplicate ~170ms tree scan when the staged tree sha, profile, and
351
+ // completeness all match. The marker lives in stateDir (gitignored plumbing).
352
+ // It is self-invalidating: any index mutation between the two hooks changes the
353
+ // tree sha, so a stale marker never matches. A missing/corrupt/mismatched
354
+ // marker makes commit-msg fall back to the full scan, so this is purely an
355
+ // optimization and never weakens the guard.
356
+ const PRECOMMIT_CLEAN_VERSION = 1;
357
+ function precommitCleanPath(repo) {
358
+ return join(repo.stateDir, 'precommit-clean.json');
359
+ }
360
+ function recordPrecommitClean(repo, profile) {
361
+ let treeSha;
362
+ try {
363
+ treeSha = stagedTreeSha(repo);
364
+ } catch {
365
+ return; // cannot compute the sha → do not record; commit-msg will scan
366
+ }
367
+ try {
368
+ writeFileSync(precommitCleanPath(repo), JSON.stringify({
369
+ version: PRECOMMIT_CLEAN_VERSION,
370
+ tree: treeSha,
371
+ profile,
372
+ complete: true,
373
+ }));
374
+ } catch {
375
+ // best effort; a missing marker just means commit-msg scans normally
376
+ }
377
+ }
378
+ function precommitCleanMatches(repo, treeSha, profile) {
379
+ let marker;
380
+ try {
381
+ marker = JSON.parse(readFileSync(precommitCleanPath(repo), 'utf8'));
382
+ } catch {
383
+ return false; // missing/corrupt → fall back to full scan
384
+ }
385
+ return marker?.version === PRECOMMIT_CLEAN_VERSION
386
+ && marker.complete === true
387
+ && marker.tree === treeSha
388
+ && marker.profile === profile;
389
+ }
390
+
391
+ // F-E1: the prevention layer keeps AI artifacts out of `git status`, which
392
+ // also keeps their exclusion silent — a `git add .` never tells the developer
393
+ // the chat log did not make the commit. Name the artifacts once per set
394
+ // change. The worktree walk is pathspec-pruned to the anchored managed
395
+ // patterns (~15ms; a bare ignored-listing costs ~150ms on a large tree), so
396
+ // `**/`-prefixed duplicates are skipped: nested artifacts still get excluded,
397
+ // they just do not get the notice. Informational only — it must never change
398
+ // an exit code, so every failure inside degrades to silence.
399
+ const IGNORED_NOTICE_VERSION = 1;
400
+ function noticeIgnoredArtifacts(repo) {
401
+ try {
402
+ const patterns = managedPatterns(repo.excludeFile)
403
+ .filter((pattern) => !pattern.startsWith('**/'));
404
+ if (!patterns.length) return;
405
+ const paths = ignoredByPatterns(repo, patterns).sort();
406
+ if (!paths.length) return;
407
+ const hash = createHash('sha256').update(paths.join('\0')).digest('hex');
408
+ const statePath = join(repo.stateDir, 'ignored-notice.json');
409
+ let previous = null;
410
+ try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
411
+ if (previous?.version === IGNORED_NOTICE_VERSION && previous.hash === hash) return;
412
+ writeFileSync(statePath, JSON.stringify({ version: IGNORED_NOTICE_VERSION, hash }));
413
+ const shown = paths.slice(0, 5);
414
+ const more = paths.length - shown.length;
415
+ process.stderr.write(
416
+ `aimhooman: ${paths.length} AI artifact(s) present locally are kept out of commits: `
417
+ + `${shown.map(visible).join(', ')}${more ? `, and ${more} more` : ''} `
418
+ + "(they stay on disk; 'git status --ignored' lists them; shown once per set change)\n"
419
+ );
420
+ } catch { /* informational only */ }
421
+ }
422
+
314
423
  function cmdPrecommit(args) {
315
424
  parseNoArguments(args);
316
425
  const repo = tryRepo();
@@ -336,6 +445,14 @@ function cmdPrecommit(args) {
336
445
  process.stderr.write(incompleteMessage(scan));
337
446
  return 31;
338
447
  }
448
+ // W5 marker dedup: record that this staged tree scanned clean so the
449
+ // upcoming commit-msg hook can skip its duplicate tree scan. The tree
450
+ // sha is the same value the commit-msg dispatcher computes via
451
+ // `git write-tree`; the index is unchanged on this no-block path, so the
452
+ // sha is stable. A missing/stale/mismatched marker makes commit-msg
453
+ // fall back to the full scan, so this is purely an optimization.
454
+ recordPrecommitClean(repo, profile);
455
+ noticeIgnoredArtifacts(repo);
339
456
  return profile === 'strict' && reviews.length ? 11 : 0;
340
457
  }
341
458
  if (profile === 'strict') {
@@ -412,6 +529,7 @@ function cmdPrecommit(args) {
412
529
  // not. The request was to commit the artifact, not to stamp the history with
413
530
  // its message and no content.
414
531
  if (emptied) return 10;
532
+ if (scan.complete) noticeIgnoredArtifacts(repo);
415
533
  return scan.complete ? 0 : 31;
416
534
  }
417
535
 
@@ -463,7 +581,14 @@ function cmdCommitmsg(args) {
463
581
  try {
464
582
  const checked = againstWouldBeTree(() => ({
465
583
  message: scanMessage(repo, text, { target: 'staged' }),
466
- tree: options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null,
584
+ // W5 marker dedup: if pre-commit already scanned this exact tree
585
+ // clean (matching sha + profile + complete), skip the duplicate
586
+ // ~170ms tree scan. The marker is self-invalidating (any index
587
+ // mutation changes the tree sha), and a missing/stale/mismatched
588
+ // marker falls back to the full scan, so this is safe.
589
+ tree: options.tree && precommitCleanMatches(repo, options.tree, hookProfile)
590
+ ? null
591
+ : (options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null),
467
592
  }));
468
593
  scan = checked.message;
469
594
  treeScan = checked.tree;
@@ -672,6 +797,15 @@ function cmdRefcheck(args) {
672
797
  process.stderr.write(human(scan.findings, tone()));
673
798
  if (!scan.complete) process.stderr.write(incompleteMessage(scan));
674
799
  process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
800
+ // The vetoed commit stays in the object store: the ref never
801
+ // moved, but the bytes — including whatever triggered the block —
802
+ // are still on disk. Whether gc collects it depends on other refs,
803
+ // so phrase the cleanup as conditional; rotating an exposed secret
804
+ // stays the operator's call either way.
805
+ process.stderr.write(
806
+ `aimhooman: note: the rejected commit object remains in the local object store; `
807
+ + "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n"
808
+ );
675
809
  return code;
676
810
  }
677
811
  }
@@ -766,8 +900,9 @@ function cmdInit(args) {
766
900
  profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
767
901
  global: { names: ['--global'], type: 'boolean' },
768
902
  yes: { names: ['--yes'], type: 'boolean' },
903
+ grandfatherSecrets: { names: ['--grandfather-secrets'], type: 'boolean' },
769
904
  },
770
- conflicts: [['profile', 'global']],
905
+ conflicts: [['profile', 'global'], ['grandfatherSecrets', 'global']],
771
906
  maxPositionals: 0,
772
907
  });
773
908
  if (rejectUnsupportedGit()) return 20;
@@ -777,166 +912,240 @@ function cmdInit(args) {
777
912
  return 20;
778
913
  }
779
914
  return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
780
- const aimDir = globalHooksDir();
781
- let existing = '';
915
+ const aimDir = globalHooksDir();
916
+ let existing = '';
917
+ try {
918
+ existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
919
+ } catch { /* unset */ }
920
+ if (existing && existing !== aimDir) {
921
+ console.error(
922
+ `aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
923
+ 'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
924
+ );
925
+ return 20;
926
+ }
927
+ // A --system hooksPath would be shadowed by the --global write below and
928
+ // could silently disable a system-wide hook manager, so refuse here too.
929
+ let systemHooksPath = '';
930
+ try {
931
+ systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
932
+ } catch { /* unset or no system config */ }
933
+ if (systemHooksPath) {
934
+ console.error(
935
+ `aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
936
+ 'a --global install would shadow it. Unset it or choose a different setup.'
937
+ );
938
+ return 20;
939
+ }
940
+ const localRepo = tryRepo();
941
+ const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
942
+ if (localHooksPath) {
943
+ console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
944
+ }
945
+ const hookSnapshots = REQUIRED_GIT_HOOKS
946
+ .map((name) => snapshotFile(join(aimDir, name)));
947
+ let rep;
948
+ try {
949
+ rep = installGlobalHooks(CLI_PATH);
950
+ if (rep.skipped?.length) {
951
+ for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
952
+ console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
953
+ return 20;
954
+ }
955
+ execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
956
+ } catch (error) {
957
+ const rollbackFailures = [];
958
+ for (const snapshot of hookSnapshots.reverse()) {
959
+ try { restoreSnapshot(snapshot); }
960
+ catch (rollbackError) {
961
+ rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
962
+ }
963
+ }
964
+ try {
965
+ if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
966
+ else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
967
+ } catch (rollbackError) {
968
+ rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
969
+ }
970
+ console.error(`aimhooman: global hook installation failed: ${error.message}`);
971
+ if (rollbackFailures.length) {
972
+ console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
973
+ }
974
+ return 30;
975
+ }
976
+ console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
977
+ for (const w of rep.warnings || []) console.log(` warning: ${w}`);
978
+ console.log(' note: this replaces the default .git/hooks directory in non-bare repositories that inherit the global setting; local/worktree core.hooksPath overrides it.');
979
+ return 0;
980
+ }, LIFECYCLE_LOCK_OPTIONS);
981
+ }
982
+ if (options.yes) throw new ArgumentError('--yes is only valid with --global');
983
+ let profile = options.profile || 'clean';
984
+ const profileExplicit = Boolean(options.profile);
985
+ const repo = tryRepo();
986
+ if (!repo) {
987
+ console.error('aimhooman: not a git repository');
988
+ return 30;
989
+ }
990
+ const code = withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
991
+ let projectPolicy;
782
992
  try {
783
- existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
784
- } catch { /* unset */ }
785
- if (existing && existing !== aimDir) {
786
- console.error(
787
- `aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
788
- 'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
789
- );
993
+ projectPolicy = loadProjectPolicy(repo.root);
994
+ } catch (e) {
995
+ console.error(`aimhooman: cannot load project policy: ${e.message}`);
790
996
  return 20;
791
997
  }
792
- // A --system hooksPath would be shadowed by the --global write below and
793
- // could silently disable a system-wide hook manager, so refuse here too.
794
- let systemHooksPath = '';
998
+ if (projectPolicy) {
999
+ if (profileExplicit && profile !== projectPolicy.profile) {
1000
+ console.error(
1001
+ `aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
1002
+ 'edit .aimhooman.json to change the team baseline'
1003
+ );
1004
+ return 20;
1005
+ }
1006
+ profile = projectPolicy.profile;
1007
+ }
1008
+ let eng;
795
1009
  try {
796
- systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
797
- } catch { /* unset or no system config */ }
798
- if (systemHooksPath) {
799
- console.error(
800
- `aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
801
- 'a --global install would shadow it. Unset it or choose a different setup.'
802
- );
1010
+ eng = configuredEngine(profile, repo);
1011
+ } catch (e) {
1012
+ console.error(`aimhooman: cannot initialise policy: ${e.message}`);
803
1013
  return 20;
804
1014
  }
805
- const localRepo = tryRepo();
806
- const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
807
- if (localHooksPath) {
808
- console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
809
- }
810
- const hookSnapshots = REQUIRED_GIT_HOOKS
811
- .map((name) => snapshotFile(join(aimDir, name)));
1015
+ const hookState = hookDiagnostics(repo);
1016
+ const hookFiles = hookState.some((hook) => hook.shared)
1017
+ ? []
1018
+ : [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
1019
+ let snapshots = [];
812
1020
  let rep;
813
1021
  try {
814
- rep = installGlobalHooks(CLI_PATH);
815
- if (rep.skipped?.length) {
816
- for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
817
- console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
818
- return 20;
1022
+ snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
1023
+ .map(snapshotFile);
1024
+ rep = installHooks(repo, CLI_PATH);
1025
+ const activeHooks = installedHooks(repo);
1026
+ if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
1027
+ // installHooks declines rather than throws when the hooks directory is
1028
+ // not ours, and its warnings are the only record of why. They are
1029
+ // printed on the success path only, so carry them into the failure or
1030
+ // the user is told nothing but "incomplete". The prefix is load-bearing:
1031
+ // the exit-code branch below matches on it.
1032
+ const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
1033
+ // When the decline is because the hooks path is shared/tracked (or,
1034
+ // after B2, worktree content the next add would commit), name the two
1035
+ // ways out so the user is not stuck: let aimhooman use the default
1036
+ // .git/hooks, or — for a worktree hooks path kept local — exclude it
1037
+ // first. The uninstall hint lets them undo this init attempt.
1038
+ const remedy = rep.shared
1039
+ ? ' To proceed, either unset core.hooksPath so aimhooman uses the default .git/hooks, or (for a worktree hooks path you keep local) add it to .gitignore or .git/info/exclude and retry. Run "aimhooman uninstall" to undo this init attempt.'
1040
+ : '';
1041
+ throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
1042
+ }
1043
+ saveConfig(repo.stateDir, { profile });
1044
+ applyExclude(repo.excludeFile, patternsForRules(eng.rules));
1045
+ const saved = loadConfig(repo.stateDir);
1046
+ const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
1047
+ if (saved.profile !== profile || !excludes.current) {
1048
+ throw new Error('post-install state verification failed');
819
1049
  }
820
- execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
821
1050
  } catch (error) {
822
1051
  const rollbackFailures = [];
823
- for (const snapshot of hookSnapshots.reverse()) {
1052
+ try {
1053
+ const uninstalled = uninstallHooks(repo);
1054
+ rollbackFailures.push(...(uninstalled.failures || []));
1055
+ } catch (rollbackError) {
1056
+ rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
1057
+ }
1058
+ const hookSet = new Set(hookFiles);
1059
+ const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
1060
+ for (const snapshot of snapshots.reverse()) {
1061
+ if (!restoreHooks && hookSet.has(snapshot.path)) continue;
824
1062
  try { restoreSnapshot(snapshot); }
825
1063
  catch (rollbackError) {
826
1064
  rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
827
1065
  }
828
1066
  }
829
- try {
830
- if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
831
- else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
832
- } catch (rollbackError) {
833
- rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
834
- }
835
- console.error(`aimhooman: global hook installation failed: ${error.message}`);
836
1067
  if (rollbackFailures.length) {
1068
+ console.error(`aimhooman: initialisation failed: ${error.message}`);
837
1069
  console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
1070
+ return 30;
838
1071
  }
839
- return 30;
1072
+ console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
1073
+ return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
840
1074
  }
841
- console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
842
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
843
- console.log(' note: this replaces the default .git/hooks directory in non-bare repositories that inherit the global setting; local/worktree core.hooksPath overrides it.');
1075
+ console.log(`aimhooman: initialised (profile: ${profile})`);
1076
+ console.log(` state: ${repo.stateDir}`);
1077
+ console.log(` excludes: ${repo.excludeFile}`);
1078
+ console.log(` note: known AI artifacts are now ignored locally; see them with 'git status --ignored'`);
1079
+ if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
1080
+ if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
1081
+ for (const warning of rep.warnings) console.log(` warning: ${warning}`);
1082
+ console.log(' undo: aimhooman uninstall');
844
1083
  return 0;
845
- }, LIFECYCLE_LOCK_OPTIONS);
846
- }
847
- if (options.yes) throw new ArgumentError('--yes is only valid with --global');
848
- let profile = options.profile || 'clean';
849
- const profileExplicit = Boolean(options.profile);
850
- const repo = tryRepo();
851
- if (!repo) {
852
- console.error('aimhooman: not a git repository');
853
- return 30;
854
- }
855
- return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
856
- let projectPolicy;
1084
+ }, LIFECYCLE_LOCK_OPTIONS);
1085
+ if (code === 0 && options.grandfatherSecrets) grandfatherTrackedSecrets(repo);
1086
+ return code;
1087
+ }
1088
+
1089
+ // --grandfather-secrets: a repository that already tracks secret-looking
1090
+ // fixtures (test certs, sample keys) would see every commit touching one
1091
+ // blocked, and allowing each path by hand does not scale. After a successful
1092
+ // init, scan the tracked tree once and write a --scope secret-path allow for
1093
+ // every path with a secret finding. The posture for NEW files is unchanged —
1094
+ // only paths found in this scan get an allow, so a secret added after init is
1095
+ // still blocked. A failed or incomplete scan never fails the init itself: the
1096
+ // guard is already active by then, so the gap is reported as a warning.
1097
+ function grandfatherTrackedSecrets(repo) {
1098
+ let scan;
857
1099
  try {
858
- projectPolicy = loadProjectPolicy(repo.root);
859
- } catch (e) {
860
- console.error(`aimhooman: cannot load project policy: ${e.message}`);
861
- return 20;
1100
+ // Commit-time budgets exist for latency, but a grandfather scan that
1101
+ // stops at the default 64 MiB total or 1,000 findings silently misses
1102
+ // fixtures in exactly the large, fixture-heavy repositories it exists
1103
+ // for (OpenSSL tracks ~200 MiB and its 279 key files produce more
1104
+ // than a thousand PEM findings). This is a one-shot, operator-invoked
1105
+ // scan of the operator's own repository, so the defaults here are the
1106
+ // same cap an env raise could reach plus a generous finding budget;
1107
+ // the per-file budget and an explicit env override still apply.
1108
+ scan = scanGitTarget(repo, {
1109
+ kind: 'tracked',
1110
+ limits: { maxTotalBytes: MAX_SCAN_LIMIT_BYTES, maxFindings: 100_000, ...scanLimits() },
1111
+ });
1112
+ } catch (error) {
1113
+ console.error(`aimhooman: warning: grandfather scan failed (${error.message}); no pre-existing secret paths were allowed`);
1114
+ return;
862
1115
  }
863
- if (projectPolicy) {
864
- if (profileExplicit && profile !== projectPolicy.profile) {
865
- console.error(
866
- `aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
867
- 'edit .aimhooman.json to change the team baseline'
868
- );
869
- return 20;
870
- }
871
- profile = projectPolicy.profile;
1116
+ if (!scan.complete) {
1117
+ console.error('aimhooman: warning: tracked scan was incomplete, so the grandfathered set may be partial; allow the remaining paths with --scope secret-path');
872
1118
  }
873
- let eng;
874
- try {
875
- eng = configuredEngine(profile, repo);
876
- } catch (e) {
877
- console.error(`aimhooman: cannot initialise policy: ${e.message}`);
878
- return 20;
1119
+ const paths = [...new Set(scan.findings
1120
+ .filter((finding) => finding.category === 'secret' && finding.path)
1121
+ .map((finding) => finding.path))].sort();
1122
+ if (!paths.length) {
1123
+ console.log('aimhooman: no tracked secret findings to grandfather');
1124
+ return;
879
1125
  }
880
- const hookState = hookDiagnostics(repo);
881
- const hookFiles = hookState.some((hook) => hook.shared)
882
- ? []
883
- : [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
884
- let snapshots = [];
885
- let rep;
886
- try {
887
- snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
888
- .map(snapshotFile);
889
- rep = installHooks(repo, CLI_PATH);
890
- const activeHooks = installedHooks(repo);
891
- if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
892
- // installHooks declines rather than throws when the hooks directory is
893
- // not ours, and its warnings are the only record of why. They are
894
- // printed on the success path only, so carry them into the failure or
895
- // the user is told nothing but "incomplete". The prefix is load-bearing:
896
- // the exit-code branch below matches on it.
897
- const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
898
- throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
899
- }
900
- saveConfig(repo.stateDir, { profile });
901
- applyExclude(repo.excludeFile, patternsForRules(eng.rules));
902
- const saved = loadConfig(repo.stateDir);
903
- const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
904
- if (saved.profile !== profile || !excludes.current) {
905
- throw new Error('post-install state verification failed');
906
- }
907
- } catch (error) {
908
- const rollbackFailures = [];
909
- try {
910
- const uninstalled = uninstallHooks(repo);
911
- rollbackFailures.push(...(uninstalled.failures || []));
912
- } catch (rollbackError) {
913
- rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
914
- }
915
- const hookSet = new Set(hookFiles);
916
- const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
917
- for (const snapshot of snapshots.reverse()) {
918
- if (!restoreHooks && hookSet.has(snapshot.path)) continue;
919
- try { restoreSnapshot(snapshot); }
920
- catch (rollbackError) {
921
- rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
922
- }
923
- }
924
- if (rollbackFailures.length) {
925
- console.error(`aimhooman: initialisation failed: ${error.message}`);
926
- console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
927
- return 30;
1126
+ const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
1127
+ const added = withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
1128
+ const ov = loadOverrides(repo.stateDir);
1129
+ let count = 0;
1130
+ for (const path of paths) {
1131
+ const entry = {
1132
+ target: path,
1133
+ scope: 'secret-path',
1134
+ reason: 'pre-existing tracked fixture (grandfathered at init)',
1135
+ actor: gitConfig(repo.root, 'user.email'),
1136
+ at: new Date().toISOString(),
1137
+ };
1138
+ const sameAllow = (candidate) => (
1139
+ candidate.target === path && effectiveOverrideScope(candidate, engine) === 'secret-path'
1140
+ );
1141
+ if (ov.allow.some(sameAllow)) continue;
1142
+ ov.allow = upsert(ov.allow, entry, sameAllow);
1143
+ count += 1;
928
1144
  }
929
- console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
930
- return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
931
- }
932
- console.log(`aimhooman: initialised (profile: ${profile})`);
933
- console.log(` state: ${repo.stateDir}`);
934
- console.log(` excludes: ${repo.excludeFile}`);
935
- if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
936
- if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
937
- for (const warning of rep.warnings) console.log(` warning: ${warning}`);
938
- return 0;
939
- }, LIFECYCLE_LOCK_OPTIONS);
1145
+ saveOverrides(repo.stateDir, ov);
1146
+ return count;
1147
+ });
1148
+ console.log(`aimhooman: grandfathered ${added} tracked secret path(s) with --scope secret-path allows; new files with secrets are still blocked`);
940
1149
  }
941
1150
 
942
1151
  function cmdStatus(args) {
@@ -1049,6 +1258,25 @@ function cmdExplain(args) {
1049
1258
  return 0;
1050
1259
  }
1051
1260
 
1261
+ // The content half of the secret-allow guard in cmdOverride: run the engine's
1262
+ // own secret-category content rules over the target's bytes. Files over the
1263
+ // scanner's per-file budget, unreadable paths, and non-regular files skip it —
1264
+ // this guard is a UX safety net against an allow that would report success yet
1265
+ // leave the block in place, and the commit-time scanner still fails closed on
1266
+ // whatever was not checked here.
1267
+ function fileContentMatchesSecret(engine, repo, target) {
1268
+ let content;
1269
+ try {
1270
+ const file = join(repo.root, target);
1271
+ const stat = lstatSync(file);
1272
+ if (!stat.isFile() || stat.size > DEFAULT_SCAN_LIMITS.maxFileBytes) return false;
1273
+ content = readFileSync(file, 'utf8');
1274
+ } catch {
1275
+ return false;
1276
+ }
1277
+ return engine.checkContent(target, content, { categories: ['secret'] }).length > 0;
1278
+ }
1279
+
1052
1280
  function cmdOverride(args, allow) {
1053
1281
  const verb = allow ? 'allow' : 'deny';
1054
1282
  const { options, positionals } = parseArguments(args, {
@@ -1080,9 +1308,13 @@ function cmdOverride(args, allow) {
1080
1308
  // allow: only --scope secret-path suppresses secret findings, deliberately,
1081
1309
  // so a local override cannot hide a possible leaked key. Without this check
1082
1310
  // a bare `allow .env.minimal` would report success but leave the block in
1083
- // place, which reads as a broken allow.
1311
+ // place, which reads as a broken allow. checkPaths only evaluates path
1312
+ // rules, so a content-shaped secret (a private key inside an
1313
+ // ordinary-looking filename) would slip past it; the content half of the
1314
+ // guard closes that hole.
1084
1315
  if (allow && scope !== 'secret-path'
1085
- && engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
1316
+ && (engine.checkPaths([target]).some((finding) => finding.category === 'secret')
1317
+ || fileContentMatchesSecret(engine, repo, target))) {
1086
1318
  throw new ArgumentError(
1087
1319
  `"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
1088
1320
  + '(a local override must not hide a possible leaked key); '
@@ -1638,41 +1870,41 @@ function cmdUninstall(args) {
1638
1870
  });
1639
1871
  if (options.global) {
1640
1872
  return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
1641
- const aimDir = globalHooksDir();
1642
- const readGlobalHooksPath = () => {
1643
- try {
1644
- return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1645
- } catch { return ''; }
1646
- };
1647
- const current = readGlobalHooksPath();
1648
- let unsetAttempted = false;
1649
- if (current === aimDir) {
1650
- unsetAttempted = true;
1651
- try {
1652
- execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1653
- } catch { /* may have failed; re-verify below */ }
1654
- // A read-only or locked ~/.gitconfig can make --unset fail silently.
1655
- // Do not remove the dispatchers while core.hooksPath still resolves to
1656
- // them, or every repository inheriting this setting would run zero
1657
- // hooks with no warning. Preserve the working dispatchers until the
1658
- // config can be cleaned.
1659
- if (readGlobalHooksPath() === aimDir) {
1660
- console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
1661
- console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
1662
- return 30;
1873
+ const aimDir = globalHooksDir();
1874
+ const readGlobalHooksPath = () => {
1875
+ try {
1876
+ return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1877
+ } catch { return ''; }
1878
+ };
1879
+ const current = readGlobalHooksPath();
1880
+ let unsetAttempted = false;
1881
+ if (current === aimDir) {
1882
+ unsetAttempted = true;
1883
+ try {
1884
+ execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1885
+ } catch { /* may have failed; re-verify below */ }
1886
+ // A read-only or locked ~/.gitconfig can make --unset fail silently.
1887
+ // Do not remove the dispatchers while core.hooksPath still resolves to
1888
+ // them, or every repository inheriting this setting would run zero
1889
+ // hooks with no warning. Preserve the working dispatchers until the
1890
+ // config can be cleaned.
1891
+ if (readGlobalHooksPath() === aimDir) {
1892
+ console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
1893
+ console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
1894
+ return 30;
1895
+ }
1663
1896
  }
1664
- }
1665
- const rep = uninstallGlobalHooks();
1666
- console.log('aimhooman: global hooks uninstalled');
1667
- if (current && current !== aimDir) {
1668
- console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
1669
- } else {
1670
- console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
1671
- }
1672
- if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
1673
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1674
- console.log(` dir kept at ${rep.dir}`);
1675
- return 0;
1897
+ const rep = uninstallGlobalHooks();
1898
+ console.log('aimhooman: global hooks uninstalled');
1899
+ if (current && current !== aimDir) {
1900
+ console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
1901
+ } else {
1902
+ console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
1903
+ }
1904
+ if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
1905
+ for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1906
+ console.log(` dir kept at ${rep.dir}`);
1907
+ return 0;
1676
1908
  }, LIFECYCLE_LOCK_OPTIONS);
1677
1909
  }
1678
1910
  const purge = Boolean(options.purgeState);
@@ -1683,72 +1915,72 @@ function cmdUninstall(args) {
1683
1915
  }
1684
1916
  const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
1685
1917
  const exitStatus = withLock(lifecycleLock, () => {
1686
- const rep = uninstallHooks(repo);
1687
- // The irreversible work is already done above, and the report is still
1688
- // below. A throw from here unwound past all of it, so a damaged marker was
1689
- // the only thing the user heard while four dispatchers kept guarding every
1690
- // commit. Report the failure beside the removal report instead of in place
1691
- // of it. Nothing is swallowed: this is also where the symlink and permission
1692
- // guards surface, and their messages still reach the user and still exit 30.
1693
- let excludeFailure = '';
1694
- try {
1695
- removeExclude(repo.excludeFile);
1696
- } catch (error) {
1697
- excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1698
- }
1699
- // Trust the directory, not the report. Every refusal below leaves a working
1700
- // dispatcher behind, and one printed under "uninstalled" reads as done.
1701
- const remaining = remainingDispatchers(repo);
1702
- if (remaining.length) {
1703
- console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1704
- for (const path of remaining) console.error(` ${path}`);
1705
- console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1706
- } else {
1707
- console.log('aimhooman: uninstalled');
1708
- }
1709
- if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1710
- if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1711
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1712
- for (const f of rep.failures || []) console.error(` failure: ${f}`);
1713
- if (excludeFailure) console.error(` failure: ${excludeFailure}`);
1714
- const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1715
- if (purge) {
1716
- // Never wipe stateDir while a predecessor hook backup is still on disk:
1717
- // a per-hook restore failure leaves the user's original hook existing
1718
- // only in <stateDir>/chained, so purging would destroy it irrecoverably.
1719
- if (unrestored.length || rep.failures?.length) {
1720
- console.error(
1721
- 'aimhooman: state NOT purged — '
1722
- + `${unrestored.length} chained hook backup(s) remain unrestored`
1723
- + `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
1724
- + "re-run 'aimhooman uninstall' to retry restore before purging."
1725
- );
1918
+ const rep = uninstallHooks(repo);
1919
+ // The irreversible work is already done above, and the report is still
1920
+ // below. A throw from here unwound past all of it, so a damaged marker was
1921
+ // the only thing the user heard while four dispatchers kept guarding every
1922
+ // commit. Report the failure beside the removal report instead of in place
1923
+ // of it. Nothing is swallowed: this is also where the symlink and permission
1924
+ // guards surface, and their messages still reach the user and still exit 30.
1925
+ let excludeFailure = '';
1926
+ try {
1927
+ removeExclude(repo.excludeFile);
1928
+ } catch (error) {
1929
+ excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1930
+ }
1931
+ // Trust the directory, not the report. Every refusal below leaves a working
1932
+ // dispatcher behind, and one printed under "uninstalled" reads as done.
1933
+ const remaining = remainingDispatchers(repo);
1934
+ if (remaining.length) {
1935
+ console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1936
+ for (const path of remaining) console.error(` ${path}`);
1937
+ console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1726
1938
  } else {
1727
- rmSync(repo.stateDir, { recursive: true, force: true });
1728
- console.log(' state purged');
1939
+ console.log('aimhooman: uninstalled');
1729
1940
  }
1730
- } else {
1731
- console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
1732
- }
1733
- // Surface when a global core.hooksPath still enforces aimhooman, so a local
1734
- // uninstall cannot be mistaken for full removal. Foreign hooksPath values
1735
- // are left to the generic "not modified" warning emitted above.
1736
- const aimDir = globalHooksDir();
1737
- let globalHooksPath = '';
1738
- try {
1739
- globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1740
- encoding: 'utf8',
1741
- timeout: GIT_TIMEOUT_MS,
1742
- }).trim();
1743
- } catch { /* unset */ }
1744
- if (globalHooksPath === aimDir) {
1745
- console.log('aimhooman: global Git guard is still active');
1746
- console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1747
- console.log(' run `aimhooman uninstall --global` to remove it.');
1748
- }
1749
- // The managed block is still in the exclude file and still ignoring paths, so
1750
- // the uninstall is genuinely incomplete and 30 is the honest answer.
1751
- return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
1941
+ if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1942
+ if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1943
+ for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1944
+ for (const f of rep.failures || []) console.error(` failure: ${f}`);
1945
+ if (excludeFailure) console.error(` failure: ${excludeFailure}`);
1946
+ const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1947
+ if (purge) {
1948
+ // Never wipe stateDir while a predecessor hook backup is still on disk:
1949
+ // a per-hook restore failure leaves the user's original hook existing
1950
+ // only in <stateDir>/chained, so purging would destroy it irrecoverably.
1951
+ if (unrestored.length || rep.failures?.length) {
1952
+ console.error(
1953
+ 'aimhooman: state NOT purged — '
1954
+ + `${unrestored.length} chained hook backup(s) remain unrestored`
1955
+ + `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
1956
+ + "re-run 'aimhooman uninstall' to retry restore before purging."
1957
+ );
1958
+ } else {
1959
+ rmSync(repo.stateDir, { recursive: true, force: true });
1960
+ console.log(' state purged');
1961
+ }
1962
+ } else {
1963
+ console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
1964
+ }
1965
+ // Surface when a global core.hooksPath still enforces aimhooman, so a local
1966
+ // uninstall cannot be mistaken for full removal. Foreign hooksPath values
1967
+ // are left to the generic "not modified" warning emitted above.
1968
+ const aimDir = globalHooksDir();
1969
+ let globalHooksPath = '';
1970
+ try {
1971
+ globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1972
+ encoding: 'utf8',
1973
+ timeout: GIT_TIMEOUT_MS,
1974
+ }).trim();
1975
+ } catch { /* unset */ }
1976
+ if (globalHooksPath === aimDir) {
1977
+ console.log('aimhooman: global Git guard is still active');
1978
+ console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1979
+ console.log(' run `aimhooman uninstall --global` to remove it.');
1980
+ }
1981
+ // The managed block is still in the exclude file and still ignoring paths, so
1982
+ // the uninstall is genuinely incomplete and 30 is the honest answer.
1983
+ return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
1752
1984
  }, LIFECYCLE_LOCK_OPTIONS);
1753
1985
  // Sweep the operational residue the guard authored in .git, so uninstall leaves
1754
1986
  // no aimhooman fingerprints behind — the same tooling residue this tool exists
@@ -1792,7 +2024,7 @@ function usage() {
1792
2024
  process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
1793
2025
 
1794
2026
  Usage:
1795
- aimhooman init [--profile clean|strict|compliance]
2027
+ aimhooman init [--profile clean|strict|compliance] [--grandfather-secrets]
1796
2028
  aimhooman init --global --yes
1797
2029
  aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
1798
2030
  aimhooman check --commit <rev> | --range <base>...<head> | --tracked