@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/bin/aimhooman.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
- import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
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
- ? 'fix the reported rule pack and retry'
235
+ ? `fix the reported rule pack ${tail}`
229
236
  : budgeted
230
- ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
231
- : 'reduce the target or limits and retry';
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
- process.stderr.write(incompleteMessage(scan));
397
- 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;
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
- // Seven of the rules that can reach the summary above are secret rules, so it
469
- // cannot name a cause without guessing wrong for a private key or an AWS
470
- // credential and it used to be the only thing printed for a block. Let the
471
- // findings speak instead: human() already carries the rule id, the path, the
472
- // reason and the remediation, and already redacts secret text. It labels each
473
- // one BLOCK, which is the decision that unstaged the path, not a stopped
474
- // 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.
475
530
  process.stderr.write(human([...blocks, ...reviews], tone()));
476
- 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 }));
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
- return scan.complete ? 0 : 31;
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
- 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 });
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
- return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
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
- saveConfig(repo.stateDir, { profile });
983
- 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);
984
1085
  const saved = loadConfig(repo.stateDir);
985
- const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
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', 'secret-path'] },
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|secret-path] [--reason "..."]
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
@@ -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 -->