@rmyndharis/aimhooman 0.1.1 → 0.1.2

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, secrets, and unwanted attribution out at the agent and Git boundary.",
12
- "version": "0.1.1",
12
+ "version": "0.1.2",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files, secrets, 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.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman maintainers",
package/CHANGELOG.md CHANGED
@@ -5,7 +5,153 @@ 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
- ## [Unreleased]
8
+ ## [0.1.2] - 2026-07-17
9
+
10
+ ### Removed
11
+
12
+ - The legacy per-clone state migration is gone, and with it the failure paths it
13
+ created. `openRepo` used to look for state under `.git/worktrees/<name>/aimhooman`
14
+ and migrate it into `<commonDir>/aimhooman`, fingerprinting both and refusing to
15
+ continue when two copies disagreed. No released version ever wrote to that
16
+ location — v0.1.0 already resolved state to `<commonDir>/aimhooman` on the line
17
+ above the migration — so the predecessor it protected never existed. What it did
18
+ produce was real: any error other than ENOENT while listing `.git/worktrees` was
19
+ re-raised through every command, including `uninstall`, so a directory Git itself
20
+ reads without trouble could freeze a repository with no supported way out.
21
+ `.git/worktrees` belongs to Git and can be owned by another uid after a
22
+ `sudo git worktree add`. Repository state resolves to `<commonDir>/aimhooman`, as
23
+ it always has.
24
+
25
+ ### Fixed
26
+
27
+ - Unstaging residue no longer stages the deletion of tracked files. The HEAD probe
28
+ reused the options object carrying the pathspec on stdin, and `git rev-parse`
29
+ never reads stdin, so once the pathspec passed the 64 KiB pipe buffer the write
30
+ failed with `EPIPE`. The catch read that as "this repository has no HEAD" and ran
31
+ `git rm --cached` where `git restore --staged` was meant, staging the deletion of
32
+ every tracked path it had been asked to restore, printing "unstaged N AI
33
+ artifact(s)", and exiting 0 so the commit carried the deletions into history. It
34
+ takes roughly 470 paths to cross that buffer, which a tracked `.claude/projects/`
35
+ tree passes without trying, and the repair loop could not see it: the staged
36
+ deletions read as still-staged, so it retried, made no progress, and reported
37
+ success anyway. The probe no longer receives the pathspec, and only git's own exit
38
+ status may pick the branch — a timeout or a missing git stops the commit instead
39
+ of guessing that HEAD is absent. Present since 0.1.0.
40
+ - A stuck `git` no longer holds a repository open indefinitely. `execFileSync` has
41
+ no default timeout, so a child that never exited blocked its caller for as long as
42
+ it lived, with nothing printed and nothing logged; one held a CI runner for six
43
+ hours and died to the platform's ceiling rather than to anything here. Every child
44
+ process now carries a bound: 120 seconds for git, far above any real call, and 5
45
+ seconds for the `ps` identity probe, which runs inside the lock retry loop and only
46
+ on macOS and BSD (Linux reads `/proc`, Windows returns early). Both throw into the
47
+ handling that was already there, so a bounded failure degrades the way a missing
48
+ git already did instead of hanging. A test walks `src/` and `bin/` and fails on any
49
+ child process without a bound.
50
+ - Renaming or moving a repository no longer freezes it. The dispatcher bakes
51
+ absolute paths, so a move changes the `CHAINED` value it carries, and ownership
52
+ was decided by comparing that string — overruling the SHA-256 fingerprint on the
53
+ line above, which had already proved the file was ours. Every commit, every
54
+ `--no-verify`, and every branch creation then failed; `status` reported the guard
55
+ as belonging to another repository; `aimhooman init`, the remedy each message
56
+ named, refused too. Ownership inside the repository's own `.git` is now settled
57
+ by the fingerprint alone, because no second repository can own a file there. The
58
+ baked path keeps its vote only where a hooks directory can genuinely be shared —
59
+ two repositories pointing `core.hooksPath` at one place — which is the case it
60
+ was written for.
61
+ - `uninstall` no longer reports success while leaving its own dispatchers behind.
62
+ Refusals were recorded as warnings, the exit code only consulted failures, and
63
+ the "uninstalled" headline printed before either was known, so a moved
64
+ repository was told it was free while four dispatchers still blocked every
65
+ commit. uninstall now checks the hooks directory instead of trusting its own
66
+ report: anything of ours still on disk is named with its full path, the headline
67
+ is withheld, and the exit code is 30. A chained backup that is a symlink is
68
+ still never read or copied through, but the dispatcher above it is now removed
69
+ rather than held as collateral, and the report says the original hook was not
70
+ restored.
71
+ - A `HOME` behind a symlink no longer makes every global dispatcher look foreign.
72
+ `hookDiagnostics` compared the effective hooks directory to the global one with
73
+ `resolve`, which does not follow symlinks, while Git reports the realpath — so
74
+ on a distribution that ships `/home` as a link, or an NFS or autofs home, the
75
+ two spellings differed and every global hook was diagnosed as managed for
76
+ another repository.
77
+ - A failed excludes refresh no longer disables the `PreToolUse` guard. Writing
78
+ `.git/info/exclude` sat inside the same `try` as the engine load, and the shared
79
+ `catch` allowed the command on every non-strict profile, so an unwritable
80
+ `.git/info` — a CI checkout, a read-only volume, a repository owned by another
81
+ user — turned a staged AWS key plus `git commit --no-verify` from `deny` into
82
+ `allow`. Nothing about the policy was wrong: the rules had loaded, and the
83
+ reported cause ("could not load policy rules") named the wrong thing. Refreshing
84
+ the excludes is gitignore hygiene, which `pre-commit` never does and still
85
+ answers correctly, so it now runs outside the verdict and reports as a warning.
86
+
87
+ - A local rule pack that cannot load no longer produces a report claiming a
88
+ complete scan. `strict` already failed closed, but `clean` and `compliance`
89
+ turned the load error into a stderr warning and left the accumulator untouched,
90
+ so `--json` returned `complete: true`, `findings: []`, `skipped: {}` and exit 0
91
+ while the team's own rules had never run. The pack most teams write is a
92
+ detector for their internal token format; one typo took it out of the scan, and
93
+ the report actively certified that nothing was missed. A failed pack is now the
94
+ counted skip reason `local-pack-error` and marks the scan incomplete, which is
95
+ the treatment `local-input-limit` already had in every profile: a rule that
96
+ never ran is a coverage gap, not an empty result. `clean` and `compliance`
97
+ therefore stop at exit 31 where they previously continued. The hint now points
98
+ at the pack instead of suggesting the caller reduce the target or limits, which
99
+ was never the remedy for a pattern that will not compile.
100
+ - Creating a branch no longer rescans the entire repository. A new branch arrives
101
+ with an all-zero old tip, so `rev-list` ran with no negative boundary and every
102
+ ancestor was re-read, tree and blob sizes included: 24.7s at 200 commits, growing
103
+ linearly, on an operation `--no-verify` cannot skip. Reachability from
104
+ `refs/heads/*` is now trusted, because those commits passed this same guard when
105
+ their branch was written, which puts `git checkout -b` back at ordinary commit
106
+ cost (0.95s in the same repository). Only `refs/heads/*` counts: tags and remote
107
+ refs are not gated here and still cannot pre-poison reachability, the refs under
108
+ review are never their own proof, and a commit no local branch reaches is still
109
+ scanned in full. Commits that predate `aimhooman init` are outside the guard's
110
+ scope; audit them with `check --range`.
111
+ - `init` no longer writes dispatchers into a hooks directory that Git tracks.
112
+ Ownership was tested by location alone, so any `core.hooksPath` inside the
113
+ worktree counted as the repository's own — including the committed `.husky` and
114
+ `.githooks` directories that husky and the vanilla pattern rely on. init edited
115
+ those tracked files in place, and the dispatcher it wrote carries this machine's
116
+ absolute CLI, Node, and PATH: once committed, the hook is dead for every
117
+ teammate, and the PATH alone discloses the author's home directory and installed
118
+ tooling. Ownership now also requires that Git is not tracking the directory, and
119
+ a directory Git cannot be asked about counts as tracked. A refusal now names the
120
+ cause instead of reporting a bare incomplete installation; as before, integrate
121
+ aimhooman into the existing hook manager or remove the override.
122
+ - A moved Node interpreter no longer disables the guard silently. 0.1.1 made the
123
+ dispatcher degrade gracefully when the CLI or Node was missing, which is right
124
+ for a half-removed install but wrong for a relocated interpreter: `brew upgrade
125
+ node`, `nvm`, `fnm`, and `volta` all move the `process.execPath` that `init`
126
+ pins, and every hook then warned once and allowed the commit unprotected. The
127
+ conditions now separate by what the user can still do about them. A missing CLI
128
+ still allows the operation, because the package is gone and no guard is wanted.
129
+ A missing pinned interpreter stops the operation and names the path and the
130
+ remedy — but only while a Node exists to run that remedy with, since `init` and
131
+ `uninstall` are both Node programs and the CLI file is inert without one.
132
+ Where no Node is present at all, the dispatcher degrades and says so, because
133
+ refusing there would leave the repository unusable with no supported way to
134
+ remove the hooks. Stopping matches what the CLI already believed —
135
+ `installedHooks` treats an unreachable Node as an inactive dispatcher, so the
136
+ shell was short-circuiting the CLI's own fail-closed path — but only where
137
+ stopping is recoverable. Re-run `aimhooman init` after upgrading Node to re-pin it.
138
+ - Attribution rules no longer miss current AI footers. `attribution.generated-with`
139
+ pinned the literal vendor link `https://claude.ai/code`, and the co-author rules
140
+ pinned the display names `Claude`, `Claude Code`, and `Codex`. Once the link was
141
+ rebranded and the display name grew a model suffix, the default footer passed
142
+ through untouched with exit 0 while the older form was still repaired perfectly.
143
+ The rules now anchor on what identifies the machine rather than what marketing
144
+ changes: a co-author trailer is matched by its `noreply@` service address alone,
145
+ and the generated-with link is matched by its shape. A new contract test rejects
146
+ any attribution pattern that pins a vendor URL, so the pack cannot rot back into
147
+ this state without failing CI.
148
+ - `attribution.ai-noreply` no longer backtracks catastrophically. `\s*[^<>]+\s*`
149
+ placed three ambiguous quantifiers before a literal `<`, which cost 49s on a
150
+ 6400-character trailer that never closes; built-in patterns are exempt from the
151
+ local input cap, so a commit message reached it directly. The redundant
152
+ whitespace quantifiers are gone (`[^<>]+` already spans them), leaving the
153
+ matched language unchanged, and a bounded-cost test now covers the built-in
154
+ message patterns.
9
155
 
