@rmyndharis/aimhooman 0.1.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 +53 -0
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.clinerules/aimhooman.md +53 -0
- package/.codex-plugin/plugin.json +37 -0
- package/.cursor/rules/aimhooman.mdc +59 -0
- package/.gemini/settings.json +7 -0
- package/.github/copilot-instructions.md +53 -0
- package/.github/hooks/aimhooman.json +22 -0
- package/.kiro/steering/aimhooman.md +53 -0
- package/.windsurf/rules/aimhooman.md +57 -0
- package/AGENTS.md +53 -0
- package/CHANGELOG.md +153 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +144 -0
- package/GEMINI.md +53 -0
- package/LICENSE +21 -0
- package/README.md +472 -0
- package/SECURITY.md +74 -0
- package/bin/aimhooman.mjs +1589 -0
- package/docs/design/agent-portability.md +73 -0
- package/docs/design/frictionless-enforcement.md +135 -0
- package/docs/hosts.json +164 -0
- package/docs/logo/aimhooman-logo.png +0 -0
- package/docs/logo/aimhooman.png +0 -0
- package/hooks/hooks.json +29 -0
- package/package.json +77 -0
- package/rules/attribution.json +142 -0
- package/rules/markers.json +88 -0
- package/rules/paths.json +407 -0
- package/rules/secrets.json +90 -0
- package/schemas/overrides.schema.json +134 -0
- package/schemas/project-policy.schema.json +12 -0
- package/schemas/rule-pack.schema.json +126 -0
- package/schemas/scan-report.schema.json +165 -0
- package/skills/aimhooman/SKILL.md +65 -0
- package/src/args.mjs +72 -0
- package/src/atomic-write.mjs +285 -0
- package/src/codex-manifest.mjs +65 -0
- package/src/exclude.mjs +125 -0
- package/src/git-environment.mjs +5 -0
- package/src/git-path.mjs +5 -0
- package/src/githooks.mjs +813 -0
- package/src/gitx.mjs +721 -0
- package/src/history-scan.mjs +295 -0
- package/src/hook.mjs +2602 -0
- package/src/policy-resolver.mjs +200 -0
- package/src/report.mjs +63 -0
- package/src/rules.mjs +509 -0
- package/src/ruleset-text.mjs +29 -0
- package/src/scan-session.mjs +152 -0
- package/src/scan-target.mjs +697 -0
- package/src/scan.mjs +325 -0
- package/src/state.mjs +360 -0
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { newEngineWithDiagnostics } from '../src/scan.mjs';
|
|
7
|
+
import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
8
|
+
import {
|
|
9
|
+
GitRevisionError,
|
|
10
|
+
gitConfig,
|
|
11
|
+
hasStagedChanges,
|
|
12
|
+
introducedCommits,
|
|
13
|
+
openRepo,
|
|
14
|
+
readCommitPath,
|
|
15
|
+
readStagedPath,
|
|
16
|
+
stagedRenameSources,
|
|
17
|
+
unstagePaths,
|
|
18
|
+
withIndexFromTree,
|
|
19
|
+
} from '../src/gitx.mjs';
|
|
20
|
+
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
21
|
+
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
22
|
+
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
23
|
+
import { runHook } from '../src/hook.mjs';
|
|
24
|
+
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
25
|
+
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
26
|
+
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
27
|
+
import { atomicWrite, withLock } from '../src/atomic-write.mjs';
|
|
28
|
+
import { commitParents, resolveCommit } from '../src/history-scan.mjs';
|
|
29
|
+
|
|
30
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
31
|
+
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
32
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
33
|
+
const PROFILES = new Set(['clean', 'strict', 'compliance']);
|
|
34
|
+
const REQUIRED_GIT_HOOKS = ['pre-commit', 'pre-merge-commit', 'commit-msg', 'reference-transaction'];
|
|
35
|
+
const MINIMUM_GIT_VERSION = [2, 28, 0];
|
|
36
|
+
const LIFECYCLE_LOCK_OPTIONS = { retries: 1000 };
|
|
37
|
+
|
|
38
|
+
function tryRepo() {
|
|
39
|
+
try {
|
|
40
|
+
return openRepo();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.name === 'StateMigrationError') throw error;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function currentRepositoryIsBare() {
|
|
48
|
+
try {
|
|
49
|
+
return execFileSync('git', ['rev-parse', '--is-bare-repository'], {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
52
|
+
}).trim() === 'true';
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tone() {
|
|
59
|
+
return process.env.AIMHOOMAN_TONE === 'professional' || process.env.CI ? 'professional' : 'playful';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function configuredEngine(profile, repo) {
|
|
63
|
+
const { engine, errors } = newEngineWithDiagnostics(profile, repo?.stateDir);
|
|
64
|
+
if (errors.length && profile === 'strict') {
|
|
65
|
+
throw new Error(errors.map((error) => error.message).join('; '));
|
|
66
|
+
}
|
|
67
|
+
for (const error of errors) {
|
|
68
|
+
process.stderr.write(`aimhooman: warning: ${error.message}; pack skipped\n`);
|
|
69
|
+
}
|
|
70
|
+
if (repo) {
|
|
71
|
+
const ov = loadOverrides(repo.stateDir);
|
|
72
|
+
const ordinary = (entries) => entries.filter((entry) => (
|
|
73
|
+
entry.scope === undefined
|
|
74
|
+
|| entry.scope === 'path'
|
|
75
|
+
|| entry.scope === 'rule'
|
|
76
|
+
|| entry.scope === 'secret-path'
|
|
77
|
+
));
|
|
78
|
+
engine.setOverrides(ordinary(ov.allow), ordinary(ov.deny));
|
|
79
|
+
}
|
|
80
|
+
return engine;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function main(argv) {
|
|
84
|
+
const [cmd, ...rest] = argv;
|
|
85
|
+
switch (cmd) {
|
|
86
|
+
case 'check':
|
|
87
|
+
return cmdCheck(rest);
|
|
88
|
+
case 'audit':
|
|
89
|
+
case 'scan':
|
|
90
|
+
return cmdCheck(['--tracked', ...rest]);
|
|
91
|
+
case 'precommit':
|
|
92
|
+
return cmdPrecommit(rest);
|
|
93
|
+
case 'commitmsg':
|
|
94
|
+
return cmdCommitmsg(rest);
|
|
95
|
+
case 'refcheck':
|
|
96
|
+
return cmdRefcheck(rest);
|
|
97
|
+
case 'init':
|
|
98
|
+
return cmdInit(rest);
|
|
99
|
+
case 'status':
|
|
100
|
+
return cmdStatus(rest);
|
|
101
|
+
case 'explain':
|
|
102
|
+
return cmdExplain(rest);
|
|
103
|
+
case 'allow':
|
|
104
|
+
return cmdOverride(rest, true);
|
|
105
|
+
case 'deny':
|
|
106
|
+
return cmdOverride(rest, false);
|
|
107
|
+
case 'override':
|
|
108
|
+
return cmdOverrideLifecycle(rest);
|
|
109
|
+
case 'review':
|
|
110
|
+
return cmdReview(rest);
|
|
111
|
+
case 'policy-review':
|
|
112
|
+
return cmdPolicyReview(rest);
|
|
113
|
+
case 'fix':
|
|
114
|
+
return cmdFix(rest);
|
|
115
|
+
case 'doctor':
|
|
116
|
+
return cmdDoctor(rest);
|
|
117
|
+
case 'uninstall':
|
|
118
|
+
return cmdUninstall(rest);
|
|
119
|
+
case 'version':
|
|
120
|
+
case '--version':
|
|
121
|
+
case '-v':
|
|
122
|
+
parseNoArguments(rest);
|
|
123
|
+
console.log(VERSION);
|
|
124
|
+
return 0;
|
|
125
|
+
case undefined:
|
|
126
|
+
case 'help':
|
|
127
|
+
case '--help':
|
|
128
|
+
case '-h':
|
|
129
|
+
parseNoArguments(rest);
|
|
130
|
+
usage();
|
|
131
|
+
return 0;
|
|
132
|
+
default:
|
|
133
|
+
console.error(`aimhooman: unknown command "${cmd}"`);
|
|
134
|
+
usage();
|
|
135
|
+
return 20;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseNoArguments(args) {
|
|
140
|
+
parseArguments(args, { maxPositionals: 0 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function expectedErrorCode(error) {
|
|
144
|
+
if (error instanceof ArgumentError) return 20;
|
|
145
|
+
if (/^(?:ProjectPolicy|LocalConfig|LocalOverrides|PolicyProfile|PolicyTarget|PolicyRules|RulePack)/.test(error?.name || '')) {
|
|
146
|
+
return 20;
|
|
147
|
+
}
|
|
148
|
+
if (error?.name === 'GitRevisionError' || error instanceof TypeError) return 20;
|
|
149
|
+
return 30;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function emitDiagnostics(diagnostics = []) {
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const diagnostic of diagnostics) {
|
|
155
|
+
const message = diagnostic.message || String(diagnostic);
|
|
156
|
+
if (seen.has(message)) continue;
|
|
157
|
+
seen.add(message);
|
|
158
|
+
process.stderr.write(`aimhooman: warning: ${message}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function incompleteMessage(scan) {
|
|
163
|
+
const skipped = Object.entries(scan.stats?.skipped || {})
|
|
164
|
+
.map(([reason, count]) => `${reason}=${count}`)
|
|
165
|
+
.join(', ');
|
|
166
|
+
return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; reduce the target or limits and retry\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function snapshotFile(path) {
|
|
170
|
+
try {
|
|
171
|
+
const stat = lstatSync(path);
|
|
172
|
+
if (stat.isSymbolicLink()) return { path, existed: true, untouched: true };
|
|
173
|
+
if (!stat.isFile()) throw new Error(`refusing to replace non-file path "${path}"`);
|
|
174
|
+
return { path, existed: true, data: readFileSync(path), mode: stat.mode & 0o777 };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error?.code === 'ENOENT') return { path, existed: false };
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function restoreSnapshot(snapshot) {
|
|
182
|
+
if (snapshot.untouched) return;
|
|
183
|
+
if (!snapshot.existed) {
|
|
184
|
+
rmSync(snapshot.path, { force: true });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
atomicWrite(snapshot.path, snapshot.data, { mode: snapshot.mode });
|
|
188
|
+
chmodSync(snapshot.path, snapshot.mode);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function gitConfigAtScope(root, scope, key) {
|
|
192
|
+
try {
|
|
193
|
+
return execFileSync('git', ['config', scope, '--get', key], {
|
|
194
|
+
cwd: root,
|
|
195
|
+
encoding: 'utf8',
|
|
196
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
197
|
+
}).trim();
|
|
198
|
+
} catch {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function gitVersion() {
|
|
204
|
+
try { return execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(); }
|
|
205
|
+
catch { return 'Git unavailable'; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function supportedGitVersion() {
|
|
209
|
+
const display = gitVersion();
|
|
210
|
+
const match = /^git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(display);
|
|
211
|
+
if (!match) return { supported: false, display };
|
|
212
|
+
const version = [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
|
213
|
+
const supported = version.some((value, index) => value > MINIMUM_GIT_VERSION[index]
|
|
214
|
+
&& version.slice(0, index).every((prior, priorIndex) => prior === MINIMUM_GIT_VERSION[priorIndex]))
|
|
215
|
+
|| version.every((value, index) => value === MINIMUM_GIT_VERSION[index]);
|
|
216
|
+
return { supported, display, version };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rejectUnsupportedGit() {
|
|
220
|
+
const current = supportedGitVersion();
|
|
221
|
+
if (current.supported) return false;
|
|
222
|
+
console.error(
|
|
223
|
+
`aimhooman: Git 2.28.0 or newer is required for the reference-transaction guard; found ${current.display}`
|
|
224
|
+
);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function dispatchHooksChanged(repo, profile) {
|
|
229
|
+
if (!process.env.AIMHOOMAN_ACTIVE_HOOK) return false;
|
|
230
|
+
const finalBoundary = process.env.AIMHOOMAN_ACTIVE_HOOK === 'reference-transaction';
|
|
231
|
+
const activeHooks = installedHooks(repo);
|
|
232
|
+
const missingHooks = REQUIRED_GIT_HOOKS.filter((name) => !activeHooks.includes(name));
|
|
233
|
+
if (!missingHooks.length) return false;
|
|
234
|
+
// A predecessor can remove a later dispatcher before it has a chance to
|
|
235
|
+
// run. Every profile must stop at the first hook that notices; otherwise a
|
|
236
|
+
// clean/compliance pre-commit predecessor could delete the final ref guard
|
|
237
|
+
// and leave no downstream boundary at all.
|
|
238
|
+
const boundary = finalBoundary ? 'final' : profile === 'strict' ? 'strict' : 'required';
|
|
239
|
+
process.stderr.write(
|
|
240
|
+
`aimhooman: ${boundary} Git guards changed while ${process.env.AIMHOOMAN_ACTIVE_HOOK} was running; ` +
|
|
241
|
+
`${missingHooks.join(', ')} ${missingHooks.length === 1 ? 'is' : 'are'} unavailable. ` +
|
|
242
|
+
"The commit was stopped; run 'aimhooman init' and retry.\n"
|
|
243
|
+
);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function cmdPrecommit(args) {
|
|
248
|
+
parseNoArguments(args);
|
|
249
|
+
const repo = tryRepo();
|
|
250
|
+
if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
|
|
251
|
+
let scan;
|
|
252
|
+
try {
|
|
253
|
+
scan = scanGitTarget(repo, { kind: 'staged' });
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.error(`aimhooman: cannot scan staged content: ${e.message}`);
|
|
256
|
+
return expectedErrorCode(e);
|
|
257
|
+
}
|
|
258
|
+
emitDiagnostics(scan.diagnostics);
|
|
259
|
+
const profile = scan.profile;
|
|
260
|
+
const allFindings = scan.findings;
|
|
261
|
+
if (dispatchHooksChanged(repo, profile)) return 20;
|
|
262
|
+
const reviews = allFindings.filter((f) => f.decision === 'review');
|
|
263
|
+
const blocks = allFindings
|
|
264
|
+
.filter((f) => f.decision === 'block');
|
|
265
|
+
if (!blocks.length) {
|
|
266
|
+
if (reviews.length) process.stderr.write(human(reviews, tone()));
|
|
267
|
+
if (!scan.complete) {
|
|
268
|
+
process.stderr.write(incompleteMessage(scan));
|
|
269
|
+
return 31;
|
|
270
|
+
}
|
|
271
|
+
return profile === 'strict' && reviews.length ? 11 : 0;
|
|
272
|
+
}
|
|
273
|
+
if (profile === 'strict') {
|
|
274
|
+
process.stderr.write(human(allFindings, tone()));
|
|
275
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
276
|
+
return 10;
|
|
277
|
+
}
|
|
278
|
+
const paths = [...new Set(blocks.map((f) => f.path).filter(Boolean))];
|
|
279
|
+
try {
|
|
280
|
+
const unstageTargets = new Set(paths);
|
|
281
|
+
for (const finding of blocks) {
|
|
282
|
+
if (finding.status === 'R' && finding.sourcePath && unstageTargets.has(finding.path)) {
|
|
283
|
+
unstageTargets.add(finding.sourcePath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
287
|
+
unstagePaths(repo, [...unstageTargets]);
|
|
288
|
+
let empty = false;
|
|
289
|
+
try { empty = !hasStagedChanges(repo); } catch { /* could not re-read */ }
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`aimhooman: unstaged ${paths.length} AI artifact(s) from this commit: ${paths.map(visible).join(', ')}${empty ? ' (nothing else staged — the commit will be empty)' : ''}\n`
|
|
292
|
+
);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
process.stderr.write(
|
|
295
|
+
`aimhooman: could not unstage protected files: ${e.message} ` +
|
|
296
|
+
'(commit stopped; repair the index and retry)\n'
|
|
297
|
+
);
|
|
298
|
+
return 10;
|
|
299
|
+
}
|
|
300
|
+
if (reviews.length) process.stderr.write(human(reviews, tone()));
|
|
301
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
302
|
+
return scan.complete ? 0 : 31;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function cmdCommitmsg(args) {
|
|
306
|
+
const { options, positionals } = parseArguments(args, {
|
|
307
|
+
options: {
|
|
308
|
+
tree: { names: ['--tree'], type: 'string' },
|
|
309
|
+
},
|
|
310
|
+
minPositionals: 1,
|
|
311
|
+
maxPositionals: 1,
|
|
312
|
+
});
|
|
313
|
+
const file = positionals[0];
|
|
314
|
+
const repo = tryRepo();
|
|
315
|
+
if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
|
|
316
|
+
// Frictionless profiles (clean/compliance) never cancel a commit merely
|
|
317
|
+
// because the message file cannot be read; only strict fails closed.
|
|
318
|
+
let hookProfile = 'clean';
|
|
319
|
+
const againstWouldBeTree = (fn) => options.tree
|
|
320
|
+
? withIndexFromTree(repo, options.tree, fn)
|
|
321
|
+
: fn();
|
|
322
|
+
try {
|
|
323
|
+
hookProfile = againstWouldBeTree(() => resolvePolicy(repo, {
|
|
324
|
+
target: options.tree ? 'staged' : 'worktree',
|
|
325
|
+
}).profile);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (options.tree) {
|
|
328
|
+
console.error(`aimhooman: cannot inspect would-be commit policy: ${error.message}`);
|
|
329
|
+
return expectedErrorCode(error);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
let messageBytes;
|
|
333
|
+
try { messageBytes = readFileSync(file); } catch (e) {
|
|
334
|
+
if (hookProfile === 'strict') {
|
|
335
|
+
console.error(`aimhooman: cannot read message file: ${e.message}`);
|
|
336
|
+
return 30;
|
|
337
|
+
}
|
|
338
|
+
console.error(`aimhooman: could not read message file: ${e.message}; skipping commit-msg guard`);
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
const text = messageBytes.toString('utf8');
|
|
342
|
+
const validUtf8 = Buffer.from(text, 'utf8').equals(messageBytes);
|
|
343
|
+
let scan;
|
|
344
|
+
let treeScan = null;
|
|
345
|
+
try {
|
|
346
|
+
const checked = againstWouldBeTree(() => ({
|
|
347
|
+
message: scanMessage(repo, text, { target: 'staged' }),
|
|
348
|
+
tree: options.tree ? scanGitTarget(repo, { kind: 'staged' }) : null,
|
|
349
|
+
}));
|
|
350
|
+
scan = checked.message;
|
|
351
|
+
treeScan = checked.tree;
|
|
352
|
+
} catch (e) {
|
|
353
|
+
console.error(`aimhooman: cannot inspect commit message: ${e.message}`);
|
|
354
|
+
return expectedErrorCode(e);
|
|
355
|
+
}
|
|
356
|
+
emitDiagnostics(scan.diagnostics);
|
|
357
|
+
if (treeScan) emitDiagnostics(treeScan.diagnostics);
|
|
358
|
+
const { profile, findings } = scan;
|
|
359
|
+
if (treeScan) {
|
|
360
|
+
const treeCode = exitCode(treeScan.findings, treeScan.profile, treeScan.complete);
|
|
361
|
+
if (treeCode !== 0) {
|
|
362
|
+
if (treeScan.findings.length) process.stderr.write(human(treeScan.findings, tone()));
|
|
363
|
+
if (!treeScan.complete) process.stderr.write(incompleteMessage(treeScan));
|
|
364
|
+
return treeCode;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (dispatchHooksChanged(repo, profile)) return 20;
|
|
368
|
+
// Strict must fail closed on an incomplete scan: a block still wins (10),
|
|
369
|
+
// otherwise an incomplete strict scan stops the commit (31) like cmdPrecommit.
|
|
370
|
+
if (profile === 'strict' && !scan.complete) {
|
|
371
|
+
if (findings.length) process.stderr.write(human(findings, tone()));
|
|
372
|
+
process.stderr.write(incompleteMessage(scan));
|
|
373
|
+
return findings.some((finding) => finding.decision === 'block') ? 10 : 31;
|
|
374
|
+
}
|
|
375
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
376
|
+
if (!findings.length) return 0;
|
|
377
|
+
const blocks = findings.filter((finding) => finding.decision === 'block');
|
|
378
|
+
if (profile === 'strict') {
|
|
379
|
+
process.stderr.write(human(findings, tone()));
|
|
380
|
+
return blocks.length ? 10 : 11;
|
|
381
|
+
}
|
|
382
|
+
const { cleaned, removed } = scan.repair;
|
|
383
|
+
if (removed.length) {
|
|
384
|
+
if (!validUtf8) {
|
|
385
|
+
process.stderr.write('aimhooman: commit message is not valid UTF-8; no bytes were changed because a safe repair cannot be proved\n');
|
|
386
|
+
process.stderr.write(human(findings, tone()));
|
|
387
|
+
return 10;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
atomicWrite(file + '.aimhooman-bak', messageBytes);
|
|
391
|
+
atomicWrite(file, cleaned);
|
|
392
|
+
} catch (e) {
|
|
393
|
+
try { rmSync(file + '.aimhooman-bak', { force: true }); } catch { /* keep the write error */ }
|
|
394
|
+
// blocks.length is always > 0 here: removed entries are block findings
|
|
395
|
+
// and blocks ⊇ removed, so this path always stops the commit (exit 10).
|
|
396
|
+
process.stderr.write(`aimhooman: could not clean commit message: ${e.message}; commit stopped\n`);
|
|
397
|
+
process.stderr.write(human(findings, tone()));
|
|
398
|
+
return 10;
|
|
399
|
+
}
|
|
400
|
+
process.stderr.write(`aimhooman: stripped ${removed.length} AI attribution line(s); backup at ${file}.aimhooman-bak\n`);
|
|
401
|
+
}
|
|
402
|
+
const removedLines = new Set(removed.map((finding) => finding.line));
|
|
403
|
+
const remaining = findings.filter((finding) => !removedLines.has(finding.line));
|
|
404
|
+
if (remaining.length) process.stderr.write(human(remaining, tone()));
|
|
405
|
+
return remaining.some((finding) => finding.decision === 'block') ? 10 : 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function cmdRefcheck(args) {
|
|
409
|
+
const { positionals } = parseArguments(args, {
|
|
410
|
+
minPositionals: 1,
|
|
411
|
+
maxPositionals: 1,
|
|
412
|
+
});
|
|
413
|
+
const phase = positionals[0];
|
|
414
|
+
if (!['preparing', 'prepared', 'committed', 'aborted'].includes(phase)) {
|
|
415
|
+
throw new ArgumentError('refcheck phase must be preparing, prepared, committed, or aborted');
|
|
416
|
+
}
|
|
417
|
+
// Git 2.54 added an earlier `preparing` callback. At that point references
|
|
418
|
+
// are not locked and symbolic refs are not resolved, so keep the full scan
|
|
419
|
+
// at `prepared`. Do not return before checking dispatcher integrity, though:
|
|
420
|
+
// a chained hook could otherwise delete this hook between the two phases.
|
|
421
|
+
if (phase === 'committed' || phase === 'aborted') return 0;
|
|
422
|
+
|
|
423
|
+
const repo = tryRepo();
|
|
424
|
+
if (!repo) {
|
|
425
|
+
// Global hooks also run in bare repositories. Bare repositories have no
|
|
426
|
+
// worktree/index policy boundary and are intentionally unsupported, so
|
|
427
|
+
// a global dispatcher must remain transparent there instead of making
|
|
428
|
+
// every receive-pack or update-ref fail merely because --show-toplevel
|
|
429
|
+
// is unavailable.
|
|
430
|
+
if (currentRepositoryIsBare()) return 0;
|
|
431
|
+
console.error('aimhooman: not a git repository');
|
|
432
|
+
return 30;
|
|
433
|
+
}
|
|
434
|
+
if (phase === 'preparing') {
|
|
435
|
+
return dispatchHooksChanged(repo, 'clean') ? 20 : 0;
|
|
436
|
+
}
|
|
437
|
+
let input;
|
|
438
|
+
try { input = readFileSync(0, 'utf8'); }
|
|
439
|
+
catch (error) {
|
|
440
|
+
console.error(`aimhooman: cannot read proposed reference updates: ${error.message}`);
|
|
441
|
+
return 30;
|
|
442
|
+
}
|
|
443
|
+
const updates = [];
|
|
444
|
+
for (const line of input.split('\n').filter(Boolean)) {
|
|
445
|
+
const match = /^(\S+) (\S+) (.+)$/.exec(line);
|
|
446
|
+
if (!match) {
|
|
447
|
+
console.error('aimhooman: malformed reference-transaction input; update stopped');
|
|
448
|
+
return 30;
|
|
449
|
+
}
|
|
450
|
+
const [, oldObjectId, newObjectId, ref] = match;
|
|
451
|
+
if (ref !== 'HEAD' && !ref.startsWith('refs/heads/')) continue;
|
|
452
|
+
if (ref === 'HEAD' && newObjectId.startsWith('ref:')) continue;
|
|
453
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oldObjectId)
|
|
454
|
+
|| !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(newObjectId)) {
|
|
455
|
+
console.error('aimhooman: malformed object ID in reference transaction; update stopped');
|
|
456
|
+
return 30;
|
|
457
|
+
}
|
|
458
|
+
if (/^0+$/.test(newObjectId)) continue;
|
|
459
|
+
updates.push({ oldObjectId, newObjectId });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let commits;
|
|
463
|
+
try {
|
|
464
|
+
const contextsByCommit = new Map();
|
|
465
|
+
for (const update of updates) {
|
|
466
|
+
for (const revision of introducedCommits(repo, [update])) {
|
|
467
|
+
const contexts = contextsByCommit.get(revision) || [];
|
|
468
|
+
contexts.push({
|
|
469
|
+
head: update.newObjectId,
|
|
470
|
+
storedTransition: revision,
|
|
471
|
+
scanTransition: revision,
|
|
472
|
+
});
|
|
473
|
+
// A staged review is bound to the exact old tip. It can be
|
|
474
|
+
// carried into the final full-snapshot scan only for the direct
|
|
475
|
+
// proposed tip whose parent is that old tip, never for an
|
|
476
|
+
// intermediate commit or a newly created branch ancestry.
|
|
477
|
+
if (revision === update.newObjectId && !/^0+$/.test(update.oldObjectId)) {
|
|
478
|
+
const { parents } = commitParents(repo, revision);
|
|
479
|
+
if (parents.includes(update.oldObjectId)) {
|
|
480
|
+
contexts.push({
|
|
481
|
+
head: update.oldObjectId,
|
|
482
|
+
storedTransition: 'staged',
|
|
483
|
+
scanTransition: revision,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
contextsByCommit.set(revision, contexts);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
commits = [...contextsByCommit];
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
|
|
494
|
+
return 30;
|
|
495
|
+
}
|
|
496
|
+
for (const [revision, reviewContexts] of commits) {
|
|
497
|
+
let scan;
|
|
498
|
+
try {
|
|
499
|
+
scan = scanGitTarget(repo, {
|
|
500
|
+
kind: 'commit',
|
|
501
|
+
revision,
|
|
502
|
+
reviewContexts,
|
|
503
|
+
policyMigrationContexts: reviewContexts,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
console.error(`aimhooman: cannot scan proposed commit ${revision}: ${error.message}`);
|
|
508
|
+
return expectedErrorCode(error);
|
|
509
|
+
}
|
|
510
|
+
emitDiagnostics(scan.diagnostics);
|
|
511
|
+
const code = exitCode(scan.findings, scan.profile, scan.complete);
|
|
512
|
+
if (code !== 0) {
|
|
513
|
+
process.stderr.write(human(scan.findings, tone()));
|
|
514
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
515
|
+
process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
|
|
516
|
+
return code;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// The reference-transaction hook is the last veto point. If its chained
|
|
520
|
+
// predecessor removed any required dispatcher, every profile stops: there
|
|
521
|
+
// is no downstream guard that can repair the lost boundary safely.
|
|
522
|
+
if (dispatchHooksChanged(repo, 'clean')) return 20;
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function cmdCheck(args) {
|
|
527
|
+
const { options: o } = parseArguments(args, {
|
|
528
|
+
options: {
|
|
529
|
+
staged: { names: ['--staged'], type: 'boolean' },
|
|
530
|
+
tracked: { names: ['--tracked'], type: 'boolean' },
|
|
531
|
+
json: { names: ['--json'], type: 'boolean' },
|
|
532
|
+
message: { names: ['--message', '-m'], type: 'string' },
|
|
533
|
+
profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
|
|
534
|
+
commit: { names: ['--commit'], type: 'string' },
|
|
535
|
+
range: { names: ['--range'], type: 'string' },
|
|
536
|
+
},
|
|
537
|
+
conflicts: [['staged', 'tracked', 'commit', 'range']],
|
|
538
|
+
maxPositionals: 0,
|
|
539
|
+
});
|
|
540
|
+
if (o.message && (o.commit || o.range)) {
|
|
541
|
+
throw new ArgumentError('--message cannot be combined with --commit or --range; Git object messages are scanned automatically');
|
|
542
|
+
}
|
|
543
|
+
const repo = tryRepo();
|
|
544
|
+
if (!repo) {
|
|
545
|
+
console.error('aimhooman: not a git repository');
|
|
546
|
+
return 30;
|
|
547
|
+
}
|
|
548
|
+
let messageText;
|
|
549
|
+
if (o.message) {
|
|
550
|
+
try { messageText = readFileSync(o.message, 'utf8'); }
|
|
551
|
+
catch (e) {
|
|
552
|
+
console.error(`aimhooman: cannot read message file: ${e.message}`);
|
|
553
|
+
return 30;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
let scan;
|
|
557
|
+
try {
|
|
558
|
+
if (o.message && !o.staged && !o.tracked) {
|
|
559
|
+
scan = scanMessage(repo, messageText, {
|
|
560
|
+
target: 'worktree',
|
|
561
|
+
explicitProfile: o.profile,
|
|
562
|
+
});
|
|
563
|
+
} else {
|
|
564
|
+
scan = scanGitTarget(repo, {
|
|
565
|
+
kind: o.tracked ? 'tracked' : o.commit ? 'commit' : o.range ? 'range' : 'staged',
|
|
566
|
+
revision: o.commit,
|
|
567
|
+
range: o.range,
|
|
568
|
+
explicitProfile: o.profile,
|
|
569
|
+
...(o.message ? { messageText } : {}),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
} catch (e) {
|
|
573
|
+
console.error(`aimhooman: scan failed: ${e.message}`);
|
|
574
|
+
return expectedErrorCode(e);
|
|
575
|
+
}
|
|
576
|
+
emitDiagnostics(scan.diagnostics);
|
|
577
|
+
const { findings } = scan;
|
|
578
|
+
if (o.json) {
|
|
579
|
+
process.stdout.write(jsonReport(findings, {
|
|
580
|
+
tool_version: VERSION,
|
|
581
|
+
target: scan.target,
|
|
582
|
+
profile: scan.profile,
|
|
583
|
+
policy_source: scan.policy_source,
|
|
584
|
+
policy_object_id: scan.policy_object_id,
|
|
585
|
+
...(scan.policy_enforced_object_ids ? {
|
|
586
|
+
policy_enforced_object_ids: scan.policy_enforced_object_ids,
|
|
587
|
+
} : {}),
|
|
588
|
+
complete: scan.complete,
|
|
589
|
+
stats: scan.stats,
|
|
590
|
+
message_scanned: scan.message_scanned,
|
|
591
|
+
...(scan.commit ? { commit: scan.commit } : {}),
|
|
592
|
+
...(scan.range ? { range: scan.range } : {}),
|
|
593
|
+
}) + '\n');
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
process.stderr.write(human(findings, tone()));
|
|
597
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
598
|
+
}
|
|
599
|
+
return exitCode(findings, scan.profile, scan.complete);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function cmdInit(args) {
|
|
603
|
+
const { options } = parseArguments(args, {
|
|
604
|
+
options: {
|
|
605
|
+
profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
|
|
606
|
+
global: { names: ['--global'], type: 'boolean' },
|
|
607
|
+
yes: { names: ['--yes'], type: 'boolean' },
|
|
608
|
+
},
|
|
609
|
+
conflicts: [['profile', 'global']],
|
|
610
|
+
maxPositionals: 0,
|
|
611
|
+
});
|
|
612
|
+
if (rejectUnsupportedGit()) return 20;
|
|
613
|
+
if (options.global) {
|
|
614
|
+
if (!options.yes) {
|
|
615
|
+
console.error('aimhooman: init --global changes inherited Git hook behavior; review the warning and rerun with --yes');
|
|
616
|
+
return 20;
|
|
617
|
+
}
|
|
618
|
+
return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
|
|
619
|
+
const aimDir = globalHooksDir();
|
|
620
|
+
let existing = '';
|
|
621
|
+
try {
|
|
622
|
+
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
623
|
+
} catch { /* unset */ }
|
|
624
|
+
if (existing && existing !== aimDir) {
|
|
625
|
+
console.error(
|
|
626
|
+
`aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
|
|
627
|
+
'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
|
|
628
|
+
);
|
|
629
|
+
return 20;
|
|
630
|
+
}
|
|
631
|
+
// A --system hooksPath would be shadowed by the --global write below and
|
|
632
|
+
// could silently disable a system-wide hook manager, so refuse here too.
|
|
633
|
+
let systemHooksPath = '';
|
|
634
|
+
try {
|
|
635
|
+
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
636
|
+
} catch { /* unset or no system config */ }
|
|
637
|
+
if (systemHooksPath) {
|
|
638
|
+
console.error(
|
|
639
|
+
`aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
|
|
640
|
+
'a --global install would shadow it. Unset it or choose a different setup.'
|
|
641
|
+
);
|
|
642
|
+
return 20;
|
|
643
|
+
}
|
|
644
|
+
const localRepo = tryRepo();
|
|
645
|
+
const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
|
|
646
|
+
if (localHooksPath) {
|
|
647
|
+
console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
|
|
648
|
+
}
|
|
649
|
+
const hookSnapshots = REQUIRED_GIT_HOOKS
|
|
650
|
+
.map((name) => snapshotFile(join(aimDir, name)));
|
|
651
|
+
let rep;
|
|
652
|
+
try {
|
|
653
|
+
rep = installGlobalHooks(CLI_PATH);
|
|
654
|
+
if (rep.skipped?.length) {
|
|
655
|
+
for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
|
|
656
|
+
console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
|
|
657
|
+
return 20;
|
|
658
|
+
}
|
|
659
|
+
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir]);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const rollbackFailures = [];
|
|
662
|
+
for (const snapshot of hookSnapshots.reverse()) {
|
|
663
|
+
try { restoreSnapshot(snapshot); }
|
|
664
|
+
catch (rollbackError) {
|
|
665
|
+
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing]);
|
|
670
|
+
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
|
|
671
|
+
} catch (rollbackError) {
|
|
672
|
+
rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
|
|
673
|
+
}
|
|
674
|
+
console.error(`aimhooman: global hook installation failed: ${error.message}`);
|
|
675
|
+
if (rollbackFailures.length) {
|
|
676
|
+
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
677
|
+
}
|
|
678
|
+
return 30;
|
|
679
|
+
}
|
|
680
|
+
console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
|
|
681
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
682
|
+
console.log(' note: this replaces the default .git/hooks directory in non-bare repositories that inherit the global setting; local/worktree core.hooksPath overrides it.');
|
|
683
|
+
return 0;
|
|
684
|
+
}, LIFECYCLE_LOCK_OPTIONS);
|
|
685
|
+
}
|
|
686
|
+
if (options.yes) throw new ArgumentError('--yes is only valid with --global');
|
|
687
|
+
let profile = options.profile || 'clean';
|
|
688
|
+
const profileExplicit = Boolean(options.profile);
|
|
689
|
+
const repo = tryRepo();
|
|
690
|
+
if (!repo) {
|
|
691
|
+
console.error('aimhooman: not a git repository');
|
|
692
|
+
return 30;
|
|
693
|
+
}
|
|
694
|
+
return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
695
|
+
let projectPolicy;
|
|
696
|
+
try {
|
|
697
|
+
projectPolicy = loadProjectPolicy(repo.root);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
console.error(`aimhooman: cannot load project policy: ${e.message}`);
|
|
700
|
+
return 20;
|
|
701
|
+
}
|
|
702
|
+
if (projectPolicy) {
|
|
703
|
+
if (profileExplicit && profile !== projectPolicy.profile) {
|
|
704
|
+
console.error(
|
|
705
|
+
`aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
|
|
706
|
+
'edit .aimhooman.json to change the team baseline'
|
|
707
|
+
);
|
|
708
|
+
return 20;
|
|
709
|
+
}
|
|
710
|
+
profile = projectPolicy.profile;
|
|
711
|
+
}
|
|
712
|
+
let eng;
|
|
713
|
+
try {
|
|
714
|
+
eng = configuredEngine(profile, repo);
|
|
715
|
+
} catch (e) {
|
|
716
|
+
console.error(`aimhooman: cannot initialise policy: ${e.message}`);
|
|
717
|
+
return 20;
|
|
718
|
+
}
|
|
719
|
+
const hookState = hookDiagnostics(repo);
|
|
720
|
+
const hookFiles = hookState.some((hook) => hook.shared)
|
|
721
|
+
? []
|
|
722
|
+
: [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
|
|
723
|
+
let snapshots = [];
|
|
724
|
+
let rep;
|
|
725
|
+
try {
|
|
726
|
+
snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
|
|
727
|
+
.map(snapshotFile);
|
|
728
|
+
rep = installHooks(repo, CLI_PATH);
|
|
729
|
+
const activeHooks = installedHooks(repo);
|
|
730
|
+
if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
|
|
731
|
+
throw new Error('hook installation incomplete; repository guard is not active');
|
|
732
|
+
}
|
|
733
|
+
saveConfig(repo.stateDir, { profile });
|
|
734
|
+
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
735
|
+
const saved = loadConfig(repo.stateDir);
|
|
736
|
+
const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
737
|
+
if (saved.profile !== profile || !excludes.current) {
|
|
738
|
+
throw new Error('post-install state verification failed');
|
|
739
|
+
}
|
|
740
|
+
} catch (error) {
|
|
741
|
+
const rollbackFailures = [];
|
|
742
|
+
try {
|
|
743
|
+
const uninstalled = uninstallHooks(repo);
|
|
744
|
+
rollbackFailures.push(...(uninstalled.failures || []));
|
|
745
|
+
} catch (rollbackError) {
|
|
746
|
+
rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
|
|
747
|
+
}
|
|
748
|
+
const hookSet = new Set(hookFiles);
|
|
749
|
+
const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
|
|
750
|
+
for (const snapshot of snapshots.reverse()) {
|
|
751
|
+
if (!restoreHooks && hookSet.has(snapshot.path)) continue;
|
|
752
|
+
try { restoreSnapshot(snapshot); }
|
|
753
|
+
catch (rollbackError) {
|
|
754
|
+
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (rollbackFailures.length) {
|
|
758
|
+
console.error(`aimhooman: initialisation failed: ${error.message}`);
|
|
759
|
+
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
760
|
+
return 30;
|
|
761
|
+
}
|
|
762
|
+
console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
|
|
763
|
+
return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
|
|
764
|
+
}
|
|
765
|
+
console.log(`aimhooman: initialised (profile: ${profile})`);
|
|
766
|
+
console.log(` state: ${repo.stateDir}`);
|
|
767
|
+
console.log(` excludes: ${repo.excludeFile}`);
|
|
768
|
+
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
769
|
+
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
770
|
+
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
771
|
+
return 0;
|
|
772
|
+
}, LIFECYCLE_LOCK_OPTIONS);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function cmdStatus(args) {
|
|
776
|
+
parseNoArguments(args);
|
|
777
|
+
const repo = tryRepo();
|
|
778
|
+
if (!repo) {
|
|
779
|
+
console.error('aimhooman: not a git repository');
|
|
780
|
+
return 30;
|
|
781
|
+
}
|
|
782
|
+
let policy;
|
|
783
|
+
let overrides;
|
|
784
|
+
try {
|
|
785
|
+
policy = resolvePolicy(repo, { target: 'worktree' });
|
|
786
|
+
overrides = loadOverrides(repo.stateDir);
|
|
787
|
+
} catch (e) {
|
|
788
|
+
console.error(`aimhooman: cannot load enforcement state: ${e.message}`);
|
|
789
|
+
return 20;
|
|
790
|
+
}
|
|
791
|
+
const hooks = hookDiagnostics(repo);
|
|
792
|
+
const installed = hooks.filter((hook) => hook.managed && hook.executable && hook.reachable)
|
|
793
|
+
.map((hook) => hook.name);
|
|
794
|
+
const hooksComplete = REQUIRED_GIT_HOOKS.every((name) => installed.includes(name));
|
|
795
|
+
const { engine, errors } = newEngineWithDiagnostics('clean', repo.stateDir);
|
|
796
|
+
const builtin = engine.rules.filter((rule) => rule.source === 'builtin').length;
|
|
797
|
+
const local = engine.rules.filter((rule) => rule.source === 'local').length;
|
|
798
|
+
let excludes;
|
|
799
|
+
let excludeError = null;
|
|
800
|
+
try {
|
|
801
|
+
excludes = inspectExclude(repo.excludeFile, patternsForRules(engine.rules));
|
|
802
|
+
} catch (e) {
|
|
803
|
+
// A malformed managed-exclude marker must not crash status; degrade like
|
|
804
|
+
// `doctor` and surface the problem instead of returning a raw stack trace.
|
|
805
|
+
excludeError = e.message;
|
|
806
|
+
excludes = { current: false, installed: false };
|
|
807
|
+
}
|
|
808
|
+
const policyLabel = policy.source === 'worktree-policy' ? 'project' : policy.source;
|
|
809
|
+
console.log(`profile: ${policy.profile}`);
|
|
810
|
+
console.log(`policy: ${policyLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})`);
|
|
811
|
+
console.log(`hooks: ${hooksComplete ? installed.join(', ') : installed.length ? `${installed.join(', ')} (incomplete; run: aimhooman init)` : 'not installed (run: aimhooman init)'}`);
|
|
812
|
+
for (const hook of hooks) {
|
|
813
|
+
console.log(`hook ${hook.name}: ${hook.managed ? 'managed' : 'not managed'}, ${hook.executable ? 'executable' : 'not executable'}, ${hook.reachable ? 'reachable' : hook.reason}`);
|
|
814
|
+
}
|
|
815
|
+
console.log(`rules: ${builtin} built-in${local ? ` + ${local} local` : ''}`);
|
|
816
|
+
console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
|
|
817
|
+
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)'}`);
|
|
818
|
+
for (const error of errors) console.log(`warning: ${error.message}`);
|
|
819
|
+
for (const diagnostic of repo.stateDiagnostics || []) console.log(`${diagnostic.level || 'info'}: ${diagnostic.message}`);
|
|
820
|
+
const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
|
|
821
|
+
const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
|
|
822
|
+
console.log(`hooks path: local=${localHooks || 'unset'}, global=${globalHooks || 'unset'}`);
|
|
823
|
+
console.log(`runtime: Node ${process.versions.node}; ${gitVersion()}`);
|
|
824
|
+
console.log(`state: ${repo.stateDir}`);
|
|
825
|
+
return 0;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function cmdExplain(args) {
|
|
829
|
+
const { positionals } = parseArguments(args, { minPositionals: 1, maxPositionals: 1 });
|
|
830
|
+
const id = positionals[0];
|
|
831
|
+
const repo = tryRepo();
|
|
832
|
+
let eng;
|
|
833
|
+
try {
|
|
834
|
+
eng = configuredEngine('clean', repo);
|
|
835
|
+
} catch (e) {
|
|
836
|
+
console.error(`aimhooman: cannot load policy rules: ${e.message}`);
|
|
837
|
+
return 20;
|
|
838
|
+
}
|
|
839
|
+
const r = eng.lookup(id);
|
|
840
|
+
if (!r) {
|
|
841
|
+
console.error(`aimhooman: no rule with id "${id}"`);
|
|
842
|
+
return 20;
|
|
843
|
+
}
|
|
844
|
+
const af = (p) => r.actions[p] || r.actions.clean || 'review';
|
|
845
|
+
console.log(`Rule: ${r.id} (v${r.version || 1})`);
|
|
846
|
+
console.log(`Category: ${r.category}`);
|
|
847
|
+
console.log(`Provider: ${r.provider}`);
|
|
848
|
+
console.log(`Confidence: ${r.confidence}`);
|
|
849
|
+
console.log(`Actions: clean=${af('clean')} strict=${af('strict')} compliance=${af('compliance')}`);
|
|
850
|
+
console.log(`Reason: ${r.reason}`);
|
|
851
|
+
if (r.match?.paths) console.log(`Paths: ${r.match.paths.join(', ')}`);
|
|
852
|
+
if (r.match?.content) console.log(`Content: ${r.match.content.join(', ')}`);
|
|
853
|
+
if (r.remediation?.length) {
|
|
854
|
+
console.log('Remediation:');
|
|
855
|
+
for (const s of r.remediation) console.log(` - ${s}`);
|
|
856
|
+
}
|
|
857
|
+
if (r.references?.length) console.log(`References: ${r.references.join(', ')}`);
|
|
858
|
+
return 0;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function cmdOverride(args, allow) {
|
|
862
|
+
const verb = allow ? 'allow' : 'deny';
|
|
863
|
+
const { options, positionals } = parseArguments(args, {
|
|
864
|
+
options: {
|
|
865
|
+
reason: { names: ['--reason'], type: 'string', nonEmpty: false },
|
|
866
|
+
scope: { names: ['--scope'], type: 'string', choices: ['path', 'rule', 'secret-path'] },
|
|
867
|
+
},
|
|
868
|
+
minPositionals: 1,
|
|
869
|
+
maxPositionals: 1,
|
|
870
|
+
});
|
|
871
|
+
const target = normalizeOverrideTarget(positionals[0]);
|
|
872
|
+
if (!target) throw new ArgumentError('override target must not be empty');
|
|
873
|
+
const repo = tryRepo();
|
|
874
|
+
if (!repo) {
|
|
875
|
+
console.error('aimhooman: not a git repository');
|
|
876
|
+
return 30;
|
|
877
|
+
}
|
|
878
|
+
const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
|
|
879
|
+
const scope = options.scope || (engine.lookup(target) ? 'rule' : 'path');
|
|
880
|
+
if (scope === 'rule' && !engine.lookup(target)) {
|
|
881
|
+
throw new ArgumentError(`unknown rule ID "${target}"; use --scope path for a path with this spelling`);
|
|
882
|
+
}
|
|
883
|
+
if (allow && scope === 'rule' && engine.lookup(target)?.category === 'secret') {
|
|
884
|
+
throw new ArgumentError(
|
|
885
|
+
`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`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
if (scope === 'secret-path' && !allow) {
|
|
889
|
+
throw new ArgumentError('--scope secret-path is only valid with allow');
|
|
890
|
+
}
|
|
891
|
+
const entry = {
|
|
892
|
+
target,
|
|
893
|
+
scope,
|
|
894
|
+
reason: options.reason || '',
|
|
895
|
+
actor: gitConfig(repo.root, 'user.email'),
|
|
896
|
+
at: new Date().toISOString(),
|
|
897
|
+
};
|
|
898
|
+
// Hold an advisory lock across load-modify-save so concurrent override
|
|
899
|
+
// writes (e.g. two worktrees) cannot lose each other's entries.
|
|
900
|
+
return withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
901
|
+
const ov = loadOverrides(repo.stateDir);
|
|
902
|
+
if (allow) {
|
|
903
|
+
ov.allow = upsert(ov.allow, entry, (candidate) => (
|
|
904
|
+
candidate.target === target && effectiveOverrideScope(candidate, engine) === scope
|
|
905
|
+
));
|
|
906
|
+
ov.deny = ov.deny.filter((candidate) => !(
|
|
907
|
+
candidate.target === target && effectiveOverrideScope(candidate, engine) === scope
|
|
908
|
+
));
|
|
909
|
+
} else {
|
|
910
|
+
ov.deny = upsert(ov.deny, entry, (candidate) => (
|
|
911
|
+
candidate.target === target && effectiveOverrideScope(candidate, engine) === scope
|
|
912
|
+
));
|
|
913
|
+
ov.allow = ov.allow.filter((candidate) => !(
|
|
914
|
+
candidate.target === target && effectiveOverrideScope(candidate, engine) === scope
|
|
915
|
+
));
|
|
916
|
+
}
|
|
917
|
+
saveOverrides(repo.stateDir, ov);
|
|
918
|
+
console.log(`aimhooman: ${allow ? 'allowed' : 'denied'} "${target}"`);
|
|
919
|
+
return 0;
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function cmdOverrideLifecycle(args) {
|
|
924
|
+
const [action, ...rest] = args;
|
|
925
|
+
if (!action) throw new ArgumentError('override requires list, remove, or reset');
|
|
926
|
+
if (!['list', 'remove', 'reset'].includes(action)) {
|
|
927
|
+
throw new ArgumentError(`unknown override action "${action}"`);
|
|
928
|
+
}
|
|
929
|
+
const repo = tryRepo();
|
|
930
|
+
if (!repo) {
|
|
931
|
+
console.error('aimhooman: not a git repository');
|
|
932
|
+
return 30;
|
|
933
|
+
}
|
|
934
|
+
if (action === 'list') {
|
|
935
|
+
const overrides = loadOverrides(repo.stateDir);
|
|
936
|
+
const { options } = parseArguments(rest, {
|
|
937
|
+
options: { json: { names: ['--json'], type: 'boolean' } },
|
|
938
|
+
maxPositionals: 0,
|
|
939
|
+
});
|
|
940
|
+
if (options.json) {
|
|
941
|
+
process.stdout.write(JSON.stringify({ schema_version: 1, ...overrides }, null, 2) + '\n');
|
|
942
|
+
} else if (!overrides.allow.length && !overrides.deny.length) {
|
|
943
|
+
console.log('aimhooman: no overrides');
|
|
944
|
+
} else {
|
|
945
|
+
for (const kind of ['allow', 'deny']) {
|
|
946
|
+
for (const entry of overrides[kind]) {
|
|
947
|
+
const binding = entry.head ? ` @ ${entry.head}` : '';
|
|
948
|
+
const reviewBinding = entry.transition
|
|
949
|
+
? ` [${entry.transition} -> ${entry.newObjectId ? `${entry.newMode}:${entry.newObjectId}` : 'deletion'}]`
|
|
950
|
+
: '';
|
|
951
|
+
console.log(`${kind.padEnd(5)} ${entry.target}${binding}${reviewBinding}${entry.reason ? ` — ${entry.reason}` : ''}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return 0;
|
|
956
|
+
}
|
|
957
|
+
if (action === 'remove') {
|
|
958
|
+
const { positionals } = parseArguments(rest, { minPositionals: 1, maxPositionals: 1 });
|
|
959
|
+
const target = normalizeOverrideTarget(positionals[0]);
|
|
960
|
+
return withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
961
|
+
const overrides = loadOverrides(repo.stateDir);
|
|
962
|
+
const before = overrides.allow.length + overrides.deny.length;
|
|
963
|
+
overrides.allow = overrides.allow.filter((entry) => entry.target !== target);
|
|
964
|
+
overrides.deny = overrides.deny.filter((entry) => entry.target !== target);
|
|
965
|
+
if (before === overrides.allow.length + overrides.deny.length) {
|
|
966
|
+
console.error(`aimhooman: no override for "${target}"`);
|
|
967
|
+
return 20;
|
|
968
|
+
}
|
|
969
|
+
saveOverrides(repo.stateDir, overrides);
|
|
970
|
+
console.log(`aimhooman: removed override "${target}"`);
|
|
971
|
+
return 0;
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
if (action === 'reset') {
|
|
975
|
+
const { options } = parseArguments(rest, {
|
|
976
|
+
options: {
|
|
977
|
+
allow: { names: ['--allow'], type: 'boolean' },
|
|
978
|
+
deny: { names: ['--deny'], type: 'boolean' },
|
|
979
|
+
all: { names: ['--all'], type: 'boolean' },
|
|
980
|
+
},
|
|
981
|
+
conflicts: [['allow', 'deny', 'all']],
|
|
982
|
+
maxPositionals: 0,
|
|
983
|
+
});
|
|
984
|
+
return withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
985
|
+
const overrides = loadOverrides(repo.stateDir);
|
|
986
|
+
if (options.allow) overrides.allow = [];
|
|
987
|
+
else if (options.deny) overrides.deny = [];
|
|
988
|
+
else overrides.allow = overrides.deny = [];
|
|
989
|
+
saveOverrides(repo.stateDir, overrides);
|
|
990
|
+
console.log(`aimhooman: reset ${options.allow ? 'allow' : options.deny ? 'deny' : 'all'} overrides`);
|
|
991
|
+
return 0;
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
throw new Error('unreachable override action');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function cmdReview(args) {
|
|
998
|
+
const { options, positionals } = parseArguments(args, {
|
|
999
|
+
options: {
|
|
1000
|
+
head: { names: ['--head'], type: 'string' },
|
|
1001
|
+
commit: { names: ['--commit'], type: 'string' },
|
|
1002
|
+
reason: { names: ['--reason'], type: 'string', nonEmpty: false },
|
|
1003
|
+
},
|
|
1004
|
+
minPositionals: 1,
|
|
1005
|
+
maxPositionals: 1,
|
|
1006
|
+
});
|
|
1007
|
+
if (!options.head) throw new ArgumentError('review requires --head <commit>');
|
|
1008
|
+
const repo = tryRepo();
|
|
1009
|
+
if (!repo) {
|
|
1010
|
+
console.error('aimhooman: not a git repository');
|
|
1011
|
+
return 30;
|
|
1012
|
+
}
|
|
1013
|
+
const target = normalizeOverrideTarget(positionals[0]);
|
|
1014
|
+
if (!target) throw new ArgumentError('review target must not be empty');
|
|
1015
|
+
const head = resolveCommit(repo, options.head);
|
|
1016
|
+
const { engine } = newEngineWithDiagnostics('strict', repo.stateDir);
|
|
1017
|
+
const finding = engine.checkPaths([target])[0];
|
|
1018
|
+
const instruction = finding?.matchedRuleIds?.includes('generic.agent-instructions');
|
|
1019
|
+
const projectPolicy = finding?.matchedRuleIds?.includes('generic.project-policy');
|
|
1020
|
+
if (!instruction && !projectPolicy) {
|
|
1021
|
+
throw new ArgumentError('review accepts only a covered agent instruction or project policy path');
|
|
1022
|
+
}
|
|
1023
|
+
let reviewed;
|
|
1024
|
+
let transition;
|
|
1025
|
+
let tombstone = false;
|
|
1026
|
+
if (options.commit) {
|
|
1027
|
+
transition = resolveCommit(repo, options.commit);
|
|
1028
|
+
reviewed = readCommitPath(repo, transition, target);
|
|
1029
|
+
if (reviewed.status === 'missing') {
|
|
1030
|
+
const { parents } = commitParents(repo, transition);
|
|
1031
|
+
const existedInParent = parents.some((parent) => (
|
|
1032
|
+
readCommitPath(repo, parent, target).status === 'present'
|
|
1033
|
+
));
|
|
1034
|
+
if (existedInParent) tombstone = true;
|
|
1035
|
+
}
|
|
1036
|
+
} else {
|
|
1037
|
+
transition = 'staged';
|
|
1038
|
+
reviewed = readStagedPath(repo, target);
|
|
1039
|
+
if (reviewed.status === 'missing') {
|
|
1040
|
+
const baseline = readCommitPath(repo, head, target);
|
|
1041
|
+
if (baseline.status === 'present') tombstone = true;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if ((!tombstone && reviewed.status !== 'present') || (!tombstone && !reviewed.oid)) {
|
|
1045
|
+
throw new ArgumentError(`review target "${target}" is missing from the selected Git snapshot`);
|
|
1046
|
+
}
|
|
1047
|
+
if (!tombstone && !['100644', '100755'].includes(reviewed.mode)) {
|
|
1048
|
+
throw new ArgumentError(
|
|
1049
|
+
`review target "${target}" must be a regular Git file, not mode ${reviewed.mode || 'unknown'}`,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
const entry = {
|
|
1053
|
+
target,
|
|
1054
|
+
scope: instruction ? 'reviewed-instruction' : 'reviewed-policy-file',
|
|
1055
|
+
head,
|
|
1056
|
+
transition,
|
|
1057
|
+
newObjectId: tombstone ? null : reviewed.oid,
|
|
1058
|
+
newMode: tombstone ? null : reviewed.mode,
|
|
1059
|
+
reason: options.reason || '',
|
|
1060
|
+
actor: gitConfig(repo.root, 'user.email'),
|
|
1061
|
+
at: new Date().toISOString(),
|
|
1062
|
+
};
|
|
1063
|
+
return withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
1064
|
+
const overrides = loadOverrides(repo.stateDir);
|
|
1065
|
+
overrides.allow = upsert(overrides.allow, entry, (candidate) => (
|
|
1066
|
+
candidate.target === target
|
|
1067
|
+
&& candidate.scope === entry.scope
|
|
1068
|
+
&& candidate.head === head
|
|
1069
|
+
&& candidate.transition === transition
|
|
1070
|
+
&& candidate.newObjectId === entry.newObjectId
|
|
1071
|
+
&& candidate.newMode === entry.newMode
|
|
1072
|
+
));
|
|
1073
|
+
saveOverrides(repo.stateDir, overrides);
|
|
1074
|
+
const object = entry.newObjectId ? `${entry.newMode} blob ${entry.newObjectId}` : 'deletion';
|
|
1075
|
+
console.log(`aimhooman: recorded review for "${target}" at ${head} (${object}, ${transition})`);
|
|
1076
|
+
return 0;
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function cmdPolicyReview(args) {
|
|
1081
|
+
const { options } = parseArguments(args, {
|
|
1082
|
+
options: {
|
|
1083
|
+
head: { names: ['--head'], type: 'string' },
|
|
1084
|
+
transition: { names: ['--transition'], type: 'string' },
|
|
1085
|
+
staged: { names: ['--staged'], type: 'boolean' },
|
|
1086
|
+
old: { names: ['--old'], type: 'string' },
|
|
1087
|
+
new: { names: ['--new'], type: 'string' },
|
|
1088
|
+
reason: { names: ['--reason'], type: 'string', nonEmpty: false },
|
|
1089
|
+
},
|
|
1090
|
+
conflicts: [['transition', 'staged']],
|
|
1091
|
+
maxPositionals: 0,
|
|
1092
|
+
});
|
|
1093
|
+
for (const name of ['head', 'old', 'new']) {
|
|
1094
|
+
if (!options[name]) throw new ArgumentError(`policy-review requires --${name}`);
|
|
1095
|
+
}
|
|
1096
|
+
if (!options.staged && !options.transition) {
|
|
1097
|
+
throw new ArgumentError('policy-review requires --staged or --transition <commit>');
|
|
1098
|
+
}
|
|
1099
|
+
const repo = tryRepo();
|
|
1100
|
+
if (!repo) {
|
|
1101
|
+
console.error('aimhooman: not a git repository');
|
|
1102
|
+
return 30;
|
|
1103
|
+
}
|
|
1104
|
+
const head = resolveCommit(repo, options.head);
|
|
1105
|
+
const transition = options.staged ? 'staged' : resolveCommit(repo, options.transition);
|
|
1106
|
+
const expectedOld = normalizeObjectId(options.old, false);
|
|
1107
|
+
const expectedNew = normalizeObjectId(options.new, true);
|
|
1108
|
+
let oldPolicies;
|
|
1109
|
+
let newPolicy;
|
|
1110
|
+
if (options.staged) {
|
|
1111
|
+
oldPolicies = [resolvePolicy(repo, { target: 'commit', revision: head })];
|
|
1112
|
+
newPolicy = resolvePolicy(repo, { target: 'staged' });
|
|
1113
|
+
} else {
|
|
1114
|
+
const transitionInfo = commitParents(repo, transition);
|
|
1115
|
+
oldPolicies = transitionInfo.parents.map((parent) => (
|
|
1116
|
+
resolvePolicy(repo, { target: 'commit', revision: parent })
|
|
1117
|
+
));
|
|
1118
|
+
newPolicy = resolvePolicy(repo, { target: 'commit', revision: transition });
|
|
1119
|
+
}
|
|
1120
|
+
const oldPolicy = oldPolicies.find((policy) => policy.policy_object_id === expectedOld);
|
|
1121
|
+
if (!oldPolicy || oldPolicy.profile !== 'strict') {
|
|
1122
|
+
throw new ArgumentError('--old must identify a strict policy object in the reviewed transition baseline');
|
|
1123
|
+
}
|
|
1124
|
+
if ((newPolicy.policy_object_id ?? null) !== expectedNew) {
|
|
1125
|
+
throw new ArgumentError('--new does not identify the policy object at the reviewed transition');
|
|
1126
|
+
}
|
|
1127
|
+
const newMode = newPolicy.policy_mode;
|
|
1128
|
+
if (newMode !== null && !['100644', '100755'].includes(newMode)) {
|
|
1129
|
+
throw new ArgumentError(
|
|
1130
|
+
`policy-review target must be a regular Git file, not mode ${newMode || 'unknown'}`,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (newPolicy.profile === 'strict' && newPolicy.policy_object_id) {
|
|
1134
|
+
throw new ArgumentError('policy-review is only for an intentional strict policy downgrade or removal');
|
|
1135
|
+
}
|
|
1136
|
+
const entry = {
|
|
1137
|
+
target: '.aimhooman.json',
|
|
1138
|
+
scope: 'policy-migration',
|
|
1139
|
+
head,
|
|
1140
|
+
transition,
|
|
1141
|
+
oldObjectId: expectedOld,
|
|
1142
|
+
newObjectId: expectedNew,
|
|
1143
|
+
newMode,
|
|
1144
|
+
reason: options.reason || '',
|
|
1145
|
+
actor: gitConfig(repo.root, 'user.email'),
|
|
1146
|
+
at: new Date().toISOString(),
|
|
1147
|
+
};
|
|
1148
|
+
return withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
1149
|
+
const overrides = loadOverrides(repo.stateDir);
|
|
1150
|
+
overrides.allow = upsert(overrides.allow, entry, (candidate) => (
|
|
1151
|
+
candidate.scope === entry.scope
|
|
1152
|
+
&& candidate.head === head
|
|
1153
|
+
&& candidate.transition === transition
|
|
1154
|
+
&& candidate.oldObjectId === expectedOld
|
|
1155
|
+
&& (candidate.newObjectId ?? null) === expectedNew
|
|
1156
|
+
&& (candidate.newMode ?? null) === newMode
|
|
1157
|
+
));
|
|
1158
|
+
saveOverrides(repo.stateDir, overrides);
|
|
1159
|
+
console.log(`aimhooman: recorded policy review for ${transition} at ${head}`);
|
|
1160
|
+
return 0;
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function normalizeObjectId(value, allowMissing) {
|
|
1165
|
+
if (allowMissing && value === 'missing') return null;
|
|
1166
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value || '')) {
|
|
1167
|
+
throw new ArgumentError(`object ID must be a full SHA-1/SHA-256 value${allowMissing ? ' or "missing"' : ''}`);
|
|
1168
|
+
}
|
|
1169
|
+
return value.toLowerCase();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function upsert(list, entry, matches = (candidate) => candidate.target === entry.target) {
|
|
1173
|
+
const i = list.findIndex(matches);
|
|
1174
|
+
if (i >= 0) {
|
|
1175
|
+
list[i] = entry;
|
|
1176
|
+
return list;
|
|
1177
|
+
}
|
|
1178
|
+
return [...list, entry];
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function cmdFix(args) {
|
|
1182
|
+
const { options } = parseArguments(args, {
|
|
1183
|
+
options: {
|
|
1184
|
+
message: { names: ['--message', '-m'], type: 'string' },
|
|
1185
|
+
apply: { names: ['--apply'], type: 'boolean' },
|
|
1186
|
+
},
|
|
1187
|
+
maxPositionals: 0,
|
|
1188
|
+
});
|
|
1189
|
+
if (!options.message) throw new ArgumentError('fix requires --message <file>');
|
|
1190
|
+
const file = options.message;
|
|
1191
|
+
let sourceBytes;
|
|
1192
|
+
try {
|
|
1193
|
+
sourceBytes = readFileSync(file);
|
|
1194
|
+
} catch (e) {
|
|
1195
|
+
console.error(`aimhooman: cannot read message file: ${e.message}`);
|
|
1196
|
+
return 30;
|
|
1197
|
+
}
|
|
1198
|
+
const data = sourceBytes.toString('utf8');
|
|
1199
|
+
const validUtf8 = Buffer.from(data, 'utf8').equals(sourceBytes);
|
|
1200
|
+
const repo = tryRepo();
|
|
1201
|
+
if (!repo) {
|
|
1202
|
+
console.error('aimhooman: not a git repository');
|
|
1203
|
+
return 30;
|
|
1204
|
+
}
|
|
1205
|
+
let scan;
|
|
1206
|
+
try {
|
|
1207
|
+
scan = scanMessage(repo, data, { target: 'staged' });
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
console.error(`aimhooman: cannot inspect message: ${e.message}`);
|
|
1210
|
+
return expectedErrorCode(e);
|
|
1211
|
+
}
|
|
1212
|
+
emitDiagnostics(scan.diagnostics);
|
|
1213
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
1214
|
+
if (scan.profile === 'compliance') {
|
|
1215
|
+
if (options.apply) throw new ArgumentError('--apply is only valid when the active profile is strict');
|
|
1216
|
+
if (scan.findings.length) process.stderr.write(human(scan.findings, tone()));
|
|
1217
|
+
console.log('aimhooman: compliance policy preserves attribution disclosures; no changes made');
|
|
1218
|
+
return exitCode(scan.findings, scan.profile, scan.complete);
|
|
1219
|
+
}
|
|
1220
|
+
let repair = scan.repair;
|
|
1221
|
+
if (scan.profile === 'strict') {
|
|
1222
|
+
const previewPolicy = {
|
|
1223
|
+
profile: 'clean',
|
|
1224
|
+
source: scan.policy_source,
|
|
1225
|
+
target: scan.target,
|
|
1226
|
+
policy_object_id: scan.policy_object_id,
|
|
1227
|
+
};
|
|
1228
|
+
try {
|
|
1229
|
+
repair = engineForPolicy(repo, previewPolicy).engine.fixMessage(data);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
console.error(`aimhooman: cannot prepare safe repair: ${error.message}`);
|
|
1232
|
+
return expectedErrorCode(error);
|
|
1233
|
+
}
|
|
1234
|
+
if (!options.apply) {
|
|
1235
|
+
let remaining = scan.findings;
|
|
1236
|
+
let remainingComplete = scan.complete;
|
|
1237
|
+
if (repair.removed.length) {
|
|
1238
|
+
try {
|
|
1239
|
+
const recheck = scanMessage(repo, repair.cleaned, { target: 'staged' });
|
|
1240
|
+
remaining = recheck.findings;
|
|
1241
|
+
remainingComplete = recheck.complete;
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
console.error(`aimhooman: cannot verify repair preview: ${error.message}`);
|
|
1244
|
+
return expectedErrorCode(error);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (remaining.length) process.stderr.write(human(remaining, tone()));
|
|
1248
|
+
console.error(
|
|
1249
|
+
repair.removed.length
|
|
1250
|
+
? `aimhooman: strict policy would remove ${repair.removed.length} exact attribution line(s); rerun with --apply to write the repair`
|
|
1251
|
+
: 'aimhooman: strict policy found no automatically repairable attribution lines'
|
|
1252
|
+
);
|
|
1253
|
+
return remaining.some((finding) => finding.decision === 'block')
|
|
1254
|
+
? 10
|
|
1255
|
+
: repair.removed.length ? 11 : exitCode(remaining, scan.profile, remainingComplete);
|
|
1256
|
+
}
|
|
1257
|
+
} else if (options.apply) {
|
|
1258
|
+
throw new ArgumentError('--apply is only needed when the active profile is strict');
|
|
1259
|
+
}
|
|
1260
|
+
const { cleaned, removed } = repair;
|
|
1261
|
+
if (!removed.length) {
|
|
1262
|
+
if (scan.findings.length) process.stderr.write(human(scan.findings, tone()));
|
|
1263
|
+
console.log('aimhooman: nothing to fix');
|
|
1264
|
+
return exitCode(scan.findings, scan.profile, scan.complete);
|
|
1265
|
+
}
|
|
1266
|
+
if (!validUtf8) {
|
|
1267
|
+
console.error('aimhooman: message is not valid UTF-8; no bytes were changed because a safe repair cannot be proved');
|
|
1268
|
+
if (scan.findings.length) process.stderr.write(human(scan.findings, tone()));
|
|
1269
|
+
return 10;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
atomicWrite(file + '.aimhooman-bak', sourceBytes);
|
|
1273
|
+
atomicWrite(file, cleaned);
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
console.error(`aimhooman: cannot write fixed message: ${e.message}`);
|
|
1276
|
+
return 30;
|
|
1277
|
+
}
|
|
1278
|
+
console.log(`aimhooman: removed ${removed.length} attribution line(s); backup at ${file}.aimhooman-bak`);
|
|
1279
|
+
let verified;
|
|
1280
|
+
try {
|
|
1281
|
+
verified = scanMessage(repo, cleaned, { target: 'staged' });
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
console.error(`aimhooman: repair was written but verification failed: ${error.message}`);
|
|
1284
|
+
return expectedErrorCode(error);
|
|
1285
|
+
}
|
|
1286
|
+
emitDiagnostics(verified.diagnostics);
|
|
1287
|
+
if (verified.findings.length) process.stderr.write(human(verified.findings, tone()));
|
|
1288
|
+
return exitCode(verified.findings, verified.profile, verified.complete);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function cmdDoctor(args) {
|
|
1292
|
+
parseNoArguments(args);
|
|
1293
|
+
const repo = tryRepo();
|
|
1294
|
+
if (!repo) {
|
|
1295
|
+
console.error('aimhooman: not a git repository');
|
|
1296
|
+
return 30;
|
|
1297
|
+
}
|
|
1298
|
+
let ok = true;
|
|
1299
|
+
try {
|
|
1300
|
+
const worktree = resolvePolicy(repo, { target: 'worktree' });
|
|
1301
|
+
const staged = resolvePolicy(repo, { target: 'staged' });
|
|
1302
|
+
let head = 'unborn';
|
|
1303
|
+
try {
|
|
1304
|
+
const committed = resolvePolicy(repo, { target: 'commit', revision: 'HEAD' });
|
|
1305
|
+
head = `${committed.profile}/${committed.source}`;
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (!(error instanceof GitRevisionError)) throw error;
|
|
1308
|
+
}
|
|
1309
|
+
console.log(`ok policy loads (worktree=${worktree.profile}/${worktree.source}, staged=${staged.profile}/${staged.source}, HEAD=${head})`);
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
console.log(`x project policy: ${e.message}`);
|
|
1312
|
+
ok = false;
|
|
1313
|
+
}
|
|
1314
|
+
try {
|
|
1315
|
+
const { engine, errors } = newEngineWithDiagnostics('clean', repo.stateDir);
|
|
1316
|
+
console.log(`ok rule pack loads (${engine.rules.length} rules)`);
|
|
1317
|
+
for (const error of errors) {
|
|
1318
|
+
console.log(`x local rule pack: ${error.message}`);
|
|
1319
|
+
ok = false;
|
|
1320
|
+
}
|
|
1321
|
+
const excludes = inspectExclude(repo.excludeFile, patternsForRules(engine.rules));
|
|
1322
|
+
if (excludes.current) console.log('ok managed excludes are current');
|
|
1323
|
+
else {
|
|
1324
|
+
console.log('x managed excludes missing or out of date (run: aimhooman init)');
|
|
1325
|
+
ok = false;
|
|
1326
|
+
}
|
|
1327
|
+
} catch (e) {
|
|
1328
|
+
console.log(`x rule pack: ${e.message}`);
|
|
1329
|
+
ok = false;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const overrides = loadOverrides(repo.stateDir);
|
|
1333
|
+
const overrideEngine = newEngineWithDiagnostics('clean', repo.stateDir).engine;
|
|
1334
|
+
const duplicate = duplicateOverride(overrides.allow, overrideEngine)
|
|
1335
|
+
|| duplicateOverride(overrides.deny, overrideEngine);
|
|
1336
|
+
const allowTargets = new Set(overrides.allow
|
|
1337
|
+
.filter((entry) => !entry.head)
|
|
1338
|
+
.map((entry) => overrideIdentity(entry, overrideEngine)));
|
|
1339
|
+
const conflict = overrides.deny.find((entry) => (
|
|
1340
|
+
!entry.head && allowTargets.has(overrideIdentity(entry, overrideEngine))
|
|
1341
|
+
));
|
|
1342
|
+
if (duplicate || conflict) {
|
|
1343
|
+
console.log(`x overrides contain ${duplicate ? `duplicate "${duplicate}"` : `allow/deny conflict "${conflict.target}"`}`);
|
|
1344
|
+
ok = false;
|
|
1345
|
+
} else {
|
|
1346
|
+
console.log(`ok overrides load (${overrides.allow.length} allow, ${overrides.deny.length} deny)`);
|
|
1347
|
+
}
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
console.log(`x overrides: ${error.message}`);
|
|
1350
|
+
ok = false;
|
|
1351
|
+
}
|
|
1352
|
+
const hooks = hookDiagnostics(repo);
|
|
1353
|
+
const incompleteHooks = hooks.filter((hook) => !hook.managed || !hook.executable || !hook.reachable);
|
|
1354
|
+
if (incompleteHooks.length) console.log(`x hooks incomplete (${incompleteHooks.map((hook) => hook.name).join(', ')}; run: aimhooman init)`);
|
|
1355
|
+
for (const hook of hooks) {
|
|
1356
|
+
if (!hook.managed || !hook.executable || !hook.reachable) {
|
|
1357
|
+
console.log(`x ${hook.name} hook: ${hook.reason || 'not active'} (${hook.path})`);
|
|
1358
|
+
ok = false;
|
|
1359
|
+
} else {
|
|
1360
|
+
console.log(`ok ${hook.name} hook v${hook.version} is fingerprint-valid and reachable`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
|
|
1364
|
+
const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
|
|
1365
|
+
console.log(`ok hook paths inspected (local=${localHooks || 'unset'}, global=${globalHooks || 'unset'})`);
|
|
1366
|
+
const adapters = adapterStatus();
|
|
1367
|
+
if (adapters.missing.length) {
|
|
1368
|
+
console.log(`x host adapters missing: ${adapters.missing.join(', ')}`);
|
|
1369
|
+
ok = false;
|
|
1370
|
+
} else {
|
|
1371
|
+
console.log(`ok host adapters present (${adapters.present}/${adapters.total})`);
|
|
1372
|
+
}
|
|
1373
|
+
for (const diagnostic of repo.stateDiagnostics || []) {
|
|
1374
|
+
console.log(`${diagnostic.level === 'error' ? 'x' : '!'} state: ${diagnostic.message}`);
|
|
1375
|
+
if (diagnostic.level === 'error') ok = false;
|
|
1376
|
+
}
|
|
1377
|
+
const gitRuntime = supportedGitVersion();
|
|
1378
|
+
if (gitRuntime.supported) {
|
|
1379
|
+
console.log(`ok runtime Node ${process.versions.node}; ${gitRuntime.display}`);
|
|
1380
|
+
} else {
|
|
1381
|
+
console.log(`x runtime Node ${process.versions.node}; ${gitRuntime.display} (Git 2.28.0+ required)`);
|
|
1382
|
+
ok = false;
|
|
1383
|
+
}
|
|
1384
|
+
console.log(`ok state directory ${repo.stateDir}`);
|
|
1385
|
+
if (!ok) return 20;
|
|
1386
|
+
console.log('aimhooman: healthy');
|
|
1387
|
+
return 0;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function duplicateOverride(entries, engine) {
|
|
1391
|
+
const seen = new Set();
|
|
1392
|
+
for (const entry of entries) {
|
|
1393
|
+
const key = [
|
|
1394
|
+
entry.target,
|
|
1395
|
+
effectiveOverrideScope(entry, engine),
|
|
1396
|
+
entry.head || '',
|
|
1397
|
+
entry.transition || '',
|
|
1398
|
+
entry.oldObjectId || '',
|
|
1399
|
+
entry.newObjectId ?? '',
|
|
1400
|
+
].join('\0');
|
|
1401
|
+
if (seen.has(key)) return entry.target;
|
|
1402
|
+
seen.add(key);
|
|
1403
|
+
}
|
|
1404
|
+
return '';
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function effectiveOverrideScope(entry, engine) {
|
|
1408
|
+
return entry.scope ?? (engine.lookup(entry.target) ? 'rule' : 'path');
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function overrideIdentity(entry, engine) {
|
|
1412
|
+
return `${effectiveOverrideScope(entry, engine)}\0${entry.target}`;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function adapterStatus() {
|
|
1416
|
+
try {
|
|
1417
|
+
const registry = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'docs/hosts.json'), 'utf8'));
|
|
1418
|
+
const paths = [...new Set((registry.hosts || []).flatMap((host) => host.files || []))];
|
|
1419
|
+
const missing = [];
|
|
1420
|
+
for (const path of paths) {
|
|
1421
|
+
try { readFileSync(join(PACKAGE_ROOT, path)); }
|
|
1422
|
+
catch { missing.push(path); }
|
|
1423
|
+
}
|
|
1424
|
+
return { total: paths.length, present: paths.length - missing.length, missing };
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
return { total: 1, present: 0, missing: [`host registry unavailable: ${error.message}`] };
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function cmdUninstall(args) {
|
|
1431
|
+
const { options } = parseArguments(args, {
|
|
1432
|
+
options: {
|
|
1433
|
+
global: { names: ['--global'], type: 'boolean' },
|
|
1434
|
+
purgeState: { names: ['--purge-state'], type: 'boolean' },
|
|
1435
|
+
},
|
|
1436
|
+
conflicts: [['global', 'purgeState']],
|
|
1437
|
+
maxPositionals: 0,
|
|
1438
|
+
});
|
|
1439
|
+
if (options.global) {
|
|
1440
|
+
return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
|
|
1441
|
+
const aimDir = globalHooksDir();
|
|
1442
|
+
const readGlobalHooksPath = () => {
|
|
1443
|
+
try {
|
|
1444
|
+
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
1445
|
+
} catch { return ''; }
|
|
1446
|
+
};
|
|
1447
|
+
const current = readGlobalHooksPath();
|
|
1448
|
+
let unsetAttempted = false;
|
|
1449
|
+
if (current === aimDir) {
|
|
1450
|
+
unsetAttempted = true;
|
|
1451
|
+
try {
|
|
1452
|
+
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
1453
|
+
} catch { /* may have failed; re-verify below */ }
|
|
1454
|
+
// A read-only or locked ~/.gitconfig can make --unset fail silently.
|
|
1455
|
+
// Do not remove the dispatchers while core.hooksPath still resolves to
|
|
1456
|
+
// them, or every repository inheriting this setting would run zero
|
|
1457
|
+
// hooks with no warning. Preserve the working dispatchers until the
|
|
1458
|
+
// config can be cleaned.
|
|
1459
|
+
if (readGlobalHooksPath() === aimDir) {
|
|
1460
|
+
console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
|
|
1461
|
+
console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
|
|
1462
|
+
return 30;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const rep = uninstallGlobalHooks();
|
|
1466
|
+
console.log('aimhooman: global hooks uninstalled');
|
|
1467
|
+
if (current && current !== aimDir) {
|
|
1468
|
+
console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
|
|
1469
|
+
} else {
|
|
1470
|
+
console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
|
|
1471
|
+
}
|
|
1472
|
+
if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
|
|
1473
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1474
|
+
console.log(` dir kept at ${rep.dir}`);
|
|
1475
|
+
return 0;
|
|
1476
|
+
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1477
|
+
}
|
|
1478
|
+
const purge = Boolean(options.purgeState);
|
|
1479
|
+
const repo = tryRepo();
|
|
1480
|
+
if (!repo) {
|
|
1481
|
+
console.error('aimhooman: not a git repository');
|
|
1482
|
+
return 30;
|
|
1483
|
+
}
|
|
1484
|
+
return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
1485
|
+
const rep = uninstallHooks(repo);
|
|
1486
|
+
removeExclude(repo.excludeFile);
|
|
1487
|
+
console.log('aimhooman: uninstalled');
|
|
1488
|
+
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1489
|
+
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1490
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1491
|
+
for (const f of rep.failures || []) console.log(` warning: ${f}`);
|
|
1492
|
+
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1493
|
+
if (purge) {
|
|
1494
|
+
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
1495
|
+
// a per-hook restore failure leaves the user's original hook existing
|
|
1496
|
+
// only in <stateDir>/chained, so purging would destroy it irrecoverably.
|
|
1497
|
+
if (unrestored.length || rep.failures?.length) {
|
|
1498
|
+
console.error(
|
|
1499
|
+
'aimhooman: state NOT purged — '
|
|
1500
|
+
+ `${unrestored.length} chained hook backup(s) remain unrestored`
|
|
1501
|
+
+ `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
|
|
1502
|
+
+ "re-run 'aimhooman uninstall' to retry restore before purging."
|
|
1503
|
+
);
|
|
1504
|
+
} else {
|
|
1505
|
+
rmSync(repo.stateDir, { recursive: true, force: true });
|
|
1506
|
+
console.log(' state purged');
|
|
1507
|
+
}
|
|
1508
|
+
} else {
|
|
1509
|
+
console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
|
|
1510
|
+
}
|
|
1511
|
+
// Surface when a global core.hooksPath still enforces aimhooman, so a local
|
|
1512
|
+
// uninstall cannot be mistaken for full removal. Foreign hooksPath values
|
|
1513
|
+
// are left to the generic "not modified" warning emitted above.
|
|
1514
|
+
const aimDir = globalHooksDir();
|
|
1515
|
+
let globalHooksPath = '';
|
|
1516
|
+
try {
|
|
1517
|
+
globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
1518
|
+
encoding: 'utf8',
|
|
1519
|
+
}).trim();
|
|
1520
|
+
} catch { /* unset */ }
|
|
1521
|
+
if (globalHooksPath === aimDir) {
|
|
1522
|
+
console.log('aimhooman: global Git guard is still active');
|
|
1523
|
+
console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
|
|
1524
|
+
console.log(' run `aimhooman uninstall --global` to remove it.');
|
|
1525
|
+
}
|
|
1526
|
+
return rep.failures?.length || unrestored.length ? 30 : 0;
|
|
1527
|
+
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function usage() {
|
|
1531
|
+
process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
|
|
1532
|
+
|
|
1533
|
+
Usage:
|
|
1534
|
+
aimhooman init [--profile clean|strict|compliance]
|
|
1535
|
+
aimhooman init --global --yes
|
|
1536
|
+
aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
|
|
1537
|
+
aimhooman check --commit <rev> | --range <base>...<head> | --tracked
|
|
1538
|
+
aimhooman audit|scan [--json] [--profile ...]
|
|
1539
|
+
aimhooman status
|
|
1540
|
+
aimhooman explain <rule-id>
|
|
1541
|
+
aimhooman allow <path|rule-id> [--scope path|rule|secret-path] [--reason "..."]
|
|
1542
|
+
aimhooman deny <path|rule-id> [--scope path|rule] [--reason "..."]
|
|
1543
|
+
aimhooman override list [--json]
|
|
1544
|
+
aimhooman override remove <target>
|
|
1545
|
+
aimhooman override reset [--allow|--deny|--all]
|
|
1546
|
+
aimhooman review <instruction-path> --head <commit> [--commit <source>] [--reason "..."]
|
|
1547
|
+
aimhooman policy-review --head <commit> (--staged|--transition <commit>) --old <oid> --new <oid|missing> [--reason "..."]
|
|
1548
|
+
aimhooman fix [-m <file>|--message <file>] [--apply (strict only)]
|
|
1549
|
+
aimhooman doctor
|
|
1550
|
+
aimhooman uninstall [--purge-state]
|
|
1551
|
+
aimhooman uninstall --global
|
|
1552
|
+
aimhooman version
|
|
1553
|
+
|
|
1554
|
+
Exit codes: 0 clean, 10 blocked, 11 review required, 20 invalid input or policy,
|
|
1555
|
+
30 Git or I/O failure, 31 incomplete scan.
|
|
1556
|
+
`);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const argv = process.argv.slice(2);
|
|
1560
|
+
if (argv[0] === 'hook') {
|
|
1561
|
+
const hookArgs = argv.slice(1);
|
|
1562
|
+
if (hookArgs.length !== 1 || !['session-start', 'pre-tool-use'].includes(hookArgs[0])) {
|
|
1563
|
+
console.error('aimhooman: hook requires exactly one supported event');
|
|
1564
|
+
process.exit(20);
|
|
1565
|
+
}
|
|
1566
|
+
runHook(hookArgs)
|
|
1567
|
+
.then((code) => {
|
|
1568
|
+
// Drain the permission decision before exiting: on a piped stdout,
|
|
1569
|
+
// process.exit() can cut off the emit() write and drop a deny. The
|
|
1570
|
+
// callback fires once the buffer is flushed to the OS.
|
|
1571
|
+
process.stdout.write('', () => process.exit(code));
|
|
1572
|
+
})
|
|
1573
|
+
.catch((error) => {
|
|
1574
|
+
console.error(`aimhooman: hook failed: ${error.message}`);
|
|
1575
|
+
process.exit(20);
|
|
1576
|
+
});
|
|
1577
|
+
} else {
|
|
1578
|
+
try {
|
|
1579
|
+
process.exitCode = main(argv);
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
if (error instanceof ArgumentError) {
|
|
1582
|
+
console.error(`aimhooman: ${error.message}`);
|
|
1583
|
+
process.exitCode = 20;
|
|
1584
|
+
} else {
|
|
1585
|
+
console.error(`aimhooman: ${error.message}`);
|
|
1586
|
+
process.exitCode = expectedErrorCode(error);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|