@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +1 -1
- package/CHANGELOG.md +91 -2
- package/README.md +11 -6
- package/bin/aimhooman.mjs +231 -91
- package/docs/cli-reference.md +120 -0
- package/docs/faq.md +62 -0
- package/docs/integrations.md +108 -0
- package/docs/policy.md +82 -0
- package/docs/secrets.md +83 -0
- package/package.json +6 -1
- package/src/args.mjs +2 -7
- package/src/githooks.mjs +65 -5
- package/src/gitx.mjs +1 -13
- package/src/hook.mjs +14 -3
- package/src/report.mjs +22 -8
- package/src/scan-session.mjs +14 -6
- package/src/scan-target.mjs +55 -19
- package/src/state.mjs +56 -16
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
|
-
-
|
|
34
|
-
|
|
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.
|
|
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
|
|
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
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|