@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.
@@ -0,0 +1,120 @@
1
+ # CLI reference
2
+
3
+ ```
4
+ aimhooman init | status | check | audit | scan | explain | allow | deny | override | review | policy-review | fix | doctor | uninstall | version
5
+ ```
6
+
7
+ ## Install and init
8
+
9
+ Node 22.8+ and Git 2.28+, zero runtime dependencies, ships as source. Git 2.28
10
+ is required for the prepared-phase reference transaction guard that checks
11
+ cherry-pick, revert, rebase, `git am`, and other ref-producing flows.
12
+
13
+ ```sh
14
+ npm install -g @rmyndharis/aimhooman
15
+ ```
16
+
17
+ Guard a repository:
18
+
19
+ ```sh
20
+ aimhooman init # git hooks + local excludes; normally no worktree files
21
+ aimhooman init --gitignore # also write the managed AI-artifact block to the worktree .gitignore
22
+ aimhooman status
23
+ aimhooman uninstall # restore hooks/excludes; keep local policy state
24
+ aimhooman uninstall --purge-state # also delete common Git-directory state
25
+ ```
26
+
27
+ `init --gitignore` is the opt-in for teams that want the ignore set committed:
28
+ it writes the same managed pattern block into the worktree `.gitignore`,
29
+ records the choice in the local config, and prints a notice to commit the file
30
+ so every clone shares it. `status` shows the recorded choice, and `uninstall`
31
+ removes the block (and the file, if aimhooman created it and nothing else
32
+ remains). The default stays local: `.git/info/exclude` only.
33
+
34
+ Two honest edges. `uninstall` deletes the `.gitignore` itself only when
35
+ aimhooman created it and the removed block leaves it empty — if you committed
36
+ the file, that deletion is a tracked worktree deletion you then commit or
37
+ restore. And the opt-in record lives in the common Git directory, so linked
38
+ worktrees share the choice while each worktree keeps its own `.gitignore`.
39
+
40
+ For commits you make at the terminal (outside your AI tool), one global setup guards
41
+ eligible non-bare repositories that do not override `core.hooksPath` locally:
42
+
43
+ ```sh
44
+ aimhooman init --global --yes # advanced: change core.hooksPath after confirmation
45
+ aimhooman uninstall --global # unset it
46
+ ```
47
+
48
+ Global `core.hooksPath` changes Git behavior for repositories that inherit it and can
49
+ replace their default hook directory. A local or worktree-scoped override takes
50
+ precedence. Bare repositories are outside the worktree/index policy boundary and the
51
+ global dispatchers leave them unchanged. `status` shows both local and global values.
52
+ Prefer repository `init` unless the global ordering is understood.
53
+
54
+ When `core.hooksPath` is set, Git reads hooks only from that effective directory and
55
+ ignores `.git/hooks`. Repository `init` installs and chains predecessors only when
56
+ that directory is absent or is proven to be owned by the repository: inside it and
57
+ not tracked by Git. It refuses to modify a global, shared, external, or tracked
58
+ hook directory, because a dispatcher committed from one machine names paths that
59
+ exist only on that machine. Those repositories are not guarded, and there is no
60
+ way to guard them today. Calling `aimhooman precommit` from an existing hook
61
+ manager runs the check but registers no managed guard, so the agent hook still
62
+ refuses the commit. Remove the override before retrying, or accept that the
63
+ repository is unguarded and do not run `init` there.
64
+
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
68
+ `commit-msg`, aimhooman pins the would-be tree before the predecessor runs, so a later
69
+ index change cannot select a weaker policy. The prepared reference transaction is the
70
+ last local check for cherry-pick, revert, rebase, `git am`, fetch/worktree branch
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
75
+ a required guard; the first running dispatcher that detects the loss aborts the
76
+ operation.
77
+
78
+ ## Commands
79
+
80
+ `check` accepts one Git target (`--staged`, `--tracked`, `--commit <rev>`, or
81
+ `--range <base>...<head>`), plus `--message <file>`, `--profile`, and `--json`.
82
+ Commit and range targets read commit messages from Git automatically. A range scans each
83
+ introduced commit, so a bad file added and deleted before the endpoint is still reported.
84
+ Use an all-zero object ID as the base when there is no prior commit; this includes the root
85
+ commit. Deleting an ordinary forbidden path is not itself a finding, while removing or
86
+ lowering a versioned strict project policy still needs a bound policy review.
87
+ `audit` and `scan` are aliases for a full tracked-index scan.
88
+ `init --global --yes` and `uninstall --global` manage the advanced terminal-Git guard;
89
+ `uninstall --global` cannot be combined with the local `--purge-state` option.
90
+ `fix` follows the active profile: clean writes an exact safe repair, compliance makes no
91
+ change, and strict previews unless `--apply` is supplied.
92
+ `allow` resolves a finding by path or rule ID; an allow entry's scope is `path` or
93
+ `rule` (see [docs/policy.md](policy.md#overrides)).
94
+
95
+ On `clean` and `compliance`, `check`, `fix`, and the commit hooks warn and continue
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.
101
+
102
+ Machine reports use `schema_version: 1` and include target policy identity, completeness,
103
+ scan statistics, commit and object metadata. Schemas are published in [`schemas/`](../schemas/).
104
+
105
+ For an existing repository, start with `aimhooman audit --json`. If a residue path is
106
+ already tracked, remove it from the index with `git rm --cached <path>` and add an
107
+ appropriate ignore/exclude. aimhooman does not scan for secrets; if one was committed,
108
+ rotate it first — [docs/secrets.md](secrets.md) covers why and what to run instead.
109
+ History cleanup is deliberately outside aimhooman's scope.
110
+
111
+ ## Exit codes
112
+
113
+ | Code | Meaning |
114
+ | --- | --- |
115
+ | 0 | clean, or non-blocking review |
116
+ | 10 | policy violation (block) |
117
+ | 11 | review-required on a non-clean profile |
118
+ | 20 | usage, configuration, or rule-pack error |
119
+ | 30 | Git or I/O error |
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 ADDED
@@ -0,0 +1,62 @@
1
+ # FAQ
2
+
3
+ **Is this a way to hide AI use?** No. aimhooman removes operational residue and
4
+ establishes human ownership. It never changes author, committer, signature, or
5
+ timestamp, and the compliance profile keeps any disclosure your policy requires.
6
+
7
+ **Will it cancel my commits?** On the default `clean` profile, aimhooman first tries to
8
+ exclude or unstage hygiene artifacts and safely remove exact attribution lines. The
9
+ commit proceeds if no block remains; a scan that runs over budget warns and continues.
10
+ A pre-existing tracked block, failed unstage/repair, or unterminated exact attribution
11
+ still stops the operation, and the final `reference-transaction` guard vetoes a commit
12
+ it cannot fully scan, on every profile. `strict` cancels findings instead of repairing,
13
+ and treats an incomplete scan as a stop.
14
+
15
+ **Does it slow commits down?** The staged check runs locally with no network and reads
16
+ Git objects in batches. Text-oriented rules skip binary files. Size and total budgets
17
+ are visible in reports. Files over 2 MiB or a scan over 64 MiB make the scan
18
+ incomplete (binary files skip complete; oversized text is what trips it): direct
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.
24
+
25
+ **Can the agent bypass it?** Any local tool can ultimately be bypassed by a user with
26
+ commit access. The agent guard fails closed on what it cannot prove: empty, invalid,
27
+ or non-object hook JSON, missing managed final guards, and hook or receive-pack
28
+ indirection around protected ref mutations are denied on every profile, and `strict`
29
+ additionally rejects `--no-verify`, unknown executor shapes, and uncertain commit
30
+ execution. Read-only commands and pipelines run; the guard stands between the agent
31
+ and protected Git mutations, not between you and reading a repository. Git hooks are
32
+ not a sandbox: an editor or another local program started during a commit has the same
33
+ filesystem access and can change a later hook. Wrappers that can select another cwd or
34
+ filesystem namespace (`sudo`, `chroot`, WSL, sandbox launchers) fail closed on every
35
+ profile; retry as a direct Git command from the target repository. Treat local
36
+ executables and Git config as trusted. For team enforcement, scan the actual PR range
37
+ in CI (a normal CI checkout has no staged changes):
38
+
39
+ ```sh
40
+ git fetch origin main
41
+ aimhooman check --range origin/main...HEAD --profile strict
42
+ ```
43
+
44
+ On GitHub Actions, configure `actions/checkout` with `fetch-depth: 0` so the
45
+ triple-dot merge base is available.
46
+
47
+ `pre-commit` and `commit-msg` do not cover every sequencer or ref movement. The managed
48
+ `reference-transaction` hook therefore checks introduced commits during Git's prepared
49
+ phase and can abort the local ref update. Git 2.54's earlier `preparing` callback is
50
+ accepted for compatibility and checks guard integrity; scanning remains in `prepared`,
51
+ after references are locked.
52
+ CI still scans the exact pushed or PR history:
53
+ local hooks do not govern another clone, server-side updates, or history created before
54
+ the guard was installed.
55
+ Bare repositories have no worktree/index boundary and are not supported by local commands.
56
+ A submodule is a separate repository with separate state and hooks; run `aimhooman init`
57
+ inside each submodule that needs local enforcement.
58
+
59
+ **What about secrets?** aimhooman does not scan for secrets since v0.3.0 and never
60
+ did history cleanup. A committed credential is exposed: rotate it, then run a
61
+ dedicated scanner over the repository. [docs/secrets.md](secrets.md) explains the
62
+ reasoning and shows the gitleaks setup we recommend.
@@ -0,0 +1,108 @@
1
+ # Integrations
2
+
3
+ aimhooman runs wherever commits get checked: as a pre-commit.com hook, as a
4
+ GitHub Action, and next to other hook tooling. This page shows each setup and
5
+ what it does and does not cover.
6
+
7
+ ## pre-commit.com
8
+
9
+ The repository ships a `.pre-commit-hooks.yaml`, so pre-commit installs
10
+ aimhooman straight from Git. Add this to `.pre-commit-config.yaml` and run
11
+ `pre-commit install`:
12
+
13
+ ```yaml
14
+ repos:
15
+ - repo: https://github.com/rmyndharis/aimhooman
16
+ rev: v0.3.0
17
+ hooks:
18
+ - id: aimhooman
19
+ ```
20
+
21
+ pre-commit npm-installs the package at the pinned revision and runs
22
+ `aimhooman check --staged` on every commit. Pin `rev` to a tag and bump it
23
+ deliberately.
24
+
25
+ The hook checks the staged tree, so it passes no filenames. Any non-zero
26
+ exit fails it: 10 for a policy violation, 11 for a review-required finding on
27
+ a non-clean profile, 20 for a usage or configuration error, 30 for a Git or
28
+ I/O error, 31 when a scan budget leaves the scan incomplete on `strict` or at
29
+ the final ref guard (`clean`/`compliance` warn and continue). On the default
30
+ `clean` profile a review-only finding exits 0, so reviews alone do not fail
31
+ the hook. Blocks always do.
32
+
33
+ This hook is the CLI check only. It does not install aimhooman's managed Git
34
+ hooks or the agent guard; `aimhooman init` in the repository adds those.
35
+
36
+ ## GitHub Actions
37
+
38
+ The repository root has an `action.yml`, so a workflow can scan the exact
39
+ pull request range:
40
+
41
+ ```yaml
42
+ # .github/workflows/aimhooman.yml
43
+ name: aimhooman
44
+ on: [pull_request]
45
+
46
+ jobs:
47
+ check:
48
+ runs-on: ubuntu-latest
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+ with:
52
+ fetch-depth: 0
53
+ - uses: rmyndharis/aimhooman@v0.3.0
54
+ with:
55
+ base: ${{ github.event.pull_request.base.sha }}
56
+ head: ${{ github.event.pull_request.head.sha }}
57
+ ```
58
+
59
+ `fetch-depth: 0` is required. The range is triple-dot, so the scan starts at
60
+ the merge base of the two commits, and a shallow checkout does not have that
61
+ commit. The action installs aimhooman from npm and defaults to the `strict`
62
+ profile; set `profile:` under `with:` to change that.
63
+
64
+ Pinning the action tag (`@v0.3.0`) pins only the action's steps, not the CLI:
65
+ the action npm-installs aimhooman at run time, and the `version` input
66
+ defaults to `latest`. Set `version:` under `with:` (for example
67
+ `version: 0.3.0`) to pin the CLI itself.
68
+
69
+ For `push` events, use `base: ${{ github.event.before }}` and
70
+ `head: ${{ github.event.after }}`. On the first push to a branch `before` is
71
+ all zeros; `aimhooman check --range` accepts an all-zero base and includes
72
+ the root commit in the scan.
73
+
74
+ CI is the enforcement tier that travels with the repository: local hooks do
75
+ not govern another clone, and a laptop can skip its own hooks. The action
76
+ cannot.
77
+
78
+ ## Secrets
79
+
80
+ aimhooman does not scan for secrets. Pair it with
81
+ [gitleaks](https://github.com/gitleaks/gitleaks), either as a pre-commit.com
82
+ hook or as a second CI job next to the action above.
83
+ [docs/secrets.md](secrets.md) explains why the built-in scanner was removed
84
+ and shows both gitleaks setups.
85
+
86
+ ## husky
87
+
88
+ husky sets `core.hooksPath` to `.husky`. `aimhooman init` refuses to install
89
+ into or over an external or shared hooks directory by design: `.husky` is
90
+ tracked repository content, and a dispatcher written there would stage this
91
+ machine's absolute CLI and Node paths for everyone who clones
92
+ (`src/githooks.mjs` has the full rule set). Two ways out:
93
+
94
+ - Keep husky and call `aimhooman check --staged` from `.husky/pre-commit`.
95
+ You get the CLI check only. The agent-tier guard (PreToolUse) asks for
96
+ aimhooman's own managed hooks and refuses guarded commits without them, so
97
+ agent-driven commits stay unguarded in this setup.
98
+ - Drop husky for the guarded hooks and let `aimhooman init` manage them.
99
+ Existing hooks are not lost: init preserves each one as a chained
100
+ predecessor, and the dispatcher runs it before its own check.
101
+
102
+ ## lint-staged
103
+
104
+ lint-staged builds a list of staged files and runs one command per batch.
105
+ aimhooman does not fit that shape: it reads the whole index and commit from
106
+ Git and takes no file list, so a per-batch invocation would check the same
107
+ index several times. Run it at hook level instead, through its own managed
108
+ hooks or the pre-commit.com setup above.
package/docs/policy.md ADDED
@@ -0,0 +1,82 @@
1
+ # Team policy and overrides
2
+
3
+ aimhooman's per-clone defaults serve a solo developer. This page covers the
4
+ team layer: a versioned policy file, owner-authorized changes to protected
5
+ paths in CI, local overrides, and local rule packs.
6
+
7
+ ## Versioned team policy
8
+
9
+ Commit `.aimhooman.json` when every clone should use the same baseline:
10
+
11
+ ```json
12
+ {
13
+ "schema_version": 1,
14
+ "profile": "strict"
15
+ }
16
+ ```
17
+
18
+ The project policy takes precedence over the per-clone profile written by `init`.
19
+ An individual `check` may escalate to `--profile strict`, but cannot weaken or replace
20
+ the team profile. Malformed project policy fails closed with an actionable error;
21
+ personal allow/deny exceptions and local rule packs remain in the common Git directory
22
+ under `aimhooman/`.
23
+ Under `strict`, policy files and agent instructions are blocking findings (exit 10):
24
+ both rules' strict action is `block`, not review. The product's `review` and
25
+ `policy-review` commands record local, object-bound decisions. An ordinary path or
26
+ rule allow does satisfy those engine findings; the one finding an allow cannot
27
+ satisfy is the strict-policy downgrade-or-removal block, which is constructed
28
+ outside the engine and clears only through a bound `policy-review` acknowledgment.
29
+
30
+ ### Owner authorization in CI
31
+
32
+ For a protected-path change, CI verifies the pinned repository and owner login plus
33
+ numeric IDs through the GitHub API, then inspects the exact workflow-run attempt.
34
+ GitHub must attribute both `actor` and `triggering_actor`, including their numeric IDs,
35
+ to that owner. CI then binds the authorization to the exact head, transition commit,
36
+ path, resulting blob and regular-file mode, or deletion tombstone. A strict-policy
37
+ migration also binds its old and new policy objects. A different attempt, commit, path
38
+ result, mode, or policy transition needs fresh authorization. A change not attributed to
39
+ the owner fails closed. This is owner authorization verified through GitHub attribution,
40
+ not independent review.
41
+
42
+ ## Overrides
43
+
44
+ Every decision has a rule ID, so you can resolve a finding narrowly:
45
+
46
+ ```sh
47
+ aimhooman allow AGENTS.md --reason "shared team config" # stop flagging this path
48
+ aimhooman deny path/or/rule-id # always block it
49
+ aimhooman explain claude.session-state # why a rule fires
50
+ ```
51
+
52
+ Overrides live in the repository's common Git directory under
53
+ `aimhooman/overrides.json` (local, never committed), so linked worktrees share them.
54
+ An allow entry's scope is `path` or `rule`.
55
+
56
+ ```sh
57
+ aimhooman override list --json
58
+ aimhooman override remove AGENTS.md
59
+ aimhooman override reset --all
60
+ ```
61
+
62
+ Secret scanning left the product in v0.3.0, and the `secret-path` allow scope went
63
+ with it: existing `secret-path` entries are dropped on load with a warning, and the
64
+ rest of the file keeps working. Use a dedicated scanner for credentials instead —
65
+ [docs/secrets.md](secrets.md) explains the reasoning and shows the gitleaks setup.
66
+
67
+ ## Local rule packs
68
+
69
+ Add your own per-repository detection with local rule packs in the common Git directory
70
+ under `aimhooman/rules/*.json`
71
+ (the structural schema is in [`schemas/rule-pack.schema.json`](../schemas/rule-pack.schema.json);
72
+ local rules only add detection — they can't weaken
73
+ a built-in block). Within one rule, local content patterns are capped at 32 expressions,
74
+ 512 characters per expression, and 4,096 characters total. Path and exception scopes
75
+ share the same glob count, per-expression, and total limits. They use a flat subset: literals,
76
+ character classes, anchors, dot, escapes, and fixed `{n}` repeats. Groups, alternation,
77
+ lookaround, backreferences, and variable quantifiers are rejected. A local expression
78
+ does not run on a line longer than 16,384 characters; that skip is reported and makes the
79
+ scan incomplete. Path rules are case-sensitive by default. Set
80
+ `match.path_case` to `"insensitive"` only for a security name whose meaning is
81
+ case-insensitive, such as `.env`; matching folds that rule's candidate and patterns but
82
+ does not change the Git path or override identity.
@@ -0,0 +1,83 @@
1
+ # Secrets and aimhooman
2
+
3
+ aimhooman v0.3.0 removed built-in secret scanning. This page explains why and
4
+ shows the setup we recommend instead.
5
+
6
+ ## Why the built-in scanner went away
7
+
8
+ aimhooman does one job: it keeps AI tooling residue (session files, local
9
+ state, AI attribution) out of Git history. Secret scanning is a different
10
+ craft. A serious scanner tracks hundreds of credential formats, tunes for
11
+ entropy and false positives, and ships pattern updates on its own cadence.
12
+ aimhooman's eight rules could not keep up with that, and a scanner that only
13
+ sometimes works is worse than none: it reads as coverage it never had. Use a
14
+ tool that does secrets full-time. We recommend [gitleaks](https://github.com/gitleaks/gitleaks).
15
+
16
+ Two things do stay. The agent-facing policy still tells agents never to commit
17
+ secrets, because an instruction costs nothing. And a local rule pack can still
18
+ declare `category: "secret"`; findings from such a rule keep their matched
19
+ text redacted in every report.
20
+
21
+ ## Recommended setup: gitleaks
22
+
23
+ ### Pre-commit hook (pre-commit.com)
24
+
25
+ Add this to `.pre-commit-config.yaml` and run `pre-commit install`:
26
+
27
+ ```yaml
28
+ repos:
29
+ - repo: https://github.com/gitleaks/gitleaks
30
+ rev: v8.30.1
31
+ hooks:
32
+ - id: gitleaks
33
+ ```
34
+
35
+ The hook scans staged changes and stops the commit when a credential shows
36
+ up. Pin `rev` and bump it deliberately.
37
+
38
+ ### GitHub Action
39
+
40
+ Laptops bypass hooks; CI should not. Add a workflow step that scans pull
41
+ requests:
42
+
43
+ ```yaml
44
+ # .github/workflows/gitleaks.yml
45
+ name: gitleaks
46
+ on: [push, pull_request]
47
+ jobs:
48
+ scan:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ with:
53
+ fetch-depth: 0
54
+ - uses: gitleaks/gitleaks-action@v2
55
+ ```
56
+
57
+ `fetch-depth: 0` gives the action the full history so a secret introduced
58
+ three commits back still gets caught.
59
+
60
+ ### Running it alongside aimhooman
61
+
62
+ The two tools do not overlap and do not fight. aimhooman owns Git's
63
+ `pre-commit`, `commit-msg`, `reference-transaction`, and `pre-push` hooks; gitleaks runs
64
+ as a pre-commit.com hook or in CI, so both fire on the same commit without
65
+ sharing state. A commit carrying an AI session file and an API key gets
66
+ stopped for both reasons, each reported by the tool that owns it. Keep
67
+ aimhooman for AI hygiene, keep gitleaks for secrets, and let each report in
68
+ its own words.
69
+
70
+ ## Migrating from the built-in scanner
71
+
72
+ Two escape hatches left with the scanner:
73
+
74
+ - `aimhooman allow <path> --scope secret-path` is gone. Existing
75
+ `secret-path` entries in `overrides.json` are dropped on load with a
76
+ warning; the rest of the file keeps working.
77
+ - `aimhooman init --grandfather-secrets` is gone.
78
+
79
+ If secrets already sit in your history, aimhooman was never the fix and
80
+ removing it changes nothing about that: a committed credential is exposed.
81
+ Rotate it, then run `gitleaks git` (or `gitleaks detect`) over the repository
82
+ to find anything else that needs rotation. Scrubbing history is optional and
83
+ secondary; rotation is not.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.3.0",
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": {
@@ -23,6 +23,11 @@
23
23
  "docs/hosts.json",
24
24
  "docs/ai-artifacts.gitignore",
25
25
  "docs/catalog.md",
26
+ "docs/secrets.md",
27
+ "docs/policy.md",
28
+ "docs/cli-reference.md",
29
+ "docs/faq.md",
30
+ "docs/integrations.md",
26
31
  "docs/design/",
27
32
  "docs/logo/",
28
33
  ".claude-plugin/",
package/src/args.mjs CHANGED
@@ -27,7 +27,7 @@ export function parseArguments(args, definition = {}) {
27
27
  const inlineValue = equal > 0 ? argument.slice(equal + 1) : undefined;
28
28
  const option = byName.get(name);
29
29
  if (!option) throw new ArgumentError(`unknown option "${name}"`);
30
- if (seen.has(option.key) && !option.repeatable) {
30
+ if (seen.has(option.key)) {
31
31
  throw new ArgumentError(`option "${name}" may only be used once`);
32
32
  }
33
33
  seen.add(option.key);
@@ -49,12 +49,7 @@ export function parseArguments(args, definition = {}) {
49
49
  if (option.choices && !option.choices.includes(value)) {
50
50
  throw new ArgumentError(`invalid value for ${name}: "${value}"`);
51
51
  }
52
- if (option.repeatable) {
53
- if (!options[option.key]) options[option.key] = [];
54
- options[option.key].push(value);
55
- } else {
56
- options[option.key] = value;
57
- }
52
+ options[option.key] = value;
58
53
  continue;
59
54
  }
60
55
  positionals.push(argument);
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/gitx.mjs CHANGED
@@ -1,17 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import {
3
- cpSync,
4
- lstatSync,
5
- mkdirSync,
6
- mkdtempSync,
7
- readFileSync,
8
- realpathSync,
9
- readdirSync,
10
- readlinkSync,
11
- renameSync,
12
- rmSync,
13
- symlinkSync,
14
- } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
15
3
  import { isAbsolute, join, resolve } from 'node:path';
16
4
  import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
17
5
 
package/src/hook.mjs CHANGED
@@ -86,12 +86,23 @@ export function hookSessionStart() {
86
86
  const policy = resolvePolicy(repo, { target: 'worktree' });
87
87
  const { engine: eng } = newEngineWithDiagnostics(policy.profile, repo.stateDir);
88
88
  const patterns = patternsForRules(eng.rules);
89
- applyExclude(repo.excludeFile, patterns);
89
+ // Each refresh is housekeeping with its own silent failure, mirroring
90
+ // the pre-tool-use path: a read-only .git/info must not skip the
91
+ // worktree .gitignore refresh below, and neither may decide anything.
92
+ try {
93
+ applyExclude(repo.excludeFile, patterns);
94
+ } catch {
95
+ /* exclude refresh is best effort */
96
+ }
90
97
  // A clone that opted into the committed variant gets the same refresh in
91
98
  // its worktree .gitignore; every failure degrades silently, same as the
92
99
  // exclude write above.
93
- if (loadConfig(repo.stateDir).gitignore?.enabled) {
94
- applyExclude(join(repo.root, '.gitignore'), patterns);
100
+ try {
101
+ if (loadConfig(repo.stateDir).gitignore?.enabled) {
102
+ applyExclude(join(repo.root, '.gitignore'), patterns);
103
+ }
104
+ } catch {
105
+ /* gitignore refresh is best effort */
95
106
  }
96
107
  } catch {
97
108
  /* not a repo; nothing to exclude */