@rmyndharis/aimhooman 0.1.3 → 0.1.5
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/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/bin/aimhooman.mjs +45 -4
- package/package.json +1 -1
- package/rules/secrets.json +3 -2
- package/src/atomic-write.mjs +18 -2
- package/src/githooks.mjs +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,70 @@ 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.1.5] - 2026-07-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- AWS's own published example key no longer blocks a commit. 0.1.4 began matching bare
|
|
13
|
+
`AKIA…`/`ASIA…` access key IDs, which also matched `AKIAIOSFODNN7EXAMPLE` — the value AWS
|
|
14
|
+
prints in its own documentation, and one that lands in READMEs, Terraform samples, and test
|
|
15
|
+
fixtures. Because the rule's category is `secret`, neither a rule allow nor a plain path
|
|
16
|
+
allow is accepted, so a documentation example became an unconditional block on every profile
|
|
17
|
+
with only a per-file escape. Every AWS example key ends in `EXAMPLE`, so the pattern now
|
|
18
|
+
excludes that suffix and keeps blocking real keys. Path scoping was deliberately not used:
|
|
19
|
+
excluding `docs/` or `tests/` from secret scanning would hide genuine keys in the places they
|
|
20
|
+
most often leak.
|
|
21
|
+
- `uninstall --purge-state` no longer leaves a backup behind in a linked worktree. Git writes
|
|
22
|
+
the commit message file into the per-worktree Git directory, so 0.1.4's sweep — which looked
|
|
23
|
+
only in the common directory — printed "state purged" with `COMMIT_EDITMSG.aimhooman-bak`
|
|
24
|
+
still on disk. Uninstall disarms every worktree at once, so it now sweeps the main Git
|
|
25
|
+
directory and each linked one, and matches the `.aimhooman-bak` suffix so a backup left by a
|
|
26
|
+
merge goes with it.
|
|
27
|
+
- A plain `uninstall` keeps the attribution backup. It printed "state kept" and then deleted
|
|
28
|
+
the one file holding the lines stripped from the last commit message. Only `--purge-state`,
|
|
29
|
+
which promises to remove everything, takes it now.
|
|
30
|
+
- Sweeping an empty lock queue can no longer stop a concurrent `aimhooman init`. Between its
|
|
31
|
+
`mkdir` and its first publication a lock contender owns no file in the queue, so the sweep
|
|
32
|
+
saw an empty directory and removed it, and the contender then failed on a bare `ENOENT`. The
|
|
33
|
+
window is wide enough to lose because building the candidate probes the process identity,
|
|
34
|
+
which spawns `ps` on macOS and BSD. Publication now recreates the directory and retries once.
|
|
35
|
+
- The unstage summary no longer claims the files were "kept in your working tree". That is
|
|
36
|
+
false when a path was staged and then deleted before the commit, and when a staged deletion
|
|
37
|
+
is unstaged — the file is absent either way, though aimhooman never removed it. Unstaging
|
|
38
|
+
only ever touches the index, so the note says that instead of guessing where the file is.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- An ordinary commit no longer pays a Node cold start for a phase that does nothing. Git fires
|
|
43
|
+
`reference-transaction` twice per commit, and `refcheck` returns immediately for `committed`
|
|
44
|
+
and `aborted`, so the dispatcher now short-circuits those phases in the shell — after the
|
|
45
|
+
chained-hook call, so a chained hook still sees every phase. Measured locally, an ordinary
|
|
46
|
+
commit drops from about 870ms to about 710ms. The `prepared` scan, and the `--no-verify`
|
|
47
|
+
backstop that rides on it, are unchanged. A repository installed with an earlier release
|
|
48
|
+
keeps a valid dispatcher; re-run `aimhooman init` to pick up the faster one.
|
|
49
|
+
- The attribution backup no longer lingers indefinitely. `commit-msg` clears the previous run's
|
|
50
|
+
backup before it may write its own, so at most one exists at a time and a later clean commit
|
|
51
|
+
leaves none. The recovery window is now until your next commit rather than unbounded.
|
|
52
|
+
|
|
53
|
+
## [0.1.4] - 2026-07-18
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- Uninstall no longer leaves aimhooman artifacts in `.git`. Every atomic write takes a
|
|
58
|
+
lock whose `<lock>.queue` directory was created but never removed — the release path
|
|
59
|
+
only unlinked its own candidate file — and `--purge-state` cleared just the state
|
|
60
|
+
directory. So `.git` kept the queue directories and the `COMMIT_EDITMSG.aimhooman-bak`
|
|
61
|
+
attribution backup while the command printed "state purged". Uninstall now sweeps this
|
|
62
|
+
residue once the lifecycle lock releases; `rmdirSync` removes only an empty directory,
|
|
63
|
+
so a concurrent contender's queue is never touched, and the lifecycle queue is removable
|
|
64
|
+
precisely because its own lock has already released. A plain uninstall keeps policy
|
|
65
|
+
state and drops the operational residue; `--purge-state` leaves no aimhooman fingerprints.
|
|
66
|
+
- A bare AWS access key ID is now detected. `secret.aws-key-content` fired only when an
|
|
67
|
+
`aws_secret_access_key` or `aws_session_token` name sat beside the value, so an
|
|
68
|
+
`AKIA…`/`ASIA…` access key ID committed on its own passed clean. The rule now matches the
|
|
69
|
+
access-key-ID prefixes directly — the fixed-prefix, highest-confidence AWS indicator. The
|
|
70
|
+
16-character body and word boundaries keep short lookalikes such as `AKIA123` from matching.
|
|
71
|
+
|
|
8
72
|
## [0.1.3] - 2026-07-18
|
|
9
73
|
|
|
10
74
|
### Fixed
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<img src="https://img.shields.io/github/actions/workflow/status/rmyndharis/aimhooman/test.yml?branch=main&label=CI" alt="CI">
|
|
12
|
-
<img src="https://img.shields.io/badge/version-v0.1.
|
|
12
|
+
<img src="https://img.shields.io/badge/version-v0.1.5-blue" alt="v0.1.5">
|
|
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">
|
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { chmodSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '../src/gitx.mjs';
|
|
21
21
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
22
22
|
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
23
|
-
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
23
|
+
import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
24
24
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
25
25
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
26
26
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
@@ -372,7 +372,7 @@ function cmdPrecommit(args) {
|
|
|
372
372
|
emptied = stagedBefore !== null
|
|
373
373
|
&& stagedBefore.every((path) => unstageTargets.has(path));
|
|
374
374
|
process.stderr.write(
|
|
375
|
-
`aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')}${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
|
|
375
|
+
`aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')} (index only; nothing on disk was deleted)${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
|
|
376
376
|
);
|
|
377
377
|
} catch (e) {
|
|
378
378
|
process.stderr.write(
|
|
@@ -409,6 +409,10 @@ function cmdCommitmsg(args) {
|
|
|
409
409
|
const file = positionals[0];
|
|
410
410
|
const repo = tryRepo();
|
|
411
411
|
if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
|
|
412
|
+
// A .aimhooman-bak from an earlier commit went stale the moment that commit
|
|
413
|
+
// finished, and git reuses this message path. Clear the previous run's backup
|
|
414
|
+
// now, before this run may write its own, so at most one ever lingers.
|
|
415
|
+
try { rmSync(`${file}.aimhooman-bak`, { force: true }); } catch { /* best effort */ }
|
|
412
416
|
// Frictionless profiles (clean/compliance) never cancel a commit merely
|
|
413
417
|
// because the message file cannot be read; only strict fails closed.
|
|
414
418
|
let hookProfile = 'clean';
|
|
@@ -1615,7 +1619,8 @@ function cmdUninstall(args) {
|
|
|
1615
1619
|
console.error('aimhooman: not a git repository');
|
|
1616
1620
|
return 30;
|
|
1617
1621
|
}
|
|
1618
|
-
|
|
1622
|
+
const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
|
|
1623
|
+
const exitStatus = withLock(lifecycleLock, () => {
|
|
1619
1624
|
const rep = uninstallHooks(repo);
|
|
1620
1625
|
// The irreversible work is already done above, and the report is still
|
|
1621
1626
|
// below. A throw from here unwound past all of it, so a damaged marker was
|
|
@@ -1683,6 +1688,42 @@ function cmdUninstall(args) {
|
|
|
1683
1688
|
// the uninstall is genuinely incomplete and 30 is the honest answer.
|
|
1684
1689
|
return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
|
|
1685
1690
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1691
|
+
// Sweep the operational residue the guard authored in .git, so uninstall leaves
|
|
1692
|
+
// no aimhooman fingerprints behind — the same tooling residue this tool exists
|
|
1693
|
+
// to remove. rmdirSync deletes only an empty directory, so a live contender's
|
|
1694
|
+
// queue is never touched; the lifecycle queue becomes removable only now, after
|
|
1695
|
+
// its own lock above has released.
|
|
1696
|
+
for (const queue of [
|
|
1697
|
+
`${lifecycleLock}.queue`,
|
|
1698
|
+
`${repo.excludeFile}.aimhooman.lock.queue`,
|
|
1699
|
+
join(effectiveHooksDir(repo), '.aimhooman-hooks.lock.queue'),
|
|
1700
|
+
]) {
|
|
1701
|
+
try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
|
|
1702
|
+
}
|
|
1703
|
+
// The attribution backup is the user's only copy of the lines stripped from
|
|
1704
|
+
// their last message — commit-msg already clears the previous one, so what
|
|
1705
|
+
// survives here is current, not stale. A plain uninstall says "state kept"
|
|
1706
|
+
// and must not delete it; only --purge-state, which promises to remove
|
|
1707
|
+
// everything, sweeps it. Git names the message file per operation
|
|
1708
|
+
// (COMMIT_EDITMSG, MERGE_MSG, ...) and the backup inherits that name, so
|
|
1709
|
+
// match the suffix rather than one filename. It lands beside that file, in
|
|
1710
|
+
// the per-worktree git directory rather than the common one, and uninstall
|
|
1711
|
+
// disarms every worktree at once — so cover the main one and each linked.
|
|
1712
|
+
if (purge) {
|
|
1713
|
+
const messageDirs = [repo.commonDir];
|
|
1714
|
+
try {
|
|
1715
|
+
const linked = join(repo.commonDir, 'worktrees');
|
|
1716
|
+
for (const name of readdirSync(linked)) messageDirs.push(join(linked, name));
|
|
1717
|
+
} catch { /* no linked worktrees */ }
|
|
1718
|
+
for (const dir of messageDirs) {
|
|
1719
|
+
try {
|
|
1720
|
+
for (const name of readdirSync(dir)) {
|
|
1721
|
+
if (name.endsWith('.aimhooman-bak')) rmSync(join(dir, name), { force: true });
|
|
1722
|
+
}
|
|
1723
|
+
} catch { /* directory missing or unreadable */ }
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
return exitStatus;
|
|
1686
1727
|
}
|
|
1687
1728
|
|
|
1688
1729
|
function usage() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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": {
|
package/rules/secrets.json
CHANGED
|
@@ -45,14 +45,15 @@
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"id": "secret.aws-key-content",
|
|
48
|
-
"version":
|
|
48
|
+
"version": 2,
|
|
49
49
|
"provider": "aws",
|
|
50
50
|
"category": "secret",
|
|
51
51
|
"confidence": "high",
|
|
52
52
|
"kind": "code",
|
|
53
53
|
"match": {
|
|
54
54
|
"content": [
|
|
55
|
-
"(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])"
|
|
55
|
+
"(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])",
|
|
56
|
+
"\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
|
|
56
57
|
]
|
|
57
58
|
},
|
|
58
59
|
"actions": {
|
package/src/atomic-write.mjs
CHANGED
|
@@ -259,18 +259,34 @@ export function withLock(lockPath, fn, options = {}) {
|
|
|
259
259
|
let held = false;
|
|
260
260
|
let published = false;
|
|
261
261
|
let primaryError = null;
|
|
262
|
+
// Between the mkdir above and the first publication below this contender owns
|
|
263
|
+
// no file in the queue, so a concurrent cleanup that removes empty queue
|
|
264
|
+
// directories — `aimhooman uninstall` sweeps them — can delete it out from
|
|
265
|
+
// under us. The gap is wide enough to lose: building the candidate probes the
|
|
266
|
+
// process identity, which spawns `ps` on macOS and BSD. Recreate and retry
|
|
267
|
+
// once rather than failing the caller with a bare ENOENT. Once a candidate is
|
|
268
|
+
// published the directory is no longer empty, so no cleanup can remove it.
|
|
269
|
+
const publish = () => {
|
|
270
|
+
try {
|
|
271
|
+
publishCandidate(candidatePath, candidate, options.candidateOperations);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
274
|
+
mkdirSync(queueDir, { recursive: true });
|
|
275
|
+
publishCandidate(candidatePath, candidate, options.candidateOperations);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
262
278
|
try {
|
|
263
279
|
// Own the UUID pathname before publication begins. If the rename lands
|
|
264
280
|
// but its directory fsync fails, finally still removes the candidate;
|
|
265
281
|
// unlinking ENOENT is harmless when publication failed earlier.
|
|
266
282
|
published = true;
|
|
267
|
-
|
|
283
|
+
publish();
|
|
268
284
|
const observed = queueCandidates(queueDir, staleMs);
|
|
269
285
|
candidate.ticket = observed.reduce((maximum, peer) => (
|
|
270
286
|
peer.ticket === null ? maximum : Math.max(maximum, peer.ticket)
|
|
271
287
|
), 0) + 1;
|
|
272
288
|
candidate.choosing = false;
|
|
273
|
-
|
|
289
|
+
publish();
|
|
274
290
|
|
|
275
291
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
276
292
|
// A file at the pre-queue lock path may belong to a concurrently
|
package/src/githooks.mjs
CHANGED
|
@@ -124,7 +124,7 @@ function canonicalPath(path) {
|
|
|
124
124
|
|
|
125
125
|
// Ask Git for the path it actually uses. In a linked worktree this resolves to
|
|
126
126
|
// the common repository hooks directory, not <gitDir>/hooks.
|
|
127
|
-
function effectiveHooksDir(repo) {
|
|
127
|
+
export function effectiveHooksDir(repo) {
|
|
128
128
|
try {
|
|
129
129
|
return execFileSync(
|
|
130
130
|
'git',
|
|
@@ -642,6 +642,14 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
642
642
|
const aimInvocation = name === 'reference-transaction'
|
|
643
643
|
? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
|
|
644
644
|
: `run_aimhooman ${aimCommand} || exit $?`;
|
|
645
|
+
// committed/aborted fire only after refs are locked in, and refcheck can do
|
|
646
|
+
// nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
|
|
647
|
+
// so an ordinary commit no longer pays a Node cold start for the committed
|
|
648
|
+
// phase. Placed after the chained-hook call, so a chained hook still sees
|
|
649
|
+
// every phase.
|
|
650
|
+
const phaseShortCircuit = name === 'reference-transaction'
|
|
651
|
+
? 'case "$1" in committed|aborted) exit 0 ;; esac\n'
|
|
652
|
+
: '';
|
|
645
653
|
const template = `#!/bin/sh -p
|
|
646
654
|
${MARKER} (${name})
|
|
647
655
|
# aimhooman-hook-version: ${HOOK_FORMAT_VERSION}
|
|
@@ -691,7 +699,7 @@ fi
|
|
|
691
699
|
if [ -x "$CHAINED" ]; then
|
|
692
700
|
${chainedInvocation}
|
|
693
701
|
fi
|
|
694
|
-
${aimInvocation}
|
|
702
|
+
${phaseShortCircuit}${aimInvocation}
|
|
695
703
|
`;
|
|
696
704
|
const fingerprint = hookFingerprint(template);
|
|
697
705
|
return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);
|