@rmyndharis/aimhooman 0.3.0 → 0.4.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.
@@ -9,7 +9,7 @@
9
9
  "name": "aimhooman",
10
10
  "source": "./",
11
11
  "description": "Keeps AI session files and unwanted attribution out at the agent and Git boundary.",
12
- "version": "0.3.0",
12
+ "version": "0.4.0",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman maintainers",
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: aimhooman - keep AI session files, secrets, and unwanted AI attribution out of Git.
2
+ description: aimhooman - keep AI session files and unwanted AI attribution out of Git.
3
3
  globs:
4
4
  alwaysApply: true
5
5
  ---
package/CHANGELOG.md CHANGED
@@ -5,6 +5,92 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-07-20
9
+
10
+ ### Added
11
+
12
+ - New managed `pre-push` hook (`pushcheck`) that scans every commit a push
13
+ would introduce, messages included. A commit vetoed by the
14
+ reference-transaction guard stays in the local object store, and pushing that
15
+ raw object ID (`git push <remote> <sha>:<ref>`) moved no local branch, so it
16
+ sailed past every guard; the veto message even printed the SHA. Such pushes
17
+ are now rejected before any objects are sent. Deletions carry nothing and
18
+ pass without scanning.
19
+
20
+ ### Changed
21
+
22
+ - A scan whose only gap is a file over the per-file size budget no longer
23
+ wedges commits on `clean`/`compliance`: the final ref guard warns and
24
+ continues instead of vetoing, because path rules still covered the file.
25
+ `strict` stays fail-closed (exit 31) on any incomplete scan, and every other
26
+ gap (total byte budget, unreadable objects, rule-pack errors) stays
27
+ fail-closed on all profiles.
28
+ - Commit overhead: the `commit-msg` dispatcher greps the message for the
29
+ attribution-rule anchors and skips its Node spawn when none is present (a
30
+ local rule pack or a missing `pre-commit` dispatcher re-enables the spawn,
31
+ since the "tree already scanned" premise needs both), and `pre-commit` skips
32
+ its spawn when the index is empty. A typical commit goes from three Node
33
+ spawns to two; an empty commit to one.
34
+ - The `not very hooman.` banner now appears only when a finding actually stops
35
+ or reshapes the operation (a block). A pure review advisory, where the
36
+ commit proceeds untouched, prints without it so the banner keeps its meaning.
37
+ - A blocked strict-policy downgrade or deletion now prints the exact
38
+ `aimhooman policy-review` invocation — oids included — instead of pointing at
39
+ "the reviewed migration command"; running the printed command unlocks the
40
+ commit.
41
+ - A non-blocking "scan incomplete" warning (the oversized-file case) prints
42
+ once per tree and gap instead of once per hook: pre-commit, commit-msg, the
43
+ final ref guard, and pre-push share a notice marker, so the same oversized
44
+ file no longer warns three times in one commit.
45
+
46
+ ## [0.3.1] - 2026-07-19
47
+
48
+ ### Fixed
49
+
50
+ - `fix` on a strict policy no longer exits 11 (review required) when the
51
+ message holds a removable attribution line but the recheck of the cleaned
52
+ message stays incomplete; it exits 31, the same verdict `--apply` reaches
53
+ for the same content.
54
+ - `commitmsg` printed the blocking "scan incomplete … and retry" framing for
55
+ a tree scan that stopped at exit 11 on the compliance profile; the framing
56
+ now follows the profile, matching `check` and `fix`.
57
+ - `loadOverrides` burned the full lock retry budget (~50 × 10 ms) when a
58
+ caller already held `overrides.json.lock` (every `allow`/`deny`/`override`/
59
+ `review`/`policy-review` write); the warn-once migration persist now takes
60
+ one lock attempt and skips quietly when the lock is held.
61
+ - Two-arg `loadConfig(stateDir, root)` dropped the per-clone `gitignore`
62
+ record whenever a project policy owned the profile; the record is now read
63
+ best effort from config.json.
64
+ - Session-start hook: a failed `.git/info/exclude` refresh no longer skips
65
+ the worktree `.gitignore` refresh; each has its own silent failure, like
66
+ the pre-tool-use path.
67
+ - `uninstall` now also sweeps the worktree `.gitignore` lock queue left by
68
+ `init --gitignore`; the sweep previously covered only the three `.git`-side
69
+ queues.
70
+ - `action.yml` gained a `version` input (default `latest`) so the installed
71
+ CLI can be pinned (pinning the action tag never pinned the CLI), and its
72
+ `actions/setup-node` step is pinned to the same SHA the test workflow uses.
73
+ - The tarball now ships `docs/secrets.md`, `docs/policy.md`,
74
+ `docs/cli-reference.md`, `docs/faq.md`, and `docs/integrations.md`; links
75
+ to them from the README and the catalog dangled inside node_modules.
76
+ - Documentation and comment accuracy: the Cursor manifest description and the
77
+ usage and integrations exit-code lines no longer mention secret scanning
78
+ and now name the strict/final-guard scope of exit 31; the README flowchart
79
+ no longer implies an incomplete scan blocks pre-commit on every profile;
80
+ `docs/policy.md` states correctly that strict turns policy-file and
81
+ agent-instruction findings into blocks and that only the policy
82
+ downgrade/removal block is allow-proof; the 0.3.0 binary bullet, the FAQ
83
+ budget answer, and two stale comments now describe what the code does.
84
+
85
+ ### Removed
86
+
87
+ - Dead code: the unreachable `release` branch of
88
+ `scripts/scan-ci-history.mjs` (no workflow sets that context) along with
89
+ `selectReleaseBase` and the `RELEASE_ENVIRONMENT_VERIFIED` contract; the
90
+ never-produced `object-read-failed` incomplete reason; the callerless
91
+ `repeatable` option in the argument parser; and eight unused `node:fs`
92
+ imports in `src/gitx.mjs`.
93
+
8
94
  ## [0.3.0] - 2026-07-19
