@rmyndharis/aimhooman 0.2.0 → 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/bin/aimhooman.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
@@ -26,7 +26,6 @@ import { applyExclude, inspectExclude, managedPatterns, patternsForRules, remove
26
26
  import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
27
27
  import { ArgumentError, parseArguments } from '../src/args.mjs';
28
28
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
29
- import { DEFAULT_SCAN_LIMITS } from '../src/scan-session.mjs';
30
29
  import { resolvePolicy } from '../src/policy-resolver.mjs';
31
30
  import { atomicWrite, withLock } from '../src/atomic-write.mjs';
32
31
  import { commitParents, resolveCommit } from '../src/history-scan.mjs';
@@ -113,7 +112,6 @@ function configuredEngine(profile, repo) {
113
112
  entry.scope === undefined
114
113
  || entry.scope === 'path'
115
114
  || entry.scope === 'rule'
116
- || entry.scope === 'secret-path'
117
115
  ));
118
116
  engine.setOverrides(ordinary(ov.allow), ordinary(ov.deny));
119
117
  }
@@ -215,7 +213,7 @@ function emitDiagnostics(diagnostics = []) {
215
213
  }
216
214
  }
217
215
 
218
- function incompleteMessage(scan) {
216
+ function incompleteMessage(scan, { blocking = true } = {}) {
219
217
  const reasons = scan.stats?.skipped || {};
220
218
  // Each count is how many items were skipped, not the limit that fired:
221
219
  // "(size-limit=1)" read as a one-byte budget. Name the noun so the number
@@ -228,14 +226,17 @@ function incompleteMessage(scan) {
228
226
  // the error, so point at that instead of misdirecting to the limits. When a
229
227
  // byte budget is what stopped the scan, name the budget: the caller whose own
230
228
  // tree outgrew it needs to raise one, and "reduce the limits" sends them the
231
- // 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';
232
233
  const budgeted = reasons['size-limit'] || reasons['total-byte-limit'];
233
234
  const hint = reasons['local-pack-error']
234
- ? 'fix the reported rule pack and retry'
235
+ ? `fix the reported rule pack ${tail}`
235
236
  : budgeted
236
- ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
237
- : 'reduce the target or limits and retry';
238
- let message = `aimhooman: scan incomplete${skipped ? ` (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`;
239
240
  const skippedPaths = scan.stats?.skippedPaths || {};
240
241
  const pathLines = [];
241
242
  for (const [reason, entries] of Object.entries(skippedPaths)) {
@@ -442,8 +443,12 @@ function cmdPrecommit(args) {
442
443
  if (!blocks.length) {
443
444
  if (reviews.length) process.stderr.write(human(reviews, tone()));
444
445
  if (!scan.complete) {
445
- process.stderr.write(incompleteMessage(scan));
446
- return 31;
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;
447
452
  }
448
453
  // W5 marker dedup: record that this staged tree scanned clean so the
449
454
  // upcoming commit-msg hook can skip its duplicate tree scan. The tree
@@ -515,22 +520,25 @@ function cmdPrecommit(args) {
515
520
  );
516
521
  return 10;
517
522
  }
518
- // Seven of the rules that can reach the summary above are secret rules, so it
519
- // cannot name a cause without guessing wrong for a private key or an AWS
520
- // credential and it used to be the only thing printed for a block. Let the
521
- // findings speak instead: human() already carries the rule id, the path, the
522
- // reason and the remediation, and already redacts secret text. It labels each
523
- // one BLOCK, which is the decision that unstaged the path, not a stopped
524
- // commit; the summary above says what actually happened to them.
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.
525
530
  process.stderr.write(human([...blocks, ...reviews], tone()));
526
- if (!scan.complete) process.stderr.write(incompleteMessage(scan));
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 }));
527
535
  // Git refuses a commit with nothing staged; repair runs after git has already
528
536
  // decided otherwise, so carrying on here mints the empty commit git would
529
537
  // not. The request was to commit the artifact, not to stamp the history with
530
538
  // its message and no content.
531
539
  if (emptied) return 10;
532
540
  if (scan.complete) noticeIgnoredArtifacts(repo);
533
- return scan.complete ? 0 : 31;
541
+ return 0;
534
542
  }
535
543
 
536
544
  function cmdCommitmsg(args) {
@@ -606,6 +614,9 @@ function cmdCommitmsg(args) {
606
614
  if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan));
607
615
  return treeCode;
608
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 }));
609
620
  }
610
621
  if (dispatchHooksChanged(repo, profile)) return 20;
611
622
  // Strict must fail closed on an incomplete scan: a block still wins (10),
@@ -615,7 +626,7 @@ function cmdCommitmsg(args) {
615
626
  process.stderr.write(incompleteMessage(scan));
616
627
  return findings.some((finding) => finding.decision === 'block') ? 10 : 31;
617
628
  }
618
- if (!scan.complete) process.stderr.write(incompleteMessage(scan));
629
+ if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: false }));
619
630
  if (!findings.length) return 0;
620
631
  const blocks = findings.filter((finding) => finding.decision === 'block');
621
632
  if (profile === 'strict') {
@@ -792,7 +803,11 @@ function cmdRefcheck(args) {
792
803
  return expectedErrorCode(error);
793
804
  }
794
805
  emitDiagnostics(scan.diagnostics);
795
- const code = exitCode(scan.findings, scan.profile, scan.complete);
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 });
796
811
  if (code !== 0) {
797
812
  process.stderr.write(human(scan.findings, tone()));
798
813
  if (!scan.complete) process.stderr.write(incompleteMessage(scan));
@@ -889,7 +904,7 @@ function cmdCheck(args) {
889
904
  }
890
905
  else {
891
906
  process.stderr.write(human(findings, tone()));
892
- if (!scan.complete) process.stderr.write(incompleteMessage(scan));
907
+ if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: scan.profile === 'strict' }));
893
908
  }
894
909
  return exitCode(findings, scan.profile, scan.complete);
895
910
  }
@@ -900,9 +915,9 @@ function cmdInit(args) {
900
915
  profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
901
916
  global: { names: ['--global'], type: 'boolean' },
902
917
  yes: { names: ['--yes'], type: 'boolean' },
903
- grandfatherSecrets: { names: ['--grandfather-secrets'], type: 'boolean' },
918
+ gitignore: { names: ['--gitignore'], type: 'boolean' },
904
919
  },
905
- conflicts: [['profile', 'global'], ['grandfatherSecrets', 'global']],
920
+ conflicts: [['profile', 'global'], ['gitignore', 'global']],
906
921
  maxPositionals: 0,
907
922
  });
908
923
  if (rejectUnsupportedGit()) return 20;
@@ -1016,10 +1031,26 @@ function cmdInit(args) {
1016
1031
  const hookFiles = hookState.some((hook) => hook.shared)
1017
1032
  ? []
1018
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);
1019
1049
  let snapshots = [];
1020
1050
  let rep;
1021
1051
  try {
1022
1052
  snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
1053
+ .concat(gitignoreWanted ? [gitignoreFile] : [])
1023
1054
  .map(snapshotFile);
1024
1055
  rep = installHooks(repo, CLI_PATH);
1025
1056
  const activeHooks = installedHooks(repo);
@@ -1040,11 +1071,21 @@ function cmdInit(args) {
1040
1071
  : '';
1041
1072
  throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
1042
1073
  }
1043
- saveConfig(repo.stateDir, { profile });
1044
- applyExclude(repo.excludeFile, patternsForRules(eng.rules));
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);
1045
1085
  const saved = loadConfig(repo.stateDir);
1046
- const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
1047
- 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)) {
1048
1089
  throw new Error('post-install state verification failed');
1049
1090
  }
1050
1091
  } catch (error) {
@@ -1076,78 +1117,19 @@ function cmdInit(args) {
1076
1117
  console.log(` state: ${repo.stateDir}`);
1077
1118
  console.log(` excludes: ${repo.excludeFile}`);
1078
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
+ }
1079
1124
  if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
1080
1125
  if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
1081
1126
  for (const warning of rep.warnings) console.log(` warning: ${warning}`);
1082
1127
  console.log(' undo: aimhooman uninstall');
1083
1128
  return 0;
1084
1129
  }, LIFECYCLE_LOCK_OPTIONS);
1085
- if (code === 0 && options.grandfatherSecrets) grandfatherTrackedSecrets(repo);
1086
1130
  return code;
1087
1131
  }
1088
1132
 
1089
- // --grandfather-secrets: a repository that already tracks secret-looking
1090
- // fixtures (test certs, sample keys) would see every commit touching one
1091
- // blocked, and allowing each path by hand does not scale. After a successful
1092
- // init, scan the tracked tree once and write a --scope secret-path allow for
1093
- // every path with a secret finding. The posture for NEW files is unchanged —
1094
- // only paths found in this scan get an allow, so a secret added after init is
1095
- // still blocked. A failed or incomplete scan never fails the init itself: the
1096
- // guard is already active by then, so the gap is reported as a warning.
1097
- function grandfatherTrackedSecrets(repo) {
1098
- let scan;
1099
- try {
1100
- // Commit-time budgets exist for latency, but a grandfather scan that
1101
- // stops at the default 64 MiB total or 1,000 findings silently misses
1102
- // fixtures in exactly the large, fixture-heavy repositories it exists
1103
- // for (OpenSSL tracks ~200 MiB and its 279 key files produce more
1104
- // than a thousand PEM findings). This is a one-shot, operator-invoked
1105
- // scan of the operator's own repository, so the defaults here are the
1106
- // same cap an env raise could reach plus a generous finding budget;
1107
- // the per-file budget and an explicit env override still apply.
1108
- scan = scanGitTarget(repo, {
1109
- kind: 'tracked',
1110
- limits: { maxTotalBytes: MAX_SCAN_LIMIT_BYTES, maxFindings: 100_000, ...scanLimits() },
1111
- });
1112
- } catch (error) {
1113
- console.error(`aimhooman: warning: grandfather scan failed (${error.message}); no pre-existing secret paths were allowed`);
1114
- return;
1115
- }
1116
- if (!scan.complete) {
1117
- console.error('aimhooman: warning: tracked scan was incomplete, so the grandfathered set may be partial; allow the remaining paths with --scope secret-path');
1118
- }
1119
- const paths = [...new Set(scan.findings
1120
- .filter((finding) => finding.category === 'secret' && finding.path)
1121
- .map((finding) => finding.path))].sort();
1122
- if (!paths.length) {
1123
- console.log('aimhooman: no tracked secret findings to grandfather');
1124
- return;
1125
- }
1126
- const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
1127
- const added = withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
1128
- const ov = loadOverrides(repo.stateDir);
1129
- let count = 0;
1130
- for (const path of paths) {
1131
- const entry = {
1132
- target: path,
1133
- scope: 'secret-path',
1134
- reason: 'pre-existing tracked fixture (grandfathered at init)',
1135
- actor: gitConfig(repo.root, 'user.email'),
1136
- at: new Date().toISOString(),
1137
- };
1138
- const sameAllow = (candidate) => (
1139
- candidate.target === path && effectiveOverrideScope(candidate, engine) === 'secret-path'
1140
- );
1141
- if (ov.allow.some(sameAllow)) continue;
1142
- ov.allow = upsert(ov.allow, entry, sameAllow);
1143
- count += 1;
1144
- }
1145
- saveOverrides(repo.stateDir, ov);
1146
- return count;
1147
- });
1148
- console.log(`aimhooman: grandfathered ${added} tracked secret path(s) with --scope secret-path allows; new files with secrets are still blocked`);
1149
- }
1150
-
1151
1133
  function cmdStatus(args) {
1152
1134
  parseNoArguments(args);
1153
1135
  const repo = tryRepo();
@@ -1189,6 +1171,24 @@ function cmdStatus(args) {
1189
1171
  excludeError = e.message;
1190
1172
  excludes = { current: false, installed: false };
1191
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
+ }
1192
1192
  // The pre-commit and reference-transaction guards resolve the policy from
1193
1193
  // the index, so the staged profile is what is actually enforced. Report it
1194
1194
  // first; the worktree file is shown alongside so an edit that has not been
@@ -1215,6 +1215,9 @@ function cmdStatus(args) {
1215
1215
  console.log(`rules: ${builtin} built-in${local ? ` + ${local} local` : ''}`);
1216
1216
  console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
1217
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
+ }
1218
1221
  for (const error of errors) console.log(`warning: ${error.message}`);
1219
1222
  const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
1220
1223
  const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
@@ -1258,31 +1261,12 @@ function cmdExplain(args) {
1258
1261
  return 0;
1259
1262
  }
1260
1263
 
1261
- // The content half of the secret-allow guard in cmdOverride: run the engine's
1262
- // own secret-category content rules over the target's bytes. Files over the
1263
- // scanner's per-file budget, unreadable paths, and non-regular files skip it —
1264
- // this guard is a UX safety net against an allow that would report success yet
1265
- // leave the block in place, and the commit-time scanner still fails closed on
1266
- // whatever was not checked here.
1267
- function fileContentMatchesSecret(engine, repo, target) {
1268
- let content;
1269
- try {
1270
- const file = join(repo.root, target);
1271
- const stat = lstatSync(file);
1272
- if (!stat.isFile() || stat.size > DEFAULT_SCAN_LIMITS.maxFileBytes) return false;
1273
- content = readFileSync(file, 'utf8');
1274
- } catch {
1275
- return false;
1276
- }
1277
- return engine.checkContent(target, content, { categories: ['secret'] }).length > 0;
1278
- }
1279
-
1280
1264
  function cmdOverride(args, allow) {
1281
1265
  const verb = allow ? 'allow' : 'deny';
1282
1266
  const { options, positionals } = parseArguments(args, {
1283
1267
  options: {
1284
1268
  reason: { names: ['--reason'], type: 'string', nonEmpty: false },
1285
- scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule', 'secret-path'] },
1269
+ scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule'] },
1286
1270
  },
1287
1271
  minPositionals: 1,
1288
1272
  maxPositionals: 1,
@@ -1299,31 +1283,6 @@ function cmdOverride(args, allow) {
1299
1283
  if (scope === 'rule' && !engine.lookup(target)) {
1300
1284
  throw new ArgumentError(`unknown rule ID "${target}"; use --scope path for a path with this spelling`);
1301
1285
  }
1302
- if (allow && scope === 'rule' && engine.lookup(target)?.category === 'secret') {
1303
- throw new ArgumentError(
1304
- `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`,
1305
- );
1306
- }
1307
- // A path that matches a secret rule cannot be silenced by a path (or rule)
1308
- // allow: only --scope secret-path suppresses secret findings, deliberately,
1309
- // so a local override cannot hide a possible leaked key. Without this check
1310
- // a bare `allow .env.minimal` would report success but leave the block in
1311
- // place, which reads as a broken allow. checkPaths only evaluates path
1312
- // rules, so a content-shaped secret (a private key inside an
1313
- // ordinary-looking filename) would slip past it; the content half of the
1314
- // guard closes that hole.
1315
- if (allow && scope !== 'secret-path'
1316
- && (engine.checkPaths([target]).some((finding) => finding.category === 'secret')
1317
- || fileContentMatchesSecret(engine, repo, target))) {
1318
- throw new ArgumentError(
1319
- `"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
1320
- + '(a local override must not hide a possible leaked key); '
1321
- + 'use --scope secret-path to explicitly allow this specific path',
1322
- );
1323
- }
1324
- if (scope === 'secret-path' && !allow) {
1325
- throw new ArgumentError('--scope secret-path is only valid with allow');
1326
- }
1327
1286
  const entry = {
1328
1287
  target,
1329
1288
  scope,
@@ -1646,7 +1605,7 @@ function cmdFix(args) {
1646
1605
  return expectedErrorCode(e);
1647
1606
  }
1648
1607
  emitDiagnostics(scan.diagnostics);
1649
- if (!scan.complete) process.stderr.write(incompleteMessage(scan));
1608
+ if (!scan.complete) process.stderr.write(incompleteMessage(scan, { blocking: scan.profile === 'strict' }));
1650
1609
  if (scan.profile === 'compliance') {
1651
1610
  if (options.apply) throw new ArgumentError('--apply is only valid when the active profile is strict');
1652
1611
  if (scan.findings.length) process.stderr.write(human(scan.findings, tone()));
@@ -1928,6 +1887,32 @@ function cmdUninstall(args) {
1928
1887
  } catch (error) {
1929
1888
  excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1930
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
+ }
1931
1916
  // Trust the directory, not the report. Every refusal below leaves a working
1932
1917
  // dispatcher behind, and one printed under "uninstalled" reads as done.
1933
1918
  const remaining = remainingDispatchers(repo);
@@ -2024,14 +2009,14 @@ function usage() {
2024
2009
  process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
2025
2010
 
2026
2011
  Usage:
2027
- aimhooman init [--profile clean|strict|compliance] [--grandfather-secrets]
2012
+ aimhooman init [--profile clean|strict|compliance] [--gitignore]
2028
2013
  aimhooman init --global --yes
2029
2014
  aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
2030
2015
  aimhooman check --commit <rev> | --range <base>...<head> | --tracked
2031
2016
  aimhooman audit|scan [--json] [--profile ...]
2032
2017
  aimhooman status
2033
2018
  aimhooman explain <rule-id>
2034
- aimhooman allow <path|rule-id> [--scope path|rule|secret-path] [--reason "..."]
2019
+ aimhooman allow <path|rule-id> [--scope path|rule] [--reason "..."]
2035
2020
  aimhooman deny <path|rule-id> [--scope path|rule] [--reason "..."]
2036
2021
  aimhooman override list [--json]
2037
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
@@ -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 -->