@rmyndharis/aimhooman 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +201 -5
- package/CONTRIBUTING.md +43 -30
- package/README.md +14 -14
- package/SECURITY.md +8 -10
- package/bin/aimhooman.mjs +99 -28
- package/package.json +1 -1
- package/rules/attribution.json +7 -7
- package/src/atomic-write.mjs +32 -1
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +114 -20
- package/src/gitx.mjs +48 -277
- package/src/history-scan.mjs +4 -1
- package/src/hook.mjs +145 -9
- package/src/scan-session.mjs +2 -1
- package/src/scan-target.mjs +51 -23
package/bin/aimhooman.mjs
CHANGED
|
@@ -3,24 +3,24 @@ import { execFileSync } from 'node:child_process';
|
|
|
3
3
|
import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
6
7
|
import { newEngineWithDiagnostics } from '../src/scan.mjs';
|
|
7
8
|
import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
8
9
|
import {
|
|
9
10
|
GitRevisionError,
|
|
10
11
|
gitConfig,
|
|
11
|
-
hasStagedChanges,
|
|
12
12
|
introducedCommits,
|
|
13
13
|
openRepo,
|
|
14
14
|
readCommitPath,
|
|
15
15
|
readStagedPath,
|
|
16
|
+
stagedPaths,
|
|
16
17
|
stagedRenameSources,
|
|
17
18
|
unstagePaths,
|
|
18
19
|
withIndexFromTree,
|
|
19
20
|
} from '../src/gitx.mjs';
|
|
20
21
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
21
22
|
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';
|
|
23
|
+
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
24
24
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
25
25
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
26
26
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
@@ -38,8 +38,7 @@ const LIFECYCLE_LOCK_OPTIONS = { retries: 1000 };
|
|
|
38
38
|
function tryRepo() {
|
|
39
39
|
try {
|
|
40
40
|
return openRepo();
|
|
41
|
-
} catch
|
|
42
|
-
if (error?.name === 'StateMigrationError') throw error;
|
|
41
|
+
} catch {
|
|
43
42
|
return null;
|
|
44
43
|
}
|
|
45
44
|
}
|
|
@@ -49,6 +48,7 @@ function currentRepositoryIsBare() {
|
|
|
49
48
|
return execFileSync('git', ['rev-parse', '--is-bare-repository'], {
|
|
50
49
|
encoding: 'utf8',
|
|
51
50
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
51
|
+
timeout: GIT_TIMEOUT_MS,
|
|
52
52
|
}).trim() === 'true';
|
|
53
53
|
} catch {
|
|
54
54
|
return false;
|
|
@@ -160,10 +160,17 @@ function emitDiagnostics(diagnostics = []) {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
function incompleteMessage(scan) {
|
|
163
|
-
const
|
|
163
|
+
const reasons = scan.stats?.skipped || {};
|
|
164
|
+
const skipped = Object.entries(reasons)
|
|
164
165
|
.map(([reason, count]) => `${reason}=${count}`)
|
|
165
166
|
.join(', ');
|
|
166
|
-
|
|
167
|
+
// Every other reason is a size or budget the caller can shrink. A pack that
|
|
168
|
+
// will not compile is not, and the warning above already names the file and
|
|
169
|
+
// the error, so point at that instead of misdirecting to the limits.
|
|
170
|
+
const hint = reasons['local-pack-error']
|
|
171
|
+
? 'fix the reported rule pack and retry'
|
|
172
|
+
: 'reduce the target or limits and retry';
|
|
173
|
+
return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
function snapshotFile(path) {
|
|
@@ -194,6 +201,7 @@ function gitConfigAtScope(root, scope, key) {
|
|
|
194
201
|
cwd: root,
|
|
195
202
|
encoding: 'utf8',
|
|
196
203
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
204
|
+
timeout: GIT_TIMEOUT_MS,
|
|
197
205
|
}).trim();
|
|
198
206
|
} catch {
|
|
199
207
|
return '';
|
|
@@ -201,7 +209,7 @@ function gitConfigAtScope(root, scope, key) {
|
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
function gitVersion() {
|
|
204
|
-
try { return execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(); }
|
|
212
|
+
try { return execFileSync('git', ['--version'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim(); }
|
|
205
213
|
catch { return 'Git unavailable'; }
|
|
206
214
|
}
|
|
207
215
|
|
|
@@ -284,9 +292,41 @@ function cmdPrecommit(args) {
|
|
|
284
292
|
}
|
|
285
293
|
}
|
|
286
294
|
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
295
|
+
// The empty-commit hint is derived from the staged paths captured before
|
|
296
|
+
// repair, not from a second git read after `git restore --staged`. That
|
|
297
|
+
// post-repair read followed an index write and could transiently report
|
|
298
|
+
// the wrong state under heavy CI load, flaking the repair tests.
|
|
299
|
+
// unstagePaths is atomic (it throws on failure), so when every staged
|
|
300
|
+
// path is a repair target the index matches HEAD afterward. The capture
|
|
301
|
+
// is best-effort and runs before the repair: if this read fails the
|
|
302
|
+
// unstage still runs and the hint is omitted (empty stays false) rather
|
|
303
|
+
// than skipping the repair or blocking the commit.
|
|
304
|
+
let stagedBefore;
|
|
305
|
+
try {
|
|
306
|
+
stagedBefore = stagedPaths(repo);
|
|
307
|
+
} catch {
|
|
308
|
+
stagedBefore = null;
|
|
309
|
+
}
|
|
287
310
|
unstagePaths(repo, [...unstageTargets]);
|
|
288
|
-
|
|
289
|
-
|
|
311
|
+
// Under heavy CI load a `git restore --staged` or the rename-source
|
|
312
|
+
// detection above can transiently leave a target staged, which flakes
|
|
313
|
+
// the repair tests and would let an artifact ride through. Re-detect
|
|
314
|
+
// rename sources and re-unstage any still-staged target until every
|
|
315
|
+
// target is gone or the budget is reached.
|
|
316
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
317
|
+
let stillStaged;
|
|
318
|
+
try {
|
|
319
|
+
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
320
|
+
stillStaged = new Set(stagedPaths(repo));
|
|
321
|
+
} catch {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
const pending = [...unstageTargets].filter((path) => stillStaged.has(path));
|
|
325
|
+
if (!pending.length) break;
|
|
326
|
+
unstagePaths(repo, pending);
|
|
327
|
+
}
|
|
328
|
+
const empty = stagedBefore !== null
|
|
329
|
+
&& stagedBefore.every((path) => unstageTargets.has(path));
|
|
290
330
|
process.stderr.write(
|
|
291
331
|
`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
332
|
);
|
|
@@ -456,7 +496,7 @@ function cmdRefcheck(args) {
|
|
|
456
496
|
return 30;
|
|
457
497
|
}
|
|
458
498
|
if (/^0+$/.test(newObjectId)) continue;
|
|
459
|
-
updates.push({ oldObjectId, newObjectId });
|
|
499
|
+
updates.push({ oldObjectId, newObjectId, ref });
|
|
460
500
|
}
|
|
461
501
|
|
|
462
502
|
let commits;
|
|
@@ -619,7 +659,7 @@ function cmdInit(args) {
|
|
|
619
659
|
const aimDir = globalHooksDir();
|
|
620
660
|
let existing = '';
|
|
621
661
|
try {
|
|
622
|
-
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
662
|
+
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
623
663
|
} catch { /* unset */ }
|
|
624
664
|
if (existing && existing !== aimDir) {
|
|
625
665
|
console.error(
|
|
@@ -632,7 +672,7 @@ function cmdInit(args) {
|
|
|
632
672
|
// could silently disable a system-wide hook manager, so refuse here too.
|
|
633
673
|
let systemHooksPath = '';
|
|
634
674
|
try {
|
|
635
|
-
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
675
|
+
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
636
676
|
} catch { /* unset or no system config */ }
|
|
637
677
|
if (systemHooksPath) {
|
|
638
678
|
console.error(
|
|
@@ -656,7 +696,7 @@ function cmdInit(args) {
|
|
|
656
696
|
console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
|
|
657
697
|
return 20;
|
|
658
698
|
}
|
|
659
|
-
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir]);
|
|
699
|
+
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
|
|
660
700
|
} catch (error) {
|
|
661
701
|
const rollbackFailures = [];
|
|
662
702
|
for (const snapshot of hookSnapshots.reverse()) {
|
|
@@ -666,8 +706,8 @@ function cmdInit(args) {
|
|
|
666
706
|
}
|
|
667
707
|
}
|
|
668
708
|
try {
|
|
669
|
-
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing]);
|
|
670
|
-
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
|
|
709
|
+
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
|
|
710
|
+
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
671
711
|
} catch (rollbackError) {
|
|
672
712
|
rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
|
|
673
713
|
}
|
|
@@ -728,7 +768,13 @@ function cmdInit(args) {
|
|
|
728
768
|
rep = installHooks(repo, CLI_PATH);
|
|
729
769
|
const activeHooks = installedHooks(repo);
|
|
730
770
|
if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
|
|
731
|
-
|
|
771
|
+
// installHooks declines rather than throws when the hooks directory is
|
|
772
|
+
// not ours, and its warnings are the only record of why. They are
|
|
773
|
+
// printed on the success path only, so carry them into the failure or
|
|
774
|
+
// the user is told nothing but "incomplete". The prefix is load-bearing:
|
|
775
|
+
// the exit-code branch below matches on it.
|
|
776
|
+
const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
|
|
777
|
+
throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
|
|
732
778
|
}
|
|
733
779
|
saveConfig(repo.stateDir, { profile });
|
|
734
780
|
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
@@ -816,7 +862,6 @@ function cmdStatus(args) {
|
|
|
816
862
|
console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
|
|
817
863
|
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
864
|
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
865
|
const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
|
|
821
866
|
const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
|
|
822
867
|
console.log(`hooks path: local=${localHooks || 'unset'}, global=${globalHooks || 'unset'}`);
|
|
@@ -885,6 +930,19 @@ function cmdOverride(args, allow) {
|
|
|
885
930
|
`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
931
|
);
|
|
887
932
|
}
|
|
933
|
+
// A path that matches a secret rule cannot be silenced by a path (or rule)
|
|
934
|
+
// allow: only --scope secret-path suppresses secret findings, deliberately,
|
|
935
|
+
// so a local override cannot hide a possible leaked key. Without this check
|
|
936
|
+
// a bare `allow .env.minimal` would report success but leave the block in
|
|
937
|
+
// place, which reads as a broken allow.
|
|
938
|
+
if (allow && scope !== 'secret-path'
|
|
939
|
+
&& engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
|
|
940
|
+
throw new ArgumentError(
|
|
941
|
+
`"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
|
|
942
|
+
+ '(a local override must not hide a possible leaked key); '
|
|
943
|
+
+ 'use --scope secret-path to explicitly allow this specific path',
|
|
944
|
+
);
|
|
945
|
+
}
|
|
888
946
|
if (scope === 'secret-path' && !allow) {
|
|
889
947
|
throw new ArgumentError('--scope secret-path is only valid with allow');
|
|
890
948
|
}
|
|
@@ -1370,10 +1428,6 @@ function cmdDoctor(args) {
|
|
|
1370
1428
|
} else {
|
|
1371
1429
|
console.log(`ok host adapters present (${adapters.present}/${adapters.total})`);
|
|
1372
1430
|
}
|
|
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
1431
|
const gitRuntime = supportedGitVersion();
|
|
1378
1432
|
if (gitRuntime.supported) {
|
|
1379
1433
|
console.log(`ok runtime Node ${process.versions.node}; ${gitRuntime.display}`);
|
|
@@ -1441,7 +1495,7 @@ function cmdUninstall(args) {
|
|
|
1441
1495
|
const aimDir = globalHooksDir();
|
|
1442
1496
|
const readGlobalHooksPath = () => {
|
|
1443
1497
|
try {
|
|
1444
|
-
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
1498
|
+
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
1445
1499
|
} catch { return ''; }
|
|
1446
1500
|
};
|
|
1447
1501
|
const current = readGlobalHooksPath();
|
|
@@ -1449,7 +1503,7 @@ function cmdUninstall(args) {
|
|
|
1449
1503
|
if (current === aimDir) {
|
|
1450
1504
|
unsetAttempted = true;
|
|
1451
1505
|
try {
|
|
1452
|
-
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
1506
|
+
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
|
|
1453
1507
|
} catch { /* may have failed; re-verify below */ }
|
|
1454
1508
|
// A read-only or locked ~/.gitconfig can make --unset fail silently.
|
|
1455
1509
|
// Do not remove the dispatchers while core.hooksPath still resolves to
|
|
@@ -1484,11 +1538,20 @@ function cmdUninstall(args) {
|
|
|
1484
1538
|
return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
1485
1539
|
const rep = uninstallHooks(repo);
|
|
1486
1540
|
removeExclude(repo.excludeFile);
|
|
1487
|
-
|
|
1541
|
+
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1542
|
+
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1543
|
+
const remaining = remainingDispatchers(repo);
|
|
1544
|
+
if (remaining.length) {
|
|
1545
|
+
console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
|
|
1546
|
+
for (const path of remaining) console.error(` ${path}`);
|
|
1547
|
+
console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
|
|
1548
|
+
} else {
|
|
1549
|
+
console.log('aimhooman: uninstalled');
|
|
1550
|
+
}
|
|
1488
1551
|
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1489
1552
|
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1490
1553
|
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1491
|
-
for (const f of rep.failures || []) console.
|
|
1554
|
+
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1492
1555
|
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1493
1556
|
if (purge) {
|
|
1494
1557
|
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
@@ -1516,6 +1579,7 @@ function cmdUninstall(args) {
|
|
|
1516
1579
|
try {
|
|
1517
1580
|
globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
1518
1581
|
encoding: 'utf8',
|
|
1582
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1519
1583
|
}).trim();
|
|
1520
1584
|
} catch { /* unset */ }
|
|
1521
1585
|
if (globalHooksPath === aimDir) {
|
|
@@ -1523,7 +1587,7 @@ function cmdUninstall(args) {
|
|
|
1523
1587
|
console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
|
|
1524
1588
|
console.log(' run `aimhooman uninstall --global` to remove it.');
|
|
1525
1589
|
}
|
|
1526
|
-
return rep.failures?.length || unrestored.length ? 30 : 0;
|
|
1590
|
+
return remaining.length || rep.failures?.length || unrestored.length ? 30 : 0;
|
|
1527
1591
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1528
1592
|
}
|
|
1529
1593
|
|
|
@@ -1563,7 +1627,14 @@ if (argv[0] === 'hook') {
|
|
|
1563
1627
|
console.error('aimhooman: hook requires exactly one supported event');
|
|
1564
1628
|
process.exit(20);
|
|
1565
1629
|
}
|
|
1566
|
-
|
|
1630
|
+
// hook.mjs (the PreToolUse shell parser) is by far the largest module and is
|
|
1631
|
+
// only needed for the `hook` subcommand. Loading it lazily cuts the heaviest
|
|
1632
|
+
// parse cost from every other command's startup, so the lifecycle-lock
|
|
1633
|
+
// candidate is published earlier. This widens the margin under the queue-wait
|
|
1634
|
+
// budget rather than guaranteeing it: openRepo's git spawns still set the
|
|
1635
|
+
// floor on slow runners.
|
|
1636
|
+
import('../src/hook.mjs')
|
|
1637
|
+
.then(({ runHook }) => runHook(hookArgs))
|
|
1567
1638
|
.then((code) => {
|
|
1568
1639
|
// Drain the permission decision before exiting: on a piped stdout,
|
|
1569
1640
|
// process.exit() can cut off the emit() write and drop a deny. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
|
|
5
5
|
"homepage": "https://github.com/rmyndharis/aimhooman#readme",
|
|
6
6
|
"repository": {
|
package/rules/attribution.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "attribution.claude-coauthor",
|
|
4
|
-
"version":
|
|
4
|
+
"version": 2,
|
|
5
5
|
"provider": "claude-code",
|
|
6
6
|
"category": "ai-attribution",
|
|
7
7
|
"confidence": "high",
|
|
8
8
|
"kind": "message",
|
|
9
9
|
"match": {
|
|
10
10
|
"content": [
|
|
11
|
-
"(?i)^\\s*co-authored-by
|
|
11
|
+
"(?i)^\\s*co-authored-by:[^<>]+<noreply@anthropic\\.com>\\s*$"
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
"actions": {
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"id": "attribution.codex-coauthor",
|
|
52
|
-
"version":
|
|
52
|
+
"version": 2,
|
|
53
53
|
"provider": "codex",
|
|
54
54
|
"category": "ai-attribution",
|
|
55
55
|
"confidence": "high",
|
|
56
56
|
"kind": "message",
|
|
57
57
|
"match": {
|
|
58
58
|
"content": [
|
|
59
|
-
"(?i)^\\s*co-authored-by
|
|
59
|
+
"(?i)^\\s*co-authored-by:[^<>]+<noreply@openai\\.com>\\s*$"
|
|
60
60
|
]
|
|
61
61
|
},
|
|
62
62
|
"actions": {
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"id": "attribution.generated-with",
|
|
98
|
-
"version":
|
|
98
|
+
"version": 2,
|
|
99
99
|
"provider": "generic",
|
|
100
100
|
"category": "ai-attribution",
|
|
101
101
|
"confidence": "high",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"match": {
|
|
104
104
|
"content": [
|
|
105
105
|
"(?i)^\\s*generated\\s+(?:with|by)\\s+(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)[.!]?\\s*$",
|
|
106
|
-
"(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[claude
|
|
106
|
+
"(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)\\]\\([^)]*\\)\\s*$"
|
|
107
107
|
]
|
|
108
108
|
},
|
|
109
109
|
"actions": {
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"kind": "message",
|
|
127
127
|
"match": {
|
|
128
128
|
"content": [
|
|
129
|
-
"(?i)^\\s*[a-z-]+-by
|
|
129
|
+
"(?i)^\\s*[a-z-]+-by:[^<>]+<noreply@(anthropic|openai)\\.com>\\s*$"
|
|
130
130
|
]
|
|
131
131
|
},
|
|
132
132
|
"actions": {
|
package/src/atomic-write.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function atomicWrite(file, data, options = {}) {
|
|
|
39
39
|
operations.sync(descriptor);
|
|
40
40
|
operations.close(descriptor);
|
|
41
41
|
descriptor = undefined;
|
|
42
|
-
operations
|
|
42
|
+
renameWithRetry(operations, temporary, file, options.renameRetries);
|
|
43
43
|
try {
|
|
44
44
|
syncDirectory(directory, operations);
|
|
45
45
|
} catch (error) {
|
|
@@ -57,6 +57,30 @@ export function atomicWrite(file, data, options = {}) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Windows fails a rename with EPERM/EACCES/EBUSY while another handle is open on
|
|
61
|
+
// either path — an antivirus or indexer scanning the file aimhooman just wrote is
|
|
62
|
+
// enough, and it clears within milliseconds. Observed on CI as a lock contender
|
|
63
|
+
// dying at its ticket publication, which then read as a lifecycle-queue timeout.
|
|
64
|
+
// The rename is the atomic commit point, so a retry either lands the complete
|
|
65
|
+
// file or leaves the original untouched; it can never publish a partial write.
|
|
66
|
+
// A non-transient error (and every code on other platforms) still throws at once.
|
|
67
|
+
const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
68
|
+
|
|
69
|
+
function renameWithRetry(operations, temporary, file, retries = 20) {
|
|
70
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
71
|
+
try {
|
|
72
|
+
operations.rename(temporary, file);
|
|
73
|
+
return;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const retryable = process.platform === 'win32'
|
|
76
|
+
&& TRANSIENT_RENAME_CODES.has(error?.code)
|
|
77
|
+
&& attempt + 1 < retries;
|
|
78
|
+
if (!retryable) throw error;
|
|
79
|
+
waitForLock(5);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
function existingMode(file) {
|
|
61
85
|
try {
|
|
62
86
|
return statSync(file).mode & 0o777;
|
|
@@ -87,6 +111,7 @@ function syncDirectory(directory, operations) {
|
|
|
87
111
|
}
|
|
88
112
|
|
|
89
113
|
const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
114
|
+
const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
|
|
90
115
|
|
|
91
116
|
function waitForLock(milliseconds) {
|
|
92
117
|
if (milliseconds > 0) Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, milliseconds);
|
|
@@ -112,6 +137,12 @@ function processIdentity(pid) {
|
|
|
112
137
|
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
113
138
|
encoding: 'utf8',
|
|
114
139
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
140
|
+
// This is the only spawn on the lock path, and it runs on macOS
|
|
141
|
+
// and BSD alone: Linux reads /proc and Windows returns early.
|
|
142
|
+
// The retry loop below budgets milliseconds per attempt, so a
|
|
143
|
+
// probe that stalls must lose rather than stretch the loop. A
|
|
144
|
+
// timeout throws into the catch below, which fails safe.
|
|
145
|
+
timeout: IDENTITY_PROBE_TIMEOUT_MS,
|
|
115
146
|
}).trim();
|
|
116
147
|
return started ? `ps:${started}` : null;
|
|
117
148
|
}
|
package/src/git-environment.mjs
CHANGED
|
@@ -3,3 +3,13 @@
|
|
|
3
3
|
export function gitEnvironment(environment = process.env) {
|
|
4
4
|
return { ...environment, GIT_NO_REPLACE_OBJECTS: '1' };
|
|
5
5
|
}
|
|
6
|
+
|
|
7
|
+
// execFileSync has no default timeout, so a git child that never exits blocks
|
|
8
|
+
// the caller forever. One did: it held a CI runner until the platform's 6-hour
|
|
9
|
+
// ceiling killed the job, with no error to read afterwards.
|
|
10
|
+
//
|
|
11
|
+
// Real calls here finish in milliseconds. This bound exists to end a stuck one,
|
|
12
|
+
// not to pace a slow one, so it sits far above any legitimate call: a huge
|
|
13
|
+
// repository on a slow disk stays well inside it, and a hang now raises an
|
|
14
|
+
// error naming the command instead of going quiet.
|
|
15
|
+
export const GIT_TIMEOUT_MS = 120_000;
|