9
95
 
10
96
  This release narrows aimhooman to the job it does better than anyone else:
@@ -30,8 +116,11 @@ reference-transaction guard keeps its veto on every profile.
30
116
  `overrides.json` are dropped on load with a warning naming the file.
31
117
  A secret already in history needs rotation and a gitleaks run, not
32
118
  aimhooman.
33
- - With no rule reading binary content, an oversized binary file no longer
34
- makes a scan incomplete; oversized text still does.
119
+ - In-budget binary blobs are no longer content-scanned: v0.2.0 read their
120
+ bytes for secrets, and with the scanner gone they now skip as binary. The
121
+ oversized-file probe is unchanged — binary up to 16 MiB still skips
122
+ complete, and binary over it is still a size-limit skip that leaves the
123
+ scan incomplete.
35
124
 
36
125
  ### Added
37
126
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <p align="center">
11
11
  <img src="https://img.shields.io/github/actions/workflow/status/rmyndharis/aimhooman/test.yml?branch=main&label=CI" alt="CI">
12
- <img src="https://img.shields.io/badge/version-v0.3.0-blue" alt="v0.3.0">
12
+ <img src="https://img.shields.io/badge/version-v0.4.0-blue" alt="v0.4.0">
13
13
  <img src="https://img.shields.io/badge/node-%E2%89%A522.8-339933?logo=node.js&logoColor=white" alt="Node 22.8+">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
15
15
  <img src="https://img.shields.io/badge/license-MIT-111111" alt="MIT">
@@ -41,7 +41,9 @@ One detection core, many enforcement surfaces. On the default profile the ordina
41
41
  path repairs hygiene findings when it can; `commit-msg` checks the pinned would-be tree,
42
42
  and the final ref boundary independently scans every commit introduced to `HEAD` or a
43
43
  branch. A block that survives repair stops the commit; an incomplete scan warns on
