claude-dev-env 8.84.1 → 8.85.1
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/codex-skill-load-block.mjs +27 -28
- package/bin/codex-skill-load-block.test.mjs +62 -30
- package/bin/install.mjs +3 -3
- package/bin/install.prompt-context.test.mjs +2 -1
- package/bin/install.question-presentation.test.mjs +3 -3
- package/bin/install.test.mjs +28 -9
- package/hooks/blocking/chat_reply_gate.py +246 -0
- package/hooks/blocking/inventory_intent_records/records.py +2 -5
- package/hooks/blocking/test_chat_reply_gate.py +224 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +21 -0
- package/hooks/hooks_constants/chat_reply_gate_constants.py +58 -0
- package/hooks/hooks_constants/session_file_id.py +17 -0
- package/hooks/hooks_constants/test_session_file_id.py +17 -0
- package/hooks/observability/session_file_edit_tracker.py +2 -5
- package/hooks/session/session_edit_tracker_cleanup.py +2 -5
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +3 -1
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -3,9 +3,6 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
export const SKILL_LOAD_BLOCK_START = '<!-- claude-dev-env skill load: start -->';
|
|
5
5
|
export const SKILL_LOAD_BLOCK_END = '<!-- claude-dev-env skill load: end -->';
|
|
6
|
-
export const SKILL_LOAD_INSTRUCTION = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
|
|
7
|
-
+ 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
|
|
8
|
-
+ 'including a helper started with spawn_agent, whatever its task message says.';
|
|
9
6
|
export const PACKAGE_GUIDANCE_BLOCK_START = '<!-- claude-dev-env package guidance: start -->';
|
|
10
7
|
export const PACKAGE_GUIDANCE_BLOCK_END = '<!-- claude-dev-env package guidance: end -->';
|
|
11
8
|
export const QUESTION_PRESENTATION_BLOCK_START = '<!-- claude-dev-env question presentation: start -->';
|
|
@@ -39,17 +36,24 @@ function withBlockReplaced(guidanceText, blockStart, blockEnd, block) {
|
|
|
39
36
|
}
|
|
40
37
|
|
|
41
38
|
/**
|
|
42
|
-
* Return
|
|
39
|
+
* Return guidance text with every complete skill-load block removed.
|
|
43
40
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* the text is replaced where it stands; otherwise the block goes first.
|
|
41
|
+
* When a block is the start of the remaining text, one following blank line is
|
|
42
|
+
* removed with it. A start marker with no end marker stays.
|
|
47
43
|
*/
|
|
48
|
-
export function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
export function withoutSkillLoadBlock(guidanceText) {
|
|
45
|
+
let remainingText = guidanceText;
|
|
46
|
+
let removedBlockCount = 0;
|
|
47
|
+
while (remainingText.includes(SKILL_LOAD_BLOCK_START)) {
|
|
48
|
+
const blockStartsAtTop = remainingText.startsWith(SKILL_LOAD_BLOCK_START);
|
|
49
|
+
const replacedText = withBlockReplaced(remainingText, SKILL_LOAD_BLOCK_START, SKILL_LOAD_BLOCK_END, '');
|
|
50
|
+
if (replacedText === null || replacedText === remainingText) break;
|
|
51
|
+
removedBlockCount += 1;
|
|
52
|
+
remainingText = blockStartsAtTop && replacedText.startsWith('\n')
|
|
53
|
+
? replacedText.slice(1)
|
|
54
|
+
: replacedText;
|
|
55
|
+
}
|
|
56
|
+
return removedBlockCount === 0 ? guidanceText : remainingText;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
function withPackageGuidanceBlock(guidanceText, packageGuidanceText) {
|
|
@@ -84,7 +88,7 @@ function writeCodexGuidanceFile(codexHome, allPackageGuidancePaths, transformGui
|
|
|
84
88
|
const isPackageGuidanceLink = agentsEntry?.isSymbolicLink() ?? false;
|
|
85
89
|
if (isPackageGuidanceLink && !linksToPackageGuidance(agentsPath, allPackageGuidancePaths)) return null;
|
|
86
90
|
const currentText = agentsEntry && !isPackageGuidanceLink ? readFileSync(agentsPath, 'utf8') : '';
|
|
87
|
-
const updatedText = transformGuidance(currentText);
|
|
91
|
+
const updatedText = transformGuidance(withoutSkillLoadBlock(currentText));
|
|
88
92
|
if (!isPackageGuidanceLink && updatedText === currentText) return null;
|
|
89
93
|
mkdirSync(codexHome, { recursive: true });
|
|
90
94
|
if (isPackageGuidanceLink) unlinkSync(agentsPath);
|
|
@@ -93,29 +97,24 @@ function writeCodexGuidanceFile(codexHome, allPackageGuidancePaths, transformGui
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* The skill-load block goes first, and the package-guidance block holds the
|
|
99
|
-
* current guidance text. A symbolic link that points at the package guidance
|
|
100
|
-
* (the shared agents-home copy, or the retired copy under the Claude home) is
|
|
101
|
-
* replaced by a regular file holding both blocks, so Codex keeps both after
|
|
102
|
-
* the package moves its guidance. A link to any other file is left alone.
|
|
100
|
+
* Remove a skill-load block and write package guidance into CODEX_HOME/AGENTS.md.
|
|
103
101
|
*
|
|
104
|
-
*
|
|
102
|
+
* Link handling matches writeCodexPackageGuidance. Returns the file path when
|
|
103
|
+
* the file changed, and null otherwise.
|
|
105
104
|
*/
|
|
106
105
|
export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
|
|
107
|
-
return
|
|
108
|
-
codexHome,
|
|
109
|
-
allPackageGuidancePaths,
|
|
110
|
-
currentText => withPackageGuidanceBlock(withSkillLoadBlock(currentText), packageGuidanceText),
|
|
111
|
-
);
|
|
106
|
+
return writeCodexPackageGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths);
|
|
112
107
|
}
|
|
113
108
|
|
|
114
109
|
/**
|
|
115
110
|
* Keep CODEX_HOME/AGENTS.md carrying the package guidance, with no skill-load block.
|
|
116
111
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
112
|
+
* An existing skill-load block is removed. A symbolic link that points at the
|
|
113
|
+
* package guidance, the shared agents-home copy or the retired copy under the
|
|
114
|
+
* Claude home, is replaced by a regular file. A link to any other file is left
|
|
115
|
+
* alone.
|
|
116
|
+
*
|
|
117
|
+
* Returns the file path when the file changed, and null otherwise.
|
|
119
118
|
*/
|
|
120
119
|
export function writeCodexPackageGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
|
|
121
120
|
return writeCodexGuidanceFile(
|
|
@@ -10,11 +10,10 @@ import {
|
|
|
10
10
|
QUESTION_PRESENTATION_BLOCK_START,
|
|
11
11
|
SKILL_LOAD_BLOCK_END,
|
|
12
12
|
SKILL_LOAD_BLOCK_START,
|
|
13
|
-
SKILL_LOAD_INSTRUCTION,
|
|
14
13
|
removeCodexDeepenGuidance,
|
|
15
14
|
removeCodexPackageGuidance,
|
|
16
15
|
removeCodexQuestionGuidance,
|
|
17
|
-
|
|
16
|
+
withoutSkillLoadBlock,
|
|
18
17
|
DEEPEN_GUIDANCE_BLOCK_END,
|
|
19
18
|
DEEPEN_GUIDANCE_BLOCK_START,
|
|
20
19
|
DEEPEN_GUIDANCE_TEXT,
|
|
@@ -24,7 +23,10 @@ import {
|
|
|
24
23
|
writeCodexQuestionGuidance,
|
|
25
24
|
} from './codex-skill-load-block.mjs';
|
|
26
25
|
|
|
27
|
-
const
|
|
26
|
+
const STALE_SKILL_LOAD_SENTENCE = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
|
|
27
|
+
+ 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
|
|
28
|
+
+ 'including a helper started with spawn_agent, whatever its task message says.';
|
|
29
|
+
const STALE_SKILL_LOAD_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${STALE_SKILL_LOAD_SENTENCE}\n${SKILL_LOAD_BLOCK_END}\n`;
|
|
28
30
|
const PACKAGE_GUIDANCE = 'Status\n\nChanged / proof / blocked.\n';
|
|
29
31
|
const QUESTION_POLICY = '# Present questions clearly\n\nAsk one question.\n';
|
|
30
32
|
|
|
@@ -52,34 +54,44 @@ function writeGuidance(homes) {
|
|
|
52
54
|
return writeCodexAgentsGuidance(homes.codexHome, PACKAGE_GUIDANCE, homes.allPackageGuidancePaths);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
test('
|
|
56
|
-
assert.
|
|
57
|
-
assert.
|
|
57
|
+
test('a complete skill-load block is removed and the lines around it stay', () => {
|
|
58
|
+
assert.equal(withoutSkillLoadBlock(`Intro\n${STALE_SKILL_LOAD_BLOCK}Outro\n`), 'Intro\nOutro\n');
|
|
59
|
+
assert.equal(withoutSkillLoadBlock(`${STALE_SKILL_LOAD_BLOCK}\nNotes\n`), 'Notes\n');
|
|
60
|
+
assert.equal(
|
|
61
|
+
withoutSkillLoadBlock(`${STALE_SKILL_LOAD_BLOCK}\n${STALE_SKILL_LOAD_BLOCK}\nNotes\n`),
|
|
62
|
+
'Notes\n',
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
withoutSkillLoadBlock(`${SKILL_LOAD_BLOCK_START}\nHalf a block\n`),
|
|
66
|
+
`${SKILL_LOAD_BLOCK_START}\nHalf a block\n`,
|
|
67
|
+
);
|
|
58
68
|
});
|
|
59
69
|
|
|
60
|
-
test('a missing Codex guidance file is created holding the
|
|
70
|
+
test('a missing Codex guidance file is created holding the package guidance', (context) => {
|
|
61
71
|
const homes = makeHomes(context);
|
|
62
72
|
|
|
63
73
|
const writtenPath = writeGuidance(homes);
|
|
64
74
|
|
|
65
75
|
assert.equal(writtenPath, homes.agentsPath);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
76
|
+
const rendered = readFileSync(homes.agentsPath, 'utf8');
|
|
77
|
+
assert.equal(rendered, guidanceBlock(PACKAGE_GUIDANCE));
|
|
78
|
+
assert.equal(rendered.includes('pstack:poteto-mode'), false);
|
|
79
|
+
assert.equal(rendered.includes(SKILL_LOAD_BLOCK_START), false);
|
|
70
80
|
});
|
|
71
81
|
|
|
72
|
-
test('existing guidance keeps
|
|
82
|
+
test('existing guidance keeps its lines and gains the package guidance', (context) => {
|
|
73
83
|
const homes = makeHomes(context);
|
|
74
84
|
mkdirSync(homes.codexHome);
|
|
75
85
|
writeFileSync(homes.agentsPath, '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
|
|
76
86
|
|
|
77
87
|
writeGuidance(homes);
|
|
78
88
|
|
|
89
|
+
const rendered = readFileSync(homes.agentsPath, 'utf8');
|
|
79
90
|
assert.equal(
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
rendered,
|
|
92
|
+
`# pstack model configuration\n\nbug-fix: gpt-6-astra\n\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
|
|
82
93
|
);
|
|
94
|
+
assert.equal(rendered.includes('pstack:poteto-mode'), false);
|
|
83
95
|
});
|
|
84
96
|
|
|
85
97
|
test('a repeat install leaves the file unchanged and reports no write', (context) => {
|
|
@@ -93,20 +105,43 @@ test('a repeat install leaves the file unchanged and reports no write', (context
|
|
|
93
105
|
assert.equal(readFileSync(homes.agentsPath, 'utf8'), afterFirstInstall);
|
|
94
106
|
});
|
|
95
107
|
|
|
96
|
-
test('
|
|
97
|
-
const
|
|
108
|
+
test('a stale skill-load sentence is removed and question and deepen blocks stay', (context) => {
|
|
109
|
+
const homes = makeHomes(context);
|
|
110
|
+
mkdirSync(homes.codexHome);
|
|
111
|
+
const questionBlock = `${QUESTION_PRESENTATION_BLOCK_START}\n${QUESTION_POLICY}${QUESTION_PRESENTATION_BLOCK_END}\n`;
|
|
112
|
+
const deepenBlock = `${DEEPEN_GUIDANCE_BLOCK_START}\n${DEEPEN_GUIDANCE_TEXT}\n${DEEPEN_GUIDANCE_BLOCK_END}\n`;
|
|
113
|
+
writeFileSync(
|
|
114
|
+
homes.agentsPath,
|
|
115
|
+
`${STALE_SKILL_LOAD_BLOCK}\n${guidanceBlock('Old guidance\n')}${questionBlock}${deepenBlock}`,
|
|
116
|
+
);
|
|
98
117
|
|
|
99
|
-
|
|
118
|
+
writeGuidance(homes);
|
|
119
|
+
|
|
120
|
+
const rendered = readFileSync(homes.agentsPath, 'utf8');
|
|
121
|
+
assert.equal(rendered.includes(STALE_SKILL_LOAD_SENTENCE), false);
|
|
122
|
+
assert.equal(rendered.includes('pstack:poteto-mode'), false);
|
|
123
|
+
assert.equal(rendered.includes(SKILL_LOAD_BLOCK_START), false);
|
|
124
|
+
assert.ok(rendered.includes(guidanceBlock(PACKAGE_GUIDANCE)));
|
|
125
|
+
assert.ok(rendered.includes(questionBlock));
|
|
126
|
+
assert.ok(rendered.includes(deepenBlock));
|
|
100
127
|
});
|
|
101
128
|
|
|
102
|
-
test('a start marker with no end marker
|
|
129
|
+
test('a start marker with no end marker stays and no skill name is added', (context) => {
|
|
130
|
+
const homes = makeHomes(context);
|
|
103
131
|
const broken = `${SKILL_LOAD_BLOCK_START}\nHalf a block\n`;
|
|
132
|
+
mkdirSync(homes.codexHome);
|
|
133
|
+
writeFileSync(homes.agentsPath, broken);
|
|
134
|
+
|
|
135
|
+
writeGuidance(homes);
|
|
104
136
|
|
|
105
|
-
|
|
137
|
+
const rendered = readFileSync(homes.agentsPath, 'utf8');
|
|
138
|
+
assert.ok(rendered.startsWith(broken));
|
|
139
|
+
assert.equal(rendered.includes('pstack:poteto-mode'), false);
|
|
140
|
+
assert.ok(rendered.includes(PACKAGE_GUIDANCE_BLOCK_START));
|
|
106
141
|
});
|
|
107
142
|
|
|
108
143
|
for (const linkName of ['retiredGuidancePath', 'sharedGuidancePath']) {
|
|
109
|
-
test(`a link to package guidance at ${linkName} becomes a file with
|
|
144
|
+
test(`a link to package guidance at ${linkName} becomes a file with the package block`, (context) => {
|
|
110
145
|
const homes = makeHomes(context);
|
|
111
146
|
mkdirSync(homes.codexHome);
|
|
112
147
|
symlinkSync(homes[linkName], homes.agentsPath);
|
|
@@ -115,24 +150,21 @@ for (const linkName of ['retiredGuidancePath', 'sharedGuidancePath']) {
|
|
|
115
150
|
|
|
116
151
|
assert.equal(writtenPath, homes.agentsPath);
|
|
117
152
|
assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), false);
|
|
118
|
-
assert.equal(
|
|
119
|
-
|
|
120
|
-
`${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
|
|
121
|
-
);
|
|
153
|
+
assert.equal(readFileSync(homes.agentsPath, 'utf8'), guidanceBlock(PACKAGE_GUIDANCE));
|
|
154
|
+
assert.equal(readFileSync(homes.agentsPath, 'utf8').includes('pstack:poteto-mode'), false);
|
|
122
155
|
});
|
|
123
156
|
}
|
|
124
157
|
|
|
125
|
-
test('a converted file takes the new package guidance
|
|
158
|
+
test('a converted file takes the new package guidance and drops a stale skill-load block', (context) => {
|
|
126
159
|
const homes = makeHomes(context);
|
|
127
160
|
mkdirSync(homes.codexHome);
|
|
128
|
-
writeFileSync(homes.agentsPath, `${
|
|
161
|
+
writeFileSync(homes.agentsPath, `${STALE_SKILL_LOAD_BLOCK}\n${guidanceBlock('Old guidance\n')}`);
|
|
129
162
|
|
|
130
163
|
writeGuidance(homes);
|
|
131
164
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
);
|
|
165
|
+
const rendered = readFileSync(homes.agentsPath, 'utf8');
|
|
166
|
+
assert.equal(rendered, guidanceBlock(PACKAGE_GUIDANCE));
|
|
167
|
+
assert.equal(rendered.includes('pstack:poteto-mode'), false);
|
|
136
168
|
});
|
|
137
169
|
|
|
138
170
|
test('a link to a file the package does not own is left alone', (context) => {
|
package/bin/install.mjs
CHANGED
|
@@ -2927,13 +2927,13 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2927
2927
|
allUserOwnedPreferencePaths.add(eachPath);
|
|
2928
2928
|
}
|
|
2929
2929
|
}
|
|
2930
|
-
const
|
|
2930
|
+
const codexGuidancePath = writeCodexAgentsGuidance(
|
|
2931
2931
|
INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
|
|
2932
2932
|
packageGuidanceText,
|
|
2933
2933
|
allPackageGuidancePaths,
|
|
2934
2934
|
);
|
|
2935
|
-
if (
|
|
2936
|
-
console.log(` \u2713 ${
|
|
2935
|
+
if (codexGuidancePath) {
|
|
2936
|
+
console.log(` \u2713 ${codexGuidancePath} (Codex package guidance)`);
|
|
2937
2937
|
}
|
|
2938
2938
|
}
|
|
2939
2939
|
printPluginHostOutcomes([PSTACK_PLUGIN_SPEC], pstackPlugin);
|
|
@@ -35,11 +35,12 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
|
|
|
35
35
|
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
|
|
36
36
|
|
|
37
37
|
const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
|
|
38
|
-
assert.equal(userPromptHooks.length,
|
|
38
|
+
assert.equal(userPromptHooks.length, 5);
|
|
39
39
|
assert.equal(userPromptHooks[0].command, userCommand);
|
|
40
40
|
assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
|
|
41
41
|
assert.match(userPromptHooks[2].command, /session\/deepen_first_prompt\.py$/);
|
|
42
42
|
assert.match(userPromptHooks[3].command, /lifecycle\/nested_project_hooks\.py$/);
|
|
43
|
+
assert.match(userPromptHooks[4].command, /blocking\/chat_reply_gate\.py$/);
|
|
43
44
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
44
45
|
|
|
45
46
|
const firstMerge = JSON.stringify(settings);
|
|
@@ -171,7 +171,7 @@ test('a selected Claude profile receives the source rule and its Codex guidance'
|
|
|
171
171
|
assert.ok(readFileSync(join(profileDirectory, '.codex', 'AGENTS.md'), 'utf8').includes(readFileSync(policyPath, 'utf8')));
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
test('a successful pstack install
|
|
174
|
+
test('a successful pstack install omits the retired skill block and gains the question block', (context) => {
|
|
175
175
|
const homeDirectory = makeScratchHome(context);
|
|
176
176
|
const commandPath = join(homeDirectory, process.platform === 'win32' ? 'codex.cmd' : 'codex');
|
|
177
177
|
writeFileSync(
|
|
@@ -191,7 +191,7 @@ test('a successful pstack install retains its skill block and gains the question
|
|
|
191
191
|
|
|
192
192
|
assert.equal(installation.status, 0, installation.stdout + installation.stderr);
|
|
193
193
|
const guidanceText = readFileSync(join(homeDirectory, '.codex', 'AGENTS.md'), 'utf8');
|
|
194
|
-
assert.
|
|
194
|
+
assert.doesNotMatch(guidanceText, /<!-- claude-dev-env skill load: start -->/);
|
|
195
195
|
assert.match(guidanceText, /<!-- claude-dev-env question presentation: start -->/);
|
|
196
196
|
assert.ok(guidanceText.includes(readFileSync(policyPath, 'utf8')));
|
|
197
197
|
});
|
|
@@ -221,7 +221,7 @@ test('enabling pstack after a pstack-off install preserves question guidance', (
|
|
|
221
221
|
assert.equal(secondInstallation.status, 0, secondInstallation.stdout + secondInstallation.stderr);
|
|
222
222
|
const updatedGuidance = readFileSync(codexGuidancePath, 'utf8');
|
|
223
223
|
assert.ok(updatedGuidance.includes(firstGuidance));
|
|
224
|
-
assert.
|
|
224
|
+
assert.doesNotMatch(updatedGuidance, /<!-- claude-dev-env skill load: start -->/);
|
|
225
225
|
assert.match(updatedGuidance, /^feature, refactoring: gpt-6-sol$/m);
|
|
226
226
|
assert.match(readFileSync(join(homeDirectory, '.codex', 'pstack-models.md'), 'utf8'), /^session hook: on$/m);
|
|
227
227
|
});
|
package/bin/install.test.mjs
CHANGED
|
@@ -57,12 +57,12 @@ import {
|
|
|
57
57
|
} from './install.mjs';
|
|
58
58
|
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
59
59
|
import {
|
|
60
|
+
DEEPEN_GUIDANCE_BLOCK_START,
|
|
60
61
|
PACKAGE_GUIDANCE_BLOCK_END,
|
|
61
62
|
PACKAGE_GUIDANCE_BLOCK_START,
|
|
62
|
-
SKILL_LOAD_BLOCK_START,
|
|
63
|
-
SKILL_LOAD_INSTRUCTION,
|
|
64
63
|
QUESTION_PRESENTATION_BLOCK_START,
|
|
65
|
-
|
|
64
|
+
SKILL_LOAD_BLOCK_END,
|
|
65
|
+
SKILL_LOAD_BLOCK_START,
|
|
66
66
|
} from './codex-skill-load-block.mjs';
|
|
67
67
|
|
|
68
68
|
|
|
@@ -3124,6 +3124,17 @@ function pstackPluginSandbox(t) {
|
|
|
3124
3124
|
};
|
|
3125
3125
|
}
|
|
3126
3126
|
|
|
3127
|
+
const STALE_SKILL_LOAD_SENTENCE = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
|
|
3128
|
+
+ 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
|
|
3129
|
+
+ 'including a helper started with spawn_agent, whatever its task message says.';
|
|
3130
|
+
|
|
3131
|
+
function assertOmitsSkillLoadSentence(codexGuidance) {
|
|
3132
|
+
assert.equal(codexGuidance.includes(SKILL_LOAD_BLOCK_START), false);
|
|
3133
|
+
assert.equal(codexGuidance.includes(SKILL_LOAD_BLOCK_END), false);
|
|
3134
|
+
assert.equal(codexGuidance.includes('pstack:poteto-mode'), false);
|
|
3135
|
+
assert.equal(codexGuidance.includes(STALE_SKILL_LOAD_SENTENCE), false);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3127
3138
|
test('a full install adds the pstack marketplace and plugin on both hosts', t => {
|
|
3128
3139
|
const sandbox = pstackPluginSandbox(t);
|
|
3129
3140
|
|
|
@@ -3145,9 +3156,10 @@ test('a full install adds the pstack marketplace and plugin on both hosts', t =>
|
|
|
3145
3156
|
const sheet = readFileSync(join(codexHome, 'pstack-models.md'), 'utf8');
|
|
3146
3157
|
const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
3147
3158
|
assert.match(sheet, /^session hook: on$/m);
|
|
3159
|
+
assert.match(agents, /^# pstack model configuration\n/);
|
|
3148
3160
|
assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
|
|
3149
3161
|
assert.doesNotMatch(agents, /^session hook:/m);
|
|
3150
|
-
|
|
3162
|
+
assertOmitsSkillLoadSentence(agents);
|
|
3151
3163
|
assert.equal(existsSync(join(sandbox.homeDirectory, '.claude', 'pstack-models.md')), false);
|
|
3152
3164
|
const manifestPath = join(sandbox.homeDirectory, '.claude', '.claude-dev-env-manifest.json');
|
|
3153
3165
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
@@ -3159,14 +3171,19 @@ test('a repeat install preserves Codex pstack configuration', t => {
|
|
|
3159
3171
|
const sandbox = pstackPluginSandbox(t);
|
|
3160
3172
|
const codexHome = join(sandbox.homeDirectory, '.codex');
|
|
3161
3173
|
runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
|
|
3162
|
-
writeFileSync(
|
|
3174
|
+
writeFileSync(
|
|
3175
|
+
join(codexHome, 'AGENTS.md'),
|
|
3176
|
+
`${SKILL_LOAD_BLOCK_START}\n${STALE_SKILL_LOAD_SENTENCE}\n${SKILL_LOAD_BLOCK_END}\nCustom Codex guidance\n`,
|
|
3177
|
+
);
|
|
3163
3178
|
writeFileSync(join(codexHome, 'pstack-models.md'), 'session hook: off\n');
|
|
3164
3179
|
|
|
3165
3180
|
runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
|
|
3166
3181
|
|
|
3167
3182
|
const codexGuidance = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
3168
|
-
assert.ok(codexGuidance.startsWith(
|
|
3183
|
+
assert.ok(codexGuidance.startsWith('Custom Codex guidance\n'));
|
|
3184
|
+
assertOmitsSkillLoadSentence(codexGuidance);
|
|
3169
3185
|
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3186
|
+
assert.ok(codexGuidance.includes(DEEPEN_GUIDANCE_BLOCK_START));
|
|
3170
3187
|
assert.equal(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), 'session hook: off\n');
|
|
3171
3188
|
});
|
|
3172
3189
|
|
|
@@ -3185,7 +3202,7 @@ test('a Codex guidance link to the retired Claude copy becomes a file Codex can
|
|
|
3185
3202
|
);
|
|
3186
3203
|
assert.equal(lstatSync(codexAgentsPath).isSymbolicLink(), false);
|
|
3187
3204
|
const codexGuidance = readFileSync(codexAgentsPath, 'utf8');
|
|
3188
|
-
|
|
3205
|
+
assertOmitsSkillLoadSentence(codexGuidance);
|
|
3189
3206
|
assert.ok(codexGuidance.includes(packageGuidance));
|
|
3190
3207
|
});
|
|
3191
3208
|
|
|
@@ -3248,8 +3265,9 @@ test('a fresh full install writes the package guidance into Codex AGENTS.md', t
|
|
|
3248
3265
|
|
|
3249
3266
|
const codexGuidance = readFileSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md'), 'utf8');
|
|
3250
3267
|
assertHoldsPackageGuidance(codexGuidance);
|
|
3251
|
-
|
|
3268
|
+
assertOmitsSkillLoadSentence(codexGuidance);
|
|
3252
3269
|
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3270
|
+
assert.ok(codexGuidance.includes(DEEPEN_GUIDANCE_BLOCK_START));
|
|
3253
3271
|
});
|
|
3254
3272
|
|
|
3255
3273
|
test('--no-pstack writes package and question guidance without the skill-load block', t => {
|
|
@@ -3329,9 +3347,10 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
|
|
|
3329
3347
|
assert.match(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), /^session hook: on$/m);
|
|
3330
3348
|
const codexGuidance = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
|
|
3331
3349
|
assert.match(codexGuidance, /^feature, refactoring: gpt-6-sol$/m);
|
|
3332
|
-
|
|
3350
|
+
assertOmitsSkillLoadSentence(codexGuidance);
|
|
3333
3351
|
assertHoldsPackageGuidance(codexGuidance);
|
|
3334
3352
|
assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
|
|
3353
|
+
assert.ok(codexGuidance.includes(DEEPEN_GUIDANCE_BLOCK_START));
|
|
3335
3354
|
});
|
|
3336
3355
|
|
|
3337
3356
|
const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hook that shows the user one short chat reply per message.
|
|
3
|
+
|
|
4
|
+
``reply_length_gate.py`` checks only the chat tools that post to Slack.
|
|
5
|
+
A reply typed straight into the chat is no tool call, so this hook runs
|
|
6
|
+
the same checks on it::
|
|
7
|
+
|
|
8
|
+
UserPromptSubmit -> additionalContext states the rules
|
|
9
|
+
MessageDisplay, batch before the last -> hold the text, show nothing yet
|
|
10
|
+
MessageDisplay, last batch, passes -> show the whole message
|
|
11
|
+
MessageDisplay, last batch, fails -> show a draft from a small model that passes
|
|
12
|
+
|
|
13
|
+
A Stop hook would see the reply only after it is on screen, so each block
|
|
14
|
+
would add a second reply under the first. MessageDisplay changes the text
|
|
15
|
+
before it renders. The transcript keeps the original, so the next
|
|
16
|
+
UserPromptSubmit tells the agent the text the user saw.
|
|
17
|
+
|
|
18
|
+
The draft comes from ``claude -p`` on DEFAULT_DRAFTER_MODEL with no setting
|
|
19
|
+
sources and no tools. The draft must pass ``reply_violation``; after
|
|
20
|
+
MAXIMUM_DRAFT_ATTEMPTS failed drafts, the original shows.
|
|
21
|
+
|
|
22
|
+
The checks come from ``reply_length_gate.reply_violation``: at most
|
|
23
|
+
MAXIMUM_SENTENCE_COUNT sentences of at most MAXIMUM_WORDS_PER_SENTENCE
|
|
24
|
+
words, no mid-sentence colon, no em dash, linked pull requests, a source
|
|
25
|
+
for each causal claim, and no banned word.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
40
|
+
if hooks_root_directory not in sys.path:
|
|
41
|
+
sys.path.insert(0, hooks_root_directory)
|
|
42
|
+
blocking_directory = str(Path(__file__).resolve().parent)
|
|
43
|
+
if blocking_directory not in sys.path:
|
|
44
|
+
sys.path.insert(0, blocking_directory)
|
|
45
|
+
|
|
46
|
+
from reply_length_gate import reply_violation
|
|
47
|
+
from hooks_constants.chat_reply_gate_constants import (
|
|
48
|
+
CLAUDE_SESSION_ID_ENV_VAR,
|
|
49
|
+
DEFAULT_DRAFTER_MODEL,
|
|
50
|
+
DELTA_KEY,
|
|
51
|
+
DISPLAY_CONTENT_KEY,
|
|
52
|
+
DRAFTER_GUARD_ENV_VAR,
|
|
53
|
+
DRAFTER_MODEL_ENV_VAR,
|
|
54
|
+
DRAFTER_REQUEST_TEMPLATE,
|
|
55
|
+
DRAFTER_ERROR_KEY,
|
|
56
|
+
DRAFTER_EXECUTABLE_NAME,
|
|
57
|
+
DRAFTER_RESULT_KEY,
|
|
58
|
+
DRAFTER_RULES,
|
|
59
|
+
DRAFTER_TIMEOUT_SECONDS,
|
|
60
|
+
FINAL_KEY,
|
|
61
|
+
HELD_TEXT_FILE_PREFIX,
|
|
62
|
+
HOOK_EVENT_NAME_INPUT_KEY,
|
|
63
|
+
MAXIMUM_DRAFT_ATTEMPTS,
|
|
64
|
+
MESSAGE_DISPLAY_EVENT_NAME,
|
|
65
|
+
MESSAGE_ID_KEY,
|
|
66
|
+
REPLY_LIMIT_REMINDER,
|
|
67
|
+
SESSION_END_EVENT_NAME,
|
|
68
|
+
SESSION_ID_KEY,
|
|
69
|
+
SHOWN_DRAFT_FILE_PREFIX,
|
|
70
|
+
SHOWN_DRAFT_PREFIX,
|
|
71
|
+
STATE_FILE_ENCODING,
|
|
72
|
+
STATE_FILE_SUFFIX,
|
|
73
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
74
|
+
)
|
|
75
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
76
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
77
|
+
HOOK_EVENT_NAME_KEY,
|
|
78
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
79
|
+
)
|
|
80
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
81
|
+
from hooks_constants.reply_length_gate_constants import (
|
|
82
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
83
|
+
ALLOW_EXIT_CODE,
|
|
84
|
+
)
|
|
85
|
+
from hooks_constants.session_file_id import session_file_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def state_file_path(file_prefix: str, state_key: object) -> Path:
|
|
89
|
+
"""Return the temp file that holds one message's held text or one session's shown draft."""
|
|
90
|
+
file_name = f"{file_prefix}{session_file_id(state_key)}{STATE_FILE_SUFFIX}"
|
|
91
|
+
return Path(tempfile.gettempdir()) / file_name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_state_text(state_path: Path) -> str:
|
|
95
|
+
"""Return the stored text, or an empty string when the file is absent or unreadable."""
|
|
96
|
+
try:
|
|
97
|
+
return state_path.read_text(encoding=STATE_FILE_ENCODING)
|
|
98
|
+
except OSError:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_state_text(state_path: Path, state_text: str) -> None:
|
|
103
|
+
"""Store the text; a failed write leaves the shown text unchanged."""
|
|
104
|
+
try:
|
|
105
|
+
state_path.write_text(state_text, encoding=STATE_FILE_ENCODING)
|
|
106
|
+
except OSError:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def drafter_command() -> list[str]:
|
|
111
|
+
"""Return the headless claude command that drafts with no settings and no tools."""
|
|
112
|
+
return [
|
|
113
|
+
shutil.which(DRAFTER_EXECUTABLE_NAME) or DRAFTER_EXECUTABLE_NAME,
|
|
114
|
+
"-p",
|
|
115
|
+
"--model",
|
|
116
|
+
os.environ.get(DRAFTER_MODEL_ENV_VAR, DEFAULT_DRAFTER_MODEL),
|
|
117
|
+
"--setting-sources",
|
|
118
|
+
"",
|
|
119
|
+
"--tools",
|
|
120
|
+
"",
|
|
121
|
+
"--no-session-persistence",
|
|
122
|
+
"--session-id",
|
|
123
|
+
str(uuid.uuid4()),
|
|
124
|
+
"--output-format",
|
|
125
|
+
"json",
|
|
126
|
+
"--system-prompt",
|
|
127
|
+
DRAFTER_RULES,
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def drafter_environment() -> dict[str, str]:
|
|
132
|
+
"""Return the drafter's environment, marked so this hook exits inside it."""
|
|
133
|
+
environment = dict(os.environ)
|
|
134
|
+
environment[DRAFTER_GUARD_ENV_VAR] = "1"
|
|
135
|
+
environment.pop(CLAUDE_SESSION_ID_ENV_VAR, None)
|
|
136
|
+
return environment
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ask_drafter(reply_text: str, violation: str) -> str | None:
|
|
140
|
+
"""Return one draft from the small model, or None when the call fails."""
|
|
141
|
+
try:
|
|
142
|
+
completed = subprocess.run(
|
|
143
|
+
drafter_command(),
|
|
144
|
+
input=DRAFTER_REQUEST_TEMPLATE.format(violation=violation, reply_text=reply_text),
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
timeout=DRAFTER_TIMEOUT_SECONDS,
|
|
148
|
+
env=drafter_environment(),
|
|
149
|
+
check=False,
|
|
150
|
+
)
|
|
151
|
+
drafter_output = json.loads(completed.stdout)
|
|
152
|
+
draft_text = drafter_output.get(DRAFTER_RESULT_KEY)
|
|
153
|
+
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, AttributeError):
|
|
154
|
+
return None
|
|
155
|
+
if drafter_output.get(DRAFTER_ERROR_KEY) is True:
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(draft_text, str) or not draft_text.strip():
|
|
158
|
+
return None
|
|
159
|
+
return draft_text.strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def compliant_draft(reply_text: str, violation: str) -> str | None:
|
|
163
|
+
"""Return a draft that passes ``reply_violation``, or None after MAXIMUM_DRAFT_ATTEMPTS."""
|
|
164
|
+
current_violation = violation
|
|
165
|
+
for _ in range(MAXIMUM_DRAFT_ATTEMPTS):
|
|
166
|
+
draft_text = ask_drafter(reply_text, current_violation)
|
|
167
|
+
if draft_text is None:
|
|
168
|
+
continue
|
|
169
|
+
draft_violation = reply_violation(draft_text, None, None)
|
|
170
|
+
if draft_violation is None:
|
|
171
|
+
return draft_text
|
|
172
|
+
current_violation = draft_violation
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def shown_text(all_hook_input: dict[str, object]) -> str | None:
|
|
177
|
+
"""Return the text to show for this batch, or None to show the batch as it is."""
|
|
178
|
+
message_id = all_hook_input.get(MESSAGE_ID_KEY)
|
|
179
|
+
if not isinstance(message_id, str) or not message_id:
|
|
180
|
+
return None
|
|
181
|
+
held_path = state_file_path(HELD_TEXT_FILE_PREFIX, message_id)
|
|
182
|
+
held_text = read_state_text(held_path)
|
|
183
|
+
message_text = held_text + str(all_hook_input.get(DELTA_KEY) or "")
|
|
184
|
+
if not all_hook_input.get(FINAL_KEY):
|
|
185
|
+
write_state_text(held_path, message_text)
|
|
186
|
+
return ""
|
|
187
|
+
held_path.unlink(missing_ok=True)
|
|
188
|
+
shown_draft_path = state_file_path(SHOWN_DRAFT_FILE_PREFIX, all_hook_input.get(SESSION_ID_KEY))
|
|
189
|
+
violation = reply_violation(message_text, None, None)
|
|
190
|
+
draft_text = None if violation is None else compliant_draft(message_text, violation)
|
|
191
|
+
if violation is None or draft_text is None:
|
|
192
|
+
shown_draft_path.unlink(missing_ok=True)
|
|
193
|
+
return message_text if held_text else None
|
|
194
|
+
write_state_text(shown_draft_path, draft_text)
|
|
195
|
+
log_hook_block(
|
|
196
|
+
Path(__file__).name,
|
|
197
|
+
MESSAGE_DISPLAY_EVENT_NAME,
|
|
198
|
+
violation,
|
|
199
|
+
offending_input_preview=message_text,
|
|
200
|
+
)
|
|
201
|
+
return draft_text
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def write_hook_output(event_name: str, output_key: str, output_text: str) -> None:
|
|
205
|
+
"""Print one hookSpecificOutput field for the event."""
|
|
206
|
+
hook_event_output = {HOOK_EVENT_NAME_KEY: event_name, output_key: output_text}
|
|
207
|
+
sys.stdout.write(json.dumps({HOOK_SPECIFIC_OUTPUT_KEY: hook_event_output}))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def reply_limit_reminder(session_id: object) -> str:
|
|
211
|
+
"""Return the reply rules, plus the draft the user saw in place of the last reply."""
|
|
212
|
+
shown_draft_path = state_file_path(SHOWN_DRAFT_FILE_PREFIX, session_id)
|
|
213
|
+
shown_draft = read_state_text(shown_draft_path)
|
|
214
|
+
if not shown_draft:
|
|
215
|
+
return REPLY_LIMIT_REMINDER
|
|
216
|
+
shown_draft_path.unlink(missing_ok=True)
|
|
217
|
+
return REPLY_LIMIT_REMINDER + SHOWN_DRAFT_PREFIX + shown_draft
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main() -> int:
|
|
221
|
+
if os.environ.get(DRAFTER_GUARD_ENV_VAR):
|
|
222
|
+
return ALLOW_EXIT_CODE
|
|
223
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
224
|
+
if hook_input is None:
|
|
225
|
+
return ALLOW_EXIT_CODE
|
|
226
|
+
event_name = hook_input.get(HOOK_EVENT_NAME_INPUT_KEY)
|
|
227
|
+
if event_name == USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
228
|
+
write_hook_output(
|
|
229
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
230
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
231
|
+
reply_limit_reminder(hook_input.get(SESSION_ID_KEY)),
|
|
232
|
+
)
|
|
233
|
+
return ALLOW_EXIT_CODE
|
|
234
|
+
if event_name == SESSION_END_EVENT_NAME:
|
|
235
|
+
state_file_path(SHOWN_DRAFT_FILE_PREFIX, hook_input.get(SESSION_ID_KEY)).unlink(missing_ok=True)
|
|
236
|
+
return ALLOW_EXIT_CODE
|
|
237
|
+
if event_name != MESSAGE_DISPLAY_EVENT_NAME:
|
|
238
|
+
return ALLOW_EXIT_CODE
|
|
239
|
+
display_text = shown_text(hook_input)
|
|
240
|
+
if display_text is not None:
|
|
241
|
+
write_hook_output(MESSAGE_DISPLAY_EVENT_NAME, DISPLAY_CONTENT_KEY, display_text)
|
|
242
|
+
return ALLOW_EXIT_CODE
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
sys.exit(main())
|
|
@@ -33,11 +33,10 @@ from inventory_intent_records.config.intent_records_constants import (
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
from hooks_constants.session_edit_stage_gate_constants import (
|
|
36
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
37
36
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
38
|
-
STATE_FILE_DEFAULT_SESSION_ID,
|
|
39
37
|
STATE_FILE_JSON_INDENT_SPACES,
|
|
40
38
|
)
|
|
39
|
+
from hooks_constants.session_file_id import session_file_id
|
|
41
40
|
|
|
42
41
|
|
|
43
42
|
def _intent_file_path(session_id: str) -> Path:
|
|
@@ -50,9 +49,7 @@ def _intent_file_path(session_id: str) -> Path:
|
|
|
50
49
|
Returns:
|
|
51
50
|
The absolute path of this session's intent-records file.
|
|
52
51
|
"""
|
|
53
|
-
|
|
54
|
-
effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
55
|
-
file_name = f"{INTENT_FILE_PREFIX}{effective_session_id}{INTENT_FILE_SUFFIX}"
|
|
52
|
+
file_name = f"{INTENT_FILE_PREFIX}{session_file_id(session_id)}{INTENT_FILE_SUFFIX}"
|
|
56
53
|
return Path(tempfile.gettempdir()) / file_name
|
|
57
54
|
|
|
58
55
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import chat_reply_gate
|
|
8
|
+
|
|
9
|
+
FOUR_SENTENCE_REPLY = "PR one merged. PR two is queued. PR three needs a fix. Nothing else is open."
|
|
10
|
+
THREE_SENTENCE_REPLY = "PR one merged. PR two is queued. Nothing else is open."
|
|
11
|
+
PASSING_DRAFT = "PR one merged. Two others wait."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def default_banned_words(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
16
|
+
monkeypatch.setenv("CLAUDE_REPLY_BANNED_WORDS_PATH", str(tmp_path / "absent.json"))
|
|
17
|
+
monkeypatch.delenv("CHAT_REPLY_DRAFTER_ACTIVE", raising=False)
|
|
18
|
+
monkeypatch.setattr(chat_reply_gate, "log_hook_block", lambda *_args, **_kwargs: None)
|
|
19
|
+
monkeypatch.setattr(chat_reply_gate.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_hook(
|
|
23
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
24
|
+
capsys: pytest.CaptureFixture[str],
|
|
25
|
+
hook_input: dict[str, object],
|
|
26
|
+
) -> tuple[int, dict[str, object] | None]:
|
|
27
|
+
monkeypatch.setattr(
|
|
28
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
29
|
+
)
|
|
30
|
+
exit_code = chat_reply_gate.main()
|
|
31
|
+
output_text = capsys.readouterr().out
|
|
32
|
+
return exit_code, json.loads(output_text)["hookSpecificOutput"] if output_text else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def display_input(delta: str, is_final: bool = True) -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"hook_event_name": "MessageDisplay",
|
|
38
|
+
"session_id": "abc",
|
|
39
|
+
"message_id": "m1",
|
|
40
|
+
"delta": delta,
|
|
41
|
+
"final": is_final,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_should_show_a_passing_draft_in_place_of_a_long_reply(
|
|
46
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
47
|
+
) -> None:
|
|
48
|
+
monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
|
|
49
|
+
exit_code, hook_output = run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
|
|
50
|
+
assert exit_code == 0
|
|
51
|
+
assert hook_output == {"hookEventName": "MessageDisplay", "displayContent": PASSING_DRAFT}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_should_show_a_reply_within_the_limit_unchanged(
|
|
55
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
56
|
+
) -> None:
|
|
57
|
+
monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: pytest.fail("drafter called"))
|
|
58
|
+
exit_code, hook_output = run_hook(monkeypatch, capsys, display_input(THREE_SENTENCE_REPLY))
|
|
59
|
+
assert exit_code == 0
|
|
60
|
+
assert hook_output is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_should_hold_batches_until_the_last_one_arrives(
|
|
64
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
65
|
+
) -> None:
|
|
66
|
+
all_requests: list[str] = []
|
|
67
|
+
monkeypatch.setattr(
|
|
68
|
+
chat_reply_gate,
|
|
69
|
+
"ask_drafter",
|
|
70
|
+
lambda reply_text, _violation: all_requests.append(reply_text) or PASSING_DRAFT,
|
|
71
|
+
)
|
|
72
|
+
_, first_output = run_hook(
|
|
73
|
+
monkeypatch, capsys, display_input("PR one merged. PR two is queued.\n", False)
|
|
74
|
+
)
|
|
75
|
+
_, last_output = run_hook(
|
|
76
|
+
monkeypatch, capsys, display_input("PR three needs a fix. Nothing else is open.")
|
|
77
|
+
)
|
|
78
|
+
assert first_output == {"hookEventName": "MessageDisplay", "displayContent": ""}
|
|
79
|
+
assert last_output == {"hookEventName": "MessageDisplay", "displayContent": PASSING_DRAFT}
|
|
80
|
+
assert all_requests == [
|
|
81
|
+
"PR one merged. PR two is queued.\nPR three needs a fix. Nothing else is open."
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_show_the_whole_held_message_when_it_passes(
|
|
86
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
87
|
+
) -> None:
|
|
88
|
+
run_hook(monkeypatch, capsys, display_input("PR one merged.\n", False))
|
|
89
|
+
_, hook_output = run_hook(monkeypatch, capsys, display_input("Nothing else is open."))
|
|
90
|
+
assert hook_output == {
|
|
91
|
+
"hookEventName": "MessageDisplay",
|
|
92
|
+
"displayContent": "PR one merged.\nNothing else is open.",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_should_show_the_original_when_no_draft_passes(
|
|
97
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
98
|
+
) -> None:
|
|
99
|
+
all_calls: list[str] = []
|
|
100
|
+
monkeypatch.setattr(
|
|
101
|
+
chat_reply_gate,
|
|
102
|
+
"ask_drafter",
|
|
103
|
+
lambda reply_text, _violation: all_calls.append(reply_text) or FOUR_SENTENCE_REPLY,
|
|
104
|
+
)
|
|
105
|
+
_, hook_output = run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
|
|
106
|
+
assert hook_output is None
|
|
107
|
+
assert len(all_calls) == 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_should_tell_the_agent_which_draft_the_user_saw(
|
|
111
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
112
|
+
) -> None:
|
|
113
|
+
monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
|
|
114
|
+
run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
|
|
115
|
+
prompt_input = {"hook_event_name": "UserPromptSubmit", "session_id": "abc"}
|
|
116
|
+
_, first_output = run_hook(monkeypatch, capsys, prompt_input)
|
|
117
|
+
_, second_output = run_hook(monkeypatch, capsys, prompt_input)
|
|
118
|
+
assert first_output is not None and second_output is not None
|
|
119
|
+
assert str(first_output["additionalContext"]).endswith(PASSING_DRAFT)
|
|
120
|
+
assert PASSING_DRAFT not in str(second_output["additionalContext"])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_should_delete_the_shown_draft_at_session_end(
|
|
124
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
125
|
+
) -> None:
|
|
126
|
+
monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
|
|
127
|
+
run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
|
|
128
|
+
shown_draft_path = chat_reply_gate.state_file_path("claude-chat-reply-shown-", "abc")
|
|
129
|
+
assert shown_draft_path.exists()
|
|
130
|
+
exit_code, hook_output = run_hook(
|
|
131
|
+
monkeypatch, capsys, {"hook_event_name": "SessionEnd", "session_id": "abc"}
|
|
132
|
+
)
|
|
133
|
+
assert exit_code == 0
|
|
134
|
+
assert hook_output is None
|
|
135
|
+
assert not shown_draft_path.exists()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_should_add_the_reply_limit_on_each_user_prompt(
|
|
139
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
140
|
+
) -> None:
|
|
141
|
+
exit_code, hook_output = run_hook(
|
|
142
|
+
monkeypatch, capsys, {"hook_event_name": "UserPromptSubmit", "prompt": "status?"}
|
|
143
|
+
)
|
|
144
|
+
assert exit_code == 0
|
|
145
|
+
assert hook_output is not None
|
|
146
|
+
assert hook_output["hookEventName"] == "UserPromptSubmit"
|
|
147
|
+
assert "3 sentences or fewer" in str(hook_output["additionalContext"])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_should_do_nothing_inside_the_drafter_process(
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
152
|
+
) -> None:
|
|
153
|
+
monkeypatch.setenv("CHAT_REPLY_DRAFTER_ACTIVE", "1")
|
|
154
|
+
exit_code, hook_output = run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
|
|
155
|
+
assert exit_code == 0
|
|
156
|
+
assert hook_output is None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_should_ignore_the_stop_event(
|
|
160
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
161
|
+
) -> None:
|
|
162
|
+
exit_code, hook_output = run_hook(
|
|
163
|
+
monkeypatch,
|
|
164
|
+
capsys,
|
|
165
|
+
{"hook_event_name": "Stop", "last_assistant_message": FOUR_SENTENCE_REPLY},
|
|
166
|
+
)
|
|
167
|
+
assert exit_code == 0
|
|
168
|
+
assert hook_output is None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_ask_drafter_should_read_the_result_field(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
172
|
+
class CompletedDrafter:
|
|
173
|
+
stdout = json.dumps({"result": f" {PASSING_DRAFT}\n"})
|
|
174
|
+
|
|
175
|
+
all_commands: list[list[str]] = []
|
|
176
|
+
monkeypatch.setattr(chat_reply_gate.shutil, "which", lambda _name: "C:/npm/claude.cmd")
|
|
177
|
+
monkeypatch.setattr(
|
|
178
|
+
chat_reply_gate.subprocess,
|
|
179
|
+
"run",
|
|
180
|
+
lambda command, **_kwargs: all_commands.append(command) or CompletedDrafter(),
|
|
181
|
+
)
|
|
182
|
+
assert chat_reply_gate.ask_drafter(FOUR_SENTENCE_REPLY, "too long") == PASSING_DRAFT
|
|
183
|
+
assert all_commands[0][:4] == ["C:/npm/claude.cmd", "-p", "--model", "haiku"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_ask_drafter_should_return_none_for_an_error_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
187
|
+
class FailedDrafter:
|
|
188
|
+
stdout = json.dumps({"is_error": True, "result": "Credit balance is too low"})
|
|
189
|
+
|
|
190
|
+
monkeypatch.setattr(chat_reply_gate.subprocess, "run", lambda *_args, **_kwargs: FailedDrafter())
|
|
191
|
+
assert chat_reply_gate.ask_drafter(FOUR_SENTENCE_REPLY, "too long") is None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_should_forget_the_shown_draft_when_a_later_reply_passes(
|
|
195
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
196
|
+
) -> None:
|
|
197
|
+
monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
|
|
198
|
+
run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
|
|
199
|
+
run_hook(monkeypatch, capsys, {**display_input(THREE_SENTENCE_REPLY), "message_id": "m2"})
|
|
200
|
+
_, hook_output = run_hook(monkeypatch, capsys, {"hook_event_name": "UserPromptSubmit", "session_id": "abc"})
|
|
201
|
+
assert hook_output is not None
|
|
202
|
+
assert PASSING_DRAFT not in str(hook_output["additionalContext"])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_should_show_a_batch_without_a_message_id_as_it_is(
|
|
206
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
207
|
+
) -> None:
|
|
208
|
+
monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: pytest.fail("drafter called"))
|
|
209
|
+
batch_input = {**display_input("PR one merged. PR two", is_final=False), "message_id": None}
|
|
210
|
+
exit_code, hook_output = run_hook(monkeypatch, capsys, batch_input)
|
|
211
|
+
assert exit_code == 0
|
|
212
|
+
assert hook_output is None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_drafter_environment_should_carry_the_guard(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
216
|
+
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "parent")
|
|
217
|
+
environment = chat_reply_gate.drafter_environment()
|
|
218
|
+
assert environment["CHAT_REPLY_DRAFTER_ACTIVE"] == "1"
|
|
219
|
+
assert "CLAUDE_CODE_SESSION_ID" not in environment
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_state_file_path_should_strip_unsafe_characters(tmp_path: Path) -> None:
|
|
223
|
+
state_path = chat_reply_gate.state_file_path("claude-chat-reply-held-", "../a b/c")
|
|
224
|
+
assert state_path == tmp_path / "claude-chat-reply-held-abc.txt"
|
|
@@ -7,6 +7,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
8
|
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with.
|
|
9
9
|
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
10
|
+
- `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
|
|
10
11
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
11
12
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
12
13
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
@@ -23,6 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
23
24
|
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision|AskUserQuestion|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
|
|
24
25
|
- `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
|
|
25
26
|
- The claim judge runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision`, timeout `30` seconds in `hooks.json`.
|
|
27
|
+
- `blocking/chat_reply_gate.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds, on `MessageDisplay`, timeout `100` seconds, and on `SessionEnd`, timeout `3` seconds, in `hooks.json`.
|
|
26
28
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
27
29
|
- `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
|
|
28
30
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
@@ -43,6 +45,7 @@ Preconditions:
|
|
|
43
45
|
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
44
46
|
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes.
|
|
45
47
|
- **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
|
|
48
|
+
- **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
|
|
46
49
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
47
50
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
48
51
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
package/hooks/hooks.json
CHANGED
|
@@ -293,6 +293,11 @@
|
|
|
293
293
|
"type": "command",
|
|
294
294
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
295
295
|
"timeout": 120
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"type": "command",
|
|
299
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
|
|
300
|
+
"timeout": 10
|
|
296
301
|
}
|
|
297
302
|
]
|
|
298
303
|
}
|
|
@@ -391,6 +396,11 @@
|
|
|
391
396
|
"type": "command",
|
|
392
397
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
|
|
393
398
|
"timeout": 3
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"type": "command",
|
|
402
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
|
|
403
|
+
"timeout": 3
|
|
394
404
|
}
|
|
395
405
|
]
|
|
396
406
|
}
|
|
@@ -470,6 +480,17 @@
|
|
|
470
480
|
}
|
|
471
481
|
]
|
|
472
482
|
}
|
|
483
|
+
],
|
|
484
|
+
"MessageDisplay": [
|
|
485
|
+
{
|
|
486
|
+
"hooks": [
|
|
487
|
+
{
|
|
488
|
+
"type": "command",
|
|
489
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
|
|
490
|
+
"timeout": 100
|
|
491
|
+
}
|
|
492
|
+
]
|
|
493
|
+
}
|
|
473
494
|
]
|
|
474
495
|
}
|
|
475
496
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Constants for the chat reply gate UserPromptSubmit and MessageDisplay hook."""
|
|
2
|
+
|
|
3
|
+
from hooks_constants.reply_length_gate_constants import (
|
|
4
|
+
MAXIMUM_SENTENCE_COUNT,
|
|
5
|
+
MAXIMUM_WORDS_PER_SENTENCE,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
HOOK_EVENT_NAME_INPUT_KEY = "hook_event_name"
|
|
9
|
+
MESSAGE_DISPLAY_EVENT_NAME = "MessageDisplay"
|
|
10
|
+
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
11
|
+
SESSION_END_EVENT_NAME = "SessionEnd"
|
|
12
|
+
SESSION_ID_KEY = "session_id"
|
|
13
|
+
MESSAGE_ID_KEY = "message_id"
|
|
14
|
+
DELTA_KEY = "delta"
|
|
15
|
+
FINAL_KEY = "final"
|
|
16
|
+
DISPLAY_CONTENT_KEY = "displayContent"
|
|
17
|
+
STATE_FILE_ENCODING = "utf-8"
|
|
18
|
+
HELD_TEXT_FILE_PREFIX = "claude-chat-reply-held-"
|
|
19
|
+
SHOWN_DRAFT_FILE_PREFIX = "claude-chat-reply-shown-"
|
|
20
|
+
STATE_FILE_SUFFIX = ".txt"
|
|
21
|
+
REPLY_LIMIT_REMINDER = (
|
|
22
|
+
f"Your reply to the user is final and gets no second pass. Keep it to {MAXIMUM_SENTENCE_COUNT}"
|
|
23
|
+
f" sentences or fewer, each {MAXIMUM_WORDS_PER_SENTENCE} words or fewer."
|
|
24
|
+
" Each line and each list item counts as one sentence, so prefer short prose over lists."
|
|
25
|
+
" Lead with the outcome and cut detail the user did not ask for."
|
|
26
|
+
" No colon in the middle of a sentence, and no em dash."
|
|
27
|
+
" Put any pull request number inside a markdown link."
|
|
28
|
+
" A sentence with so, because, caused, due to, therefore, or means needs inline code, a URL, or a link in it."
|
|
29
|
+
" Never write real, really, genuine, actual, actually, likely, probably, seems, maybe, perhaps, may be, or might be."
|
|
30
|
+
" Count your sentences before you send."
|
|
31
|
+
)
|
|
32
|
+
SHOWN_DRAFT_PREFIX = "\n\nThe user saw your last reply condensed to this text, so build on it:\n\n"
|
|
33
|
+
DRAFTER_GUARD_ENV_VAR = "CHAT_REPLY_DRAFTER_ACTIVE"
|
|
34
|
+
DRAFTER_MODEL_ENV_VAR = "CHAT_REPLY_DRAFTER_MODEL"
|
|
35
|
+
DEFAULT_DRAFTER_MODEL = "haiku"
|
|
36
|
+
DRAFTER_TIMEOUT_SECONDS = 45
|
|
37
|
+
MAXIMUM_DRAFT_ATTEMPTS = 2
|
|
38
|
+
DRAFTER_RESULT_KEY = "result"
|
|
39
|
+
DRAFTER_ERROR_KEY = "is_error"
|
|
40
|
+
DRAFTER_EXECUTABLE_NAME = "claude"
|
|
41
|
+
CLAUDE_SESSION_ID_ENV_VAR = "CLAUDE_CODE_SESSION_ID"
|
|
42
|
+
DRAFTER_REQUEST_TEMPLATE = "Rule the reply broke: {violation}\n\nReply to rewrite:\n\n{reply_text}"
|
|
43
|
+
DRAFTER_RULES = (
|
|
44
|
+
f"Rewrite the reply below so it obeys every rule. At most {MAXIMUM_SENTENCE_COUNT} sentences,"
|
|
45
|
+
f" each at most {MAXIMUM_WORDS_PER_SENTENCE} words. Each line and each list item counts as one sentence."
|
|
46
|
+
" Keep the outcome, the recommendation, and any number, path, or command the user needs."
|
|
47
|
+
" Drop background the user did not ask for."
|
|
48
|
+
" No colon in the middle of a sentence. No em dash."
|
|
49
|
+
" Write a pull request number only inside a markdown link."
|
|
50
|
+
" A sentence that gives a cause, with so, because, caused, due to, therefore, or means,"
|
|
51
|
+
" must hold its own evidence as inline code, a URL, or a markdown link. Otherwise state the fact without the cause word."
|
|
52
|
+
" Never use these words: real, really, genuine, actual, actually, likely, probably, seems, maybe, perhaps,"
|
|
53
|
+
" might be, may be, appears to, suspect, guess, presumably."
|
|
54
|
+
" Speak as the original author to the original reader."
|
|
55
|
+
" Never mention the rule, the length, or this rewrite."
|
|
56
|
+
" Never add, drop, or change a fact, a command, a flag, or a file name; leave out a whole point instead."
|
|
57
|
+
" Return only the rewritten reply, with no preface."
|
|
58
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Turn a hook payload's session id into a safe part of a temp file name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hooks_constants.session_edit_stage_gate_constants import (
|
|
6
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
7
|
+
STATE_FILE_DEFAULT_SESSION_ID,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["session_file_id"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def session_file_id(session_id: object) -> str:
|
|
14
|
+
"""Return the session id with unsafe characters stripped, or the default id when none remain."""
|
|
15
|
+
raw_session_id = session_id if isinstance(session_id, str) else ""
|
|
16
|
+
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", raw_session_id)
|
|
17
|
+
return sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tests for the shared session id that names per-session temp files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hooks_constants.session_file_id import session_file_id
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_should_strip_path_characters_from_the_session_id() -> None:
|
|
9
|
+
assert session_file_id("../a b/c-1_2") == "abc-1_2"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_should_fall_back_to_the_default_id_when_nothing_safe_remains() -> None:
|
|
13
|
+
assert session_file_id("../ /") == "default"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_should_fall_back_to_the_default_id_for_a_missing_session_id() -> None:
|
|
17
|
+
assert session_file_id(None) == "default"
|
|
@@ -39,11 +39,10 @@ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
|
39
39
|
APPLY_PATCH_TOOL_NAME,
|
|
40
40
|
SESSION_EDIT_FILE_PREFIX,
|
|
41
41
|
SESSION_EDIT_FILE_SUFFIX,
|
|
42
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
43
42
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
44
|
-
STATE_FILE_DEFAULT_SESSION_ID,
|
|
45
43
|
STATE_FILE_JSON_INDENT_SPACES,
|
|
46
44
|
)
|
|
45
|
+
from hooks_constants.session_file_id import session_file_id
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
def _resolved_path_or_none(file_path: str) -> str | None:
|
|
@@ -74,9 +73,7 @@ def _session_edit_file_path(session_id: str) -> Path:
|
|
|
74
73
|
Returns:
|
|
75
74
|
Absolute path to this session's tracker file.
|
|
76
75
|
"""
|
|
77
|
-
|
|
78
|
-
effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
79
|
-
file_name = f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
76
|
+
file_name = f"{SESSION_EDIT_FILE_PREFIX}{session_file_id(session_id)}{SESSION_EDIT_FILE_SUFFIX}"
|
|
80
77
|
return Path(tempfile.gettempdir()) / file_name
|
|
81
78
|
|
|
82
79
|
|
|
@@ -35,11 +35,10 @@ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
|
35
35
|
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
36
36
|
SESSION_EDIT_FILE_PREFIX,
|
|
37
37
|
SESSION_EDIT_FILE_SUFFIX,
|
|
38
|
-
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
39
38
|
SESSION_START_SOURCE_FRESH_STARTUP,
|
|
40
39
|
SESSION_START_SOURCE_PAYLOAD_KEY,
|
|
41
|
-
STATE_FILE_DEFAULT_SESSION_ID,
|
|
42
40
|
)
|
|
41
|
+
from hooks_constants.session_file_id import session_file_id
|
|
43
42
|
|
|
44
43
|
|
|
45
44
|
def _delete_file(target_file: Path) -> None:
|
|
@@ -66,9 +65,7 @@ def _current_session_file_name(session_id: str) -> str:
|
|
|
66
65
|
The tracker file name for the sanitized session id, falling back to
|
|
67
66
|
the default id when sanitizing leaves an empty string.
|
|
68
67
|
"""
|
|
69
|
-
|
|
70
|
-
effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
71
|
-
return f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
68
|
+
return f"{SESSION_EDIT_FILE_PREFIX}{session_file_id(session_id)}{SESSION_EDIT_FILE_SUFFIX}"
|
|
72
69
|
|
|
73
70
|
|
|
74
71
|
def _is_tracker_deletion_lifecycle_event(payload_by_field: dict[str, object]) -> bool:
|
package/package.json
CHANGED
|
@@ -130,6 +130,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
130
130
|
hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
|
|
131
131
|
hooks/blocking/step_note_gate.py -> exempt
|
|
132
132
|
hooks/blocking/reply_length_gate.py -> exempt
|
|
133
|
+
hooks/blocking/chat_reply_gate.py -> exempt
|
|
133
134
|
hooks/blocking/edit_marker_gate.py -> exempt
|
|
134
135
|
hooks/blocking/issue_close_handoff_gate.py -> exempt
|
|
135
136
|
hooks/blocking/verify_before_acting.py -> exempt
|
|
@@ -154,7 +155,8 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
154
155
|
It asks for a readable status line and decides no code or safety policy.
|
|
155
156
|
|
|
156
157
|
The reply length gate caps the sentences in a chat reply to the user. It
|
|
157
|
-
decides the shape of prose and no code or safety policy.
|
|
158
|
+
decides the shape of prose and no code or safety policy. The chat reply
|
|
159
|
+
gate runs the same checks on a reply typed straight into the chat.
|
|
158
160
|
|
|
159
161
|
The edit marker gate keeps strikethrough and edit notes out of an edited
|
|
160
162
|
chat message. It decides the shape of prose and no code or safety policy.
|
|
@@ -67,6 +67,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
67
67
|
"blocking/merge_review_gate.py",
|
|
68
68
|
"blocking/step_note_gate.py",
|
|
69
69
|
"blocking/reply_length_gate.py",
|
|
70
|
+
"blocking/chat_reply_gate.py",
|
|
70
71
|
"blocking/edit_marker_gate.py",
|
|
71
72
|
"blocking/issue_close_handoff_gate.py",
|
|
72
73
|
"blocking/verify_before_acting.py",
|
|
@@ -90,6 +90,14 @@ def test_new_reply_length_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
90
90
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def test_new_chat_reply_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
94
|
+
current_document = _hook_document(
|
|
95
|
+
["hooks/blocking/chat_reply_gate.py"],
|
|
96
|
+
'{"hooks": {}}',
|
|
97
|
+
)
|
|
98
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
99
|
+
|
|
100
|
+
|
|
93
101
|
def test_new_edit_marker_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
94
102
|
current_document = _hook_document(
|
|
95
103
|
["hooks/blocking/edit_marker_gate.py"],
|