oh-my-customcode 1.1.52 → 1.1.54
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/dist/cli/index.js +509 -5
- package/dist/index.js +505 -1
- package/package.json +1 -1
- package/templates/.claude/hooks/hooks.json +24 -34
- package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +38 -3
- package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +20 -2
- package/templates/.claude/hooks/scripts/stuck-detector.sh +117 -6
- package/templates/.claude/rules/MAY-optimization.md +2 -0
- package/templates/.claude/rules/MUST-completion-verification.md +2 -0
- package/templates/.claude/rules/MUST-enforcement-policy.md +8 -2
- package/templates/.claude/settings.json +650 -0
- package/templates/manifest.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1307,6 +1307,489 @@ async function cleanupPreservation(tempDir) {
|
|
|
1307
1307
|
}
|
|
1308
1308
|
}
|
|
1309
1309
|
|
|
1310
|
+
// src/core/hooks-settings.ts
|
|
1311
|
+
init_fs();
|
|
1312
|
+
import { dirname as dirname2 } from "node:path";
|
|
1313
|
+
var IDENT_CHAR = /[A-Za-z0-9_.]/;
|
|
1314
|
+
var IDENT_START = /[A-Za-z_]/;
|
|
1315
|
+
function consumeSingleCharOp(input, i) {
|
|
1316
|
+
const ch = input[i];
|
|
1317
|
+
if (ch === "(" || ch === ")" || ch === "!") {
|
|
1318
|
+
return { token: { type: "OP", value: ch }, next: i + 1 };
|
|
1319
|
+
}
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
function consumeTwoCharOp(input, i) {
|
|
1323
|
+
const two = input.slice(i, i + 2);
|
|
1324
|
+
if (two === "&&" || two === "||" || two === "==") {
|
|
1325
|
+
return { token: { type: "OP", value: two }, next: i + 2 };
|
|
1326
|
+
}
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
function consumeStringLiteral(input, i) {
|
|
1330
|
+
const n = input.length;
|
|
1331
|
+
let j = i + 1;
|
|
1332
|
+
let value = "";
|
|
1333
|
+
while (j < n && input[j] !== '"') {
|
|
1334
|
+
if (input[j] === "\\" && j + 1 < n) {
|
|
1335
|
+
value += input[j + 1];
|
|
1336
|
+
j += 2;
|
|
1337
|
+
} else {
|
|
1338
|
+
value += input[j];
|
|
1339
|
+
j++;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (j >= n) {
|
|
1343
|
+
throw new Error(`unterminated string literal in matcher expression: ${input}`);
|
|
1344
|
+
}
|
|
1345
|
+
return { token: { type: "STRING", value }, next: j + 1 };
|
|
1346
|
+
}
|
|
1347
|
+
function consumeIdentifierOrKeyword(input, i) {
|
|
1348
|
+
const n = input.length;
|
|
1349
|
+
let j = i;
|
|
1350
|
+
while (j < n && IDENT_CHAR.test(input[j]))
|
|
1351
|
+
j++;
|
|
1352
|
+
const word = input.slice(i, j);
|
|
1353
|
+
const token = word === "matches" ? { type: "OP", value: "matches" } : { type: "IDENT", value: word };
|
|
1354
|
+
return { token, next: j };
|
|
1355
|
+
}
|
|
1356
|
+
function tokenizeMatcher(input) {
|
|
1357
|
+
const tokens = [];
|
|
1358
|
+
const n = input.length;
|
|
1359
|
+
let i = 0;
|
|
1360
|
+
while (i < n) {
|
|
1361
|
+
const ch = input[i];
|
|
1362
|
+
if (ch === " " || ch === "\t" || ch === `
|
|
1363
|
+
` || ch === "\r") {
|
|
1364
|
+
i++;
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const twoCharOp = consumeTwoCharOp(input, i);
|
|
1368
|
+
if (twoCharOp) {
|
|
1369
|
+
tokens.push(twoCharOp.token);
|
|
1370
|
+
i = twoCharOp.next;
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
const singleCharOp = consumeSingleCharOp(input, i);
|
|
1374
|
+
if (singleCharOp) {
|
|
1375
|
+
tokens.push(singleCharOp.token);
|
|
1376
|
+
i = singleCharOp.next;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (ch === '"') {
|
|
1380
|
+
const literal = consumeStringLiteral(input, i);
|
|
1381
|
+
tokens.push(literal.token);
|
|
1382
|
+
i = literal.next;
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
if (IDENT_START.test(ch)) {
|
|
1386
|
+
const ident = consumeIdentifierOrKeyword(input, i);
|
|
1387
|
+
tokens.push(ident.token);
|
|
1388
|
+
i = ident.next;
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
throw new Error(`unexpected character '${ch}' at offset ${i} in matcher expression: ${input}`);
|
|
1392
|
+
}
|
|
1393
|
+
return tokens;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
class MatcherParser {
|
|
1397
|
+
tokens;
|
|
1398
|
+
pos = 0;
|
|
1399
|
+
constructor(tokens) {
|
|
1400
|
+
this.tokens = tokens;
|
|
1401
|
+
}
|
|
1402
|
+
parse() {
|
|
1403
|
+
const node = this.parseOr();
|
|
1404
|
+
if (this.pos !== this.tokens.length) {
|
|
1405
|
+
throw new Error(`trailing tokens after position ${this.pos} in matcher expression`);
|
|
1406
|
+
}
|
|
1407
|
+
return node;
|
|
1408
|
+
}
|
|
1409
|
+
peek() {
|
|
1410
|
+
return this.tokens[this.pos];
|
|
1411
|
+
}
|
|
1412
|
+
next() {
|
|
1413
|
+
const t = this.tokens[this.pos];
|
|
1414
|
+
if (!t)
|
|
1415
|
+
throw new Error("unexpected end of matcher expression");
|
|
1416
|
+
this.pos++;
|
|
1417
|
+
return t;
|
|
1418
|
+
}
|
|
1419
|
+
expectOp(value) {
|
|
1420
|
+
const t = this.next();
|
|
1421
|
+
if (t.type !== "OP" || t.value !== value) {
|
|
1422
|
+
throw new Error(`expected '${value}', got ${JSON.stringify(t)}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
parseOr() {
|
|
1426
|
+
let left = this.parseAnd();
|
|
1427
|
+
while (this.peek()?.type === "OP" && this.peek()?.value === "||") {
|
|
1428
|
+
this.next();
|
|
1429
|
+
const right = this.parseAnd();
|
|
1430
|
+
left = { kind: "or", left, right };
|
|
1431
|
+
}
|
|
1432
|
+
return left;
|
|
1433
|
+
}
|
|
1434
|
+
parseAnd() {
|
|
1435
|
+
let left = this.parseUnary();
|
|
1436
|
+
while (this.peek()?.type === "OP" && this.peek()?.value === "&&") {
|
|
1437
|
+
this.next();
|
|
1438
|
+
const right = this.parseUnary();
|
|
1439
|
+
left = { kind: "and", left, right };
|
|
1440
|
+
}
|
|
1441
|
+
return left;
|
|
1442
|
+
}
|
|
1443
|
+
parseUnary() {
|
|
1444
|
+
if (this.peek()?.type === "OP" && this.peek()?.value === "!") {
|
|
1445
|
+
this.next();
|
|
1446
|
+
this.expectOp("(");
|
|
1447
|
+
const inner = this.parseOr();
|
|
1448
|
+
this.expectOp(")");
|
|
1449
|
+
return { kind: "not", child: inner };
|
|
1450
|
+
}
|
|
1451
|
+
return this.parseAtom();
|
|
1452
|
+
}
|
|
1453
|
+
parseAtom() {
|
|
1454
|
+
if (this.peek()?.type === "OP" && this.peek()?.value === "(") {
|
|
1455
|
+
this.next();
|
|
1456
|
+
const inner = this.parseOr();
|
|
1457
|
+
this.expectOp(")");
|
|
1458
|
+
return inner;
|
|
1459
|
+
}
|
|
1460
|
+
const identTok = this.next();
|
|
1461
|
+
if (identTok.type !== "IDENT") {
|
|
1462
|
+
throw new Error(`expected identifier, got ${JSON.stringify(identTok)}`);
|
|
1463
|
+
}
|
|
1464
|
+
const opTok = this.next();
|
|
1465
|
+
if (opTok.type !== "OP" || opTok.value !== "==" && opTok.value !== "matches") {
|
|
1466
|
+
throw new Error(`expected '==' or 'matches' after identifier '${identTok.value}'`);
|
|
1467
|
+
}
|
|
1468
|
+
const strTok = this.next();
|
|
1469
|
+
if (strTok.type !== "STRING") {
|
|
1470
|
+
throw new Error(`expected string literal after '${opTok.value}'`);
|
|
1471
|
+
}
|
|
1472
|
+
if (opTok.value === "==") {
|
|
1473
|
+
if (identTok.value !== "tool") {
|
|
1474
|
+
throw new Error(`unsupported '==' comparison on field '${identTok.value}' (only 'tool' is supported)`);
|
|
1475
|
+
}
|
|
1476
|
+
return { kind: "toolEq", tool: strTok.value };
|
|
1477
|
+
}
|
|
1478
|
+
return { kind: "fieldMatch", field: identTok.value, pattern: strTok.value };
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
function parseMatcherExpr(raw) {
|
|
1482
|
+
const tokens = tokenizeMatcher(raw);
|
|
1483
|
+
if (tokens.length === 0) {
|
|
1484
|
+
throw new Error("empty matcher expression");
|
|
1485
|
+
}
|
|
1486
|
+
return new MatcherParser(tokens).parse();
|
|
1487
|
+
}
|
|
1488
|
+
function isToolOnly(node) {
|
|
1489
|
+
switch (node.kind) {
|
|
1490
|
+
case "toolEq":
|
|
1491
|
+
return true;
|
|
1492
|
+
case "and":
|
|
1493
|
+
case "or":
|
|
1494
|
+
return isToolOnly(node.left) && isToolOnly(node.right);
|
|
1495
|
+
case "not":
|
|
1496
|
+
return isToolOnly(node.child);
|
|
1497
|
+
case "fieldMatch":
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
function collectToolNames(node) {
|
|
1502
|
+
switch (node.kind) {
|
|
1503
|
+
case "toolEq":
|
|
1504
|
+
return [node.tool];
|
|
1505
|
+
case "and":
|
|
1506
|
+
case "or":
|
|
1507
|
+
return [...collectToolNames(node.left), ...collectToolNames(node.right)];
|
|
1508
|
+
default:
|
|
1509
|
+
return [];
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
function flattenAnd(node) {
|
|
1513
|
+
if (node.kind === "and") {
|
|
1514
|
+
return [...flattenAnd(node.left), ...flattenAnd(node.right)];
|
|
1515
|
+
}
|
|
1516
|
+
return [node];
|
|
1517
|
+
}
|
|
1518
|
+
function tryPureOrFieldRegex(node, field) {
|
|
1519
|
+
if (node.kind === "fieldMatch") {
|
|
1520
|
+
return node.field === field ? node.pattern : null;
|
|
1521
|
+
}
|
|
1522
|
+
if (node.kind === "or") {
|
|
1523
|
+
const left = tryPureOrFieldRegex(node.left, field);
|
|
1524
|
+
const right = tryPureOrFieldRegex(node.right, field);
|
|
1525
|
+
if (left === null || right === null)
|
|
1526
|
+
return null;
|
|
1527
|
+
return `${left}|${right}`;
|
|
1528
|
+
}
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
function fallbackExtractToolNames(raw) {
|
|
1532
|
+
const names = new Set;
|
|
1533
|
+
const re = /tool\s*==\s*"([^"]+)"/g;
|
|
1534
|
+
let m;
|
|
1535
|
+
while ((m = re.exec(raw)) !== null) {
|
|
1536
|
+
names.add(m[1]);
|
|
1537
|
+
}
|
|
1538
|
+
return Array.from(names);
|
|
1539
|
+
}
|
|
1540
|
+
function analyzeMixedAndExpr(ast, raw) {
|
|
1541
|
+
const parts = flattenAnd(ast);
|
|
1542
|
+
const toolParts = parts.filter(isToolOnly);
|
|
1543
|
+
const fieldParts = parts.filter((p) => !isToolOnly(p));
|
|
1544
|
+
if (toolParts.length === 0) {
|
|
1545
|
+
const names = fallbackExtractToolNames(raw);
|
|
1546
|
+
if (names.length === 0) {
|
|
1547
|
+
throw new Error(`cannot extract a tool matcher from expression (no top-level tool==X clause): ${raw}`);
|
|
1548
|
+
}
|
|
1549
|
+
return { toolRegex: names.join("|"), fieldNode: null };
|
|
1550
|
+
}
|
|
1551
|
+
const tools = Array.from(new Set(toolParts.flatMap(collectToolNames)));
|
|
1552
|
+
const toolRegex = tools.join("|") || "*";
|
|
1553
|
+
if (fieldParts.length === 0) {
|
|
1554
|
+
return { toolRegex, fieldNode: null };
|
|
1555
|
+
}
|
|
1556
|
+
const fieldNode = fieldParts.reduce((acc, part) => acc === null ? part : { kind: "and", left: acc, right: part }, null);
|
|
1557
|
+
return { toolRegex, fieldNode };
|
|
1558
|
+
}
|
|
1559
|
+
function analyzeMatcher(raw) {
|
|
1560
|
+
if (raw === undefined || raw === "*" || raw.trim() === "") {
|
|
1561
|
+
return { toolRegex: "*", fieldNode: null };
|
|
1562
|
+
}
|
|
1563
|
+
let ast;
|
|
1564
|
+
try {
|
|
1565
|
+
ast = parseMatcherExpr(raw);
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
const names = fallbackExtractToolNames(raw);
|
|
1568
|
+
if (names.length === 0) {
|
|
1569
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1570
|
+
throw new Error(`unparseable matcher expression and no fallback tool name found: ${raw} (${message})`);
|
|
1571
|
+
}
|
|
1572
|
+
return { toolRegex: names.join("|"), fieldNode: null };
|
|
1573
|
+
}
|
|
1574
|
+
if (isToolOnly(ast)) {
|
|
1575
|
+
const tools = Array.from(new Set(collectToolNames(ast)));
|
|
1576
|
+
return { toolRegex: tools.join("|") || "*", fieldNode: null };
|
|
1577
|
+
}
|
|
1578
|
+
const mcpRegex = tryPureOrFieldRegex(ast, "mcp_tool_name");
|
|
1579
|
+
if (mcpRegex !== null) {
|
|
1580
|
+
return { toolRegex: mcpRegex, fieldNode: null };
|
|
1581
|
+
}
|
|
1582
|
+
return analyzeMixedAndExpr(ast, raw);
|
|
1583
|
+
}
|
|
1584
|
+
function fieldNodeToJq(node) {
|
|
1585
|
+
switch (node.kind) {
|
|
1586
|
+
case "and":
|
|
1587
|
+
return `(${fieldNodeToJq(node.left)} and ${fieldNodeToJq(node.right)})`;
|
|
1588
|
+
case "or":
|
|
1589
|
+
return `(${fieldNodeToJq(node.left)} or ${fieldNodeToJq(node.right)})`;
|
|
1590
|
+
case "not":
|
|
1591
|
+
return `(${fieldNodeToJq(node.child)} | not)`;
|
|
1592
|
+
case "fieldMatch": {
|
|
1593
|
+
const jqPath = node.field === "mcp_tool_name" ? ".tool_name" : `.${node.field}`;
|
|
1594
|
+
return `((${jqPath} // "") | test(${JSON.stringify(node.pattern)}))`;
|
|
1595
|
+
}
|
|
1596
|
+
case "toolEq":
|
|
1597
|
+
return "true";
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
function shellSingleQuote(value) {
|
|
1601
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
1602
|
+
}
|
|
1603
|
+
function buildGuardedCommand(jqCond, originalCommand) {
|
|
1604
|
+
const quotedCond = shellSingleQuote(jqCond);
|
|
1605
|
+
return [
|
|
1606
|
+
"input=$(cat)",
|
|
1607
|
+
`if printf '%s' "$input" | jq -e ${quotedCond} >/dev/null 2>&1; then`,
|
|
1608
|
+
` printf '%s' "$input" | (`,
|
|
1609
|
+
originalCommand,
|
|
1610
|
+
" )",
|
|
1611
|
+
"else",
|
|
1612
|
+
` printf '%s' "$input"`,
|
|
1613
|
+
"fi"
|
|
1614
|
+
].join(`
|
|
1615
|
+
`);
|
|
1616
|
+
}
|
|
1617
|
+
var CLASSIFICATION_TABLE = [
|
|
1618
|
+
{
|
|
1619
|
+
event: "PreToolUse",
|
|
1620
|
+
description: "Block dev servers outside tmux - ensures you can access logs",
|
|
1621
|
+
decision: "wrap-guard",
|
|
1622
|
+
reason: 'inline script unconditionally `exit 1`-blocks on ANY match once invoked; it never re-parses tool_input.command, so widening the matcher to bare "Bash" would block every Bash call'
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
event: "PreToolUse",
|
|
1626
|
+
description: "Reminder to use tmux for long-running commands",
|
|
1627
|
+
decision: "wrap-guard",
|
|
1628
|
+
reason: 'inline script only checks `[ -z "$TMUX" ]`; it never re-checks tool_input.command against the npm/pnpm/yarn/bun/cargo/docker/pytest/vitest/playwright pattern, so widening the matcher would nag on every Bash call'
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
event: "PreToolUse",
|
|
1632
|
+
description: "Pause before git push to review changes",
|
|
1633
|
+
decision: "wrap-guard",
|
|
1634
|
+
reason: 'inline script unconditionally prints the pause prompt once invoked; it never re-checks tool_input.command for "git push", so widening the matcher would pause on every Bash call'
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
event: "PreToolUse",
|
|
1638
|
+
description: "Block creation of random .md files - keeps docs consolidated",
|
|
1639
|
+
decision: "drop-condition",
|
|
1640
|
+
reason: "inline script re-parses tool_input.file_path via jq and re-tests both the `\\.(md|txt)$` and the README/CLAUDE/AGENT/SKILL exclusion with its own [[ =~ ]] checks before blocking — the matcher condition is fully redundant with the script body"
|
|
1641
|
+
},
|
|
1642
|
+
{
|
|
1643
|
+
event: "PreToolUse",
|
|
1644
|
+
description: "Warn before destructive git commands — advisory recovery guidance (R001/R021)",
|
|
1645
|
+
decision: "drop-condition",
|
|
1646
|
+
reason: "destructive-git-guard.sh re-parses tool_input.command via jq/python3 and re-greps for each destructive git subcommand (reset --hard, clean -f*, restore, checkout -- ., branch -D) itself before warning — matcher condition is redundant"
|
|
1647
|
+
},
|
|
1648
|
+
{
|
|
1649
|
+
event: "PreToolUse",
|
|
1650
|
+
description: "Block rule file deletion — requires individual user confirmation per rule (R001 safety)",
|
|
1651
|
+
decision: "drop-condition",
|
|
1652
|
+
reason: 'rule-deletion-guard.sh explicitly re-checks `tool != "Bash"` (passthrough) and re-greps tool_input.command for rm/git rm/mv/unlink AND a .claude/rules path before blocking — matcher condition is redundant'
|
|
1653
|
+
},
|
|
1654
|
+
{
|
|
1655
|
+
event: "PostToolUse",
|
|
1656
|
+
description: "Auto-format JS/TS files with Prettier after edits",
|
|
1657
|
+
decision: "wrap-guard",
|
|
1658
|
+
reason: 'inline script only checks file existence (`[ -f "$file_path" ]`); it never re-checks the .ts/.tsx/.js/.jsx extension, so widening the matcher to bare "Edit" would run prettier on every edited file regardless of type'
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
event: "PostToolUse",
|
|
1662
|
+
description: "TypeScript check after editing .ts/.tsx files",
|
|
1663
|
+
decision: "wrap-guard",
|
|
1664
|
+
reason: "inline script never re-checks the .ts/.tsx extension before walking up for tsconfig.json and invoking tsc — widening the matcher would run a TS project check after editing non-TS files"
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
event: "PostToolUse",
|
|
1668
|
+
description: "Warn about console.log statements after edits",
|
|
1669
|
+
decision: "wrap-guard",
|
|
1670
|
+
reason: "inline script greps the edited file for `console\\.log` unconditionally on file existence; it never re-checks the .ts/.tsx/.js/.jsx extension, so widening the matcher would run this grep on every edited file type"
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
event: "PostToolUse",
|
|
1674
|
+
description: "Auto-format Go files with gofmt after edits",
|
|
1675
|
+
decision: "wrap-guard",
|
|
1676
|
+
reason: "inline script never re-checks the .go extension before invoking gofmt -w; running gofmt on a non-Go file would error/corrupt output"
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
event: "PostToolUse",
|
|
1680
|
+
description: "Auto-format and lint Python files with ruff after edits",
|
|
1681
|
+
decision: "wrap-guard",
|
|
1682
|
+
reason: "inline script never re-checks the .py extension before invoking ruff format/check --fix; running ruff on a non-Python file would error"
|
|
1683
|
+
},
|
|
1684
|
+
{
|
|
1685
|
+
event: "PostToolUse",
|
|
1686
|
+
description: "Type check Python files with ty after edits",
|
|
1687
|
+
decision: "wrap-guard",
|
|
1688
|
+
reason: "inline script never re-checks the .py extension before invoking `ty check`; running a Python type checker on a non-Python file would error"
|
|
1689
|
+
}
|
|
1690
|
+
];
|
|
1691
|
+
function findClassification(event, description) {
|
|
1692
|
+
if (description === undefined)
|
|
1693
|
+
return;
|
|
1694
|
+
return CLASSIFICATION_TABLE.find((c) => c.event === event && c.description === description);
|
|
1695
|
+
}
|
|
1696
|
+
function looksHardBlock(command) {
|
|
1697
|
+
return /\bexit\s+[12]\b/.test(command);
|
|
1698
|
+
}
|
|
1699
|
+
function convertHookCommand(cmd, fieldNode, decision, warnCollector, context) {
|
|
1700
|
+
if (fieldNode === null || decision === "drop-condition") {
|
|
1701
|
+
return { ...cmd };
|
|
1702
|
+
}
|
|
1703
|
+
if (cmd.type !== "command" || typeof cmd.command !== "string") {
|
|
1704
|
+
warnCollector.push(`hooks: ${context} — cannot wrap a non-command hook (type="${cmd.type}") with a stdin guard; condition dropped, hook now fires unconditionally on matcher match`);
|
|
1705
|
+
return { ...cmd };
|
|
1706
|
+
}
|
|
1707
|
+
let jqCond;
|
|
1708
|
+
try {
|
|
1709
|
+
jqCond = fieldNodeToJq(fieldNode);
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1712
|
+
if (looksHardBlock(cmd.command)) {
|
|
1713
|
+
warnCollector.push(`hooks: ${context} — could not translate condition to jq (${message}); hook looks hard-block (exit 1/2) so it was conservatively EXCLUDED from the converted settings — manual review required`);
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
warnCollector.push(`hooks: ${context} — could not translate condition to jq (${message}); relaxed to unconditional firing (advisory hook, over-firing preferred over silent drop)`);
|
|
1717
|
+
return { ...cmd };
|
|
1718
|
+
}
|
|
1719
|
+
return {
|
|
1720
|
+
...cmd,
|
|
1721
|
+
command: buildGuardedCommand(jqCond, cmd.command)
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function convertHooksEntry(event, entry, warnCollector) {
|
|
1725
|
+
const context = `${event} / "${entry.description ?? "(no description)"}"`;
|
|
1726
|
+
let analysis;
|
|
1727
|
+
try {
|
|
1728
|
+
analysis = analyzeMatcher(entry.matcher);
|
|
1729
|
+
} catch (err) {
|
|
1730
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1731
|
+
warnCollector.push(`hooks: ${context} — matcher could not be auto-converted, entry EXCLUDED: ${message}`);
|
|
1732
|
+
return null;
|
|
1733
|
+
}
|
|
1734
|
+
let decision = "auto";
|
|
1735
|
+
if (analysis.fieldNode !== null) {
|
|
1736
|
+
const classification = findClassification(event, entry.description);
|
|
1737
|
+
if (classification) {
|
|
1738
|
+
decision = classification.decision;
|
|
1739
|
+
} else {
|
|
1740
|
+
decision = "wrap-guard";
|
|
1741
|
+
warnCollector.push(`hooks: ${context} — has a tool_input condition not present in CLASSIFICATION_TABLE; defaulted to wrap-guard (safer) — add an explicit classification entry after reading the script`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const convertedHooks = entry.hooks.map((cmd) => convertHookCommand(cmd, analysis.fieldNode, decision, warnCollector, context)).filter((cmd) => cmd !== null);
|
|
1745
|
+
if (convertedHooks.length === 0) {
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
const out = { hooks: convertedHooks };
|
|
1749
|
+
if (analysis.toolRegex !== "*") {
|
|
1750
|
+
out.matcher = analysis.toolRegex;
|
|
1751
|
+
} else if (entry.matcher !== undefined) {
|
|
1752
|
+
out.matcher = "*";
|
|
1753
|
+
}
|
|
1754
|
+
if (entry.description !== undefined) {
|
|
1755
|
+
out.description = entry.description;
|
|
1756
|
+
}
|
|
1757
|
+
return out;
|
|
1758
|
+
}
|
|
1759
|
+
function convertHooksJson(hooksJson) {
|
|
1760
|
+
const warnings = [];
|
|
1761
|
+
const warnCollector = { push: (msg) => warnings.push(msg) };
|
|
1762
|
+
const result = {};
|
|
1763
|
+
for (const [event, entries] of Object.entries(hooksJson.hooks ?? {})) {
|
|
1764
|
+
const convertedEntries = [];
|
|
1765
|
+
for (const entry of entries) {
|
|
1766
|
+
const converted = convertHooksEntry(event, entry, warnCollector);
|
|
1767
|
+
if (converted)
|
|
1768
|
+
convertedEntries.push(converted);
|
|
1769
|
+
}
|
|
1770
|
+
if (convertedEntries.length > 0) {
|
|
1771
|
+
result[event] = convertedEntries;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return { hooks: result, warnings };
|
|
1775
|
+
}
|
|
1776
|
+
async function mergeHooksIntoSettings(settingsPath, hooksJsonPath) {
|
|
1777
|
+
const hooksJson = await readJsonFile(hooksJsonPath);
|
|
1778
|
+
const { hooks, warnings } = convertHooksJson(hooksJson);
|
|
1779
|
+
let settings = {};
|
|
1780
|
+
try {
|
|
1781
|
+
const existingRaw = await readTextFile(settingsPath);
|
|
1782
|
+
settings = JSON.parse(existingRaw);
|
|
1783
|
+
} catch {
|
|
1784
|
+
settings = {};
|
|
1785
|
+
}
|
|
1786
|
+
settings.hooks = hooks;
|
|
1787
|
+
await ensureDirectory(dirname2(settingsPath));
|
|
1788
|
+
await writeTextFile(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
1789
|
+
`);
|
|
1790
|
+
return { warnings };
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1310
1793
|
// src/core/layout.ts
|
|
1311
1794
|
var CLAUDE_LAYOUT = {
|
|
1312
1795
|
rootDir: ".claude",
|
|
@@ -1732,6 +2215,26 @@ async function installSettingsLocal(targetDir, result) {
|
|
|
1732
2215
|
await writeJsonFile(settingsPath, statusLineConfig);
|
|
1733
2216
|
debug("install.settings_local_created", {});
|
|
1734
2217
|
}
|
|
2218
|
+
async function installHooksSettings(targetDir, _options, result) {
|
|
2219
|
+
const layout = getProviderLayout();
|
|
2220
|
+
const hooksJsonPath = join5(targetDir, getComponentPath("hooks"), "hooks.json");
|
|
2221
|
+
const settingsPath = join5(targetDir, layout.rootDir, "settings.local.json");
|
|
2222
|
+
if (!await fileExists(hooksJsonPath)) {
|
|
2223
|
+
debug("install.hooks_settings_skipped", { reason: "hooks.json not installed" });
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
try {
|
|
2227
|
+
const mergeResult = await mergeHooksIntoSettings(settingsPath, hooksJsonPath);
|
|
2228
|
+
if (mergeResult && Array.isArray(mergeResult.warnings) && mergeResult.warnings.length > 0) {
|
|
2229
|
+
result.warnings.push(...mergeResult.warnings);
|
|
2230
|
+
}
|
|
2231
|
+
debug("install.hooks_settings_merged", {});
|
|
2232
|
+
} catch (err) {
|
|
2233
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2234
|
+
result.warnings.push(`Failed to merge hooks into settings.local.json: ${message}`);
|
|
2235
|
+
warn("install.hooks_settings_failed", { error: message });
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
1735
2238
|
async function installEntryDocWithTracking(targetDir, options, result) {
|
|
1736
2239
|
const language = options.language ?? DEFAULT_LANGUAGE;
|
|
1737
2240
|
const overwrite = !!(options.force || options.backup);
|
|
@@ -1790,6 +2293,7 @@ async function install(options) {
|
|
|
1790
2293
|
await installStatusline(options.targetDir, options, result);
|
|
1791
2294
|
await installTestsConfig(options.targetDir, options, result);
|
|
1792
2295
|
await installSettingsLocal(options.targetDir, result);
|
|
2296
|
+
await installHooksSettings(options.targetDir, options, result);
|
|
1793
2297
|
await installEntryDocWithTracking(options.targetDir, options, result);
|
|
1794
2298
|
if (preservation) {
|
|
1795
2299
|
const layout = getProviderLayout();
|
|
@@ -2031,7 +2535,7 @@ var package_default = {
|
|
|
2031
2535
|
workspaces: [
|
|
2032
2536
|
"packages/*"
|
|
2033
2537
|
],
|
|
2034
|
-
version: "1.1.
|
|
2538
|
+
version: "1.1.54",
|
|
2035
2539
|
description: "Batteries-included agent harness for Claude Code",
|
|
2036
2540
|
type: "module",
|
|
2037
2541
|
bin: {
|