44
- `clean`/`compliance` and still vetoes at the final ref guard.
44
+ `clean`/`compliance` and still vetoes at the final ref guard, unless the only gap is a
45
+ file over the per-file size budget. `pre-push` scans what a push would send, so a commit
46
+ pushed by raw object ID cannot bypass the local guards.
45
47
 
46
48
  ```mermaid
47
49
  flowchart TD
@@ -52,20 +54,23 @@ flowchart TD
52
54
  COMMIT([Ordinary git commit or merge]) --> PRE["pre-commit / pre-merge-commit<br/>run predecessor, resolve staged policy,<br/>scan exact index"]
53
55
  DIRECT([Sequencer or direct ref path<br/>cherry-pick · rebase · fetch · worktree · update-ref]) --> REF
54
56
  PRE -->|clean: safe repair| MSG["commit-msg snapshots would-be tree,<br/>runs predecessor, then checks<br/>the message and pinned full tree"]
55
- PRE -->|strict violation, incomplete scan,<br/>or failed repair| BLOCK([Operation stops])
57
+ PRE -->|strict violation or failed repair| BLOCK([Operation stops])
56
58
  MSG -->|message and tree accepted| REF["reference-transaction prepared<br/>scans what each introduced commit changes<br/>(messages of locally authored commits only)"]
57
59
  MSG -->|unsafe or unrepairable| BLOCK
58
60
  REF -->|accepted| SHIP([Ref update commits])
59
61
  REF -->|violation or incomplete scan| BLOCK
62
+ PUSH([git push]) --> PREPUSH["pre-push<br/>scans every commit the push would send,<br/>messages included — even a push by raw object ID"]
63
+ PREPUSH -->|accepted| SENT([Objects leave for the remote])
64
+ PREPUSH -->|violation| BLOCK
60
65
 
61
66
  classDef entry fill:#f8fafc,stroke:#94a3b8,color:#0f172a,stroke-width:1.5px
62
67
  classDef hook fill:#eef2ff,stroke:#6366f1,color:#312e81,stroke-width:2px
63
68
  classDef ok fill:#ecfdf5,stroke:#10b981,color:#064e3b,stroke-width:1.5px
64
69
  classDef stop fill:#fef2f2,stroke:#ef4444,color:#7f1d1d,stroke-width:2px
65
70
 
66
- class SESS,AGENT,RUN,COMMIT,DIRECT entry
67
- class PRE,MSG,REF hook
68
- class SHIP ok
71
+ class SESS,AGENT,RUN,COMMIT,DIRECT,PUSH entry
72
+ class PRE,MSG,REF,PREPUSH hook
73
+ class SHIP,SENT ok
69
74
  class BLOCK stop
70
75
  ```
71
76
 
