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.
@@ -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 the Codex guidance text with the package's skill-load block in it.
39
+ * Return guidance text with every complete skill-load block removed.
43
40
  *
44
- * Codex starts a spawn_agent helper without running any hook, so the global
45
- * AGENTS.md is the one channel that reaches every helper. A block already in
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 withSkillLoadBlock(guidanceText) {
49
- const block = managedBlock(SKILL_LOAD_BLOCK_START, SKILL_LOAD_INSTRUCTION, SKILL_LOAD_BLOCK_END);
50
- const replacedText = withBlockReplaced(guidanceText, SKILL_LOAD_BLOCK_START, SKILL_LOAD_BLOCK_END, block);
51
- if (replacedText !== null) return replacedText;
52
- return guidanceText ? `${block}\n${guidanceText}` : block;
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
- * Keep CODEX_HOME/AGENTS.md carrying the skill-load block and the package guidance.
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
- * Returns the file path when the file changed, and null otherwise.
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 writeCodexGuidanceFile(
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
- * Links follow the same rule as writeCodexAgentsGuidance. Returns the file
118
- * path when the file changed, and null otherwise.
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
- withSkillLoadBlock,
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 EXPECTED_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
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('the instruction names the skill and binds spawned helpers', () => {
56
- assert.match(SKILL_LOAD_INSTRUCTION, /pstack:poteto-mode/);
57
- assert.match(SKILL_LOAD_INSTRUCTION, /spawn_agent/);
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 skill-load block and the package guidance', (context) => {
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
- assert.equal(
67
- readFileSync(homes.agentsPath, 'utf8'),
68
- `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
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 every line between the skill-load block and the package guidance', (context) => {
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
- readFileSync(homes.agentsPath, 'utf8'),
81
- `${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
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('an outdated block is replaced in place and the text around it is kept', () => {
97
- const outdated = `Intro\n${SKILL_LOAD_BLOCK_START}\nOld wording\n${SKILL_LOAD_BLOCK_END}\nOutro\n`;
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
- assert.equal(withSkillLoadBlock(outdated), `Intro\n${EXPECTED_BLOCK}Outro\n`);
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 gets a fresh block and keeps the stray text', () => {
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
- assert.equal(withSkillLoadBlock(broken), `${EXPECTED_BLOCK}\n${broken}`);
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 both blocks`, (context) => {
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
- readFileSync(homes.agentsPath, 'utf8'),
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 on the next install', (context) => {
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, `${EXPECTED_BLOCK}\n${guidanceBlock('Old guidance\n')}`);
161
+ writeFileSync(homes.agentsPath, `${STALE_SKILL_LOAD_BLOCK}\n${guidanceBlock('Old guidance\n')}`);
129
162
 
130
163
  writeGuidance(homes);
131
164
 
132
- assert.equal(
133
- readFileSync(homes.agentsPath, 'utf8'),
134
- `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
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 skillLoadGuidancePath = writeCodexAgentsGuidance(
2930
+ const codexGuidancePath = writeCodexAgentsGuidance(
2931
2931
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2932
2932
  packageGuidanceText,
2933
2933
  allPackageGuidancePaths,
2934
2934
  );
2935
- if (skillLoadGuidancePath) {
2936
- console.log(` \u2713 ${skillLoadGuidancePath} (Codex skill-load line)`);
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, 4);
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 retains its skill block and gains the question block', (context) => {
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.match(guidanceText, /<!-- claude-dev-env skill load: start -->/);
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.match(updatedGuidance, /<!-- claude-dev-env skill load: start -->/);
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
  });
@@ -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
- withSkillLoadBlock,
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
- assert.ok(agents.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
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(join(codexHome, 'AGENTS.md'), 'Custom Codex guidance\n');
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(withSkillLoadBlock('Custom Codex guidance\n')));
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
- assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
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
- assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
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
- assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
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
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
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
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
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
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.84.1",
3
+ "version": "8.85.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"],