claude-dev-env 7.4.0 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.mjs +0 -1
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +4 -4
- package/bin/pstack.mjs +24 -4
- package/bin/pstack.test.mjs +20 -10
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +7 -13
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +1 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
- package/hooks/blocking/env_var_table_rows.py +130 -0
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_scanner.py +19 -29
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
- package/hooks/blocking/test_env_var_table_rows.py +100 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/blocking/test_pii_scanner.py +34 -24
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +36 -1
- package/scripts/policy_lint/config/constants.py +74 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +6 -2
- package/scripts/pstack-adapters/common.md +1 -1
- package/scripts/repository_checks/config/constants.py +7 -17
- package/scripts/repository_checks/config/email_exemptions.py +10 -0
- package/scripts/repository_checks/email_exemptions.py +96 -0
- package/scripts/repository_checks/env_var_documentation.py +1 -9
- package/scripts/repository_checks/tracked_secrets.py +10 -4
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_email_exemptions.py +121 -0
- package/scripts/tests/test_env_var_documentation.py +26 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +107 -0
- package/scripts/tests/test_tracked_secrets.py +13 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- package/hooks/hooks_constants/text_stripping.py +0 -36
package/bin/install.mjs
CHANGED
|
@@ -1292,7 +1292,6 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
|
|
|
1292
1292
|
'blocking/nas_ssh_binary_enforcer.py',
|
|
1293
1293
|
'blocking/block_main_commit.py',
|
|
1294
1294
|
'blocking/session_edit_stage_gate.py',
|
|
1295
|
-
'blocking/bash_pre_tool_use_dispatcher.py',
|
|
1296
1295
|
'blocking/stop_dispatcher.py',
|
|
1297
1296
|
'blocking/bot_mention_comment_blocker.py',
|
|
1298
1297
|
'blocking/fable_spawn_gate.py',
|
|
@@ -36,7 +36,6 @@ const allRetiredPolicyRegistrations = [
|
|
|
36
36
|
'blocking/nas_ssh_binary_enforcer.py',
|
|
37
37
|
'blocking/block_main_commit.py',
|
|
38
38
|
'blocking/session_edit_stage_gate.py',
|
|
39
|
-
'blocking/bash_pre_tool_use_dispatcher.py',
|
|
40
39
|
'blocking/stop_dispatcher.py',
|
|
41
40
|
'blocking/bot_mention_comment_blocker.py',
|
|
42
41
|
'blocking/fable_spawn_gate.py',
|
|
@@ -117,7 +116,7 @@ test('upgrade prunes legacy direct file checks while retaining foreign hooks', (
|
|
|
117
116
|
});
|
|
118
117
|
assert.ok(commands.every(command => !command.includes('/blocking/stop_dispatcher.py')));
|
|
119
118
|
assert.ok(commands.some(command => command.includes('/blocking/pre_tool_use_dispatcher.py')));
|
|
120
|
-
assert.ok(commands.
|
|
119
|
+
assert.ok(commands.some(command => command.includes('/blocking/bash_pre_tool_use_dispatcher.py')));
|
|
121
120
|
});
|
|
122
121
|
|
|
123
122
|
test('reinstall does not restore retired direct checks or duplicate foreign hooks', () => {
|
package/bin/install.test.mjs
CHANGED
|
@@ -1195,7 +1195,6 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1195
1195
|
'blocking/nas_ssh_binary_enforcer.py',
|
|
1196
1196
|
'blocking/block_main_commit.py',
|
|
1197
1197
|
'blocking/session_edit_stage_gate.py',
|
|
1198
|
-
'blocking/bash_pre_tool_use_dispatcher.py',
|
|
1199
1198
|
'blocking/stop_dispatcher.py',
|
|
1200
1199
|
'blocking/bot_mention_comment_blocker.py',
|
|
1201
1200
|
'blocking/fable_spawn_gate.py',
|
|
@@ -3047,9 +3046,10 @@ test('the base install publishes the pstack poteto-mode entry into the managed s
|
|
|
3047
3046
|
|
|
3048
3047
|
assert.equal(outcome.status, 'installed');
|
|
3049
3048
|
assert.equal(outcome.warning, null);
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
assert.equal(
|
|
3049
|
+
const pstackRoot = join(fixture.managedRoot, 'skills', 'pstack');
|
|
3050
|
+
const entryPath = join(pstackRoot, 'poteto-mode');
|
|
3051
|
+
assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true, 'the pstack root is a pointer into the release');
|
|
3052
|
+
assert.equal(existsSync(join(entryPath, 'SKILL.md')), true, 'the nested entry resolves to a skill');
|
|
3053
3053
|
});
|
|
3054
3054
|
|
|
3055
3055
|
test('an unreachable pstack upstream reports a warning and keeps the base install going', t => {
|
package/bin/pstack.mjs
CHANGED
|
@@ -84,6 +84,17 @@ function adaptSkill(text, name, releaseRoot, skillRoot) {
|
|
|
84
84
|
+ fork + '\n' + match[2].trimStart();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function writePstackPluginManifest(stage, skills) {
|
|
88
|
+
const manifest = {
|
|
89
|
+
$schema: 'https://anthropic.com/claude-code/plugin.schema.json',
|
|
90
|
+
name: 'pstack',
|
|
91
|
+
skills: skills.filter(skill => skill.component === 'pstack').map(skill => `./${skill.slug}`),
|
|
92
|
+
};
|
|
93
|
+
const pluginRoot = join(stage, 'runtime', 'pstack', 'skills');
|
|
94
|
+
mkdirSync(join(pluginRoot, '.claude-plugin'), { recursive: true });
|
|
95
|
+
writeFileSync(join(pluginRoot, '.claude-plugin', 'plugin.json'), json(manifest));
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
88
99
|
const skills = [];
|
|
89
100
|
const agents = [];
|
|
@@ -102,10 +113,11 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
|
102
113
|
}
|
|
103
114
|
for (const slug of names) {
|
|
104
115
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`Invalid skill directory: ${slug}`);
|
|
105
|
-
const name = `${component}-${slug}`;
|
|
116
|
+
const name = component === 'pstack' ? `pstack:${slug}` : `${component}-${slug}`;
|
|
117
|
+
const frontmatterName = component === 'pstack' ? slug : name;
|
|
106
118
|
const path = join('runtime', component, 'skills', slug);
|
|
107
119
|
const text = readFileSync(join(source, 'skills', slug, 'SKILL.md'), 'utf8');
|
|
108
|
-
writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text,
|
|
120
|
+
writeFileSync(join(stage, path, 'SKILL.md'), adaptSkill(text, frontmatterName, finalRoot, join(finalRoot, path)));
|
|
109
121
|
skills.push({ name, component, slug, path });
|
|
110
122
|
}
|
|
111
123
|
const agentHome = join(source, 'agents');
|
|
@@ -118,6 +130,7 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
|
|
|
118
130
|
mkdirSync(join(stage, creatorPath), { recursive: true });
|
|
119
131
|
writeFileSync(join(stage, creatorPath, 'SKILL.md'), adapters['create-skill.md']);
|
|
120
132
|
skills.push({ name: 'cde-create-skill', component: 'cde', slug: 'create-skill', path: creatorPath });
|
|
133
|
+
writePstackPluginManifest(stage, skills);
|
|
121
134
|
const knownNames = new Set(skills.map(skill => `${skill.component}:${skill.slug}`));
|
|
122
135
|
for (const path of filesUnder(join(stage, 'upstream')).filter(path => path.endsWith('.md') && (path.includes(sep + 'skills' + sep) || path.includes(sep + 'agents' + sep)))) {
|
|
123
136
|
const references = readFileSync(path, 'utf8').matchAll(/\b(pstack|cursor-team-kit):([a-z][a-z0-9-]*)/g);
|
|
@@ -146,7 +159,9 @@ export function verifyRelease(root) {
|
|
|
146
159
|
if (digest(readFileSync(assertContained(root, path))) !== hash) throw new Error(`Changed installed file: ${path}`);
|
|
147
160
|
}
|
|
148
161
|
for (const skill of release.skills) {
|
|
149
|
-
|
|
162
|
+
const validPart = value => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
163
|
+
const expectedName = skill.component === 'pstack' ? `pstack:${skill.slug}` : `${skill.component}-${skill.slug}`;
|
|
164
|
+
if (!validPart(skill.component) || !validPart(skill.slug) || skill.name !== expectedName) throw new Error('Invalid installed skill name');
|
|
150
165
|
const path = join(assertContained(root, skill.path), 'SKILL.md');
|
|
151
166
|
if (!Object.hasOwn(release.fileDigests, relative(root, path)) || !existsSync(path)) throw new Error(`Missing entry: ${skill.name}`);
|
|
152
167
|
}
|
|
@@ -177,7 +192,12 @@ export function verifyInstallation(options = {}) {
|
|
|
177
192
|
|
|
178
193
|
function publish(releaseRoot, release, homes, prior) {
|
|
179
194
|
const uniqueHomes = [...new Set(homes.map(home => { mkdirSync(home, { recursive: true }); return realpathSync(home); }))];
|
|
180
|
-
const
|
|
195
|
+
const pstackSkill = release.skills.find(skill => skill.component === 'pstack');
|
|
196
|
+
const directSkills = release.skills.filter(skill => skill.component !== 'pstack');
|
|
197
|
+
const links = Object.fromEntries(uniqueHomes.flatMap(home => [
|
|
198
|
+
...(pstackSkill ? [[join(home, 'pstack'), join(releaseRoot, dirname(pstackSkill.path))]] : []),
|
|
199
|
+
...directSkills.map(skill => [join(home, skill.name), join(releaseRoot, skill.path)]),
|
|
200
|
+
]));
|
|
181
201
|
const paths = new Set([...Object.keys(prior?.links ?? {}), ...Object.keys(links)]);
|
|
182
202
|
const before = new Map();
|
|
183
203
|
for (const path of paths) {
|
package/bin/pstack.test.mjs
CHANGED
|
@@ -44,15 +44,23 @@ for (const host of ['claude', 'codex', 'cursor']) {
|
|
|
44
44
|
assert.equal(result.commit, '27e2a62ff94f9af4b5e68435e41cdceacadb840c');
|
|
45
45
|
assert.equal(result.verification, 'filesystem-only');
|
|
46
46
|
const release = join(f.store, 'releases', result.release);
|
|
47
|
-
const
|
|
47
|
+
const releaseManifest = JSON.parse(readFileSync(join(release, 'release.json'), 'utf8'));
|
|
48
|
+
assert.equal(releaseManifest.skills.find(skill => skill.component === 'pstack').name, 'pstack:poteto-mode');
|
|
49
|
+
const expected = ['cde-create-skill', 'cursor-team-kit-control-cli', 'cursor-team-kit-control-ui', 'cursor-team-kit-deslop', 'pstack'];
|
|
48
50
|
for (const home of ['.claude', '.agents']) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const skillsHome = join(f.options.project, home, 'skills');
|
|
52
|
+
assert.deepEqual(readdirSync(skillsHome).sort(), expected);
|
|
53
|
+
const pstackRoot = join(skillsHome, 'pstack');
|
|
54
|
+
assert.equal(lstatSync(pstackRoot).isSymbolicLink(), true);
|
|
55
|
+
const entry = join(pstackRoot, 'poteto-mode');
|
|
51
56
|
assert.equal(realpathSync(entry), join(release, 'runtime', 'pstack', 'skills', 'poteto-mode'));
|
|
52
57
|
const text = readFileSync(join(entry, 'SKILL.md'), 'utf8');
|
|
53
|
-
assert.match(text, /name:
|
|
58
|
+
assert.match(text, /name: poteto-mode/);
|
|
54
59
|
assert.ok(text.includes(JSON.stringify(join(release, 'compat', 'common.md'))));
|
|
55
60
|
assert.equal(readFileSync(join(entry, 'playbooks', 'feature.md'), 'utf8'), 'Build a small task, delegate, and verify it.\n');
|
|
61
|
+
const manifest = JSON.parse(readFileSync(join(pstackRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
62
|
+
assert.equal(manifest.name, 'pstack');
|
|
63
|
+
assert.deepEqual(manifest.skills, ['./poteto-mode']);
|
|
56
64
|
}
|
|
57
65
|
assert.equal(existsSync(join(f.options.project, '.cursor')), false);
|
|
58
66
|
for (const component of baseLock.components) {
|
|
@@ -89,10 +97,12 @@ test('changed, added, and removed skills converge while old releases remain read
|
|
|
89
97
|
const after = installPstack({ ...f.options, lock: { ...baseLock, commit: 'b'.repeat(40) } }, f.dependencies);
|
|
90
98
|
assert.equal(after.skillCount, 6);
|
|
91
99
|
for (const home of ['.claude', '.agents']) {
|
|
92
|
-
const
|
|
93
|
-
assert.equal(existsSync(join(
|
|
94
|
-
assert.equal(readFileSync(join(
|
|
95
|
-
assert.equal(readFileSync(join(
|
|
100
|
+
const pstackRoot = join(f.options.project, home, 'skills', 'pstack');
|
|
101
|
+
assert.equal(existsSync(join(pstackRoot, 'old')), false);
|
|
102
|
+
assert.equal(readFileSync(join(pstackRoot, 'new', 'SKILL.md'), 'utf8').endsWith('New body.\n'), true);
|
|
103
|
+
assert.equal(readFileSync(join(pstackRoot, 'poteto-mode', 'playbooks', 'feature.md'), 'utf8'), 'Changed playbook.\n');
|
|
104
|
+
const manifest = JSON.parse(readFileSync(join(pstackRoot, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
105
|
+
assert.deepEqual(manifest.skills, ['./new', './poteto-mode']);
|
|
96
106
|
}
|
|
97
107
|
assert.equal(readFileSync(join(oldRelease, 'runtime', 'pstack', 'skills', 'poteto-mode', 'SKILL.md'), 'utf8'), firstBody);
|
|
98
108
|
});
|
|
@@ -117,9 +127,9 @@ test('a failed first install returns failure and publishes no skills', t => {
|
|
|
117
127
|
assert.equal(existsSync(join(f.store, '.install-lock')), false);
|
|
118
128
|
});
|
|
119
129
|
|
|
120
|
-
test('unmanaged
|
|
130
|
+
test('unmanaged pstack root collision preserves user files', t => {
|
|
121
131
|
const f = fixture(t);
|
|
122
|
-
const userFile = join(f.options.project, '.claude', 'skills', 'pstack
|
|
132
|
+
const userFile = join(f.options.project, '.claude', 'skills', 'pstack', 'poteto-mode', 'SKILL.md');
|
|
123
133
|
put(userFile, 'Personal workflow\n');
|
|
124
134
|
assert.throws(() => installPstack(f.options, f.dependencies), /Keep existing unmanaged path/);
|
|
125
135
|
assert.equal(readFileSync(userFile, 'utf8'), 'Personal workflow\n');
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse context advisory: Git Bash rewrote a ``<rev>:<path>`` argument.
|
|
3
|
+
|
|
4
|
+
This hook never blocks. It watches every Bash call finish and, when git failed
|
|
5
|
+
with either its ambiguous-argument error or its invalid-object-name error and
|
|
6
|
+
the quoted argument carries the marks of MSYS path conversion, adds one loud
|
|
7
|
+
note to the agent's context::
|
|
8
|
+
|
|
9
|
+
=== MSYS PATH CONVERSION (context reminder, never a block) ===
|
|
10
|
+
Git saw: origin\\main;.claude\\skills\\x\\test_run_evals.py
|
|
11
|
+
Git Bash rewrote a <rev>:<path> argument before git saw it, turning the
|
|
12
|
+
colon into a semicolon and the slashes into backslashes.
|
|
13
|
+
Fix: export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
|
|
14
|
+
Put that export and the git command in the same command.
|
|
15
|
+
|
|
16
|
+
Why a reminder and not a gate: the rewriting happens inside the shell, so the
|
|
17
|
+
command text the agent wrote looks correct and the error names an argument the
|
|
18
|
+
agent never typed. The note arrives at the moment the failure lands, which is
|
|
19
|
+
the only moment the two names line up.
|
|
20
|
+
|
|
21
|
+
Six quiet branches emit nothing: a non-Bash tool, a zero-exit call, a failure
|
|
22
|
+
carrying neither marker, a quoted argument whose closing quote never arrives, a
|
|
23
|
+
quoted argument free of both mangling marks, and a command that already names
|
|
24
|
+
either workaround variable.
|
|
25
|
+
|
|
26
|
+
The verdict rests on the failure text alone and never parses the command, so
|
|
27
|
+
two shapes get the note without deserving it: an argument the agent typed with
|
|
28
|
+
a semicolon or a backslash of its own, and a failing call whose stdout quotes
|
|
29
|
+
one of the two git messages. Both are rare, and the note costs a reader one
|
|
30
|
+
paragraph, so the detector keeps its single read of the failure text.
|
|
31
|
+
|
|
32
|
+
Hosted by ``blocking/bash_post_call_dispatcher.py``, which forwards the
|
|
33
|
+
``hookSpecificOutput.additionalContext`` this hook prints.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
43
|
+
if _hooks_root_directory not in sys.path:
|
|
44
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
45
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
46
|
+
EXIT_CODE_ERROR_PREFIX,
|
|
47
|
+
)
|
|
48
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
49
|
+
ALL_BASH_ONLY_TOOL_NAMES,
|
|
50
|
+
)
|
|
51
|
+
from hooks_constants.msys_path_conversion_advisor_constants import (
|
|
52
|
+
ADVISORY_HEADER,
|
|
53
|
+
ADVISORY_LINE_SEPARATOR,
|
|
54
|
+
ALL_MANGLED_ARGUMENT_MARKERS,
|
|
55
|
+
ALL_MSYS_MANGLING_CHARACTERS,
|
|
56
|
+
ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES,
|
|
57
|
+
FIX_LINE,
|
|
58
|
+
MANGLED_ARGUMENT_CLOSING_QUOTE,
|
|
59
|
+
MANGLED_ARGUMENT_LINE_TEMPLATE,
|
|
60
|
+
MANGLING_EXPLANATION_LINE,
|
|
61
|
+
SAME_COMMAND_LINE,
|
|
62
|
+
)
|
|
63
|
+
from hooks_constants.post_tool_use_context import (
|
|
64
|
+
write_post_tool_use_context_to_stdout,
|
|
65
|
+
)
|
|
66
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
67
|
+
except ImportError as import_error:
|
|
68
|
+
raise ImportError(
|
|
69
|
+
"msys_path_conversion_advisor is unable to import its dependencies; "
|
|
70
|
+
"ensure the hooks directory is importable."
|
|
71
|
+
) from import_error
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def text_after_first_mangled_argument_marker(response_text: str) -> str | None:
|
|
75
|
+
"""Return what follows the first marker present in a git failure, else None.
|
|
76
|
+
|
|
77
|
+
Git names a mangled ``<rev>:<path>`` argument under two messages, one for a
|
|
78
|
+
path after the colon and one for an absolute path after the colon. The
|
|
79
|
+
markers are walked in order and the first one present wins.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
response_text: The harness response text for the failed call.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The text following the opening quote of the first marker present, or
|
|
86
|
+
None when the response carries no marker.
|
|
87
|
+
"""
|
|
88
|
+
for each_marker in ALL_MANGLED_ARGUMENT_MARKERS:
|
|
89
|
+
_, marker_text, text_after_marker = response_text.partition(each_marker)
|
|
90
|
+
if marker_text:
|
|
91
|
+
return text_after_marker
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def argument_carries_msys_mangling_marks(argument_text: str) -> bool:
|
|
96
|
+
"""Return whether an argument carries a mark left behind by path conversion.
|
|
97
|
+
|
|
98
|
+
Path conversion leaves a backslash or a semicolon in the argument, and that
|
|
99
|
+
is what separates this failure from a revision that is simply absent.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
argument_text: The quoted argument git named in its failure.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True when one of those marks is present, else False.
|
|
106
|
+
"""
|
|
107
|
+
return any(each_character in argument_text for each_character in ALL_MSYS_MANGLING_CHARACTERS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def command_names_a_path_conversion_variable(command_text: str) -> bool:
|
|
111
|
+
"""Return whether the command already names a variable that stops conversion.
|
|
112
|
+
|
|
113
|
+
The advisory names two exports, and either one on its own suppresses the
|
|
114
|
+
rewriting, so a command carrying either has nothing left to learn.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
command_text: The Bash command text the agent ran.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True when one of those variable names is present, else False.
|
|
121
|
+
"""
|
|
122
|
+
return any(
|
|
123
|
+
each_variable_name in command_text
|
|
124
|
+
for each_variable_name in ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def mangled_revision_path_argument(command_text: str, tool_response: object) -> str | None:
|
|
129
|
+
"""Return the argument git reported, when MSYS mangled it, else None.
|
|
130
|
+
|
|
131
|
+
Four conditions all hold before an argument is returned. The harness
|
|
132
|
+
reported a non-zero exit status, which arrives as a string carrying the
|
|
133
|
+
exit-code prefix. Git printed either its ambiguous-argument error or its
|
|
134
|
+
invalid-object-name error, and the quoted argument after it closes. That
|
|
135
|
+
argument carries a path-conversion mark, and the command names neither of
|
|
136
|
+
the two variables the workaround exports.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command_text: The Bash command text the agent ran.
|
|
140
|
+
tool_response: The harness response for the finished call.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The quoted argument word for word, or None to stay quiet.
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(tool_response, str) or not tool_response.startswith(EXIT_CODE_ERROR_PREFIX):
|
|
146
|
+
return None
|
|
147
|
+
text_after_marker = text_after_first_mangled_argument_marker(tool_response)
|
|
148
|
+
if text_after_marker is None:
|
|
149
|
+
return None
|
|
150
|
+
argument_text, closing_quote, _ = text_after_marker.partition(MANGLED_ARGUMENT_CLOSING_QUOTE)
|
|
151
|
+
if not closing_quote:
|
|
152
|
+
return None
|
|
153
|
+
if not argument_carries_msys_mangling_marks(argument_text):
|
|
154
|
+
return None
|
|
155
|
+
if command_names_a_path_conversion_variable(command_text):
|
|
156
|
+
return None
|
|
157
|
+
return argument_text
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_advisory_context(mangled_argument: str) -> str:
|
|
161
|
+
"""Build the advisory text naming the mangled argument and the two exports.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
mangled_argument: The argument git reported, word for word.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The multi-line note the dispatcher forwards as additionalContext.
|
|
168
|
+
"""
|
|
169
|
+
all_lines = [
|
|
170
|
+
ADVISORY_HEADER,
|
|
171
|
+
MANGLED_ARGUMENT_LINE_TEMPLATE.format(argument=mangled_argument),
|
|
172
|
+
MANGLING_EXPLANATION_LINE,
|
|
173
|
+
FIX_LINE,
|
|
174
|
+
SAME_COMMAND_LINE,
|
|
175
|
+
]
|
|
176
|
+
return ADVISORY_LINE_SEPARATOR.join(all_lines)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> None:
|
|
180
|
+
"""Add the MSYS path-conversion note after a git call the shell mangled.
|
|
181
|
+
|
|
182
|
+
Reads the PostToolUse payload from stdin. Every quiet branch returns with
|
|
183
|
+
no output, so this hook can never alter or block a tool call.
|
|
184
|
+
"""
|
|
185
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
186
|
+
if hook_payload is None or hook_payload.get("tool_name") not in ALL_BASH_ONLY_TOOL_NAMES:
|
|
187
|
+
return
|
|
188
|
+
tool_input = hook_payload.get("tool_input")
|
|
189
|
+
command_text = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
|
|
190
|
+
if not isinstance(command_text, str):
|
|
191
|
+
return
|
|
192
|
+
mangled_argument = mangled_revision_path_argument(
|
|
193
|
+
command_text, hook_payload.get("tool_response")
|
|
194
|
+
)
|
|
195
|
+
if mangled_argument is not None:
|
|
196
|
+
write_post_tool_use_context_to_stdout(build_advisory_context(mangled_argument))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
|
@@ -43,13 +43,18 @@ try:
|
|
|
43
43
|
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
44
44
|
ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
45
45
|
)
|
|
46
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
47
|
+
EXIT_CODE_ERROR_PREFIX,
|
|
48
|
+
)
|
|
49
|
+
from hooks_constants.post_tool_use_context import (
|
|
50
|
+
write_post_tool_use_context_to_stdout,
|
|
51
|
+
)
|
|
46
52
|
from hooks_constants.pr_done_reminder_constants import (
|
|
47
53
|
ADD_LABEL_COMMAND_TEMPLATE,
|
|
48
54
|
ALL_FAILING_CHECK_CONCLUSIONS,
|
|
49
55
|
ALL_PASSING_CHECK_CONCLUSIONS,
|
|
50
56
|
CHECK_COMPLETED_STATUS,
|
|
51
57
|
DONE_LABEL_NAME,
|
|
52
|
-
EXIT_CODE_ERROR_PREFIX,
|
|
53
58
|
GH_PR_CREATE_ACTION,
|
|
54
59
|
GH_PR_SUBCOMMAND,
|
|
55
60
|
ALL_GH_PR_VIEW_ARGUMENTS,
|
|
@@ -304,17 +309,6 @@ def _probe_pull_request(cwd: str) -> str | None:
|
|
|
304
309
|
return build_reminder_context(all_pr_fields)
|
|
305
310
|
|
|
306
311
|
|
|
307
|
-
def _emit_context(context_text: str) -> None:
|
|
308
|
-
payload = {
|
|
309
|
-
"hookSpecificOutput": {
|
|
310
|
-
"hookEventName": "PostToolUse",
|
|
311
|
-
"additionalContext": context_text,
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
sys.stdout.write(json.dumps(payload))
|
|
315
|
-
sys.stdout.flush()
|
|
316
|
-
|
|
317
|
-
|
|
318
312
|
def main() -> None:
|
|
319
313
|
"""Add the PR done checklist to context after a successful push or PR creation.
|
|
320
314
|
|
|
@@ -335,7 +329,7 @@ def main() -> None:
|
|
|
335
329
|
return
|
|
336
330
|
context_text = _probe_pull_request(str(hook_payload.get("cwd") or ""))
|
|
337
331
|
if context_text is not None:
|
|
338
|
-
|
|
332
|
+
write_post_tool_use_context_to_stdout(context_text)
|
|
339
333
|
|
|
340
334
|
|
|
341
335
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Behavior tests for the MSYS path-conversion advisory hook.
|
|
2
|
+
|
|
3
|
+
The hook is a PostToolUse observer on Bash. It never blocks. These tests pin
|
|
4
|
+
which failed git call wakes it, which lookalike failures stay quiet, and what
|
|
5
|
+
the advisory text says.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
advisory_directory = str(Path(__file__).resolve().parent)
|
|
19
|
+
if advisory_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, advisory_directory)
|
|
21
|
+
import msys_path_conversion_advisor
|
|
22
|
+
except ImportError as import_error:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"test_msys_path_conversion_advisor: cannot import its sibling modules; "
|
|
25
|
+
"ensure the advisory directory is importable."
|
|
26
|
+
) from import_error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_MANGLED_ARGUMENT = r"origin\main;.claude\skills\x\test_run_evals.py"
|
|
30
|
+
_MANGLED_FAILURE_RESPONSE = (
|
|
31
|
+
"Error: Exit code 128\n"
|
|
32
|
+
r"fatal: ambiguous argument 'origin\main;.claude\skills\x\test_run_evals.py': "
|
|
33
|
+
"unknown revision or path not in the working tree."
|
|
34
|
+
)
|
|
35
|
+
_MISSING_REVISION_RESPONSE = (
|
|
36
|
+
"Error: Exit code 128\n"
|
|
37
|
+
"fatal: ambiguous argument 'origin/nope:packages/app/main.py': "
|
|
38
|
+
"unknown revision or path not in the working tree."
|
|
39
|
+
)
|
|
40
|
+
_MANGLED_OBJECT_NAME_RESPONSE = (
|
|
41
|
+
"Error: Exit code 128\n" + r"fatal: invalid object name 'origin\main;C'."
|
|
42
|
+
)
|
|
43
|
+
_MISSING_OBJECT_NAME_RESPONSE = (
|
|
44
|
+
"Error: Exit code 128\nfatal: invalid object name 'nosuchbranch'."
|
|
45
|
+
)
|
|
46
|
+
_SHOW_COMMAND = "git show origin/main:.claude/skills/x/test_run_evals.py"
|
|
47
|
+
_ABSOLUTE_PATH_SHOW_COMMAND = "git show origin/main:/nope"
|
|
48
|
+
_MISSING_OBJECT_SHOW_COMMAND = "git show nosuchbranch:AGENTS.md"
|
|
49
|
+
_EXPORT_LINE = "export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _payload(command: str, tool_response: object) -> str:
|
|
53
|
+
return json.dumps(
|
|
54
|
+
{
|
|
55
|
+
"session_id": "msys-session",
|
|
56
|
+
"cwd": "C:/repo",
|
|
57
|
+
"tool_name": "Bash",
|
|
58
|
+
"tool_input": {"command": command},
|
|
59
|
+
"tool_response": tool_response,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run_main(
|
|
65
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
66
|
+
capsys: pytest.CaptureFixture[str],
|
|
67
|
+
payload_text: str,
|
|
68
|
+
) -> str:
|
|
69
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
|
|
70
|
+
msys_path_conversion_advisor.main()
|
|
71
|
+
return capsys.readouterr().out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_return_the_mangled_argument_word_for_word() -> None:
|
|
75
|
+
mangled_argument = msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
76
|
+
_SHOW_COMMAND, _MANGLED_FAILURE_RESPONSE
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert mangled_argument == r"origin\main;.claude\skills\x\test_run_evals.py"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_return_the_mangled_argument_from_an_invalid_object_name_failure() -> None:
|
|
83
|
+
mangled_argument = msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
84
|
+
_ABSOLUTE_PATH_SHOW_COMMAND, _MANGLED_OBJECT_NAME_RESPONSE
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert mangled_argument == r"origin\main;C"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_should_stay_quiet_when_the_object_name_failure_names_an_unmangled_argument() -> None:
|
|
91
|
+
assert (
|
|
92
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
93
|
+
_MISSING_OBJECT_SHOW_COMMAND, _MISSING_OBJECT_NAME_RESPONSE
|
|
94
|
+
)
|
|
95
|
+
is None
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_should_stay_quiet_when_the_command_succeeded() -> None:
|
|
100
|
+
zero_exit_response = {"stdout": "", "stderr": ""}
|
|
101
|
+
|
|
102
|
+
assert (
|
|
103
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
104
|
+
_SHOW_COMMAND, zero_exit_response
|
|
105
|
+
)
|
|
106
|
+
is None
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_should_stay_quiet_when_the_quoted_argument_is_unmangled() -> None:
|
|
111
|
+
assert (
|
|
112
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
113
|
+
"git show origin/nope:packages/app/main.py", _MISSING_REVISION_RESPONSE
|
|
114
|
+
)
|
|
115
|
+
is None
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_should_stay_quiet_when_the_command_already_exports_the_workaround() -> None:
|
|
120
|
+
already_fixed_command = (
|
|
121
|
+
"export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' && "
|
|
122
|
+
"git show origin/main:.claude/skills/x/test_run_evals.py"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
assert (
|
|
126
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
127
|
+
already_fixed_command, _MANGLED_FAILURE_RESPONSE
|
|
128
|
+
)
|
|
129
|
+
is None
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_should_stay_quiet_when_the_command_exports_only_the_argument_exclusion() -> None:
|
|
134
|
+
argument_exclusion_only_command = (
|
|
135
|
+
"export MSYS2_ARG_CONV_EXCL='*' && "
|
|
136
|
+
"git show origin/main:.claude/skills/x/test_run_evals.py"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert (
|
|
140
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
141
|
+
argument_exclusion_only_command, _MANGLED_FAILURE_RESPONSE
|
|
142
|
+
)
|
|
143
|
+
is None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_should_stay_quiet_without_the_ambiguous_argument_marker() -> None:
|
|
148
|
+
other_failure = "Error: Exit code 1\nfatal: not a git repository"
|
|
149
|
+
|
|
150
|
+
assert (
|
|
151
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(_SHOW_COMMAND, other_failure)
|
|
152
|
+
is None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_should_stay_quiet_when_the_quoted_argument_never_closes() -> None:
|
|
157
|
+
unterminated_failure = (
|
|
158
|
+
"Error: Exit code 128\n" + r"fatal: ambiguous argument 'origin\main;.claude"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert (
|
|
162
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
163
|
+
_SHOW_COMMAND, unterminated_failure
|
|
164
|
+
)
|
|
165
|
+
is None
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_advisory_should_name_the_argument_and_the_export_line() -> None:
|
|
170
|
+
advisory_text = msys_path_conversion_advisor.build_advisory_context(_MANGLED_ARGUMENT)
|
|
171
|
+
|
|
172
|
+
assert advisory_text.startswith("=== MSYS PATH CONVERSION")
|
|
173
|
+
assert "never a block" in advisory_text
|
|
174
|
+
assert r"origin\main;.claude\skills\x\test_run_evals.py" in advisory_text
|
|
175
|
+
assert _EXPORT_LINE in advisory_text
|
|
176
|
+
assert "Git Bash rewrote a <rev>:<path> argument" in advisory_text
|
|
177
|
+
assert "same command" in advisory_text
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_main_should_emit_additional_context_after_a_mangled_failure(
|
|
181
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
182
|
+
) -> None:
|
|
183
|
+
stdout_text = _run_main(monkeypatch, capsys, _payload(_SHOW_COMMAND, _MANGLED_FAILURE_RESPONSE))
|
|
184
|
+
|
|
185
|
+
payload = json.loads(stdout_text)
|
|
186
|
+
assert payload["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
|
|
187
|
+
additional_context = str(payload["hookSpecificOutput"]["additionalContext"])
|
|
188
|
+
assert _EXPORT_LINE in additional_context
|
|
189
|
+
assert r"origin\main;.claude\skills\x\test_run_evals.py" in additional_context
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_main_should_stay_quiet_for_a_non_bash_tool(
|
|
193
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
194
|
+
) -> None:
|
|
195
|
+
payload = json.loads(_payload(_SHOW_COMMAND, _MANGLED_FAILURE_RESPONSE))
|
|
196
|
+
payload["tool_name"] = "PowerShell"
|
|
197
|
+
|
|
198
|
+
assert _run_main(monkeypatch, capsys, json.dumps(payload)) == ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_should_return_an_argument_whose_only_mangling_mark_is_the_semicolon() -> None:
|
|
202
|
+
semicolon_only_response = (
|
|
203
|
+
"Error: Exit code 128\nfatal: invalid object name 'HEAD;.gitignore'."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
mangled_argument = msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
207
|
+
"git show HEAD:.gitignore", semicolon_only_response
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
assert mangled_argument == "HEAD;.gitignore"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_should_stay_quiet_when_the_command_exports_only_the_path_conversion_switch() -> None:
|
|
214
|
+
path_conversion_only_command = (
|
|
215
|
+
"export MSYS_NO_PATHCONV=1 && git show origin/main:.claude/skills/x/test_run_evals.py"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
assert (
|
|
219
|
+
msys_path_conversion_advisor.mangled_revision_path_argument(
|
|
220
|
+
path_conversion_only_command, _MANGLED_FAILURE_RESPONSE
|
|
221
|
+
)
|
|
222
|
+
is None
|
|
223
|
+
)
|