package/bin/aimhooman.mjs CHANGED
@@ -128,7 +128,7 @@ function main(argv) {
128
128
  // switch below.
129
129
  const SUBCOMMAND_HELP_FLAGS = new Set(['--help', '-h', 'help']);
130
130
  const knownSubcommands = new Set([
131
- 'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'init',
131
+ 'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'pushcheck', 'init',
132
132
  'status', 'explain', 'allow', 'deny', 'override', 'review',
133
133
  'policy-review', 'fix', 'doctor', 'uninstall',
134
134
  ]);
@@ -148,6 +148,8 @@ function main(argv) {
148
148
  return cmdCommitmsg(rest);
149
149
  case 'refcheck':
150
150
  return cmdRefcheck(rest);
151
+ case 'pushcheck':
152
+ return cmdPushcheck(rest);
151
153
  case 'init':
152
154
  return cmdInit(rest);
153
155
  case 'status':
@@ -249,6 +251,31 @@ function incompleteMessage(scan, { blocking = true } = {}) {
249
251
  return message + pathLines.join('');
250
252
  }
251
253
 
254
+ // One oversized file trips the same non-blocking "scan incomplete" warning in
255
+ // up to three hooks of a single commit (pre-commit, commit-msg, the final ref
256
+ // guard) and again in pre-push. Print it once per tree and gap: the first
257
+ // guard to warn records the tree plus the skip signature, later guards stay
258
+ // silent for the identical gap. State failures degrade to printing — a
259
+ // duplicate warning is noise, a swallowed one is a miss.
260
+ const INCOMPLETE_NOTICE_VERSION = 1;
261
+ function warnIncompleteOnce(repo, treeSha, scan) {
262
+ const signature = createHash('sha256')
263
+ .update(JSON.stringify([scan.stats?.skipped || {}, scan.stats?.skippedPaths || {}]))
264
+ .digest('hex');
265
+ if (treeSha) {
266
+ try {
267
+ const statePath = join(repo.stateDir, 'incomplete-notice.json');
268
+ let previous = null;
269
+ try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
270
+ if (previous?.version === INCOMPLETE_NOTICE_VERSION
271
+ && previous.tree === treeSha
272
+ && previous.signature === signature) return;
273
+ writeFileSync(statePath, JSON.stringify({ version: INCOMPLETE_NOTICE_VERSION, tree: treeSha, signature }));
274
+ } catch { /* fall through to printing */ }
275
+ }
276
+ process.stderr.write(incompleteMessage(scan, { blocking: false }));
277
+ }
278
+
252
279
  // Most skip reasons tally files; the three below tally something else, so the
253
280
  // noun travels with the reason instead of reading "local-pack-error=1 file".
254
281
  function skipCountNoun(reason, count) {
@@ -444,11 +471,15 @@ function cmdPrecommit(args) {
444
471
  if (reviews.length) process.stderr.write(human(reviews, tone()));
445
472
  if (!scan.complete) {
446
473
  // Strict stops on an unchecked remainder. Clean/compliance go ahead
447
- // with a warning: the reference-transaction guard rescans the
448
- // introduced commit before any ref moves. The clean marker stays
449
- // unwritten so commit-msg does not skip its own tree scan.
450
- process.stderr.write(incompleteMessage(scan, { blocking: profile === 'strict' }));
451
- return profile === 'strict' ? 31 : 0;
474
+ // with a warning printed once per tree across all of a commit's
475
+ // hooks (see warnIncompleteOnce). The clean marker stays unwritten
476
+ // so commit-msg does not skip its own tree scan.
477
+ if (profile === 'strict') {
478
+ process.stderr.write(incompleteMessage(scan));
479
+ return 31;
480
+ }
481
+ warnIncompleteOnce(repo, stagedTreeSha(repo), scan);
482
+ return 0;
452
483
  }
453
484
  // W5 marker dedup: record that this staged tree scanned clean so the
454
485
  // upcoming commit-msg hook can skip its duplicate tree scan. The tree
@@ -611,12 +642,13 @@ function cmdCommitmsg(args) {
611
642
  const treeCode = exitCode(treeScan.findings, treeScan.profile, treeScan.complete);
612
643
  if (treeCode !== 0) {
613
644
  if (treeScan.findings.length) process.stderr.write(human(treeScan.findings, tone()));
614
- if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan));
645
+ if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan, { blocking: treeScan.profile === 'strict' }));
615
646
  return treeCode;
616
647
  }
617
648
  // exitCode passes an incomplete tree scan on frictionless profiles; the
618
649
  // skip still names itself so the commit does not sail through silently.
619
- if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan, { blocking: false }));
650
+ // pre-commit already printed this exact gap for this tree — warn once.
651
+ if (!treeScan.complete) warnIncompleteOnce(repo, options.tree, treeScan);
620
652
  }
621
653
  if (dispatchHooksChanged(repo, profile)) return 20;
622
654
  // Strict must fail closed on an incomplete scan: a block still wins (10),