10
156
  ## [0.1.1] - 2026-07-16
11
157
 
package/CONTRIBUTING.md CHANGED
@@ -88,7 +88,39 @@ Personal, per-clone detection belongs in `<git-common-dir>/aimhooman/rules/*.jso
88
88
  (local, never committed). Linked worktrees share it. Contribute general-purpose
89
89
  rules to `rules/` instead.
90
90
 
91
- ## Commit policy (important)
91
+ ## Commits
92
+
93
+ Subject lines follow [Conventional Commits](https://www.conventionalcommits.org/):
94
+ `type(scope): summary`.
95
+
96
+ | Type | Use for |
97
+ | --- | --- |
98
+ | `feat` | a new rule, host adapter, or CLI capability |
99
+ | `fix` | a bug fix |
100
+ | `docs` | documentation only |
101
+ | `test` | tests and fixtures only |
102
+ | `refactor` | restructuring that leaves behavior identical |
103
+ | `perf` | a speedup you can measure |
104
+ | `ci` | workflows and release automation |
105
+ | `chore` | dependency bumps, version stamping, housekeeping |
106
+
107
+ Scopes track the project layout: `rules`, `hook`, `githooks`, `cli`, `state`,
108
+ `adapter`, `deps`, `release`. Drop the scope when a change spans several.
109
+
110
+ The `type(scope):` prefix is the only part that is a format. Everything after the
111
+ colon stays a plain imperative sentence that names what actually changed:
112
+
113
+ ```
114
+ fix(state): retry the rename when Windows reports a transient lock
115
+ fix(hook): allow Git operations when the aimhooman CLI or Node is missing
116
+ ci: run the test matrix once per change instead of twice
117
+ chore(release): 0.1.1
118
+ ```
119
+
120
+ PRs land as squash merges, so the PR title is the commit message that lands on
121
+ `main`. Title the PR the same way.
122
+
123
+ ### Policy (important)
92
124
 
93
125
  This project keeps AI tooling residue out of history — **including its own**.
94
126
  Commits must read as if a human wrote them:
@@ -100,10 +132,11 @@ Commits must read as if a human wrote them:
100
132
 
101
133
  ## Pull requests
102
134
 
103
- 1. Open a PR against `main`.
135
+ 1. Open a PR against `main`. Title it `type(scope): summary` — it becomes the
136
+ commit message on `main`.
104
137
  2. Include tests for any new behavior and run `npm run verify`.
105
138
  3. Update `CHANGELOG.md` and `docs/` where relevant.
106
- 4. Make sure the PR follows the commit policy above.
139
+ 4. Make sure the PR follows the commit conventions above.
107
140
 
108
141
  By participating, you agree to abide by the [Code of Conduct](CODE_OF_CONDUCT.md).
109
142
 
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.1.1-blue" alt="v0.1.1">
12
+ <img src="https://img.shields.io/badge/version-v0.1.2-blue" alt="v0.1.2">
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">
@@ -151,8 +151,10 @@ Prefer repository `init` unless the global ordering is understood.
151
151
 
152
152
  When `core.hooksPath` is set, Git reads hooks only from that effective directory and
153
153
  ignores `.git/hooks`. Repository `init` installs and chains predecessors only when
154
- that directory is absent or is proven to be owned by the repository. It refuses to
155
- modify a global, shared, or external hook directory. In that case, integrate
154
+ that directory is absent or is proven to be owned by the repository: inside it and
155
+ not tracked by Git. It refuses to modify a global, shared, external, or tracked
156
+ hook directory, because a dispatcher committed from one machine names paths that
157
+ exist only on that machine. In that case, integrate
156
158
  aimhooman into the existing hook manager or remove the override before retrying.
157
159
 
158
160
  Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`, and
