claude-dev-env 2.34.0 → 2.36.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/skills/autoconverge/SKILL.md +19 -15
- package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
- package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
- package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
- package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
- package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
- package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
- package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
- package/bin/cde.mjs +175 -0
- package/bin/cde.test.mjs +222 -0
- package/bin/install-constants.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +233 -2
- package/bin/install.mjs +49 -3
- package/bin/pstack-model-preferences.codex.json +23 -0
- package/bin/select-pstack-models.test.mjs +378 -0
- package/hooks/hooks_constants/tool_names.py +20 -0
- package/hooks/validators/fast_save_validators.py +63 -89
- package/hooks/validators/run_all_validators.py +16 -8
- package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
- package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
- package/package.json +3 -2
- package/rules/pstack-models.md +119 -0
- package/scripts/audit-hooks-cli.mjs +54 -0
- package/scripts/audit-hooks.mjs +397 -0
- package/scripts/cde_lint.py +258 -0
- package/scripts/policy_lint/__init__.py +41 -0
- package/scripts/policy_lint/adapter_configuration.py +91 -0
- package/scripts/policy_lint/adapter_detectors.py +328 -0
- package/scripts/policy_lint/adapter_pairing.py +145 -0
- package/scripts/policy_lint/adapter_support.py +97 -0
- package/scripts/policy_lint/adapters.py +352 -0
- package/scripts/policy_lint/config/__init__.py +1 -0
- package/scripts/policy_lint/config/constants.py +66 -0
- package/scripts/policy_lint/engine.py +224 -0
- package/scripts/policy_lint/model.py +387 -0
- package/scripts/policy_lint/registry.py +162 -0
- package/scripts/policy_lint/render.py +91 -0
- package/scripts/policy_lint/selection.py +372 -0
- package/scripts/policy_lint/selection_git.py +147 -0
- package/scripts/select_pstack_models.mjs +304 -0
- package/scripts/tests/test-audit-hooks.mjs +379 -0
- package/scripts/tests/test_cde_lint.py +374 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
- package/scripts/tests/test_policy_lint_selection.py +373 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Prove the policy-linter validator seam keeps native tools separate."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from . import run_all_validators
|
|
8
|
+
|
|
9
|
+
_CLEAN_SOURCE = (
|
|
10
|
+
"def add_two_numbers(first_number: int, second_number: int) -> int:\n"
|
|
11
|
+
" return first_number + second_number\n"
|
|
12
|
+
)
|
|
13
|
+
_OVERLAPPING_VALIDATOR_NAMES = frozenset(
|
|
14
|
+
{"Python Style", "Magic Values", "Type Safety", "Test Safety", "React"}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _result_names(all_results: list[run_all_validators.ValidatorResult]) -> set[str]:
|
|
19
|
+
return {each_result.name for each_result in all_results}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_policy_lint_can_run_only_unique_in_process_validators(tmp_path: Path) -> None:
|
|
23
|
+
all_results = run_all_validators.validate_proposed_file(
|
|
24
|
+
str(tmp_path / "probe.py"),
|
|
25
|
+
_CLEAN_SOURCE,
|
|
26
|
+
include_ruff=False,
|
|
27
|
+
excluded_validator_names=_OVERLAPPING_VALIDATOR_NAMES,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
all_result_names = _result_names(all_results)
|
|
31
|
+
assert all_result_names.isdisjoint(_OVERLAPPING_VALIDATOR_NAMES)
|
|
32
|
+
assert "Ruff" not in all_result_names
|
|
33
|
+
assert "Abbreviations" in all_result_names
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_validate_proposed_file_keeps_ruff_as_an_explicit_native_step(
|
|
37
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
38
|
+
) -> None:
|
|
39
|
+
monkeypatch.setattr(
|
|
40
|
+
run_all_validators,
|
|
41
|
+
"run_ruff_checks",
|
|
42
|
+
lambda _files, _config_source_path: run_all_validators.ValidatorResult(
|
|
43
|
+
"Ruff", "37", True, "clean"
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
all_results = run_all_validators.validate_proposed_file(
|
|
47
|
+
str(tmp_path / "probe.py"),
|
|
48
|
+
_CLEAN_SOURCE,
|
|
49
|
+
excluded_validator_names=_OVERLAPPING_VALIDATOR_NAMES,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert "Ruff" in _result_names(all_results)
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.36.0",
|
|
4
4
|
"description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-dev-env": "bin/install.mjs",
|
|
8
|
-
"codex-compat": "bin/codex-compat.mjs"
|
|
8
|
+
"codex-compat": "bin/codex-compat.mjs",
|
|
9
|
+
"cde": "bin/cde.mjs"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
12
|
"test": "node --test \"bin/*.test.mjs\" \".agents/skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Pstack delegation policy
|
|
2
|
+
|
|
3
|
+
Pstack uses portable role requirements. Model IDs belong to host preference
|
|
4
|
+
files. The active native subagent tool decides which model IDs the current host
|
|
5
|
+
accepts.
|
|
6
|
+
|
|
7
|
+
This policy replaces the fixed model defaults in pstack skills and setup-pstack's
|
|
8
|
+
legacy step that writes one universal model rule. Setup updates only the selected
|
|
9
|
+
host preference JSON. The installer keeps this portable policy current.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
When setup-pstack runs, read the active native subagent tool metadata. Use the
|
|
14
|
+
host name from that tool or the current host context. Do not infer the host from
|
|
15
|
+
CODEX_HOME, CLAUDE_CONFIG_DIR, or another environment variable.
|
|
16
|
+
|
|
17
|
+
Save choices in the agents home under
|
|
18
|
+
`rules/pstack-model-preferences.<host>.json`. Keep each host in its own file.
|
|
19
|
+
The file contains this shape:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"host": "current-host",
|
|
24
|
+
"modelsByRole": {
|
|
25
|
+
"feature, refactoring": ["confirmed-host-model"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Offer only model IDs the active native tool accepts. Keep the user's order.
|
|
31
|
+
Treat `inherit-parent` and `auto` as parent inheritance, not model IDs. A
|
|
32
|
+
different host starts with its own confirmed choices. It never copies the Codex
|
|
33
|
+
preference file.
|
|
34
|
+
|
|
35
|
+
## Selection before every delegation
|
|
36
|
+
|
|
37
|
+
Refresh the accepted model IDs from the active native subagent tool immediately
|
|
38
|
+
before each native delegation. Run the installed selector once for each agent.
|
|
39
|
+
Use `~/.agents/scripts/select_pstack_models.mjs` for the main profile. For a
|
|
40
|
+
named or explicit profile, use the selector under that profile's sibling agents
|
|
41
|
+
home.
|
|
42
|
+
|
|
43
|
+
Send one JSON object through standard input. Use the active host shell on
|
|
44
|
+
other operating systems:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
$selectorInput = @'
|
|
48
|
+
{
|
|
49
|
+
"host": "current-host",
|
|
50
|
+
"inventoryHost": "current-host",
|
|
51
|
+
"role": "how critics",
|
|
52
|
+
"delegationIndex": 0,
|
|
53
|
+
"panel": {
|
|
54
|
+
"agentCount": 3,
|
|
55
|
+
"requiresDistinctModels": false
|
|
56
|
+
},
|
|
57
|
+
"availableModelIds": ["confirmed-host-model"],
|
|
58
|
+
"confirmedSuitableModelIds": [],
|
|
59
|
+
"parentModelId": "current-parent-model",
|
|
60
|
+
"parentFallback": {
|
|
61
|
+
"isAllowed": true,
|
|
62
|
+
"hasMaterialCapabilityLoss": false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
'@
|
|
66
|
+
$selection = $selectorInput | node "$HOME/.agents/scripts/select_pstack_models.mjs"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`availableModelIds` comes from the current native tool metadata.
|
|
70
|
+
`confirmedSuitableModelIds` contains available alternatives whose suitability
|
|
71
|
+
for this role the host metadata or user has confirmed. Do not rank or classify a
|
|
72
|
+
model from its name.
|
|
73
|
+
|
|
74
|
+
Use the returned `nativeSpawnArguments` in the native subagent call. An empty
|
|
75
|
+
object with `omitNativeModelArgument: true` means parent inheritance, so omit
|
|
76
|
+
the native `model` argument. Stop when `canDelegate` is false. Ask the user
|
|
77
|
+
when `requiresUserChoice` is true.
|
|
78
|
+
|
|
79
|
+
Run the selector again before the next agent call. Pass the same panel contract
|
|
80
|
+
and the new delegation index with the refreshed inventory. Report
|
|
81
|
+
`panel.selectedModelIds` and `panel.isCrossModelDiverse` as returned. A panel
|
|
82
|
+
that repeats one model is a same-model panel.
|
|
83
|
+
|
|
84
|
+
## Portable role requirements
|
|
85
|
+
|
|
86
|
+
feature, refactoring: reliable code execution
|
|
87
|
+
bug-fix: careful diagnosis and code execution
|
|
88
|
+
perf-issue: systems reasoning and performance analysis
|
|
89
|
+
hillclimb: strong iterative judgment
|
|
90
|
+
judgment and prose: strong judgment and clear prose
|
|
91
|
+
hardest tasks: highest available reasoning capability
|
|
92
|
+
how explorer: fast codebase exploration
|
|
93
|
+
how explainer: clear technical synthesis
|
|
94
|
+
how critics: independent technical criticism
|
|
95
|
+
why investigators: fast evidence gathering
|
|
96
|
+
why synthesizer: strong evidence synthesis
|
|
97
|
+
reflect tooling: reliable tool and workflow analysis
|
|
98
|
+
reflect judgment, divergent, synthesizer: strong judgment and divergent analysis
|
|
99
|
+
arena runners: independent solution development
|
|
100
|
+
arena cross-judge pool: independent comparative judgment
|
|
101
|
+
swarm workers: fast bounded task execution
|
|
102
|
+
architect runners: independent architecture design
|
|
103
|
+
interrogate reviewers: independent adversarial review
|
|
104
|
+
|
|
105
|
+
## Panel requirements
|
|
106
|
+
|
|
107
|
+
Panel size and model diversity are separate from model preferences.
|
|
108
|
+
|
|
109
|
+
- How critics use three agents by default. Cross-model diversity is optional.
|
|
110
|
+
- Arena runners use three agents by default and require distinct models.
|
|
111
|
+
- The arena cross-judge pool selects one judge. Pass `parentModelId` so the
|
|
112
|
+
selector chooses another available preference before the parent's model.
|
|
113
|
+
- Swarm uses the requested worker count. Same-model workers are valid.
|
|
114
|
+
- Architect uses two agents by default and requires distinct models.
|
|
115
|
+
- Interrogate uses three agents by default and requires distinct models.
|
|
116
|
+
|
|
117
|
+
A required-diversity panel stops when the current host lacks enough distinct
|
|
118
|
+
confirmed models. Parent inheritance is the last fallback. When inheritance
|
|
119
|
+
causes material capability loss, stop and ask the user before delegating.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { auditHooks } from "./audit-hooks.mjs";
|
|
6
|
+
|
|
7
|
+
function parseArguments(argv) {
|
|
8
|
+
const options = {
|
|
9
|
+
catalogPath: null,
|
|
10
|
+
format: "text",
|
|
11
|
+
homeDirectory: null,
|
|
12
|
+
includeInstalled: false,
|
|
13
|
+
outputPath: null,
|
|
14
|
+
repositoryRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."),
|
|
15
|
+
};
|
|
16
|
+
for (let offset = 0; offset < argv.length; offset += 1) {
|
|
17
|
+
const argument = argv[offset];
|
|
18
|
+
if (argument === "--installed") options.includeInstalled = true;
|
|
19
|
+
else if (argument === "--catalog") options.catalogPath = argv[++offset];
|
|
20
|
+
else if (argument === "--format") options.format = argv[++offset];
|
|
21
|
+
else if (argument === "--home") options.homeDirectory = argv[++offset];
|
|
22
|
+
else if (argument === "--output") options.outputPath = argv[++offset];
|
|
23
|
+
else if (argument === "--repository-root") options.repositoryRoot = argv[++offset];
|
|
24
|
+
else throw new Error(`unknown argument ${argument}`);
|
|
25
|
+
}
|
|
26
|
+
return options;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderText(report) {
|
|
30
|
+
const summary = report.summary;
|
|
31
|
+
return [
|
|
32
|
+
`canonical direct: ${summary.directCount}`,
|
|
33
|
+
`dispatchers: ${summary.dispatcherCount}`,
|
|
34
|
+
`hosted entries: ${summary.effectiveCount}`,
|
|
35
|
+
`logical associations: ${summary.logicalAssociationCount}`,
|
|
36
|
+
`unclassified targets: ${summary.unclassifiedCount}`,
|
|
37
|
+
`installed Claude direct: ${summary.claudeDirectCount}`,
|
|
38
|
+
`installed Codex direct: ${summary.codexDirectCount}`,
|
|
39
|
+
`active native Git hooks: ${summary.gitHookCount}`,
|
|
40
|
+
`findings: ${summary.findingCount}`,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function main() {
|
|
45
|
+
const options = parseArguments(process.argv.slice(2));
|
|
46
|
+
const report = auditHooks(options);
|
|
47
|
+
const rendered =
|
|
48
|
+
options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`;
|
|
49
|
+
if (options.outputPath) writeFileSync(options.outputPath, rendered, "utf8");
|
|
50
|
+
else process.stdout.write(rendered);
|
|
51
|
+
process.exitCode = report.findings.some(({ severity }) => severity === "error") ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main();
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const expectedCounts = Object.freeze({ direct: 32, dispatchers: 5, hosted: 43 });
|
|
7
|
+
const dispatcherSpecs = Object.freeze([
|
|
8
|
+
["script:blocking/pre_tool_use_dispatcher.py", "hooks_constants/pre_tool_use_dispatcher_constants.py", "ALL_HOSTED_HOOK_ENTRIES"],
|
|
9
|
+
["script:blocking/bash_pre_tool_use_dispatcher.py", "hooks_constants/bash_pre_tool_use_dispatcher_constants.py", "ALL_BASH_HOSTED_HOOK_ENTRIES"],
|
|
10
|
+
["script:blocking/stop_dispatcher.py", "hooks_constants/stop_dispatcher_constants.py", "ALL_STOP_HOSTED_HOOK_PATHS"],
|
|
11
|
+
["script:validation/post_tool_use_dispatcher.py", "hooks_constants/post_tool_use_dispatcher_constants.py", "ALL_POST_HOSTED_HOOK_ENTRIES"],
|
|
12
|
+
["script:blocking/bash_post_call_dispatcher.py", "hooks_constants/bash_post_call_dispatcher_constants.py", "ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES"],
|
|
13
|
+
]);
|
|
14
|
+
const lifecycleNames = new Set([
|
|
15
|
+
"delete",
|
|
16
|
+
"keep_boundary_check",
|
|
17
|
+
"keep_nonblocking_automation",
|
|
18
|
+
"move_to_ci",
|
|
19
|
+
"move_to_explicit_command",
|
|
20
|
+
"move_to_linter",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function normalizeMatcher(rawMatcher) {
|
|
24
|
+
return typeof rawMatcher === "string" && rawMatcher
|
|
25
|
+
? rawMatcher.split("|").sort().join("|")
|
|
26
|
+
: "*";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function targetFromCommand(command, scope, event, ordinal) {
|
|
30
|
+
const normalized = command.replaceAll("\\", "/");
|
|
31
|
+
const hookMarker = "/hooks/";
|
|
32
|
+
if (normalized.includes(hookMarker)) {
|
|
33
|
+
const relative = normalized.split(hookMarker).at(-1).split(/[\s"']/u, 1)[0];
|
|
34
|
+
if (relative.endsWith(".py")) return `script:${relative}`;
|
|
35
|
+
}
|
|
36
|
+
const importMatch = /\bfrom\s+([A-Za-z_][\w.]*)\s+import\s+main\b/u.exec(command);
|
|
37
|
+
if (importMatch) return `script:${importMatch[1].replaceAll(".", "/")}.py`;
|
|
38
|
+
const scriptMatches = [...normalized.matchAll(/(?:^|\s)["']?([^"'/\s]+\.(?:exe|js|mjs|ps1|py))\b/gu)];
|
|
39
|
+
if (scriptMatches.length) return `external:${scriptMatches.at(-1)[1]}`;
|
|
40
|
+
return `external:${scope}:${event}:${ordinal}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readDirect(configPath, scope) {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
45
|
+
const records = [];
|
|
46
|
+
for (const [event, groups] of Object.entries(parsed.hooks ?? {})) {
|
|
47
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
48
|
+
appendGroupRecords(records, scope, event, group);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return records;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function appendGroupRecords(records, scope, event, group) {
|
|
55
|
+
const matcher = normalizeMatcher(group.matcher);
|
|
56
|
+
for (const hook of Array.isArray(group.hooks) ? group.hooks : []) {
|
|
57
|
+
if (hook?.type !== "command" || typeof hook.command !== "string") continue;
|
|
58
|
+
const ordinal = records.length;
|
|
59
|
+
const target = targetFromCommand(hook.command, scope, event, ordinal);
|
|
60
|
+
const isDispatcher = dispatcherSpecs.some(([knownTarget]) => knownTarget === target);
|
|
61
|
+
records.push({
|
|
62
|
+
event,
|
|
63
|
+
matcher,
|
|
64
|
+
ordinal,
|
|
65
|
+
role: isDispatcher ? "dispatcher" : "hook",
|
|
66
|
+
scope,
|
|
67
|
+
target,
|
|
68
|
+
timeoutSeconds: Number.isInteger(hook.timeout) ? hook.timeout : null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assignmentBody(source, rosterName) {
|
|
74
|
+
const rosterOffset = source.lastIndexOf(rosterName);
|
|
75
|
+
if (rosterOffset < 0) throw new Error(`missing roster ${rosterName}`);
|
|
76
|
+
const equalsOffset = source.indexOf("=", rosterOffset);
|
|
77
|
+
const openOffset = source.indexOf("(", equalsOffset);
|
|
78
|
+
if (equalsOffset < 0 || openOffset < 0) throw new Error(`invalid roster ${rosterName}`);
|
|
79
|
+
const closeOffset = matchingParenthesis(source, openOffset);
|
|
80
|
+
return source.slice(openOffset + 1, closeOffset);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchingParenthesis(source, openOffset) {
|
|
84
|
+
let depth = 0;
|
|
85
|
+
let quote = "";
|
|
86
|
+
for (let offset = openOffset; offset < source.length; offset += 1) {
|
|
87
|
+
const character = source[offset];
|
|
88
|
+
const previous = source[offset - 1];
|
|
89
|
+
if (quote && character === quote && previous !== "\\") quote = "";
|
|
90
|
+
else if (!quote && (character === '"' || character === "'")) quote = character;
|
|
91
|
+
else if (!quote && character === "(") depth += 1;
|
|
92
|
+
else if (!quote && character === ")") depth -= 1;
|
|
93
|
+
if (depth === 0) return offset;
|
|
94
|
+
}
|
|
95
|
+
throw new Error("unclosed roster");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function topLevelEntries(source) {
|
|
99
|
+
const entries = [];
|
|
100
|
+
let depth = 0;
|
|
101
|
+
let quote = "";
|
|
102
|
+
let start = 0;
|
|
103
|
+
for (let offset = 0; offset < source.length; offset += 1) {
|
|
104
|
+
const character = source[offset];
|
|
105
|
+
const previous = source[offset - 1];
|
|
106
|
+
if (quote && character === quote && previous !== "\\") quote = "";
|
|
107
|
+
else if (!quote && (character === '"' || character === "'")) quote = character;
|
|
108
|
+
else if (!quote && /[([{]/u.test(character)) depth += 1;
|
|
109
|
+
else if (!quote && /[\])}]/u.test(character)) depth -= 1;
|
|
110
|
+
else if (!quote && character === "," && depth === 0) {
|
|
111
|
+
entries.push(source.slice(start, offset));
|
|
112
|
+
start = offset + 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
entries.push(source.slice(start));
|
|
116
|
+
return entries.filter((entry) => entry.trim());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toolMatchers(source) {
|
|
120
|
+
const matchers = new Map();
|
|
121
|
+
for (const match of source.matchAll(/\b([A-Z][A-Z0-9_]*_TOOL_NAME)\s*=\s*["']([^"']+)["']/gu)) {
|
|
122
|
+
matchers.set(match[1], new Set([match[2]]));
|
|
123
|
+
}
|
|
124
|
+
const collections = [...source.matchAll(/\b([A-Z][A-Z0-9_]*_TOOL_NAMES)\s*(?::[^=]+)?=\s*frozenset\s*\(/gu)];
|
|
125
|
+
for (const collection of collections) {
|
|
126
|
+
const openOffset = source.indexOf("(", collection.index + collection[0].indexOf("frozenset"));
|
|
127
|
+
const body = source.slice(openOffset + 1, matchingParenthesis(source, openOffset));
|
|
128
|
+
const names = new Set();
|
|
129
|
+
for (const token of body.match(/\b[A-Z][A-Z0-9_]+\b/gu) ?? []) {
|
|
130
|
+
for (const toolName of matchers.get(token) ?? []) names.add(toolName);
|
|
131
|
+
}
|
|
132
|
+
matchers.set(collection[1], names);
|
|
133
|
+
}
|
|
134
|
+
return matchers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rosterEntries(hooksRoot, constantsPath, rosterName) {
|
|
138
|
+
const source = readFileSync(path.join(hooksRoot, constantsPath), "utf8");
|
|
139
|
+
const body = assignmentBody(source, rosterName);
|
|
140
|
+
const matchers = toolMatchers(source);
|
|
141
|
+
return topLevelEntries(body).map((expression) => {
|
|
142
|
+
const targetMatch = /["']([^"']+\.py)["']/u.exec(expression);
|
|
143
|
+
if (!targetMatch) throw new Error(`invalid roster entry ${rosterName}`);
|
|
144
|
+
const names = new Set();
|
|
145
|
+
for (const token of expression.match(/\b[A-Z][A-Z0-9_]+\b/gu) ?? []) {
|
|
146
|
+
for (const toolName of matchers.get(token) ?? []) names.add(toolName);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
matcher: names.size ? [...names].sort().join("|") : null,
|
|
150
|
+
target: `script:${targetMatch[1].replaceAll("\\", "/")}`,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function expandHosted(directRecords, hooksRoot) {
|
|
156
|
+
const parents = new Map(
|
|
157
|
+
directRecords.filter(({ role }) => role === "dispatcher").map((record) => [record.target, record]),
|
|
158
|
+
);
|
|
159
|
+
const hosted = [];
|
|
160
|
+
for (const [dispatcherTarget, constantsPath, rosterName] of dispatcherSpecs) {
|
|
161
|
+
const parent = parents.get(dispatcherTarget);
|
|
162
|
+
if (!parent) continue;
|
|
163
|
+
const entries = rosterEntries(hooksRoot, constantsPath, rosterName);
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
hosted.push({
|
|
166
|
+
event: parent.event,
|
|
167
|
+
matcher: entry.matcher ?? parent.matcher,
|
|
168
|
+
ordinal: hosted.length,
|
|
169
|
+
parentTarget: parent.target,
|
|
170
|
+
role: "hosted",
|
|
171
|
+
scope: parent.scope,
|
|
172
|
+
target: entry.target,
|
|
173
|
+
timeoutSeconds: parent.timeoutSeconds,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return hosted;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function logicalRecords(directRecords, hostedRecords) {
|
|
181
|
+
return [...directRecords.filter(({ role }) => role !== "dispatcher"), ...hostedRecords].sort(recordSort);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function recordSort(left, right) {
|
|
185
|
+
const leftKey = [left.scope, left.event, left.matcher, left.target, left.role, left.ordinal].join("\0");
|
|
186
|
+
const rightKey = [right.scope, right.event, right.matcher, right.target, right.role, right.ordinal].join("\0");
|
|
187
|
+
return leftKey.localeCompare(rightKey);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function duplicateFindings(records, scope) {
|
|
191
|
+
const counts = new Map();
|
|
192
|
+
for (const record of records) {
|
|
193
|
+
const key = [record.event, record.matcher, record.target].join("\0");
|
|
194
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
return [...counts.entries()]
|
|
197
|
+
.filter(([, count]) => count > 1)
|
|
198
|
+
.map(([key, count]) => ({
|
|
199
|
+
code: "DUPLICATE_EFFECTIVE_EXECUTION",
|
|
200
|
+
message: `The same event, matcher, and target run ${count} times.`,
|
|
201
|
+
scope,
|
|
202
|
+
severity: "error",
|
|
203
|
+
target: key.split("\0").at(-1),
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function missingTargetFindings(records, hooksRoot, scope) {
|
|
208
|
+
return records
|
|
209
|
+
.filter(({ target }) => target.startsWith("script:"))
|
|
210
|
+
.filter(({ target }) => !existsSync(path.join(hooksRoot, target.slice("script:".length))))
|
|
211
|
+
.map(({ target }) => ({
|
|
212
|
+
code: "MISSING_HOOK_TARGET",
|
|
213
|
+
message: "The registered script is missing from the installed hook tree.",
|
|
214
|
+
scope,
|
|
215
|
+
severity: "error",
|
|
216
|
+
target,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function missingManagedFindings(expectedRecords, installedRecords, scope) {
|
|
221
|
+
const installedIdentities = new Set(installedRecords.map(recordIdentity));
|
|
222
|
+
return expectedRecords
|
|
223
|
+
.filter((record) => !installedIdentities.has(recordIdentity(record)))
|
|
224
|
+
.map(({ target }) => ({
|
|
225
|
+
code: "MISSING_MANAGED_REGISTRATION",
|
|
226
|
+
message: "The installed configuration is missing a canonical registration.",
|
|
227
|
+
scope,
|
|
228
|
+
severity: "error",
|
|
229
|
+
target,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function recordIdentity({ event, matcher, target }) {
|
|
234
|
+
return [event, matcher, target].join("\0");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readLifecycle(catalogPath) {
|
|
238
|
+
if (!catalogPath) return [];
|
|
239
|
+
const parsed = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
240
|
+
return Object.entries(parsed.hooks ?? {}).map(([target, decision]) => {
|
|
241
|
+
const safeTarget = /^(?:external:[A-Za-z0-9_.:-]+|git:(?:post-commit|pre-commit|pre-push)|script:[A-Za-z0-9_./-]+)$/u;
|
|
242
|
+
if (!safeTarget.test(target) || !lifecycleNames.has(decision?.lifecycle) || !decision.reason) {
|
|
243
|
+
throw new Error("invalid lifecycle entry");
|
|
244
|
+
}
|
|
245
|
+
return { lifecycle: decision.lifecycle, target };
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function lifecycleFindings(records, lifecycle) {
|
|
250
|
+
const targets = new Set(records.map(({ target }) => target));
|
|
251
|
+
const classified = new Set(lifecycle.map(({ target }) => target));
|
|
252
|
+
const missing = [...targets].filter((target) => !classified.has(target));
|
|
253
|
+
const stale = [...classified].filter((target) => !targets.has(target));
|
|
254
|
+
return [
|
|
255
|
+
...missing.map((target) => ({
|
|
256
|
+
code: "UNCLASSIFIED_HOOK",
|
|
257
|
+
message: "The effective hook has no lifecycle decision.",
|
|
258
|
+
scope: "catalog",
|
|
259
|
+
severity: "error",
|
|
260
|
+
target,
|
|
261
|
+
})),
|
|
262
|
+
...stale.map((target) => ({
|
|
263
|
+
code: "STALE_LIFECYCLE_ENTRY",
|
|
264
|
+
message: "The lifecycle target is absent from the canonical graph.",
|
|
265
|
+
scope: "catalog",
|
|
266
|
+
severity: "warning",
|
|
267
|
+
target,
|
|
268
|
+
})),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readInstalled(configPath, hooksRoot, scope) {
|
|
273
|
+
if (!existsSync(configPath)) return { findings: [], logical: [], records: [] };
|
|
274
|
+
const records = readDirect(configPath, scope);
|
|
275
|
+
const hosted = expandHosted(records, hooksRoot);
|
|
276
|
+
const logical = logicalRecords(records, hosted);
|
|
277
|
+
return {
|
|
278
|
+
findings: [
|
|
279
|
+
...duplicateFindings(logical, scope),
|
|
280
|
+
...missingTargetFindings([...records, ...hosted], hooksRoot, scope),
|
|
281
|
+
],
|
|
282
|
+
logical,
|
|
283
|
+
records,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function activeGitHooks(repositoryRoot) {
|
|
288
|
+
const gitQuery = spawnSync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
289
|
+
cwd: repositoryRoot,
|
|
290
|
+
encoding: "utf8",
|
|
291
|
+
shell: false,
|
|
292
|
+
timeout: 10_000,
|
|
293
|
+
});
|
|
294
|
+
if (gitQuery.status !== 0) return [];
|
|
295
|
+
const hooksPath = gitQuery.stdout?.trim();
|
|
296
|
+
if (!hooksPath) return [];
|
|
297
|
+
const hooksRoot = path.isAbsolute(hooksPath) ? hooksPath : path.resolve(repositoryRoot, hooksPath);
|
|
298
|
+
return ["pre-commit", "pre-push", "post-commit"]
|
|
299
|
+
.filter((name) => existsSync(path.join(hooksRoot, name)))
|
|
300
|
+
.map((name, ordinal) => ({
|
|
301
|
+
event: name,
|
|
302
|
+
matcher: "*",
|
|
303
|
+
ordinal,
|
|
304
|
+
role: "native",
|
|
305
|
+
scope: "git",
|
|
306
|
+
target: `git:${name}`,
|
|
307
|
+
timeoutSeconds: null,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function countFindings(directCount, dispatcherCount, hostedCount) {
|
|
312
|
+
const actual = { direct: directCount, dispatchers: dispatcherCount, hosted: hostedCount };
|
|
313
|
+
return Object.entries(expectedCounts)
|
|
314
|
+
.filter(([name, expected]) => actual[name] !== expected)
|
|
315
|
+
.map(([name, expected]) => ({
|
|
316
|
+
code: `${name.toUpperCase()}_COUNT_DRIFT`,
|
|
317
|
+
message: `Expected ${expected} entries and found ${actual[name]}.`,
|
|
318
|
+
scope: "canonical",
|
|
319
|
+
severity: "error",
|
|
320
|
+
target: null,
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function auditHooks(options) {
|
|
325
|
+
const repositoryRoot = path.resolve(options.repositoryRoot);
|
|
326
|
+
const hooksRoot = path.join(repositoryRoot, "packages", "claude-dev-env", "hooks");
|
|
327
|
+
const direct = readDirect(path.join(hooksRoot, "hooks.json"), "canonical");
|
|
328
|
+
const hosted = expandHosted(direct, hooksRoot);
|
|
329
|
+
const logical = logicalRecords(direct, hosted);
|
|
330
|
+
const lifecycle = readLifecycle(options.catalogPath);
|
|
331
|
+
const dispatcherCount = direct.filter(({ role }) => role === "dispatcher").length;
|
|
332
|
+
const installed = options.includeInstalled
|
|
333
|
+
? installedAudit(options.homeDirectory ?? homedir(), repositoryRoot, logical)
|
|
334
|
+
: { claudeCount: 0, codexCount: 0, findings: [], git: [], logical: [], records: [] };
|
|
335
|
+
const allLogical = [...logical, ...installed.logical, ...installed.git];
|
|
336
|
+
const findings = [
|
|
337
|
+
...countFindings(direct.length, dispatcherCount, hosted.length),
|
|
338
|
+
...duplicateFindings(logical, "canonical"),
|
|
339
|
+
...(options.catalogPath ? lifecycleFindings(allLogical, lifecycle) : []),
|
|
340
|
+
...installed.findings,
|
|
341
|
+
].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
342
|
+
const targets = new Set(allLogical.map(({ target }) => target));
|
|
343
|
+
const classified = new Set(lifecycle.map(({ target }) => target));
|
|
344
|
+
return {
|
|
345
|
+
directRegistrations: direct.sort(recordSort),
|
|
346
|
+
findings,
|
|
347
|
+
hostedRegistrations: hosted.sort(recordSort),
|
|
348
|
+
installedRegistrations: installed.records.sort(recordSort),
|
|
349
|
+
installedLogicalRegistrations: installed.logical.sort(recordSort),
|
|
350
|
+
gitRegistrations: installed.git,
|
|
351
|
+
lifecycle,
|
|
352
|
+
logicalRegistrations: logical,
|
|
353
|
+
schemaVersion: 1,
|
|
354
|
+
summary: {
|
|
355
|
+
claudeDirectCount: installed.claudeCount,
|
|
356
|
+
codexDirectCount: installed.codexCount,
|
|
357
|
+
directCount: direct.length,
|
|
358
|
+
dispatcherCount,
|
|
359
|
+
effectiveCount: hosted.length,
|
|
360
|
+
findingCount: findings.length,
|
|
361
|
+
gitHookCount: installed.git.length,
|
|
362
|
+
lifecycleCount: lifecycle.length,
|
|
363
|
+
logicalAssociationCount: logical.length,
|
|
364
|
+
unclassifiedCount: options.catalogPath
|
|
365
|
+
? [...targets].filter((target) => !classified.has(target)).length
|
|
366
|
+
: 0,
|
|
367
|
+
uniqueTargetCount: targets.size,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function installedAudit(homeDirectory, repositoryRoot, canonicalLogical) {
|
|
373
|
+
const claude = readInstalled(
|
|
374
|
+
path.join(homeDirectory, ".claude", "settings.json"),
|
|
375
|
+
path.join(homeDirectory, ".claude", "hooks"),
|
|
376
|
+
"claude",
|
|
377
|
+
);
|
|
378
|
+
const codex = readInstalled(
|
|
379
|
+
path.join(homeDirectory, ".codex", "hooks.json"),
|
|
380
|
+
path.join(homeDirectory, ".codex", "hooks"),
|
|
381
|
+
"codex",
|
|
382
|
+
);
|
|
383
|
+
const git = activeGitHooks(repositoryRoot);
|
|
384
|
+
return {
|
|
385
|
+
claudeCount: claude.records.length,
|
|
386
|
+
codexCount: codex.records.length,
|
|
387
|
+
findings: [
|
|
388
|
+
...claude.findings,
|
|
389
|
+
...codex.findings,
|
|
390
|
+
...missingManagedFindings(canonicalLogical, claude.logical, "claude"),
|
|
391
|
+
...missingManagedFindings(canonicalLogical, codex.logical, "codex"),
|
|
392
|
+
],
|
|
393
|
+
git,
|
|
394
|
+
logical: [...claude.logical, ...codex.logical],
|
|
395
|
+
records: [...claude.records, ...codex.records],
|
|
396
|
+
};
|
|
397
|
+
}
|