@@ -626,7 +658,7 @@ function cmdCommitmsg(args) {
626
658
  process.stderr.write(incompleteMessage(scan));
627
659
  return findings.some((finding) => finding.decision === 'block') ? 10 : 31;
628
660
  }
629
- if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: false }));
661
+ if (!scan.complete) warnIncompleteOnce(repo, options.tree, scan);
630
662
  if (!findings.length) return 0;
631
663
  const blocks = findings.filter((finding) => finding.decision === 'block');
632
664
  if (profile === 'strict') {
@@ -659,6 +691,180 @@ function cmdCommitmsg(args) {
659
691
  return remaining.some((finding) => finding.decision === 'block') ? 10 : 0;
660
692
  }
661
693
 
694
+ // resolveIntroduced maps each proposed update to the commits it introduces,
695
+ // with the review contexts and local-authorship flags the scan needs. Shared
696
+ // by refcheck (local ref updates) and pushcheck (about-to-be-pushed refs).
697
+ // includeStagedContexts carries a staged review into the direct tip's scan;
698
+ // pushcheck leaves it off because a pushed commit is judged as an object, not
699
+ // against the live index it may never have passed through.
700
+ function resolveIntroduced(repo, updates, { includeStagedContexts = true } = {}) {
701
+ const contextsByCommit = new Map();
702
+ // A commit's message belongs to whoever wrote it. Attribution and marker
703
+ // rules police the text a local developer can edit, so they are scoped
704
+ // to commits authored here: an update that introduces exactly one new
705
+ // commit on top of a non-zero old tip (a plain commit, an --amend, or a
706
+ // local --no-ff merge of an already-gated branch). Anything else — a new
707
+ // branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
708
+ // history — imports other people's commit text the developer cannot
709
+ // change, and scanning it only blocks the review.
710
+ const localAuthorTips = new Set();
711
+ for (const update of updates) {
712
+ const introduced = introducedCommits(repo, [update]);
713
+ if (!/^0+$/.test(update.oldObjectId)
714
+ && introduced.length === 1
715
+ && introduced[0] === update.newObjectId) {
716
+ localAuthorTips.add(update.newObjectId);
717
+ }
718
+ for (const revision of introduced) {
719
+ const contexts = contextsByCommit.get(revision) || [];
720
+ contexts.push({
721
+ head: update.newObjectId,
722
+ storedTransition: revision,
723
+ scanTransition: revision,
724
+ });
725
+ // A staged review is bound to the exact old tip. It can be
726
+ // carried into the final full-snapshot scan only for the direct
727
+ // proposed tip whose parent is that old tip, never for an
728
+ // intermediate commit or a newly created branch ancestry.
729
+ if (includeStagedContexts && revision === update.newObjectId && !/^0+$/.test(update.oldObjectId)) {
730
+ const { parents } = commitParents(repo, revision);
731
+ if (parents.includes(update.oldObjectId)) {
732
+ contexts.push({
733
+ head: update.oldObjectId,
734
+ storedTransition: 'staged',
735
+ scanTransition: revision,
736
+ });
737
+ }
738
+ }
739
+ contextsByCommit.set(revision, contexts);
740
+ }
741
+ }
742
+ return [...contextsByCommit].map(([revision, reviewContexts]) => [
743
+ revision,
744
+ reviewContexts,
745
+ localAuthorTips.has(revision),
746
+ ]);
747
+ }
748
+
749
+ // scanProposedCommits scans each introduced commit and returns the first
750
+ // non-zero verdict. rejectNote(revision) supplies the caller-specific veto
751
+ // line(s) printed after the findings; callers without a note pass null.
752
+ function scanProposedCommits(repo, commits, { rejectNote }) {
753
+ const limits = scanLimits();
754
+ for (const [revision, reviewContexts, authoredLocally] of commits) {
755
+ let scan;
756
+ try {
757
+ scan = scanGitTarget(repo, {
758
+ kind: 'commit',
759
+ revision,
760
+ reviewContexts,
761
+ policyMigrationContexts: reviewContexts,
762
+ limits,
763
+ messageScope: authoredLocally ? 'commit' : 'changes-only',
764
+ });
765
+ }
766
+ catch (error) {
767
+ console.error(`aimhooman: cannot scan proposed commit ${revision}: ${error.message}`);
768
+ return expectedErrorCode(error);
769
+ }
770
+ emitDiagnostics(scan.diagnostics);
771
+ // The reference transaction is the final boundary --no-verify cannot
772
+ // skip, so an incomplete scan vetoes the update on every profile, even
773
+ // though earlier guards let frictionless profiles through with a
774
+ // warning. The single carve-out (a size-limit-only gap, see exitCode)
775
+ // warns here instead of vetoing.
776
+ const code = exitCode(scan.findings, scan.profile, scan.complete, {
777
+ failClosedIncomplete: true,
778
+ incompleteReasons: scan.incomplete_reasons,
779
+ });
780
+ if (code !== 0) {
781
+ process.stderr.write(human(scan.findings, tone()));
782
+ if (!scan.complete) process.stderr.write(incompleteMessage(scan));
783
+ if (rejectNote) process.stderr.write(rejectNote(revision));
784
+ return code;
785
+ }
786
+ if (!scan.complete) {
787
+ // The commit's tree is the staged tree pre-commit already warned
788
+ // about, so this is usually the duplicate the once-guard swallows.
789
+ // rev-parse runs only on this rare path.
790
+ let treeSha = null;
791
+ try {
792
+ treeSha = execFileSync('git', ['rev-parse', `${revision}^{tree}`], {
793
+ cwd: repo.root,
794
+ encoding: 'utf8',
795
+ timeout: GIT_TIMEOUT_MS,
796
+ }).trim();
797
+ } catch { /* warn without dedup */ }
798
+ warnIncompleteOnce(repo, treeSha, scan);
799
+ }
800
+ }
801
+ return 0;
802
+ }
803
+
804
+ function cmdPushcheck(args) {
805
+ parseNoArguments(args);
806
+ let input;
807
+ try { input = readFileSync(0, 'utf8'); }
808
+ catch (error) {
809
+ console.error(`aimhooman: cannot read pre-push input: ${error.message}`);
810
+ return 30;
811
+ }
812
+ // pre-push lines: <local ref> <local oid> <remote ref> <remote oid>. The
813
+ // local ref name is irrelevant (it is the raw oid for a dangling push);
814
+ // what matters is which objects are about to be published. Deletions
815
+ // (zero local oid) carry nothing and were already filtered by the
816
+ // dispatcher, but belt-and-braces here since pushcheck can be driven
817
+ // directly.
818
+ const updates = [];
819
+ for (const line of input.split('\n').filter(Boolean)) {
820
+ const match = /^(\S+) (\S+) (\S+) (\S+)$/.exec(line);
821
+ if (!match) {
822
+ console.error('aimhooman: malformed pre-push input; push stopped');
823
+ return 30;
824
+ }
825
+ const [, , localOid, remoteRef, remoteOid] = match;
826
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(localOid)
827
+ || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(remoteOid)) {
828
+ console.error('aimhooman: malformed object ID in pre-push input; push stopped');
829
+ return 30;
830
+ }
831
+ if (/^0+$/.test(localOid)) continue;
832
+ updates.push({ oldObjectId: remoteOid, newObjectId: localOid, ref: remoteRef });
833
+ }
834
+ if (!updates.length) return 0;
835
+ const repo = tryRepo();
836
+ if (!repo) {
837
+ // Same bare carve-out as refcheck: a global dispatcher must stay
838
+ // transparent where no worktree/index policy boundary exists.
839
+ if (currentRepositoryIsBare()) return 0;
840
+ console.error('aimhooman: not a git repository');
841
+ return 30;
842
+ }
843
+ let commits;
844
+ try {
845
+ // Every pushed commit gets its message scanned (final `true`):
846
+ // publishing is the act being gated, and a rejected commit pushed by
847
+ // raw oid has no local branch to vouch for its message. Reachability
848
+ // trust stays with refs/heads/*, exactly as in refcheck — remote
849
+ // tracking refs arrive ungated via fetch.
850
+ commits = resolveIntroduced(repo, updates, { includeStagedContexts: false })
851
+ .map(([revision, reviewContexts]) => [revision, reviewContexts, true]);
852
+ }
853
+ catch (error) {
854
+ console.error(`aimhooman: cannot resolve pushed commits: ${error.message}`);
855
+ return 30;
856
+ }
857
+ const code = scanProposedCommits(repo, commits, {
858
+ rejectNote: (revision) =>
859
+ `aimhooman: push of commit ${revision} was rejected before any objects were sent\n`,
860
+ });
861
+ if (code !== 0) return code;
862
+ // Same last-veto-point integrity check as refcheck: if a chained
863
+ // predecessor removed any required dispatcher, every profile stops.
864
+ if (dispatchHooksChanged(repo, 'clean')) return 20;
865
+ return 0;
866
+ }
867
+
662
868
  function cmdRefcheck(args) {
663
869
  const { positionals } = parseArguments(args, {
664
870
  minPositionals: 1,
@@ -734,96 +940,24 @@ function cmdRefcheck(args) {
734
940
 
735
941
  let commits;
736
942
  try {
737
- const contextsByCommit = new Map();
738
- // A commit's message belongs to whoever wrote it. Attribution and marker
739
- // rules police the text a local developer can edit, so they are scoped
740
- // to commits authored here: an update that introduces exactly one new
741
- // commit on top of a non-zero old tip (a plain commit, an --amend, or a
742
- // local --no-ff merge of an already-gated branch). Anything else — a new
743
- // branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
744
- // history — imports other people's commit text the developer cannot
745
- // change, and scanning it only blocks the review.
746
- const localAuthorTips = new Set();
747
- for (const update of updates) {
748
- const introduced = introducedCommits(repo, [update]);
749
- if (!/^0+$/.test(update.oldObjectId)
750
- && introduced.length === 1
751
- && introduced[0] === update.newObjectId) {
752
- localAuthorTips.add(update.newObjectId);
753
- }
754
- for (const revision of introduced) {
755
- const contexts = contextsByCommit.get(revision) || [];
756
- contexts.push({
757
- head: update.newObjectId,
758
- storedTransition: revision,
759
- scanTransition: revision,
760
- });
761
- // A staged review is bound to the exact old tip. It can be
762
- // carried into the final full-snapshot scan only for the direct
763
- // proposed tip whose parent is that old tip, never for an
764
- // intermediate commit or a newly created branch ancestry.
765
- if (revision === update.newObjectId && !/^0+$/.test(update.oldObjectId)) {
766
- const { parents } = commitParents(repo, revision);
767
- if (parents.includes(update.oldObjectId)) {
768
- contexts.push({
769
- head: update.oldObjectId,
770
- storedTransition: 'staged',
771
- scanTransition: revision,
772
- });
773
- }
774
- }
775
- contextsByCommit.set(revision, contexts);
776
- }
777
- }
778
- commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
779
- revision,
780
- reviewContexts,
781
- localAuthorTips.has(revision),
782
- ]);
943
+ commits = resolveIntroduced(repo, updates);
783
944
  }
784
945
  catch (error) {
785
946
  console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
786
947
  return 30;
787
948
  }
788
- const limits = scanLimits();
789
- for (const [revision, reviewContexts, authoredLocally] of commits) {
790
- let scan;
791
- try {
792
- scan = scanGitTarget(repo, {
793
- kind: 'commit',
794
- revision,
795
- reviewContexts,
796
- policyMigrationContexts: reviewContexts,
797
- limits,
798
- messageScope: authoredLocally ? 'commit' : 'changes-only',
799
- });
800
- }
801
- catch (error) {
802
- console.error(`aimhooman: cannot scan proposed commit ${revision}: ${error.message}`);
803
- return expectedErrorCode(error);
804
- }
805
- emitDiagnostics(scan.diagnostics);
806
- // The reference transaction is the final boundary --no-verify cannot
807
- // skip, so an incomplete scan vetoes the update on every profile, even
808
- // though earlier guards let frictionless profiles through with a
809
- // warning.
810
- const code = exitCode(scan.findings, scan.profile, scan.complete, { failClosedIncomplete: true });
811
- if (code !== 0) {
812
- process.stderr.write(human(scan.findings, tone()));
813
- if (!scan.complete) process.stderr.write(incompleteMessage(scan));
814
- process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
949
+ const code = scanProposedCommits(repo, commits, {
950
+ rejectNote: (revision) =>
951
+ `aimhooman: proposed commit ${revision} was rejected before refs changed\n`
815
952
  // The vetoed commit stays in the object store: the ref never
816
953
  // moved, but the bytes — including whatever triggered the block —
817
954
  // are still on disk. Whether gc collects it depends on other refs,
818
955
  // so phrase the cleanup as conditional; rotating an exposed secret
819
956
  // stays the operator's call either way.
820
- process.stderr.write(
821
- `aimhooman: note: the rejected commit object remains in the local object store; `
822
- + "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n"
823
- );
824
- return code;
825
- }
826
- }
957
+ + 'aimhooman: note: the rejected commit object remains in the local object store; '
958
+ + "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n",
959
+ });
960
+ if (code !== 0) return code;
827
961
  // The reference-transaction hook is the last veto point. If its chained
828
962
  // predecessor removed any required dispatcher, every profile stops: there
829
963
  // is no downstream guard that can repair the lost boundary safely.
@@ -1645,9 +1779,13 @@ function cmdFix(args) {
1645
1779
  ? `aimhooman: strict policy would remove ${repair.removed.length} exact attribution line(s); rerun with --apply to write the repair`
1646
1780
  : 'aimhooman: strict policy found no automatically repairable attribution lines'
1647
1781
  );
1782
+ // 11 asks a human to review the lines a repair would remove, which is
1783
+ // only honest when the recheck saw the whole message. An incomplete
1784
+ // recheck on strict fails closed (31) — the same verdict --apply
1785
+ // reaches below for the same content.
1648
1786
  return remaining.some((finding) => finding.decision === 'block')
1649
1787
  ? 10
1650
- : repair.removed.length ? 11 : exitCode(remaining, scan.profile, remainingComplete);
1788
+ : repair.removed.length && remainingComplete ? 11 : exitCode(remaining, scan.profile, remainingComplete);
1651
1789
  }
1652
1790
  } else if (options.apply) {
1653
1791
  throw new ArgumentError('--apply is only needed when the active profile is strict');
@@ -1975,6 +2113,7 @@ function cmdUninstall(args) {
1975
2113
  for (const queue of [
1976
2114
  `${lifecycleLock}.queue`,
1977
2115
  `${repo.excludeFile}.aimhooman.lock.queue`,
2116
+ `${join(repo.root, '.gitignore')}.aimhooman.lock.queue`,
1978
2117
  join(effectiveHooksDir(repo), '.aimhooman-hooks.lock.queue'),
1979
2118
  ]) {
1980
2119
  try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
@@ -2030,7 +2169,8 @@ Usage:
2030
2169
  aimhooman version
2031
2170
 
2032
2171
  Exit codes: 0 clean, 10 blocked, 11 review required, 20 invalid input or policy,
2033
- 30 Git or I/O failure, 31 incomplete scan.
2172
+ 30 Git or I/O failure, 31 incomplete scan on strict or at the final ref guard
2173
+ (clean/compliance warn and continue).
2034
2174
  `);
2035
2175
  }
2036
2176