@rmyndharis/aimhooman 0.1.8 → 0.3.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/.agents/rules/aimhooman.md +9 -6
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.clinerules/aimhooman.md +9 -6
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor/rules/aimhooman.mdc +9 -6
- package/.github/copilot-instructions.md +9 -6
- package/.kiro/steering/aimhooman.md +9 -6
- package/.windsurf/rules/aimhooman.md +9 -6
- package/AGENTS.md +9 -6
- package/CHANGELOG.md +147 -0
- package/CONTRIBUTING.md +2 -3
- package/GEMINI.md +9 -6
- package/README.md +56 -374
- package/SECURITY.md +6 -4
- package/bin/aimhooman.mjs +190 -55
- package/docs/ai-artifacts.gitignore +63 -0
- package/docs/catalog.md +41 -0
- package/docs/design/frictionless-enforcement.md +27 -17
- package/hooks/hooks.json +1 -1
- package/package.json +5 -2
- package/rules/paths.json +0 -114
- package/schemas/overrides.schema.json +4 -1
- package/skills/aimhooman/SKILL.md +11 -8
- package/src/exclude.mjs +16 -2
- package/src/githooks.mjs +38 -3
- package/src/gitx.mjs +68 -6
- package/src/hook.mjs +33 -12
- package/src/report.mjs +23 -5
- package/src/scan-session.mjs +14 -21
- package/src/scan-target.mjs +10 -10
- package/src/scan.mjs +9 -24
- package/src/state.mjs +59 -10
- package/rules/secrets.json +0 -92
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { chmodSync, existsSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
@@ -9,6 +10,7 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
|
9
10
|
import {
|
|
10
11
|
GitRevisionError,
|
|
11
12
|
gitConfig,
|
|
13
|
+
ignoredByPatterns,
|
|
12
14
|
introducedCommits,
|
|
13
15
|
openRepo,
|
|
14
16
|
readCommitPath,
|
|
@@ -20,7 +22,7 @@ import {
|
|
|
20
22
|
withIndexFromTree,
|
|
21
23
|
} from '../src/gitx.mjs';
|
|
22
24
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
23
|
-
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
25
|
+
import { applyExclude, inspectExclude, managedPatterns, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
24
26
|
import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
25
27
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
26
28
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
@@ -110,7 +112,6 @@ function configuredEngine(profile, repo) {
|
|
|
110
112
|
entry.scope === undefined
|
|
111
113
|
|| entry.scope === 'path'
|
|
112
114
|
|| entry.scope === 'rule'
|
|
113
|
-
|| entry.scope === 'secret-path'
|
|
114
115
|
));
|
|
115
116
|
engine.setOverrides(ordinary(ov.allow), ordinary(ov.deny));
|
|
116
117
|
}
|
|
@@ -212,24 +213,30 @@ function emitDiagnostics(diagnostics = []) {
|
|
|
212
213
|
}
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
function incompleteMessage(scan) {
|
|
216
|
+
function incompleteMessage(scan, { blocking = true } = {}) {
|
|
216
217
|
const reasons = scan.stats?.skipped || {};
|
|
218
|
+
// Each count is how many items were skipped, not the limit that fired:
|
|
219
|
+
// "(size-limit=1)" read as a one-byte budget. Name the noun so the number
|
|
220
|
+
// cannot be mistaken for the budget it tripped.
|
|
217
221
|
const skipped = Object.entries(reasons)
|
|
218
|
-
.map(([reason, count]) => `${reason}=${count}`)
|
|
222
|
+
.map(([reason, count]) => `${reason}=${count} ${skipCountNoun(reason, count)}`)
|
|
219
223
|
.join(', ');
|
|
220
224
|
// Every other reason is a size or budget the caller can shrink. A pack that
|
|
221
225
|
// will not compile is not, and the warning above already names the file and
|
|
222
226
|
// the error, so point at that instead of misdirecting to the limits. When a
|
|
223
227
|
// byte budget is what stopped the scan, name the budget: the caller whose own
|
|
224
228
|
// tree outgrew it needs to raise one, and "reduce the limits" sends them the
|
|
225
|
-
// wrong way down a road they cannot leave.
|
|
229
|
+
// wrong way down a road they cannot leave. A blocking stop says "and retry";
|
|
230
|
+
// a warning names what to change so the next run covers what this one
|
|
231
|
+
// skipped — this run is already through.
|
|
232
|
+
const tail = blocking ? 'and retry' : 'so the next scan covers it';
|
|
226
233
|
const budgeted = reasons['size-limit'] || reasons['total-byte-limit'];
|
|
227
234
|
const hint = reasons['local-pack-error']
|
|
228
|
-
?
|
|
235
|
+
? `fix the reported rule pack ${tail}`
|
|
229
236
|
: budgeted
|
|
230
|
-
?
|
|
231
|
-
:
|
|
232
|
-
let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
|
|
237
|
+
? `reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, ${tail}`
|
|
238
|
+
: `reduce the target or limits ${tail}`;
|
|
239
|
+
let message = `aimhooman: ${blocking ? '' : 'warning: '}scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
|
|
233
240
|
const skippedPaths = scan.stats?.skippedPaths || {};
|
|
234
241
|
const pathLines = [];
|
|
235
242
|
for (const [reason, entries] of Object.entries(skippedPaths)) {
|
|
@@ -242,6 +249,17 @@ function incompleteMessage(scan) {
|
|
|
242
249
|
return message + pathLines.join('');
|
|
243
250
|
}
|
|
244
251
|
|
|
252
|
+
// Most skip reasons tally files; the three below tally something else, so the
|
|
253
|
+
// noun travels with the reason instead of reading "local-pack-error=1 file".
|
|
254
|
+
function skipCountNoun(reason, count) {
|
|
255
|
+
const noun = {
|
|
256
|
+
'finding-limit': 'finding',
|
|
257
|
+
'local-input-limit': 'input',
|
|
258
|
+
'local-pack-error': 'pack',
|
|
259
|
+
}[reason] || 'file';
|
|
260
|
+
return `${noun}${count === 1 ? '' : 's'}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
245
263
|
function formatBytes(bytes) {
|
|
246
264
|
if (bytes == null || bytes < 0) return '?';
|
|
247
265
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -371,6 +389,38 @@ function precommitCleanMatches(repo, treeSha, profile) {
|
|
|
371
389
|
&& marker.profile === profile;
|
|
372
390
|
}
|
|
373
391
|
|
|
392
|
+
// F-E1: the prevention layer keeps AI artifacts out of `git status`, which
|
|
393
|
+
// also keeps their exclusion silent — a `git add .` never tells the developer
|
|
394
|
+
// the chat log did not make the commit. Name the artifacts once per set
|
|
395
|
+
// change. The worktree walk is pathspec-pruned to the anchored managed
|
|
396
|
+
// patterns (~15ms; a bare ignored-listing costs ~150ms on a large tree), so
|
|
397
|
+
// `**/`-prefixed duplicates are skipped: nested artifacts still get excluded,
|
|
398
|
+
// they just do not get the notice. Informational only — it must never change
|
|
399
|
+
// an exit code, so every failure inside degrades to silence.
|
|
400
|
+
const IGNORED_NOTICE_VERSION = 1;
|
|
401
|
+
function noticeIgnoredArtifacts(repo) {
|
|
402
|
+
try {
|
|
403
|
+
const patterns = managedPatterns(repo.excludeFile)
|
|
404
|
+
.filter((pattern) => !pattern.startsWith('**/'));
|
|
405
|
+
if (!patterns.length) return;
|
|
406
|
+
const paths = ignoredByPatterns(repo, patterns).sort();
|
|
407
|
+
if (!paths.length) return;
|
|
408
|
+
const hash = createHash('sha256').update(paths.join('\0')).digest('hex');
|
|
409
|
+
const statePath = join(repo.stateDir, 'ignored-notice.json');
|
|
410
|
+
let previous = null;
|
|
411
|
+
try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
|
|
412
|
+
if (previous?.version === IGNORED_NOTICE_VERSION && previous.hash === hash) return;
|
|
413
|
+
writeFileSync(statePath, JSON.stringify({ version: IGNORED_NOTICE_VERSION, hash }));
|
|
414
|
+
const shown = paths.slice(0, 5);
|
|
415
|
+
const more = paths.length - shown.length;
|
|
416
|
+
process.stderr.write(
|
|
417
|
+
`aimhooman: ${paths.length} AI artifact(s) present locally are kept out of commits: `
|
|
418
|
+
+ `${shown.map(visible).join(', ')}${more ? `, and ${more} more` : ''} `
|
|
419
|
+
+ "(they stay on disk; 'git status --ignored' lists them; shown once per set change)\n"
|
|
420
|
+
);
|
|
421
|
+
} catch { /* informational only */ }
|
|
422
|
+
}
|
|
423
|
+
|
|
374
424
|
function cmdPrecommit(args) {
|
|
375
425
|
parseNoArguments(args);
|
|
376
426
|
const repo = tryRepo();
|
|
@@ -393,8 +443,12 @@ function cmdPrecommit(args) {
|
|
|
393
443
|
if (!blocks.length) {
|
|
394
444
|
if (reviews.length) process.stderr.write(human(reviews, tone()));
|
|
395
445
|
if (!scan.complete) {
|
|
396
|
-
|
|
397
|
-
|
|
446
|
+
// Strict stops on an unchecked remainder. Clean/compliance go ahead
|
|
447
|
+
// with a warning: the reference-transaction guard rescans the
|
|
448
|
+
// introduced commit before any ref moves. The clean marker stays
|
|
449
|
+
// unwritten so commit-msg does not skip its own tree scan.
|
|
450
|
+
process.stderr.write(incompleteMessage(scan, { blocking: profile === 'strict' }));
|
|
451
|
+
return profile === 'strict' ? 31 : 0;
|
|
398
452
|
}
|
|
399
453
|
// W5 marker dedup: record that this staged tree scanned clean so the
|
|
400
454
|
// upcoming commit-msg hook can skip its duplicate tree scan. The tree
|
|
@@ -403,6 +457,7 @@ function cmdPrecommit(args) {
|
|
|
403
457
|
// sha is stable. A missing/stale/mismatched marker makes commit-msg
|
|
404
458
|
// fall back to the full scan, so this is purely an optimization.
|
|
405
459
|
recordPrecommitClean(repo, profile);
|
|
460
|
+
noticeIgnoredArtifacts(repo);
|
|
406
461
|
return profile === 'strict' && reviews.length ? 11 : 0;
|
|
407
462
|
}
|
|
408
463
|
if (profile === 'strict') {
|
|
@@ -465,21 +520,25 @@ function cmdPrecommit(args) {
|
|
|
465
520
|
);
|
|
466
521
|
return 10;
|
|
467
522
|
}
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
523
|
+
// The summary above cannot name a cause without guessing which rule fired,
|
|
524
|
+
// and it used to be the only thing printed for a block. Let the findings
|
|
525
|
+
// speak instead: human() already carries the rule id, the path, the reason
|
|
526
|
+
// and the remediation, and redacts secret-category text from local packs.
|
|
527
|
+
// It labels each one BLOCK, which is the decision that unstaged the path,
|
|
528
|
+
// not a stopped commit; the summary above says what actually happened to
|
|
529
|
+
// them.
|
|
475
530
|
process.stderr.write(human([...blocks, ...reviews], tone()));
|
|
476
|
-
|
|
531
|
+
// Only frictionless profiles reach the repair path (strict returned 10
|
|
532
|
+
// above), so an incomplete post-repair scan is a warning, not a stop: the
|
|
533
|
+
// reference-transaction guard rescans the introduced commit.
|
|
534
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: false }));
|
|
477
535
|
// Git refuses a commit with nothing staged; repair runs after git has already
|
|
478
536
|
// decided otherwise, so carrying on here mints the empty commit git would
|
|
479
537
|
// not. The request was to commit the artifact, not to stamp the history with
|
|
480
538
|
// its message and no content.
|
|
481
539
|
if (emptied) return 10;
|
|
482
|
-
|
|
540
|
+
if (scan.complete) noticeIgnoredArtifacts(repo);
|
|
541
|
+
return 0;
|
|
483
542
|
}
|
|
484
543
|
|
|
485
544
|
function cmdCommitmsg(args) {
|
|
@@ -555,6 +614,9 @@ function cmdCommitmsg(args) {
|
|
|
555
614
|
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan));
|
|
556
615
|
return treeCode;
|
|
557
616
|
}
|
|
617
|
+
// exitCode passes an incomplete tree scan on frictionless profiles; the
|
|
618
|
+
// skip still names itself so the commit does not sail through silently.
|
|
619
|
+
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan, { blocking: false }));
|
|
558
620
|
}
|
|
559
621
|
if (dispatchHooksChanged(repo, profile)) return 20;
|
|
560
622
|
// Strict must fail closed on an incomplete scan: a block still wins (10),
|
|
@@ -564,7 +626,7 @@ function cmdCommitmsg(args) {
|
|
|
564
626
|
process.stderr.write(incompleteMessage(scan));
|
|
565
627
|
return findings.some((finding) => finding.decision === 'block') ? 10 : 31;
|
|
566
628
|
}
|
|
567
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
629
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: false }));
|
|
568
630
|
if (!findings.length) return 0;
|
|
569
631
|
const blocks = findings.filter((finding) => finding.decision === 'block');
|
|
570
632
|
if (profile === 'strict') {
|
|
@@ -741,11 +803,24 @@ function cmdRefcheck(args) {
|
|
|
741
803
|
return expectedErrorCode(error);
|
|
742
804
|
}
|
|
743
805
|
emitDiagnostics(scan.diagnostics);
|
|
744
|
-
|
|
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 });
|
|
745
811
|
if (code !== 0) {
|
|
746
812
|
process.stderr.write(human(scan.findings, tone()));
|
|
747
813
|
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
748
814
|
process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
|
|
815
|
+
// The vetoed commit stays in the object store: the ref never
|
|
816
|
+
// moved, but the bytes — including whatever triggered the block —
|
|
817
|
+
// are still on disk. Whether gc collects it depends on other refs,
|
|
818
|
+
// so phrase the cleanup as conditional; rotating an exposed secret
|
|
819
|
+
// stays the operator's call either way.
|
|
820
|
+
process.stderr.write(
|
|
821
|
+
`aimhooman: note: the rejected commit object remains in the local object store; `
|
|
822
|
+
+ "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n"
|
|
823
|
+
);
|
|
749
824
|
return code;
|
|
750
825
|
}
|
|
751
826
|
}
|
|
@@ -829,7 +904,7 @@ function cmdCheck(args) {
|
|
|
829
904
|
}
|
|
830
905
|
else {
|
|
831
906
|
process.stderr.write(human(findings, tone()));
|
|
832
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
907
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: scan.profile === 'strict' }));
|
|
833
908
|
}
|
|
834
909
|
return exitCode(findings, scan.profile, scan.complete);
|
|
835
910
|
}
|
|
@@ -840,8 +915,9 @@ function cmdInit(args) {
|
|
|
840
915
|
profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
|
|
841
916
|
global: { names: ['--global'], type: 'boolean' },
|
|
842
917
|
yes: { names: ['--yes'], type: 'boolean' },
|
|
918
|
+
gitignore: { names: ['--gitignore'], type: 'boolean' },
|
|
843
919
|
},
|
|
844
|
-
conflicts: [['profile', 'global']],
|
|
920
|
+
conflicts: [['profile', 'global'], ['gitignore', 'global']],
|
|
845
921
|
maxPositionals: 0,
|
|
846
922
|
});
|
|
847
923
|
if (rejectUnsupportedGit()) return 20;
|
|
@@ -926,7 +1002,7 @@ function cmdInit(args) {
|
|
|
926
1002
|
console.error('aimhooman: not a git repository');
|
|
927
1003
|
return 30;
|
|
928
1004
|
}
|
|
929
|
-
|
|
1005
|
+
const code = withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
930
1006
|
let projectPolicy;
|
|
931
1007
|
try {
|
|
932
1008
|
projectPolicy = loadProjectPolicy(repo.root);
|
|
@@ -955,10 +1031,26 @@ function cmdInit(args) {
|
|
|
955
1031
|
const hookFiles = hookState.some((hook) => hook.shared)
|
|
956
1032
|
? []
|
|
957
1033
|
: [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
|
|
1034
|
+
// --gitignore opts the clone into the committed variant of the managed
|
|
1035
|
+
// block. A plain re-init keeps a previously recorded choice instead of
|
|
1036
|
+
// silently dropping a block a teammate may already have committed. The
|
|
1037
|
+
// record lives in the local config: whether this clone created its
|
|
1038
|
+
// .gitignore is per-clone state, never team policy.
|
|
1039
|
+
let previousGitignore;
|
|
1040
|
+
try {
|
|
1041
|
+
previousGitignore = loadConfig(repo.stateDir).gitignore;
|
|
1042
|
+
} catch {
|
|
1043
|
+
// An unreadable local config is rewritten by saveConfig below, the
|
|
1044
|
+
// same recovery a plain init has always had.
|
|
1045
|
+
previousGitignore = undefined;
|
|
1046
|
+
}
|
|
1047
|
+
const gitignoreFile = join(repo.root, '.gitignore');
|
|
1048
|
+
const gitignoreWanted = Boolean(options.gitignore) || Boolean(previousGitignore?.enabled);
|
|
958
1049
|
let snapshots = [];
|
|
959
1050
|
let rep;
|
|
960
1051
|
try {
|
|
961
1052
|
snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
|
|
1053
|
+
.concat(gitignoreWanted ? [gitignoreFile] : [])
|
|
962
1054
|
.map(snapshotFile);
|
|
963
1055
|
rep = installHooks(repo, CLI_PATH);
|
|
964
1056
|
const activeHooks = installedHooks(repo);
|
|
@@ -979,11 +1071,21 @@ function cmdInit(args) {
|
|
|
979
1071
|
: '';
|
|
980
1072
|
throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
|
|
981
1073
|
}
|
|
982
|
-
|
|
983
|
-
|
|
1074
|
+
const patterns = patternsForRules(eng.rules);
|
|
1075
|
+
// created is sticky: once aimhooman introduced the file it stays
|
|
1076
|
+
// recorded as ours, so uninstall can remove an emptied husk it made.
|
|
1077
|
+
const gitignore = gitignoreWanted
|
|
1078
|
+
? { enabled: true, created: Boolean(previousGitignore?.created) || !existsSync(gitignoreFile) }
|
|
1079
|
+
: undefined;
|
|
1080
|
+
saveConfig(repo.stateDir, gitignore ? { profile, gitignore } : { profile });
|
|
1081
|
+
// The opt-in worktree file goes first, so every later failure —
|
|
1082
|
+
// including the core exclude write — rolls it back with the rest.
|
|
1083
|
+
if (gitignore) applyExclude(gitignoreFile, patterns);
|
|
1084
|
+
applyExclude(repo.excludeFile, patterns);
|
|
984
1085
|
const saved = loadConfig(repo.stateDir);
|
|
985
|
-
const excludes = inspectExclude(repo.excludeFile,
|
|
986
|
-
if (saved.profile !== profile || !excludes.current
|
|
1086
|
+
const excludes = inspectExclude(repo.excludeFile, patterns);
|
|
1087
|
+
if (saved.profile !== profile || !excludes.current
|
|
1088
|
+
|| (gitignore && !inspectExclude(gitignoreFile, patterns).current)) {
|
|
987
1089
|
throw new Error('post-install state verification failed');
|
|
988
1090
|
}
|
|
989
1091
|
} catch (error) {
|
|
@@ -1014,11 +1116,18 @@ function cmdInit(args) {
|
|
|
1014
1116
|
console.log(`aimhooman: initialised (profile: ${profile})`);
|
|
1015
1117
|
console.log(` state: ${repo.stateDir}`);
|
|
1016
1118
|
console.log(` excludes: ${repo.excludeFile}`);
|
|
1119
|
+
console.log(` note: known AI artifacts are now ignored locally; see them with 'git status --ignored'`);
|
|
1120
|
+
if (options.gitignore) {
|
|
1121
|
+
console.log('aimhooman: wrote AI-artifact ignores to .gitignore — commit it to share them with every clone');
|
|
1122
|
+
console.log(' note: gitignore matching is case-sensitive; the commit hooks still catch case variants');
|
|
1123
|
+
}
|
|
1017
1124
|
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
1018
1125
|
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
1019
1126
|
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
1127
|
+
console.log(' undo: aimhooman uninstall');
|
|
1020
1128
|
return 0;
|
|
1021
1129
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1130
|
+
return code;
|
|
1022
1131
|
}
|
|
1023
1132
|
|
|
1024
1133
|
function cmdStatus(args) {
|
|
@@ -1062,6 +1171,24 @@ function cmdStatus(args) {
|
|
|
1062
1171
|
excludeError = e.message;
|
|
1063
1172
|
excludes = { current: false, installed: false };
|
|
1064
1173
|
}
|
|
1174
|
+
// The committed variant is reported only when the local config records the
|
|
1175
|
+
// opt-in; anything else in a worktree .gitignore is the user's own file.
|
|
1176
|
+
let gitignoreRecord;
|
|
1177
|
+
try {
|
|
1178
|
+
gitignoreRecord = loadConfig(repo.stateDir).gitignore;
|
|
1179
|
+
} catch {
|
|
1180
|
+
gitignoreRecord = undefined;
|
|
1181
|
+
}
|
|
1182
|
+
let gitignoreExcludes;
|
|
1183
|
+
let gitignoreError = null;
|
|
1184
|
+
if (gitignoreRecord?.enabled) {
|
|
1185
|
+
try {
|
|
1186
|
+
gitignoreExcludes = inspectExclude(join(repo.root, '.gitignore'), patternsForRules(engine.rules));
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
gitignoreError = e.message;
|
|
1189
|
+
gitignoreExcludes = { current: false, installed: false };
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1065
1192
|
// The pre-commit and reference-transaction guards resolve the policy from
|
|
1066
1193
|
// the index, so the staged profile is what is actually enforced. Report it
|
|
1067
1194
|
// first; the worktree file is shown alongside so an edit that has not been
|
|
@@ -1088,6 +1215,9 @@ function cmdStatus(args) {
|
|
|
1088
1215
|
console.log(`rules: ${builtin} built-in${local ? ` + ${local} local` : ''}`);
|
|
1089
1216
|
console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
|
|
1090
1217
|
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)'}`);
|
|
1218
|
+
if (gitignoreRecord?.enabled) {
|
|
1219
|
+
console.log(`gitignore: ${gitignoreError ? `unknown (malformed markers: ${gitignoreError}; run: aimhooman init)` : gitignoreExcludes.current ? 'current' : gitignoreExcludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init --gitignore)'}`);
|
|
1220
|
+
}
|
|
1091
1221
|
for (const error of errors) console.log(`warning: ${error.message}`);
|
|
1092
1222
|
const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
|
|
1093
1223
|
const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
|
|
@@ -1136,7 +1266,7 @@ function cmdOverride(args, allow) {
|
|
|
1136
1266
|
const { options, positionals } = parseArguments(args, {
|
|
1137
1267
|
options: {
|
|
1138
1268
|
reason: { names: ['--reason'], type: 'string', nonEmpty: false },
|
|
1139
|
-
scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule'
|
|
1269
|
+
scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule'] },
|
|
1140
1270
|
},
|
|
1141
1271
|
minPositionals: 1,
|
|
1142
1272
|
maxPositionals: 1,
|
|
@@ -1153,27 +1283,6 @@ function cmdOverride(args, allow) {
|
|
|
1153
1283
|
if (scope === 'rule' && !engine.lookup(target)) {
|
|
1154
1284
|
throw new ArgumentError(`unknown rule ID "${target}"; use --scope path for a path with this spelling`);
|
|
1155
1285
|
}
|
|
1156
|
-
if (allow && scope === 'rule' && engine.lookup(target)?.category === 'secret') {
|
|
1157
|
-
throw new ArgumentError(
|
|
1158
|
-
`secret rules cannot be allowed at --scope rule (that would suppress every matching secret path under every profile); use --scope secret-path <path> to allow a specific secret path`,
|
|
1159
|
-
);
|
|
1160
|
-
}
|
|
1161
|
-
// A path that matches a secret rule cannot be silenced by a path (or rule)
|
|
1162
|
-
// allow: only --scope secret-path suppresses secret findings, deliberately,
|
|
1163
|
-
// so a local override cannot hide a possible leaked key. Without this check
|
|
1164
|
-
// a bare `allow .env.minimal` would report success but leave the block in
|
|
1165
|
-
// place, which reads as a broken allow.
|
|
1166
|
-
if (allow && scope !== 'secret-path'
|
|
1167
|
-
&& engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
|
|
1168
|
-
throw new ArgumentError(
|
|
1169
|
-
`"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
|
|
1170
|
-
+ '(a local override must not hide a possible leaked key); '
|
|
1171
|
-
+ 'use --scope secret-path to explicitly allow this specific path',
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
if (scope === 'secret-path' && !allow) {
|
|
1175
|
-
throw new ArgumentError('--scope secret-path is only valid with allow');
|
|
1176
|
-
}
|
|
1177
1286
|
const entry = {
|
|
1178
1287
|
target,
|
|
1179
1288
|
scope,
|
|
@@ -1496,7 +1605,7 @@ function cmdFix(args) {
|
|
|
1496
1605
|
return expectedErrorCode(e);
|
|
1497
1606
|
}
|
|
1498
1607
|
emitDiagnostics(scan.diagnostics);
|
|
1499
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
1608
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: scan.profile === 'strict' }));
|
|
1500
1609
|
if (scan.profile === 'compliance') {
|
|
1501
1610
|
if (options.apply) throw new ArgumentError('--apply is only valid when the active profile is strict');
|
|
1502
1611
|
if (scan.findings.length) process.stderr.write(human(scan.findings, tone()));
|
|
@@ -1778,6 +1887,32 @@ function cmdUninstall(args) {
|
|
|
1778
1887
|
} catch (error) {
|
|
1779
1888
|
excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
|
|
1780
1889
|
}
|
|
1890
|
+
// The committed variant of the block is only touched while the local
|
|
1891
|
+
// config says this clone opted in; without that record the worktree
|
|
1892
|
+
// .gitignore is treated as user-authored and left alone.
|
|
1893
|
+
let gitignoreRecord;
|
|
1894
|
+
try {
|
|
1895
|
+
gitignoreRecord = loadConfig(repo.stateDir).gitignore;
|
|
1896
|
+
} catch {
|
|
1897
|
+
gitignoreRecord = undefined;
|
|
1898
|
+
}
|
|
1899
|
+
if (gitignoreRecord?.enabled) {
|
|
1900
|
+
const gitignoreFile = join(repo.root, '.gitignore');
|
|
1901
|
+
try {
|
|
1902
|
+
removeExclude(gitignoreFile);
|
|
1903
|
+
// We introduced the file: once the block is gone and nothing
|
|
1904
|
+
// else remains, delete it rather than leave an empty husk. A
|
|
1905
|
+
// file with any other content is user-authored and stays.
|
|
1906
|
+
if (gitignoreRecord.created
|
|
1907
|
+
&& existsSync(gitignoreFile)
|
|
1908
|
+
&& readFileSync(gitignoreFile, 'utf8').trim() === '') {
|
|
1909
|
+
rmSync(gitignoreFile, { force: true });
|
|
1910
|
+
}
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
const gitignoreFailure = `gitignore block left in ${gitignoreFile}: ${error.message}`;
|
|
1913
|
+
excludeFailure = excludeFailure ? `${excludeFailure}; ${gitignoreFailure}` : gitignoreFailure;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1781
1916
|
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1782
1917
|
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1783
1918
|
const remaining = remainingDispatchers(repo);
|
|
@@ -1874,14 +2009,14 @@ function usage() {
|
|
|
1874
2009
|
process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
|
|
1875
2010
|
|
|
1876
2011
|
Usage:
|
|
1877
|
-
aimhooman init [--profile clean|strict|compliance]
|
|
2012
|
+
aimhooman init [--profile clean|strict|compliance] [--gitignore]
|
|
1878
2013
|
aimhooman init --global --yes
|
|
1879
2014
|
aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
|
|
1880
2015
|
aimhooman check --commit <rev> | --range <base>...<head> | --tracked
|
|
1881
2016
|
aimhooman audit|scan [--json] [--profile ...]
|
|
1882
2017
|
aimhooman status
|
|
1883
2018
|
aimhooman explain <rule-id>
|
|
1884
|
-
aimhooman allow <path|rule-id> [--scope path|rule
|
|
2019
|
+
aimhooman allow <path|rule-id> [--scope path|rule] [--reason "..."]
|
|
1885
2020
|
aimhooman deny <path|rule-id> [--scope path|rule] [--reason "..."]
|
|
1886
2021
|
aimhooman override list [--json]
|
|
1887
2022
|
aimhooman override remove <target>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# AI tooling artifacts for .gitignore
|
|
2
|
+
#
|
|
3
|
+
# These patterns cover the session and state files that AI coding tools
|
|
4
|
+
# write into a working tree: transcripts, history, caches, logs, and local
|
|
5
|
+
# settings. aimhooman keeps them out of commits. Copy the lines below into
|
|
6
|
+
# your .gitignore as-is. Matching follows normal gitignore rules and is
|
|
7
|
+
# case-sensitive. The machine-enforced version of this list lives in the
|
|
8
|
+
# aimhooman CLI, which blocks these paths at commit time; this file is the
|
|
9
|
+
# standalone copy for anyone who wants the same coverage without the hooks.
|
|
10
|
+
#
|
|
11
|
+
# Generated from rules/*.json by `npm run sync:catalog`; do not edit by hand.
|
|
12
|
+
# `npm run check` fails when this file is stale.
|
|
13
|
+
|
|
14
|
+
# >>> aimhooman:catalog-start
|
|
15
|
+
**/.agent/**
|
|
16
|
+
**/.aider.*
|
|
17
|
+
**/.claude.json
|
|
18
|
+
**/.claude/history*
|
|
19
|
+
**/.claude/logs/**
|
|
20
|
+
**/.claude/projects/**
|
|
21
|
+
**/.claude/session*.json
|
|
22
|
+
**/.claude/settings.local.json
|
|
23
|
+
**/.claude/shell-snapshots/**
|
|
24
|
+
**/.claude/statsig/**
|
|
25
|
+
**/.claude/todos/**
|
|
26
|
+
**/.codex/history*
|
|
27
|
+
**/.codex/log/**
|
|
28
|
+
**/.codex/logs/**
|
|
29
|
+
**/.codex/sessions/**
|
|
30
|
+
**/.continue/sessions/**
|
|
31
|
+
**/.copilot/**
|
|
32
|
+
**/.cursor/chats/**
|
|
33
|
+
**/.cursor/logs/**
|
|
34
|
+
**/.cursor/session*
|
|
35
|
+
**/.playwright-mcp/**
|
|
36
|
+
**/.remember/**
|
|
37
|
+
**/.specstory/**
|
|
38
|
+
**/.superpowers/**
|
|
39
|
+
.agent/**
|
|
40
|
+
.aider.*
|
|
41
|
+
.claude.json
|
|
42
|
+
.claude/history*
|
|
43
|
+
.claude/logs/**
|
|
44
|
+
.claude/projects/**
|
|
45
|
+
.claude/session*.json
|
|
46
|
+
.claude/settings.local.json
|
|
47
|
+
.claude/shell-snapshots/**
|
|
48
|
+
.claude/statsig/**
|
|
49
|
+
.claude/todos/**
|
|
50
|
+
.codex/history*
|
|
51
|
+
.codex/log/**
|
|
52
|
+
.codex/logs/**
|
|
53
|
+
.codex/sessions/**
|
|
54
|
+
.continue/sessions/**
|
|
55
|
+
.copilot/**
|
|
56
|
+
.cursor/chats/**
|
|
57
|
+
.cursor/logs/**
|
|
58
|
+
.cursor/session*
|
|
59
|
+
.playwright-mcp/**
|
|
60
|
+
.remember/**
|
|
61
|
+
.specstory/**
|
|
62
|
+
.superpowers/**
|
|
63
|
+
# <<< aimhooman:catalog-end
|
package/docs/catalog.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# AI-artifact catalog
|
|
2
|
+
|
|
3
|
+
<!-- Generated from rules/*.json by `npm run sync:catalog`; do not edit by hand. -->
|
|
4
|
+
|
|
5
|
+
aimhooman watches commits for AI residue: tooling artifacts (session files,
|
|
6
|
+
local settings, and agent state that belong on your machine, not in history)
|
|
7
|
+
and AI attribution (co-author trailers, "generated with" lines, leftover
|
|
8
|
+
markers in code). The table lists every built-in rule and what each profile
|
|
9
|
+
does when it matches: `block` stops the commit, `review` asks a human to
|
|
10
|
+
confirm, `allow` lets it through.
|
|
11
|
+
|
|
12
|
+
Secret scanning is out of scope since v0.3.0. See
|
|
13
|
+
[docs/secrets.md](secrets.md) for the reasoning and the gitleaks setup we
|
|
14
|
+
recommend instead.
|
|
15
|
+
|
|
16
|
+
<!-- aimhooman:catalog-start -->
|
|
17
|
+
| Rule | Provider | Category | Kind | clean | strict | compliance | Reason |
|
|
18
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
19
|
+
| `attribution.claude-coauthor` | claude-code | ai-attribution | message | block | block | allow | Unwanted AI co-author attribution (Claude). |
|
|
20
|
+
| `attribution.copilot-coauthor` | copilot | ai-attribution | message | block | block | allow | Unwanted AI co-author attribution (Copilot). |
|
|
21
|
+
| `attribution.codex-coauthor` | codex | ai-attribution | message | block | block | allow | Unwanted AI co-author attribution (Codex). |
|
|
22
|
+
| `attribution.bot-coauthor` | generic | ai-attribution | message | review | review | allow | Unwanted bot co-author attribution. |
|
|
23
|
+
| `attribution.generated-with` | generic | ai-attribution | message | block | block | allow | Unwanted AI generation attribution in the message. |
|
|
24
|
+
| `attribution.ai-noreply` | generic | ai-attribution | message | review | review | allow | AI service noreply email in the message. |
|
|
25
|
+
| `marker.corner-cut` | generic | ai-marker | code | review | block | review | AI-tooling corner-cut marker left in committed code. |
|
|
26
|
+
| `marker.ai-authored` | generic | ai-marker | code | review | block | review | AI-authored code marker left in committed code. |
|
|
27
|
+
| `claude.local-settings` | claude-code | local-settings | path | block | block | block | Personal Claude Code settings are not intended for source control. |
|
|
28
|
+
| `claude.session-state` | claude-code | ephemeral-state | path | block | block | block | Claude Code session and state artifacts are local, not repository content. |
|
|
29
|
+
| `codex.session-state` | codex | ephemeral-state | path | block | block | block | Codex session, history, and log artifacts are local, not repository content. |
|
|
30
|
+
| `copilot.session-state` | copilot | ephemeral-state | path | block | block | block | Copilot local state is not repository content. |
|
|
31
|
+
| `cursor.session-state` | cursor | ephemeral-state | path | block | block | block | Cursor session artifacts are local, not repository content. |
|
|
32
|
+
| `aider.history` | aider | ephemeral-state | path | block | block | block | Aider dotfiles are local, not repository content. |
|
|
33
|
+
| `specstory.history` | specstory | ephemeral-state | path | block | block | block | SpecStory session history is local, not repository content. |
|
|
34
|
+
| `continue.sessions` | continue | ephemeral-state | path | block | block | block | Continue session artifacts are local, not repository content. |
|
|
35
|
+
| `generic.agent-instructions` | generic | ambiguous-instructions | path | review | block | review | Agent instruction files may be intentional team config. Review before committing. |
|
|
36
|
+
| `generic.project-policy` | aimhooman | policy-config | path | review | block | review | Versioned enforcement policy changes require explicit human review. |
|
|
37
|
+
| `playwright-mcp.state` | playwright-mcp | ephemeral-state | path | block | block | block | Playwright MCP session artifacts are local, not repository content. |
|
|
38
|
+
| `remember.state` | remember | ephemeral-state | path | block | block | block | Remember second-brain data is local, not repository content. |
|
|
39
|
+
| `superpowers.state` | superpowers | ephemeral-state | path | block | block | block | Superpowers plugin state is local, not repository content. |
|
|
40
|
+
| `agent.state` | generic | ephemeral-state | path | block | block | block | Generic agent state is local, not repository content. |
|
|
41
|
+
<!-- aimhooman:catalog-end -->
|