@rmyndharis/aimhooman 0.1.6 → 0.1.8

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,6 @@
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 { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
@@ -15,6 +15,7 @@ import {
15
15
  readStagedPath,
16
16
  stagedPaths,
17
17
  stagedRenameSources,
18
+ stagedTreeSha,
18
19
  unstagePaths,
19
20
  withIndexFromTree,
20
21
  } from '../src/gitx.mjs';
@@ -118,6 +119,22 @@ function configuredEngine(profile, repo) {
118
119
 
119
120
  function main(argv) {
120
121
  const [cmd, ...rest] = argv;
122
+ // Subcommand-level --help: `aimhooman override --help` (or `init -h`, etc.)
123
+ // previously fell into the subcommand's strict argument parser, which rejects
124
+ // --help as an unknown option and exits 20. Recognise a leading help flag on
125
+ // any real subcommand and route to usage() instead, so help works everywhere.
126
+ // The top-level help forms (cmd itself is help/--help/-h) are handled in the
127
+ // switch below.
128
+ const SUBCOMMAND_HELP_FLAGS = new Set(['--help', '-h', 'help']);
129
+ const knownSubcommands = new Set([
130
+ 'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'init',
131
+ 'status', 'explain', 'allow', 'deny', 'override', 'review',
132
+ 'policy-review', 'fix', 'doctor', 'uninstall',
133
+ ]);
134
+ if (knownSubcommands.has(cmd) && rest.length && SUBCOMMAND_HELP_FLAGS.has(rest[0])) {
135
+ usage();
136
+ return 0;
137
+ }
121
138
  switch (cmd) {
122
139
  case 'check':
123
140
  return cmdCheck(rest);
@@ -311,6 +328,49 @@ function dispatchHooksChanged(repo, profile) {
311
328
  return true;
312
329
  }
313
330
 
331
+ // W5 pre-commit/commit-msg marker dedup. pre-commit writes a marker after a
332
+ // clean, complete scan of the staged tree; commit-msg reads it and skips its
333
+ // duplicate ~170ms tree scan when the staged tree sha, profile, and
334
+ // completeness all match. The marker lives in stateDir (gitignored plumbing).
335
+ // It is self-invalidating: any index mutation between the two hooks changes the
336
+ // tree sha, so a stale marker never matches. A missing/corrupt/mismatched
337
+ // marker makes commit-msg fall back to the full scan, so this is purely an
338
+ // optimization and never weakens the guard.
339
+ const PRECOMMIT_CLEAN_VERSION = 1;
340
+ function precommitCleanPath(repo) {
341
+ return join(repo.stateDir, 'precommit-clean.json');
342
+ }
343
+ function recordPrecommitClean(repo, profile) {
344
+ let treeSha;
345
+ try {
346
+ treeSha = stagedTreeSha(repo);
347
+ } catch {
348
+ return; // cannot compute the sha → do not record; commit-msg will scan
349
+ }
350
+ try {
351
+ writeFileSync(precommitCleanPath(repo), JSON.stringify({
352
+ version: PRECOMMIT_CLEAN_VERSION,
353
+ tree: treeSha,
354
+ profile,
355
+ complete: true,
356
+ }));
357
+ } catch {
358
+ // best effort; a missing marker just means commit-msg scans normally
359
+ }
360
+ }
361
+ function precommitCleanMatches(repo, treeSha, profile) {
362
+ let marker;
363
+ try {
364
+ marker = JSON.parse(readFileSync(precommitCleanPath(repo), 'utf8'));
365
+ } catch {
366
+ return false; // missing/corrupt → fall back to full scan
367
+ }
368
+ return marker?.version === PRECOMMIT_CLEAN_VERSION
369
+ && marker.complete === true
370
+ && marker.tree === treeSha
371
+ && marker.profile === profile;
372
+ }
373
+
314
374
  function cmdPrecommit(args) {
315
375
  parseNoArguments(args);
316
376
  const repo = tryRepo();
@@ -336,6 +396,13 @@ function cmdPrecommit(args) {
336
396
  process.stderr.write(incompleteMessage(scan));
337
397
  return 31;
338
398
  }
399
+ // W5 marker dedup: record that this staged tree scanned clean so the
400
+ // upcoming commit-msg hook can skip its duplicate tree scan. The tree
401
+ // sha is the same value the commit-msg dispatcher computes via
402
+ // `git write-tree`; the index is unchanged on this no-block path, so the
403
+ // sha is stable. A missing/stale/mismatched marker makes commit-msg
404
+ // fall back to the full scan, so this is purely an optimization.
405
+ recordPrecommitClean(repo, profile);
339
406
  return profile === 'strict' && reviews.length ? 11 : 0;
340
407
  }
341
408
  if (profile === 'strict') {
@@ -463,7 +530,14 @@ function cmdCommitmsg(args) {
463
530
  try {
464
531
  const checked = againstWouldBeTree(() => ({
465
532
  message: scanMessage(repo, text, { target: 'staged' }),
466
- tree: options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null,
533
+ // W5 marker dedup: if pre-commit already scanned this exact tree
534
+ // clean (matching sha + profile + complete), skip the duplicate
535
+ // ~170ms tree scan. The marker is self-invalidating (any index
536
+ // mutation changes the tree sha), and a missing/stale/mismatched
537
+ // marker falls back to the full scan, so this is safe.
538
+ tree: options.tree && precommitCleanMatches(repo, options.tree, hookProfile)
539
+ ? null
540
+ : (options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null),
467
541
  }));
468
542
  scan = checked.message;
469
543
  treeScan = checked.tree;
@@ -599,8 +673,23 @@ function cmdRefcheck(args) {
599
673
  let commits;
600
674
  try {
601
675
  const contextsByCommit = new Map();
676
+ // A commit's message belongs to whoever wrote it. Attribution and marker
677
+ // rules police the text a local developer can edit, so they are scoped
678
+ // to commits authored here: an update that introduces exactly one new
679
+ // commit on top of a non-zero old tip (a plain commit, an --amend, or a
680
+ // local --no-ff merge of an already-gated branch). Anything else — a new
681
+ // branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
682
+ // history — imports other people's commit text the developer cannot
683
+ // change, and scanning it only blocks the review.
684
+ const localAuthorTips = new Set();
602
685
  for (const update of updates) {
603
- for (const revision of introducedCommits(repo, [update])) {
686
+ const introduced = introducedCommits(repo, [update]);
687
+ if (!/^0+$/.test(update.oldObjectId)
688
+ && introduced.length === 1
689
+ && introduced[0] === update.newObjectId) {
690
+ localAuthorTips.add(update.newObjectId);
691
+ }
692
+ for (const revision of introduced) {
604
693
  const contexts = contextsByCommit.get(revision) || [];
605
694
  contexts.push({
606
695
  head: update.newObjectId,
@@ -624,14 +713,18 @@ function cmdRefcheck(args) {
624
713
  contextsByCommit.set(revision, contexts);
625
714
  }
626
715
  }
627
- commits = [...contextsByCommit];
716
+ commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
717
+ revision,
718
+ reviewContexts,
719
+ localAuthorTips.has(revision),
720
+ ]);
628
721
  }
629
722
  catch (error) {
630
723
  console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
631
724
  return 30;
632
725
  }
633
726
  const limits = scanLimits();
634
- for (const [revision, reviewContexts] of commits) {
727
+ for (const [revision, reviewContexts, authoredLocally] of commits) {
635
728
  let scan;
636
729
  try {
637
730
  scan = scanGitTarget(repo, {
@@ -640,6 +733,7 @@ function cmdRefcheck(args) {
640
733
  reviewContexts,
641
734
  policyMigrationContexts: reviewContexts,
642
735
  limits,
736
+ messageScope: authoredLocally ? 'commit' : 'changes-only',
643
737
  });
644
738
  }
645
739
  catch (error) {
@@ -757,71 +851,71 @@ function cmdInit(args) {
757
851
  return 20;
758
852
  }
759
853
  return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
760
- const aimDir = globalHooksDir();
761
- let existing = '';
762
- try {
763
- existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
764
- } catch { /* unset */ }
765
- if (existing && existing !== aimDir) {
766
- console.error(
767
- `aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
768
- 'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
769
- );
770
- return 20;
771
- }
772
- // A --system hooksPath would be shadowed by the --global write below and
773
- // could silently disable a system-wide hook manager, so refuse here too.
774
- let systemHooksPath = '';
775
- try {
776
- systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
777
- } catch { /* unset or no system config */ }
778
- if (systemHooksPath) {
779
- console.error(
780
- `aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
781
- 'a --global install would shadow it. Unset it or choose a different setup.'
782
- );
783
- return 20;
784
- }
785
- const localRepo = tryRepo();
786
- const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
787
- if (localHooksPath) {
788
- console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
789
- }
790
- const hookSnapshots = REQUIRED_GIT_HOOKS
791
- .map((name) => snapshotFile(join(aimDir, name)));
792
- let rep;
793
- try {
794
- rep = installGlobalHooks(CLI_PATH);
795
- if (rep.skipped?.length) {
796
- for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
797
- console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
854
+ const aimDir = globalHooksDir();
855
+ let existing = '';
856
+ try {
857
+ existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
858
+ } catch { /* unset */ }
859
+ if (existing && existing !== aimDir) {
860
+ console.error(
861
+ `aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
862
+ 'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
863
+ );
798
864
  return 20;
799
865
  }
800
- execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
801
- } catch (error) {
802
- const rollbackFailures = [];
803
- for (const snapshot of hookSnapshots.reverse()) {
804
- try { restoreSnapshot(snapshot); }
805
- catch (rollbackError) {
806
- rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
807
- }
808
- }
866
+ // A --system hooksPath would be shadowed by the --global write below and
867
+ // could silently disable a system-wide hook manager, so refuse here too.
868
+ let systemHooksPath = '';
809
869
  try {
810
- if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
811
- else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
812
- } catch (rollbackError) {
813
- rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
870
+ systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
871
+ } catch { /* unset or no system config */ }
872
+ if (systemHooksPath) {
873
+ console.error(
874
+ `aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
875
+ 'a --global install would shadow it. Unset it or choose a different setup.'
876
+ );
877
+ return 20;
814
878
  }
815
- console.error(`aimhooman: global hook installation failed: ${error.message}`);
816
- if (rollbackFailures.length) {
817
- console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
879
+ const localRepo = tryRepo();
880
+ const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
881
+ if (localHooksPath) {
882
+ console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
818
883
  }
819
- return 30;
820
- }
821
- console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
822
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
823
- 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.');
824
- return 0;
884
+ const hookSnapshots = REQUIRED_GIT_HOOKS
885
+ .map((name) => snapshotFile(join(aimDir, name)));
886
+ let rep;
887
+ try {
888
+ rep = installGlobalHooks(CLI_PATH);
889
+ if (rep.skipped?.length) {
890
+ for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
891
+ console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
892
+ return 20;
893
+ }
894
+ execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
895
+ } catch (error) {
896
+ const rollbackFailures = [];
897
+ for (const snapshot of hookSnapshots.reverse()) {
898
+ try { restoreSnapshot(snapshot); }
899
+ catch (rollbackError) {
900
+ rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
901
+ }
902
+ }
903
+ try {
904
+ if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
905
+ else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
906
+ } catch (rollbackError) {
907
+ rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
908
+ }
909
+ console.error(`aimhooman: global hook installation failed: ${error.message}`);
910
+ if (rollbackFailures.length) {
911
+ console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
912
+ }
913
+ return 30;
914
+ }
915
+ console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
916
+ for (const w of rep.warnings || []) console.log(` warning: ${w}`);
917
+ 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.');
918
+ return 0;
825
919
  }, LIFECYCLE_LOCK_OPTIONS);
826
920
  }
827
921
  if (options.yes) throw new ArgumentError('--yes is only valid with --global');
@@ -833,89 +927,97 @@ function cmdInit(args) {
833
927
  return 30;
834
928
  }
835
929
  return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
836
- let projectPolicy;
837
- try {
838
- projectPolicy = loadProjectPolicy(repo.root);
839
- } catch (e) {
840
- console.error(`aimhooman: cannot load project policy: ${e.message}`);
841
- return 20;
842
- }
843
- if (projectPolicy) {
844
- if (profileExplicit && profile !== projectPolicy.profile) {
845
- console.error(
846
- `aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
847
- 'edit .aimhooman.json to change the team baseline'
848
- );
930
+ let projectPolicy;
931
+ try {
932
+ projectPolicy = loadProjectPolicy(repo.root);
933
+ } catch (e) {
934
+ console.error(`aimhooman: cannot load project policy: ${e.message}`);
849
935
  return 20;
850
936
  }
851
- profile = projectPolicy.profile;
852
- }
853
- let eng;
854
- try {
855
- eng = configuredEngine(profile, repo);
856
- } catch (e) {
857
- console.error(`aimhooman: cannot initialise policy: ${e.message}`);
858
- return 20;
859
- }
860
- const hookState = hookDiagnostics(repo);
861
- const hookFiles = hookState.some((hook) => hook.shared)
862
- ? []
863
- : [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
864
- let snapshots = [];
865
- let rep;
866
- try {
867
- snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
868
- .map(snapshotFile);
869
- rep = installHooks(repo, CLI_PATH);
870
- const activeHooks = installedHooks(repo);
871
- if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
872
- // installHooks declines rather than throws when the hooks directory is
873
- // not ours, and its warnings are the only record of why. They are
874
- // printed on the success path only, so carry them into the failure or
875
- // the user is told nothing but "incomplete". The prefix is load-bearing:
876
- // the exit-code branch below matches on it.
877
- const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
878
- throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
879
- }
880
- saveConfig(repo.stateDir, { profile });
881
- applyExclude(repo.excludeFile, patternsForRules(eng.rules));
882
- const saved = loadConfig(repo.stateDir);
883
- const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
884
- if (saved.profile !== profile || !excludes.current) {
885
- throw new Error('post-install state verification failed');
937
+ if (projectPolicy) {
938
+ if (profileExplicit && profile !== projectPolicy.profile) {
939
+ console.error(
940
+ `aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
941
+ 'edit .aimhooman.json to change the team baseline'
942
+ );
943
+ return 20;
944
+ }
945
+ profile = projectPolicy.profile;
886
946
  }
887
- } catch (error) {
888
- const rollbackFailures = [];
947
+ let eng;
889
948
  try {
890
- const uninstalled = uninstallHooks(repo);
891
- rollbackFailures.push(...(uninstalled.failures || []));
892
- } catch (rollbackError) {
893
- rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
949
+ eng = configuredEngine(profile, repo);
950
+ } catch (e) {
951
+ console.error(`aimhooman: cannot initialise policy: ${e.message}`);
952
+ return 20;
894
953
  }
895
- const hookSet = new Set(hookFiles);
896
- const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
897
- for (const snapshot of snapshots.reverse()) {
898
- if (!restoreHooks && hookSet.has(snapshot.path)) continue;
899
- try { restoreSnapshot(snapshot); }
900
- catch (rollbackError) {
901
- rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
954
+ const hookState = hookDiagnostics(repo);
955
+ const hookFiles = hookState.some((hook) => hook.shared)
956
+ ? []
957
+ : [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
958
+ let snapshots = [];
959
+ let rep;
960
+ try {
961
+ snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
962
+ .map(snapshotFile);
963
+ rep = installHooks(repo, CLI_PATH);
964
+ const activeHooks = installedHooks(repo);
965
+ if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
966
+ // installHooks declines rather than throws when the hooks directory is
967
+ // not ours, and its warnings are the only record of why. They are
968
+ // printed on the success path only, so carry them into the failure or
969
+ // the user is told nothing but "incomplete". The prefix is load-bearing:
970
+ // the exit-code branch below matches on it.
971
+ const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
972
+ // When the decline is because the hooks path is shared/tracked (or,
973
+ // after B2, worktree content the next add would commit), name the two
974
+ // ways out so the user is not stuck: let aimhooman use the default
975
+ // .git/hooks, or — for a worktree hooks path kept local — exclude it
976
+ // first. The uninstall hint lets them undo this init attempt.
977
+ const remedy = rep.shared
978
+ ? ' 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.'
979
+ : '';
980
+ throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
902
981
  }
982
+ saveConfig(repo.stateDir, { profile });
983
+ applyExclude(repo.excludeFile, patternsForRules(eng.rules));
984
+ const saved = loadConfig(repo.stateDir);
985
+ const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
986
+ if (saved.profile !== profile || !excludes.current) {
987
+ throw new Error('post-install state verification failed');
988
+ }
989
+ } catch (error) {
990
+ const rollbackFailures = [];
991
+ try {
992
+ const uninstalled = uninstallHooks(repo);
993
+ rollbackFailures.push(...(uninstalled.failures || []));
994
+ } catch (rollbackError) {
995
+ rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
996
+ }
997
+ const hookSet = new Set(hookFiles);
998
+ const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
999
+ for (const snapshot of snapshots.reverse()) {
1000
+ if (!restoreHooks && hookSet.has(snapshot.path)) continue;
1001
+ try { restoreSnapshot(snapshot); }
1002
+ catch (rollbackError) {
1003
+ rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
1004
+ }
1005
+ }
1006
+ if (rollbackFailures.length) {
1007
+ console.error(`aimhooman: initialisation failed: ${error.message}`);
1008
+ console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
1009
+ return 30;
1010
+ }
1011
+ console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
1012
+ return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
903
1013
  }
904
- if (rollbackFailures.length) {
905
- console.error(`aimhooman: initialisation failed: ${error.message}`);
906
- console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
907
- return 30;
908
- }
909
- console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
910
- return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
911
- }
912
- console.log(`aimhooman: initialised (profile: ${profile})`);
913
- console.log(` state: ${repo.stateDir}`);
914
- console.log(` excludes: ${repo.excludeFile}`);
915
- if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
916
- if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
917
- for (const warning of rep.warnings) console.log(` warning: ${warning}`);
918
- return 0;
1014
+ console.log(`aimhooman: initialised (profile: ${profile})`);
1015
+ console.log(` state: ${repo.stateDir}`);
1016
+ console.log(` excludes: ${repo.excludeFile}`);
1017
+ if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
1018
+ if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
1019
+ for (const warning of rep.warnings) console.log(` warning: ${warning}`);
1020
+ return 0;
919
1021
  }, LIFECYCLE_LOCK_OPTIONS);
920
1022
  }
921
1023
 
@@ -927,9 +1029,17 @@ function cmdStatus(args) {
927
1029
  return 30;
928
1030
  }
929
1031
  let policy;
1032
+ let stagedPolicy;
930
1033
  let overrides;
931
1034
  try {
1035
+ // `policy` describes the worktree file as written; `stagedPolicy` is the
1036
+ // one the enforcing hooks actually read (they resolve from the index, so
1037
+ // a worktree profile that has not been staged is invisible to them).
1038
+ // Reporting the worktree value alone used to advertise a profile the
1039
+ // guard was not applying, so the two are compared below and the
1040
+ // mismatch is named when they disagree.
932
1041
  policy = resolvePolicy(repo, { target: 'worktree' });
1042
+ stagedPolicy = resolvePolicy(repo, { target: 'staged' });
933
1043
  overrides = loadOverrides(repo.stateDir);
934
1044
  } catch (e) {
935
1045
  console.error(`aimhooman: cannot load enforcement state: ${e.message}`);
@@ -952,9 +1062,25 @@ function cmdStatus(args) {
952
1062
  excludeError = e.message;
953
1063
  excludes = { current: false, installed: false };
954
1064
  }
955
- const policyLabel = policy.source === 'worktree-policy' ? 'project' : policy.source;
956
- console.log(`profile: ${policy.profile}`);
957
- console.log(`policy: ${policyLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})`);
1065
+ // The pre-commit and reference-transaction guards resolve the policy from
1066
+ // the index, so the staged profile is what is actually enforced. Report it
1067
+ // first; the worktree file is shown alongside so an edit that has not been
1068
+ // staged (or a file that was never `git add`ed) cannot masquerade as active.
1069
+ const policyLabelOf = (resolved) => (
1070
+ resolved.source === 'worktree-policy'
1071
+ || resolved.source === 'staged-policy'
1072
+ || resolved.source === 'commit-policy'
1073
+ ) ? 'project' : resolved.source;
1074
+ const enforced = stagedPolicy.profile;
1075
+ const enforcedLabel = policyLabelOf(stagedPolicy);
1076
+ const worktreeLabel = policyLabelOf(policy);
1077
+ const policyDrift = policy.profile !== stagedPolicy.profile
1078
+ || policy.policy_object_id !== stagedPolicy.policy_object_id;
1079
+ console.log(`profile: ${enforced}${policyDrift ? ` (worktree: ${policy.profile})` : ''}`);
1080
+ console.log(`policy: ${enforcedLabel} (${stagedPolicy.source}, object=${stagedPolicy.policy_object_id || 'none'})${policyDrift ? `; worktree ${worktreeLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})` : ''}`);
1081
+ if (policyDrift) {
1082
+ console.log(`warning: worktree .aimhooman.json is not staged, so the hooks enforce the staged profile (${enforced}); run \`git add .aimhooman.json\` to apply ${policy.profile}`);
1083
+ }
958
1084
  console.log(`hooks: ${hooksComplete ? installed.join(', ') : installed.length ? `${installed.join(', ')} (incomplete; run: aimhooman init)` : 'not installed (run: aimhooman init)'}`);
959
1085
  for (const hook of hooks) {
960
1086
  console.log(`hook ${hook.name}: ${hook.managed ? 'managed' : 'not managed'}, ${hook.executable ? 'executable' : 'not executable'}, ${hook.reachable ? 'reachable' : hook.reason}`);
@@ -1594,41 +1720,41 @@ function cmdUninstall(args) {
1594
1720
  });
1595
1721
  if (options.global) {
1596
1722
  return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
1597
- const aimDir = globalHooksDir();
1598
- const readGlobalHooksPath = () => {
1599
- try {
1600
- return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1601
- } catch { return ''; }
1602
- };
1603
- const current = readGlobalHooksPath();
1604
- let unsetAttempted = false;
1605
- if (current === aimDir) {
1606
- unsetAttempted = true;
1607
- try {
1608
- execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1609
- } catch { /* may have failed; re-verify below */ }
1610
- // A read-only or locked ~/.gitconfig can make --unset fail silently.
1611
- // Do not remove the dispatchers while core.hooksPath still resolves to
1612
- // them, or every repository inheriting this setting would run zero
1613
- // hooks with no warning. Preserve the working dispatchers until the
1614
- // config can be cleaned.
1615
- if (readGlobalHooksPath() === aimDir) {
1616
- console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
1617
- console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
1618
- return 30;
1723
+ const aimDir = globalHooksDir();
1724
+ const readGlobalHooksPath = () => {
1725
+ try {
1726
+ return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1727
+ } catch { return ''; }
1728
+ };
1729
+ const current = readGlobalHooksPath();
1730
+ let unsetAttempted = false;
1731
+ if (current === aimDir) {
1732
+ unsetAttempted = true;
1733
+ try {
1734
+ execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1735
+ } catch { /* may have failed; re-verify below */ }
1736
+ // A read-only or locked ~/.gitconfig can make --unset fail silently.
1737
+ // Do not remove the dispatchers while core.hooksPath still resolves to
1738
+ // them, or every repository inheriting this setting would run zero
1739
+ // hooks with no warning. Preserve the working dispatchers until the
1740
+ // config can be cleaned.
1741
+ if (readGlobalHooksPath() === aimDir) {
1742
+ console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
1743
+ console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
1744
+ return 30;
1745
+ }
1619
1746
  }
1620
- }
1621
- const rep = uninstallGlobalHooks();
1622
- console.log('aimhooman: global hooks uninstalled');
1623
- if (current && current !== aimDir) {
1624
- console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
1625
- } else {
1626
- console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
1627
- }
1628
- if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
1629
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1630
- console.log(` dir kept at ${rep.dir}`);
1631
- return 0;
1747
+ const rep = uninstallGlobalHooks();
1748
+ console.log('aimhooman: global hooks uninstalled');
1749
+ if (current && current !== aimDir) {
1750
+ console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
1751
+ } else {
1752
+ console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
1753
+ }
1754
+ if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
1755
+ for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1756
+ console.log(` dir kept at ${rep.dir}`);
1757
+ return 0;
1632
1758
  }, LIFECYCLE_LOCK_OPTIONS);
1633
1759
  }
1634
1760
  const purge = Boolean(options.purgeState);
@@ -1639,72 +1765,72 @@ function cmdUninstall(args) {
1639
1765
  }
1640
1766
  const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
1641
1767
  const exitStatus = withLock(lifecycleLock, () => {
1642
- const rep = uninstallHooks(repo);
1643
- // The irreversible work is already done above, and the report is still
1644
- // below. A throw from here unwound past all of it, so a damaged marker was
1645
- // the only thing the user heard while four dispatchers kept guarding every
1646
- // commit. Report the failure beside the removal report instead of in place
1647
- // of it. Nothing is swallowed: this is also where the symlink and permission
1648
- // guards surface, and their messages still reach the user and still exit 30.
1649
- let excludeFailure = '';
1650
- try {
1651
- removeExclude(repo.excludeFile);
1652
- } catch (error) {
1653
- excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1654
- }
1655
- // Trust the directory, not the report. Every refusal below leaves a working
1656
- // dispatcher behind, and one printed under "uninstalled" reads as done.
1657
- const remaining = remainingDispatchers(repo);
1658
- if (remaining.length) {
1659
- console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1660
- for (const path of remaining) console.error(` ${path}`);
1661
- console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1662
- } else {
1663
- console.log('aimhooman: uninstalled');
1664
- }
1665
- if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1666
- if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1667
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1668
- for (const f of rep.failures || []) console.error(` failure: ${f}`);
1669
- if (excludeFailure) console.error(` failure: ${excludeFailure}`);
1670
- const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1671
- if (purge) {
1672
- // Never wipe stateDir while a predecessor hook backup is still on disk:
1673
- // a per-hook restore failure leaves the user's original hook existing
1674
- // only in <stateDir>/chained, so purging would destroy it irrecoverably.
1675
- if (unrestored.length || rep.failures?.length) {
1676
- console.error(
1677
- 'aimhooman: state NOT purged — '
1678
- + `${unrestored.length} chained hook backup(s) remain unrestored`
1679
- + `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
1680
- + "re-run 'aimhooman uninstall' to retry restore before purging."
1681
- );
1768
+ const rep = uninstallHooks(repo);
1769
+ // The irreversible work is already done above, and the report is still
1770
+ // below. A throw from here unwound past all of it, so a damaged marker was
1771
+ // the only thing the user heard while four dispatchers kept guarding every
1772
+ // commit. Report the failure beside the removal report instead of in place
1773
+ // of it. Nothing is swallowed: this is also where the symlink and permission
1774
+ // guards surface, and their messages still reach the user and still exit 30.
1775
+ let excludeFailure = '';
1776
+ try {
1777
+ removeExclude(repo.excludeFile);
1778
+ } catch (error) {
1779
+ excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1780
+ }
1781
+ // Trust the directory, not the report. Every refusal below leaves a working
1782
+ // dispatcher behind, and one printed under "uninstalled" reads as done.
1783
+ const remaining = remainingDispatchers(repo);
1784
+ if (remaining.length) {
1785
+ console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1786
+ for (const path of remaining) console.error(` ${path}`);
1787
+ console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1682
1788
  } else {
1683
- rmSync(repo.stateDir, { recursive: true, force: true });
1684
- console.log(' state purged');
1789
+ console.log('aimhooman: uninstalled');
1685
1790
  }
1686
- } else {
1687
- console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
1688
- }
1689
- // Surface when a global core.hooksPath still enforces aimhooman, so a local
1690
- // uninstall cannot be mistaken for full removal. Foreign hooksPath values
1691
- // are left to the generic "not modified" warning emitted above.
1692
- const aimDir = globalHooksDir();
1693
- let globalHooksPath = '';
1694
- try {
1695
- globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1696
- encoding: 'utf8',
1697
- timeout: GIT_TIMEOUT_MS,
1698
- }).trim();
1699
- } catch { /* unset */ }
1700
- if (globalHooksPath === aimDir) {
1701
- console.log('aimhooman: global Git guard is still active');
1702
- console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1703
- console.log(' run `aimhooman uninstall --global` to remove it.');
1704
- }
1705
- // The managed block is still in the exclude file and still ignoring paths, so
1706
- // the uninstall is genuinely incomplete and 30 is the honest answer.
1707
- return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
1791
+ if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1792
+ if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1793
+ for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1794
+ for (const f of rep.failures || []) console.error(` failure: ${f}`);
1795
+ if (excludeFailure) console.error(` failure: ${excludeFailure}`);
1796
+ const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1797
+ if (purge) {
1798
+ // Never wipe stateDir while a predecessor hook backup is still on disk:
1799
+ // a per-hook restore failure leaves the user's original hook existing
1800
+ // only in <stateDir>/chained, so purging would destroy it irrecoverably.
1801
+ if (unrestored.length || rep.failures?.length) {
1802
+ console.error(
1803
+ 'aimhooman: state NOT purged — '
1804
+ + `${unrestored.length} chained hook backup(s) remain unrestored`
1805
+ + `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
1806
+ + "re-run 'aimhooman uninstall' to retry restore before purging."
1807
+ );
1808
+ } else {
1809
+ rmSync(repo.stateDir, { recursive: true, force: true });
1810
+ console.log(' state purged');
1811
+ }
1812
+ } else {
1813
+ console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
1814
+ }
1815
+ // Surface when a global core.hooksPath still enforces aimhooman, so a local
1816
+ // uninstall cannot be mistaken for full removal. Foreign hooksPath values
1817
+ // are left to the generic "not modified" warning emitted above.
1818
+ const aimDir = globalHooksDir();
1819
+ let globalHooksPath = '';
1820
+ try {
1821
+ globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1822
+ encoding: 'utf8',
1823
+ timeout: GIT_TIMEOUT_MS,
1824
+ }).trim();
1825
+ } catch { /* unset */ }
1826
+ if (globalHooksPath === aimDir) {
1827
+ console.log('aimhooman: global Git guard is still active');
1828
+ console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1829
+ console.log(' run `aimhooman uninstall --global` to remove it.');
1830
+ }
1831
+ // The managed block is still in the exclude file and still ignoring paths, so
1832
+ // the uninstall is genuinely incomplete and 30 is the honest answer.
1833
+ return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
1708
1834
  }, LIFECYCLE_LOCK_OPTIONS);
1709
1835
  // Sweep the operational residue the guard authored in .git, so uninstall leaves
1710
1836
  // no aimhooman fingerprints behind — the same tooling residue this tool exists