package/bin/aimhooman.mjs CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
6
7
  import { newEngineWithDiagnostics } from '../src/scan.mjs';
7
8
  import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
8
9
  import {
@@ -19,7 +20,7 @@ import {
19
20
  } from '../src/gitx.mjs';
20
21
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
21
22
  import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
22
- import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
+ import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
24
  import { ArgumentError, parseArguments } from '../src/args.mjs';
24
25
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
25
26
  import { resolvePolicy } from '../src/policy-resolver.mjs';
@@ -37,8 +38,7 @@ const LIFECYCLE_LOCK_OPTIONS = { retries: 1000 };
37
38
  function tryRepo() {
38
39
  try {
39
40
  return openRepo();
40
- } catch (error) {
41
- if (error?.name === 'StateMigrationError') throw error;
41
+ } catch {
42
42
  return null;
43
43
  }
44
44
  }
@@ -48,6 +48,7 @@ function currentRepositoryIsBare() {
48
48
  return execFileSync('git', ['rev-parse', '--is-bare-repository'], {
49
49
  encoding: 'utf8',
50
50
  stdio: ['ignore', 'pipe', 'ignore'],
51
+ timeout: GIT_TIMEOUT_MS,
51
52
  }).trim() === 'true';
52
53
  } catch {
53
54
  return false;
@@ -159,10 +160,17 @@ function emitDiagnostics(diagnostics = []) {
159
160
  }
160
161
 
161
162
  function incompleteMessage(scan) {
162
- const skipped = Object.entries(scan.stats?.skipped || {})
163
+ const reasons = scan.stats?.skipped || {};
164
+ const skipped = Object.entries(reasons)
163
165
  .map(([reason, count]) => `${reason}=${count}`)
164
166
  .join(', ');
165
- return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; reduce the target or limits and retry\n`;
167
+ // Every other reason is a size or budget the caller can shrink. A pack that
168
+ // will not compile is not, and the warning above already names the file and
169
+ // the error, so point at that instead of misdirecting to the limits.
170
+ const hint = reasons['local-pack-error']
171
+ ? 'fix the reported rule pack and retry'
172
+ : 'reduce the target or limits and retry';
173
+ return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
166
174
  }
167
175
 
168
176
  function snapshotFile(path) {
@@ -193,6 +201,7 @@ function gitConfigAtScope(root, scope, key) {
193
201
  cwd: root,
194
202
  encoding: 'utf8',
195
203
  stdio: ['ignore', 'pipe', 'ignore'],
204
+ timeout: GIT_TIMEOUT_MS,
196
205
  }).trim();
197
206
  } catch {
198
207
  return '';
@@ -200,7 +209,7 @@ function gitConfigAtScope(root, scope, key) {
200
209
  }
201
210
 
202
211
  function gitVersion() {
203
- try { return execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(); }
212
+ try { return execFileSync('git', ['--version'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim(); }
204
213
  catch { return 'Git unavailable'; }
205
214
  }
206
215
 
@@ -487,7 +496,7 @@ function cmdRefcheck(args) {
487
496
  return 30;
488
497
  }
489
498
  if (/^0+$/.test(newObjectId)) continue;
490
- updates.push({ oldObjectId, newObjectId });
499
+ updates.push({ oldObjectId, newObjectId, ref });
491
500
  }
492
501
 
493
502
  let commits;
@@ -650,7 +659,7 @@ function cmdInit(args) {
650
659
  const aimDir = globalHooksDir();
651
660
  let existing = '';
652
661
  try {
653
- existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
662
+ existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
654
663
  } catch { /* unset */ }
655
664
  if (existing && existing !== aimDir) {
656
665
  console.error(
@@ -663,7 +672,7 @@ function cmdInit(args) {
663
672
  // could silently disable a system-wide hook manager, so refuse here too.
664
673
  let systemHooksPath = '';
665
674
  try {
666
- systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
675
+ systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
667
676
  } catch { /* unset or no system config */ }
668
677
  if (systemHooksPath) {
669
678
  console.error(
@@ -687,7 +696,7 @@ function cmdInit(args) {
687
696
  console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
688
697
  return 20;
689
698
  }
690
- execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir]);
699
+ execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
691
700
  } catch (error) {
692
701
  const rollbackFailures = [];
693
702
  for (const snapshot of hookSnapshots.reverse()) {
@@ -697,8 +706,8 @@ function cmdInit(args) {
697
706
  }
698
707
  }
699
708
  try {
700
- if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing]);
701
- else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
709
+ if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
710
+ else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
702
711
  } catch (rollbackError) {
703
712
  rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
704
713
  }
@@ -759,7 +768,13 @@ function cmdInit(args) {
759
768
  rep = installHooks(repo, CLI_PATH);
760
769
  const activeHooks = installedHooks(repo);
761
770
  if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
762
- throw new Error('hook installation incomplete; repository guard is not active');
771
+ // installHooks declines rather than throws when the hooks directory is
772
+ // not ours, and its warnings are the only record of why. They are
773
+ // printed on the success path only, so carry them into the failure or
774
+ // the user is told nothing but "incomplete". The prefix is load-bearing:
775
+ // the exit-code branch below matches on it.
776
+ const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
777
+ throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
763
778
  }
764
779
  saveConfig(repo.stateDir, { profile });
765
780
  applyExclude(repo.excludeFile, patternsForRules(eng.rules));
@@ -847,7 +862,6 @@ function cmdStatus(args) {
847
862
  console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
848
863
  console.log(`excludes: ${excludeError ? `unknown (malformed markers: ${excludeError}; run: aimhooman init)` : excludes.current ? 'current' : excludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init)'}`);
849
864
  for (const error of errors) console.log(`warning: ${error.message}`);
850
- for (const diagnostic of repo.stateDiagnostics || []) console.log(`${diagnostic.level || 'info'}: ${diagnostic.message}`);
851
865
  const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
852
866
  const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
853
867
  console.log(`hooks path: local=${localHooks || 'unset'}, global=${globalHooks || 'unset'}`);
@@ -1414,10 +1428,6 @@ function cmdDoctor(args) {
1414
1428
  } else {
1415
1429
  console.log(`ok host adapters present (${adapters.present}/${adapters.total})`);
1416
1430
  }
1417
- for (const diagnostic of repo.stateDiagnostics || []) {
1418
- console.log(`${diagnostic.level === 'error' ? 'x' : '!'} state: ${diagnostic.message}`);
1419
- if (diagnostic.level === 'error') ok = false;
1420
- }
1421
1431
  const gitRuntime = supportedGitVersion();
1422
1432
  if (gitRuntime.supported) {
1423
1433
  console.log(`ok runtime Node ${process.versions.node}; ${gitRuntime.display}`);
@@ -1485,7 +1495,7 @@ function cmdUninstall(args) {
1485
1495
  const aimDir = globalHooksDir();
1486
1496
  const readGlobalHooksPath = () => {
1487
1497
  try {
1488
- return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
1498
+ return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1489
1499
  } catch { return ''; }
1490
1500
  };
1491
1501
  const current = readGlobalHooksPath();
@@ -1493,7 +1503,7 @@ function cmdUninstall(args) {
1493
1503
  if (current === aimDir) {
1494
1504
  unsetAttempted = true;
1495
1505
  try {
1496
- execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'] });
1506
+ execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1497
1507
  } catch { /* may have failed; re-verify below */ }
1498
1508
  // A read-only or locked ~/.gitconfig can make --unset fail silently.
1499
1509
  // Do not remove the dispatchers while core.hooksPath still resolves to
@@ -1528,11 +1538,20 @@ function cmdUninstall(args) {
1528
1538
  return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
1529
1539
  const rep = uninstallHooks(repo);
1530
1540
  removeExclude(repo.excludeFile);
1531
- console.log('aimhooman: uninstalled');
1541
+ // Trust the directory, not the report. Every refusal below leaves a working
1542
+ // dispatcher behind, and one printed under "uninstalled" reads as done.
1543
+ const remaining = remainingDispatchers(repo);
1544
+ if (remaining.length) {
1545
+ console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1546
+ for (const path of remaining) console.error(` ${path}`);
1547
+ console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1548
+ } else {
1549
+ console.log('aimhooman: uninstalled');
1550
+ }
1532
1551
  if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1533
1552
  if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1534
1553
  for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1535
- for (const f of rep.failures || []) console.log(` warning: ${f}`);
1554
+ for (const f of rep.failures || []) console.error(` failure: ${f}`);
1536
1555
  const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1537
1556
  if (purge) {
1538
1557
  // Never wipe stateDir while a predecessor hook backup is still on disk:
@@ -1560,6 +1579,7 @@ function cmdUninstall(args) {
1560
1579
  try {
1561
1580
  globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1562
1581
  encoding: 'utf8',
1582
+ timeout: GIT_TIMEOUT_MS,
1563
1583
  }).trim();
1564
1584
  } catch { /* unset */ }
1565
1585
  if (globalHooksPath === aimDir) {
@@ -1567,7 +1587,7 @@ function cmdUninstall(args) {
1567
1587
  console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1568
1588
  console.log(' run `aimhooman uninstall --global` to remove it.');
1569
1589
  }
1570
- return rep.failures?.length || unrestored.length ? 30 : 0;
1590
+ return remaining.length || rep.failures?.length || unrestored.length ? 30 : 0;
1571
1591
  }, LIFECYCLE_LOCK_OPTIONS);
1572
1592
  }
1573
1593
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
@@ -1,14 +1,14 @@
1
1
  [
2
2
  {
3
3
  "id": "attribution.claude-coauthor",
4
- "version": 1,
4
+ "version": 2,
5
5
  "provider": "claude-code",
6
6
  "category": "ai-attribution",
7
7
  "confidence": "high",
8
8
  "kind": "message",
9
9
  "match": {
10
10
  "content": [
11
- "(?i)^\\s*co-authored-by:\\s*(?:claude|claude code)\\s*<noreply@anthropic\\.com>\\s*$"
11
+ "(?i)^\\s*co-authored-by:[^<>]+<noreply@anthropic\\.com>\\s*$"
12
12
  ]
13
13
  },
14
14
  "actions": {
@@ -49,14 +49,14 @@
49
49
  },
50
50
  {
51
51
  "id": "attribution.codex-coauthor",
52
- "version": 1,
52
+ "version": 2,
53
53
  "provider": "codex",
54
54
  "category": "ai-attribution",
55
55
  "confidence": "high",
56
56
  "kind": "message",
57
57
  "match": {
58
58
  "content": [
59
- "(?i)^\\s*co-authored-by:\\s*(?:openai\\s+)?codex\\s*<noreply@openai\\.com>\\s*$"
59
+ "(?i)^\\s*co-authored-by:[^<>]+<noreply@openai\\.com>\\s*$"
60
60
  ]
61
61
  },
62
62
  "actions": {
@@ -95,7 +95,7 @@
95
95
  },
96
96
  {
97
97
  "id": "attribution.generated-with",
98
- "version": 1,
98
+ "version": 2,
99
99
  "provider": "generic",
100
100
  "category": "ai-attribution",
101
101
  "confidence": "high",
@@ -103,7 +103,7 @@
103
103
  "match": {
104
104
  "content": [
105
105
  "(?i)^\\s*generated\\s+(?:with|by)\\s+(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)[.!]?\\s*$",
106
- "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[claude code\\]\\(https://claude\\.ai/code\\)\\s*$"
106
+ "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)\\]\\([^)]*\\)\\s*$"
107
107
  ]
108
108
  },
109
109
  "actions": {
@@ -126,7 +126,7 @@
126
126
  "kind": "message",
127
127
  "match": {
128
128
  "content": [
129
- "(?i)^\\s*[a-z-]+-by:\\s*[^<>]+\\s*<noreply@(anthropic|openai)\\.com>\\s*$"
129
+ "(?i)^\\s*[a-z-]+-by:[^<>]+<noreply@(anthropic|openai)\\.com>\\s*$"
130
130
  ]
131
131
  },
132
132
  "actions": {
@@ -111,6 +111,7 @@ function syncDirectory(directory, operations) {
111
111
  }
112
112
 
113
113
  const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
114
+ const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
114
115
 
115
116
  function waitForLock(milliseconds) {
116
117
  if (milliseconds > 0) Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, milliseconds);
@@ -136,6 +137,12 @@ function processIdentity(pid) {
136
137
  const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
137
138
  encoding: 'utf8',
138
139
  stdio: ['ignore', 'pipe', 'ignore'],
140
+ // This is the only spawn on the lock path, and it runs on macOS
141
+ // and BSD alone: Linux reads /proc and Windows returns early.
142
+ // The retry loop below budgets milliseconds per attempt, so a
143
+ // probe that stalls must lose rather than stretch the loop. A
144
+ // timeout throws into the catch below, which fails safe.
145
+ timeout: IDENTITY_PROBE_TIMEOUT_MS,
139
146
  }).trim();
140
147
  return started ? `ps:${started}` : null;
141
148
  }
@@ -3,3 +3,13 @@
3
3
  export function gitEnvironment(environment = process.env) {
4
4
  return { ...environment, GIT_NO_REPLACE_OBJECTS: '1' };
5
5
  }
6
+
7
+ // execFileSync has no default timeout, so a git child that never exits blocks
8
+ // the caller forever. One did: it held a CI runner until the platform's 6-hour
9
+ // ceiling killed the job, with no error to read afterwards.
10
+ //
11
+ // Real calls here finish in milliseconds. This bound exists to end a stuck one,
12
+ // not to pace a slow one, so it sits far above any legitimate call: a huge
13
+ // repository on a slow disk stays well inside it, and a hang now raises an
14
+ // error naming the command instead of going quiet.
15
+ export const GIT_TIMEOUT_MS = 120_000;