@rmyndharis/aimhooman 0.3.1 → 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.1",
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.1",
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.1",
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",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ 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
+
8
46
  ## [0.3.1] - 2026-07-19
9
47
 
10
48
  ### Fixed
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.1-blue" alt="v0.3.1">
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
@@ -57,15 +59,18 @@ flowchart TD
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
@@ -616,7 +647,8 @@ function cmdCommitmsg(args) {
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.
@@ -62,12 +62,16 @@ manager runs the check but registers no managed guard, so the agent hook still
62
62
  refuses the commit. Remove the override before retrying, or accept that the
63
63
  repository is unguarded and do not run `init` there.
64
64
 
65
- Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`, and
66
- `reference-transaction`, and preserves an existing hook as a predecessor. For
65
+ Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`,
66
+ `reference-transaction`, and `pre-push`, and preserves an existing hook as a
67
+ predecessor. For
67
68
  `commit-msg`, aimhooman pins the would-be tree before the predecessor runs, so a later
68
69
  index change cannot select a weaker policy. The prepared reference transaction is the
69
70
  last local check for cherry-pick, revert, rebase, `git am`, fetch/worktree branch
70
- creation, and direct branch-ref updates. Every profile stops if a predecessor removes
71
+ creation, and direct branch-ref updates. The `pre-push` hook scans every commit a push
72
+ would introduce — including one pushed by raw object ID, which moves no local branch
73
+ and therefore never passes the reference-transaction guard. Every profile stops if a
74
+ predecessor removes
71
75
  a required guard; the first running dispatcher that detects the loss aborts the
72
76
  operation.
73
77
 
@@ -89,9 +93,11 @@ change, and strict previews unless `--apply` is supplied.
89
93
  `rule` (see [docs/policy.md](policy.md#overrides)).
90
94
 
91
95
  On `clean` and `compliance`, `check`, `fix`, and the commit hooks warn and continue
92
- when a scan is incomplete; `strict` exits 31 instead. The final ref guard
93
- (`reference-transaction`) stays fail-closed on every profile and still vetoes what
94
- it cannot fully scan.
96
+ when a scan is incomplete; `strict` exits 31 instead. The final ref guards
97
+ (`reference-transaction`, `pre-push`) stay fail-closed on every profile and still
98
+ veto what they cannot fully scan — with one carve-out: a scan whose only gap is a
99
+ file over the per-file size budget warns and continues on `clean`/`compliance`,
100
+ because path rules still covered that file.
95
101
 
96
102
  Machine reports use `schema_version: 1` and include target policy identity, completeness,
97
103
  scan statistics, commit and object metadata. Schemas are published in [`schemas/`](../schemas/).
@@ -111,4 +117,4 @@ History cleanup is deliberately outside aimhooman's scope.
111
117
  | 11 | review-required on a non-clean profile |
112
118
  | 20 | usage, configuration, or rule-pack error |
113
119
  | 30 | Git or I/O error |
114
- | 31 | scan incomplete on `strict` or at the final ref guard (`clean`/`compliance` warn and continue) |
120
+ | 31 | scan incomplete on `strict`, or at the final ref guard for any gap other than a per-file size-limit on `clean`/`compliance` (those warn and continue) |
package/docs/faq.md CHANGED
@@ -16,9 +16,11 @@ and treats an incomplete scan as a stop.
16
16
  Git objects in batches. Text-oriented rules skip binary files. Size and total budgets
17
17
  are visible in reports. Files over 2 MiB or a scan over 64 MiB make the scan
18
18
  incomplete (binary files skip complete; oversized text is what trips it): direct
19
- checks and the Git pre-commit guards warn on `clean`/`compliance` and stop on
20
- `strict`, and the final ref guard stops on every profile rather than
21
- claiming that content was checked.
19
+ checks and the Git guards warn on `clean`/`compliance` and stop on
20
+ `strict`. At the final ref guard the carve-out narrows: a scan whose only gap is
21
+ an oversized file (path rules still ran on it) warns and continues there too, while
22
+ any other gap — a blown total budget, an unreadable object — stops on every profile
23
+ rather than claiming that content was checked.
22
24
 
23
25
  **Can the agent bypass it?** Any local tool can ultimately be bypassed by a user with
24
26
  commit access. The agent guard fails closed on what it cannot prove: empty, invalid,
package/docs/secrets.md CHANGED
@@ -60,7 +60,7 @@ three commits back still gets caught.
60
60
  ### Running it alongside aimhooman
61
61
 
62
62
  The two tools do not overlap and do not fight. aimhooman owns Git's
63
- `pre-commit`, `commit-msg`, and `reference-transaction` hooks; gitleaks runs
63
+ `pre-commit`, `commit-msg`, `reference-transaction`, and `pre-push` hooks; gitleaks runs
64
64
  as a pre-commit.com hook or in CI, so both fire on the same commit without
65
65
  sharing state. A commit carrying an AI session file and an API key gets
66
66
  stopped for both reasons, each reported by the tool that owns it. Keep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
package/src/githooks.mjs CHANGED
@@ -26,7 +26,15 @@ const MANAGED = {
26
26
  'pre-merge-commit': 'precommit',
27
27
  'commit-msg': 'commitmsg "$1"',
28
28
  'reference-transaction': 'refcheck "$1"',
29
+ 'pre-push': 'pushcheck',
29
30
  };
31
+ // Literal anchors shared by every built-in message-kind rule. The commit-msg
32
+ // dispatcher greps the message for this ERE before paying a Node spawn: a
33
+ // message with no anchor cannot match any attribution rule, so skipping the
34
+ // spawn cannot let attribution through. Pinned by a test that walks
35
+ // rules/attribution.json — a new message rule must carry one of these anchors
36
+ // or the fast path would silently bypass it.
37
+ export const MESSAGE_ANCHOR_ERE = 'anthropic|openai|copilot|claude|chatgpt|gpt|codex|cursor|\\[bot\\]|bot@users';
30
38
  const LEGACY_MANAGED = {
31
39
  'pre-commit': 'precommit',
32
40
  'commit-msg': 'commitmsg "$1"',
@@ -664,7 +672,57 @@ function hookScript(name, cmd, cliPath, chainedPath) {
664
672
  }
665
673
  `
666
674
  : '';
667
- const captureTransaction = name === 'reference-transaction'
675
+ // Fast paths that skip the Node spawn for work the guard can prove is a
676
+ // no-op. Both sit after the chained-hook call, so a chained predecessor
677
+ // always runs, and both fall through to the full guard on any doubt (grep
678
+ // or git erroring included).
679
+ // - commit-msg: every built-in message rule carries one of the
680
+ // MESSAGE_ANCHOR_ERE literals, so a message with no anchor match has
681
+ // nothing to strip and no finding to raise. Local packs can declare
682
+ // their own message rules, so their presence disables the fast path.
683
+ // The tree snapshot stays at the top of the script on purpose: it must
684
+ // capture the would-be tree BEFORE a chained predecessor can stage a
685
+ // policy change into the live index. The fast path also yields when the
686
+ // pre-commit dispatcher itself is gone: its "the tree was already
687
+ // scanned" premise no longer holds, so commit-msg pays the spawn and
688
+ // scans the captured tree itself.
689
+ // - pre-commit: an empty index holds nothing to scan (`--allow-empty`,
690
+ // `commit -m` with no staged change). git diff exits 1 on staged changes
691
+ // and >1 on read errors; only a proven-empty index skips.
692
+ // - pre-push: deletion lines (zero local oid) carry nothing to scan, so
693
+ // they are dropped before the spawn; a push of only deletions never
694
+ // starts Node. The chained predecessor still saw the full unfiltered
695
+ // input above. The filter is grep, not a shell `case` loop: case
696
+ // patterns break inside $( ) on older POSIX shells (bash 3.2 /bin/sh).
697
+ // grep exit 1 means "only deletions" — not an error; anything worse
698
+ // aborts the push rather than skipping the guard.
699
+ const preAimFilter = name === 'commit-msg'
700
+ ? `AIMHOOMAN_COMMON=$(PATH="$AIMHOOMAN_PATH" git rev-parse --git-common-dir 2>/dev/null) || AIMHOOMAN_COMMON=
701
+ if [ ! -x "$(PATH="$AIMHOOMAN_PATH" dirname "$0")/pre-commit" ]; then
702
+ :
703
+ elif [ -n "$AIMHOOMAN_COMMON" ] && PATH="$AIMHOOMAN_PATH" ls "$AIMHOOMAN_COMMON/aimhooman/rules/"*.json >/dev/null 2>&1; then
704
+ :
705
+ else
706
+ PATH="$AIMHOOMAN_PATH" grep -Eiq -- ${shq(MESSAGE_ANCHOR_ERE)} "$1" 2>/dev/null
707
+ case $? in
708
+ 1) exit 0 ;;
709
+ esac
710
+ fi
711
+ `
712
+ : name === 'pre-commit'
713
+ ? `PATH="$AIMHOOMAN_PATH" git diff --cached --quiet
714
+ case $? in
715
+ 0) exit 0 ;;
716
+ esac
717
+ `
718
+ : name === 'pre-push'
719
+ ? `AIMHOOMAN_PUSH_UPDATES=$(
720
+ printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | PATH="$AIMHOOMAN_PATH" grep -v '^[^ ]* 0* ' || [ $? -eq 1 ]
721
+ ) || exit $?
722
+ [ -n "$AIMHOOMAN_PUSH_UPDATES" ] || exit 0
723
+ `
724
+ : '';
725
+ const captureTransaction = name === 'reference-transaction' || name === 'pre-push'
668
726
  ? `AIMHOOMAN_REF_UPDATES=$(
669
727
  while IFS= read -r AIMHOOMAN_REF_UPDATE || [ -n "$AIMHOOMAN_REF_UPDATE" ]; do
670
728
  printf '%s\\n' "$AIMHOOMAN_REF_UPDATE" || exit $?
@@ -682,7 +740,7 @@ function hookScript(name, cmd, cliPath, chainedPath) {
682
740
  // shell (/bin/sh -p), so its shebang is honoured only for sh-compatible
683
741
  // scripts; bash-only predecessors are out of scope. exit inside the
684
742
  // predecessor exits the subshell and propagates via || exit $?.
685
- const chainedInvocation = name === 'reference-transaction'
743
+ const chainedInvocation = name === 'reference-transaction' || name === 'pre-push'
686
744
  ? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | ( . "$CHAINED" ) || exit $?`
687
745
  : ` ( . "$CHAINED" ) || exit $?`;
688
746
  const aimCommand = name === 'commit-msg'
@@ -692,7 +750,9 @@ function hookScript(name, cmd, cliPath, chainedPath) {
692
750
  : cmd;
693
751
  const aimInvocation = name === 'reference-transaction'
694
752
  ? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
695
- : `run_aimhooman ${aimCommand} || exit $?`;
753
+ : name === 'pre-push'
754
+ ? `printf '%s\\n' "$AIMHOOMAN_PUSH_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
755
+ : `run_aimhooman ${aimCommand} || exit $?`;
696
756
  // committed/aborted fire only after refs are locked in, and refcheck can do
697
757
  // nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
698
758
  // so an ordinary commit no longer pays a Node cold start for the committed
@@ -711,7 +771,7 @@ case "$1" in prepared)
711
771
  case "$AIMHOOMAN_REF_UPDATES" in
712
772
  *refs/heads/*|*" HEAD"*) ;;
713
773
  *)
714
- for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction; do
774
+ for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction pre-push; do
715
775
  [ -x "$(dirname "$0")/$AIMHOOMAN_GUARD" ] || {
716
776
  echo "aimhooman: required Git guards changed while reference-transaction was running; $AIMHOOMAN_GUARD is unavailable. The operation was stopped; run 'aimhooman init' and retry." >&2
717
777
  exit 20
@@ -779,7 +839,7 @@ fi
779
839
  if [ -x "$CHAINED" ]; then
780
840
  ${chainedInvocation}
781
841
  fi
782
- ${phaseShortCircuit}${aimInvocation}
842
+ ${phaseShortCircuit}${preAimFilter}${aimInvocation}
783
843
  `;
784
844
  const fingerprint = hookFingerprint(template);
785
845
  return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);
package/src/report.mjs CHANGED
@@ -9,13 +9,17 @@ const HUMAN_FINDING_CAP = 20;
9
9
 
10
10
  export function human(findings, tone) {
11
11
  if (!findings.length) return '';
12
- let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
13
12
  let block = 0;
14
13
  let review = 0;
15
14
  for (const f of findings) {
16
15
  if (f.decision === 'block') block += 1;
17
16
  else if (f.decision === 'review') review += 1;
18
17
  }
18
+ // The banner means "aimhooman stopped or reshaped your operation". A pure
19
+ // advisory (review findings only, the commit proceeds untouched) prints
20
+ // without it — otherwise the banner fires on allowed work and stops
21
+ // meaning anything when a real block lands.
22
+ let out = tone === 'professional' || block === 0 ? '\n' : '\nnot very hooman.\n\n';
19
23
  // Render findings in order up to the cap, then collapse the rest into one
20
24
  // summary line. Keeps the first findings fully visible (the actionable ones)
21
25
  // while bounding stderr for repeated-rule scans.
@@ -92,8 +96,13 @@ function isSensitive(finding) {
92
96
  // final ref boundary (failClosedIncomplete). Frictionless profiles warn and
93
97
  // continue — the reference-transaction guard still scans introduced commits
94
98
  // with failClosedIncomplete set, so the skipped content is checked before any
95
- // ref moves.
96
- export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false } = {}) {
99
+ // ref moves. One exception at that final boundary: a scan whose only gap is
100
+ // 'size-limit' (a file over maxFileBytes went unscanned for content rules
101
+ // path rules still ran) warns and continues on frictionless profiles. Blocking
102
+ // the whole commit there cost developers a legit large file for a marker risk
103
+ // that clean/compliance treat as advisory anyway. Strict stays fail-closed on
104
+ // any gap, size-limit included.
105
+ export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false, incompleteReasons = [] } = {}) {
97
106
  let block = false;
98
107
  let review = false;
99
108
  for (const f of findings) {
@@ -101,7 +110,11 @@ export function exitCode(findings, profile, complete = true, { failClosedIncompl
101
110
  else if (f.decision === 'review') review = true;
102
111
  }
103
112
  if (block) return 10;
104
- if (!complete && (profile === 'strict' || failClosedIncomplete)) return 31;
113
+ if (!complete && (profile === 'strict' || failClosedIncomplete)) {
114
+ const sizeLimitOnly = incompleteReasons.length > 0
115
+ && incompleteReasons.every((reason) => reason === 'size-limit');
116
+ if (profile === 'strict' || !sizeLimitOnly) return 31;
117
+ }
105
118
  if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
106
119
  return 0;
107
120
  }
@@ -7,6 +7,17 @@ export const DEFAULT_SCAN_LIMITS = Object.freeze({
7
7
  maxFindings: 1000,
8
8
  });
9
9
 
10
+ // Skip reasons that mean a rule never ran on its input, so the scan cannot
11
+ // claim full coverage. What that costs is the caller's call: strict stops on
12
+ // any of them; the final ref guard stops on all but 'size-limit' (see
13
+ // exitCode in report.mjs). 'local-pack-error' never originates here — it is
14
+ // tallied by scan-target — but it belongs to the same contract.
15
+ export const FATAL_SKIP_REASONS = new Set([
16
+ 'metadata-unavailable', 'size-limit', 'total-byte-limit',
17
+ 'missing-object', 'unexpected-object', 'finding-limit',
18
+ 'local-input-limit', 'local-pack-error',
19
+ ]);
20
+
10
21
  export function scanEntries(repo, engine, entries, options = {}) {
11
22
  const limits = { ...DEFAULT_SCAN_LIMITS, ...options };
12
23
  const stats = {
@@ -131,12 +142,9 @@ export function scanEntries(repo, engine, entries, options = {}) {
131
142
  stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
132
143
  }
133
144
 
134
- const incompleteReasons = new Set([
135
- 'metadata-unavailable', 'size-limit', 'total-byte-limit',
136
- 'missing-object', 'unexpected-object', 'finding-limit', 'local-input-limit',
137
- ]);
138
- const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
139
- return { findings, complete, stats };
145
+ const complete = !Object.keys(stats.skipped).some((reason) => FATAL_SKIP_REASONS.has(reason));
146
+ const incompleteReasons = Object.keys(stats.skipped).filter((reason) => FATAL_SKIP_REASONS.has(reason));
147
+ return { findings, complete, incompleteReasons, stats };
140
148
  }
141
149
 
142
150
  function readObjects(repo, objectIds, expectedBytes = 0) {
@@ -8,7 +8,7 @@ import {
8
8
  unmergedPaths,
9
9
  } from './gitx.mjs';
10
10
  import { commitChanges, commitMessage, commitSnapshot, historyRange } from './history-scan.mjs';
11
- import { DEFAULT_SCAN_LIMITS, scanEntries } from './scan-session.mjs';
11
+ import { DEFAULT_SCAN_LIMITS, FATAL_SKIP_REASONS, scanEntries } from './scan-session.mjs';
12
12
  import { loadRulesWithDiagnostics } from './rules.mjs';
13
13
 
14
14
  // A tiebreak for the one profile a range report has to name, not a strength
@@ -75,7 +75,7 @@ export function scanMessage(repo, text, options = {}) {
75
75
 
76
76
  function scanIndex(repo, options) {
77
77
  const kind = options.kind || 'staged';
78
- const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
78
+ const { rawPolicy, floor, policy, head, acknowledged, baselineObjectId } = resolveStagedPolicy(repo, options.explicitProfile);
79
79
  const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
80
80
  const accumulator = createAccumulator(options.limits);
81
81
  accumulator.addSkipped(loaded.skipped);
@@ -96,11 +96,14 @@ function scanIndex(repo, options) {
96
96
  });
97
97
  // A reviewed-path override can suppress a matching path rule, but it
98
98
  // cannot turn an unresolved index into a complete commit snapshot.
99
- accumulator.markIncomplete();
99
+ accumulator.markIncomplete('index-unresolved');
100
100
  }
101
101
 
102
102
  if (floor && !isVersionedStrict(rawPolicy)) {
103
- accumulator.add([protectedPolicyFinding(rawPolicy, policy, null)]);
103
+ accumulator.add([protectedPolicyFinding(rawPolicy, policy, null, {
104
+ oldObjectId: baselineObjectId,
105
+ transition: 'staged',
106
+ })]);
104
107
  }
105
108
  scanEntryGroup(repo, loaded.engine, entries, policy, accumulator, {
106
109
  allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
@@ -140,6 +143,7 @@ export function resolveStagedPolicy(repo, explicitProfile) {
140
143
  floor,
141
144
  head,
142
145
  acknowledged,
146
+ baselineObjectId: baselineStrict ? baseline.policy_object_id : null,
143
147
  policy: effectivePolicy(
144
148
  rawPolicy,
145
149
  explicitProfile,
@@ -209,11 +213,14 @@ function scanCommit(repo, options) {
209
213
  throw new GitRevisionError(options.revision, message);
210
214
  }
211
215
  diagnostics.push({ level: 'warning', message });
212
- accumulator.markIncomplete();
216
+ accumulator.markIncomplete('shallow-repository');
213
217
  }
214
218
 
215
219
  if (floor && !isVersionedStrict(rawPolicy)) {
216
- accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot)]);
220
+ accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot, {
221
+ oldObjectId: strictParentPolicies[0]?.policy_object_id ?? null,
222
+ transition: snapshot.commit,
223
+ })]);
217
224
  }
218
225
  // A commit is judged by what it changes, not by the whole tree it inherits.
219
226
  // Scanning snapshot.entries (a full `ls-tree`) re-applied path rules to
@@ -284,7 +291,7 @@ function scanRange(repo, options) {
284
291
  throw new GitRevisionError(options.range, message);
285
292
  }
286
293
  diagnostics.push({ level: 'warning', message });
287
- accumulator.markIncomplete();
294
+ accumulator.markIncomplete('shallow-repository');
288
295
  }
289
296
  const policies = [];
290
297
  let usedStrictFloor = false;
@@ -336,7 +343,10 @@ function scanRange(repo, options) {
336
343
  diagnostics.push(...loaded.diagnostics);
337
344
 
338
345
  if (floor && !isVersionedStrict(rawPolicy)) {
339
- accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit)]);
346
+ accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit, {
347
+ oldObjectId: [...parentStrictObjects][0] ?? null,
348
+ transition: commit.commit,
349
+ })]);
340
350
  }
341
351
  scanEntryGroup(repo, loaded.engine, changes.entries, policy, accumulator, {
342
352
  allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
@@ -556,12 +566,27 @@ function isVersionedStrict(policy) {
556
566
  ));
557
567
  }
558
568
 
559
- function protectedPolicyFinding(rawPolicy, policy, entry) {
569
+ function protectedPolicyFinding(rawPolicy, policy, entry, migration = {}) {
560
570
  const removed = !rawPolicy.policy_object_id;
561
571
  const reason = removed
562
572
  ? 'A versioned strict project policy cannot be deleted or renamed away without a bound review acknowledgment.'
563
573
  : 'A versioned strict project policy cannot be downgraded without a bound review acknowledgment.';
564
- const remediation = ['Restore the strict policy or use the reviewed migration command bound to this target.'];
574
+ // Name the exact command, oids included: "use the reviewed migration
575
+ // command" sent developers to the docs for a three-flag incantation they
576
+ // could not guess (field-test rerun). The staged form binds to the live
577
+ // index; the transition form binds to the commit that carries the change.
578
+ const remediation = ['Restore the strict policy, or unstage the change to keep the current baseline.'];
579
+ if (migration.oldObjectId && migration.transition) {
580
+ const newOid = rawPolicy.policy_object_id ?? 'missing';
581
+ const form = migration.transition === 'staged'
582
+ ? '--staged'
583
+ : `--transition ${migration.transition}`;
584
+ remediation.push(
585
+ `or acknowledge the migration: aimhooman policy-review --head HEAD ${form} --old ${migration.oldObjectId} --new ${newOid} --reason "reviewed policy migration"`
586
+ );
587
+ } else {
588
+ remediation.push('or acknowledge the migration with a reviewed aimhooman policy-review invocation.');
589
+ }
565
590
  return decorate({
566
591
  ruleId: 'generic.project-policy',
567
592
  ruleVersion: 1,
@@ -637,10 +662,10 @@ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfil
637
662
  };
638
663
  }
639
664
 
640
- // Skip reasons that mean a rule never ran, as opposed to a rule running and
641
- // matching nothing. Either way the scan cannot claim to have covered its input.
642
- const INCOMPLETE_SKIP_REASONS = new Set(['local-input-limit', 'local-pack-error']);
643
-
665
+ // The canonical set of fatal skip reasons lives in scan-session.mjs
666
+ // (FATAL_SKIP_REASONS); the accumulator records which of them fired so callers
667
+ // can tell "only an oversized file went unscanned" apart from gaps that must
668
+ // stay fail-closed.
644
669
  function createAccumulator(limits = {}) {
645
670
  const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
646
671
  const findings = [];
@@ -657,6 +682,12 @@ function createAccumulator(limits = {}) {
657
682
  skippedPaths: {},
658
683
  };
659
684
  let complete = true;
685
+ const incompleteReasons = new Set();
686
+
687
+ function flagIncomplete(reason) {
688
+ complete = false;
689
+ incompleteReasons.add(reason);
690
+ }
660
691
 
661
692
  function add(values, total = values.length) {
662
693
  stats.findings_total += total;
@@ -667,7 +698,7 @@ function createAccumulator(limits = {}) {
667
698
  continue;
668
699
  }
669
700
  if (findings.length >= effectiveLimits.maxFindings) {
670
- complete = false;
701
+ flagIncomplete('finding-limit');
671
702
  increment(stats.skipped, 'finding-limit');
672
703
  continue;
673
704
  }
@@ -675,7 +706,7 @@ function createAccumulator(limits = {}) {
675
706
  findings.push(finding);
676
707
  }
677
708
  stats.findings_returned = findings.length;
678
- if (total > values.length) complete = false;
709
+ if (total > values.length) flagIncomplete('finding-limit');
679
710
  }
680
711
 
681
712
  return {
@@ -698,16 +729,17 @@ function createAccumulator(limits = {}) {
698
729
  if (stats.skippedPaths[reason].length < 10) stats.skippedPaths[reason].push(entry);
699
730
  }
700
731
  }
732
+ for (const reason of scanned.incompleteReasons || []) incompleteReasons.add(reason);
701
733
  if (!scanned.complete) complete = false;
702
734
  },
703
735
  addSkipped(skipped = {}) {
704
736
  for (const [reason, count] of Object.entries(skipped)) {
705
737
  stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
706
- if (INCOMPLETE_SKIP_REASONS.has(reason)) complete = false;
738
+ if (FATAL_SKIP_REASONS.has(reason)) flagIncomplete(reason);
707
739
  }
708
740
  },
709
- markIncomplete() {
710
- complete = false;
741
+ markIncomplete(reason = 'target-unverifiable') {
742
+ flagIncomplete(reason);
711
743
  },
712
744
  remaining() {
713
745
  return Math.max(0, effectiveLimits.maxFindings - findings.length);
@@ -718,6 +750,9 @@ function createAccumulator(limits = {}) {
718
750
  isComplete() {
719
751
  return complete;
720
752
  },
753
+ get incompleteReasons() {
754
+ return [...incompleteReasons];
755
+ },
721
756
  };
722
757
  }
723
758
 
@@ -731,6 +766,7 @@ function result({ target, policy, accumulator, diagnostics, messageScanned, comm
731
766
  policy_enforced_object_ids: policy.enforced_policy_object_ids,
732
767
  } : {}),
733
768
  complete: accumulator.isComplete(),
769
+ incomplete_reasons: accumulator.incompleteReasons,
734
770
  stats: accumulator.stats,
735
771
  findings: accumulator.findings,
736
772
  diagnostics,