claude-dev-env 8.106.1 → 8.106.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install-constants.mjs +0 -5
- package/bin/install.cursor-rules.test.mjs +11 -6
- package/bin/install.mjs +3 -2
- package/bin/install.test.mjs +1 -0
- package/docs/codex-compatibility.md +2 -2
- package/hooks/blocking/reply_length_gate.py +229 -106
- package/hooks/blocking/test_reply_length_gate.py +77 -0
- package/hooks/features/README.md +1 -1
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/features/lifecycle-cleanup.md +2 -2
- package/hooks/features/observability.md +1 -5
- package/hooks/features/session-context.md +4 -4
- package/hooks/hooks.json +1 -16
- package/hooks/hooks_constants/hook_specific_output_keys.py +2 -0
- package/hooks/hooks_constants/nested_project_hooks_constants.py +3 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +2 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -23
- package/hooks/hooks_constants/test_hook_specific_output_keys.py +1 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +1 -0
- package/hooks/lifecycle/nested_project_hooks.py +52 -12
- package/hooks/lifecycle/test_nested_project_hooks.py +81 -0
- package/hooks/session/skill_loaded_reminder.py +2 -52
- package/hooks/session/test_skill_loaded_reminder.py +22 -85
- package/hooks/test_visual_reply_rules.py +12 -0
- package/hooks/visual_reply_rules.py +29 -12
- package/package.json +1 -1
- package/hooks/workflow/investigation_tracker_reset.py +0 -48
- package/hooks/workflow/test_investigation_tracker_reset.py +0 -90
|
@@ -174,11 +174,6 @@ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
|
|
|
174
174
|
export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
|
|
175
175
|
export const ADDITIONAL_SETTINGS_BLOB_PREFIX = 'settings-';
|
|
176
176
|
export const CODEX_NATIVE_ROUTING_MATCHER = 'multi_agent_v1__spawn_agent';
|
|
177
|
-
export const CODEX_SUBAGENT_PROMPT_MATCHER = 'Agent|Task';
|
|
178
|
-
export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
|
|
179
|
-
CODEX_NATIVE_ROUTING_MATCHER,
|
|
180
|
-
CODEX_SUBAGENT_PROMPT_MATCHER,
|
|
181
|
-
]);
|
|
182
177
|
export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
|
|
183
178
|
'open_tasks_stop_gate.py',
|
|
184
179
|
'owned_pr_merge_stop_gate.py',
|
|
@@ -130,11 +130,10 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
130
130
|
assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
|
|
131
131
|
assert.match(firstRoutingGroups[0].hooks[1].command, /spawn_readiness_hook\.py/);
|
|
132
132
|
assert.match(firstRoutingGroups[0].hooks[2].command, /spawn_oversight_hook\.py/);
|
|
133
|
-
|
|
134
|
-
group => group.matcher
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
firstCodexHooks.hooks.PreToolUse.map(group => group.matcher),
|
|
135
|
+
['multi_agent_v1__spawn_agent'],
|
|
135
136
|
);
|
|
136
|
-
assert.equal(firstSpawnPromptGroups.length, 1);
|
|
137
|
-
assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
|
|
138
137
|
assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'Stop', 'SessionStart']);
|
|
139
138
|
const allCodexPostToolUseCommands = firstCodexHooks.hooks.PostToolUse.flatMap(
|
|
140
139
|
group => group.hooks.map(hook => hook.command),
|
|
@@ -276,6 +275,7 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
|
|
|
276
275
|
const allLegacyRegistrations = [
|
|
277
276
|
['PreToolUse', 'Edit', '.claude', 'blocking/pre_tool_use_dispatcher.py'],
|
|
278
277
|
['PreToolUse', 'Bash', '.agents', 'blocking/bash_pre_tool_use_dispatcher.py'],
|
|
278
|
+
['PreToolUse', 'Agent|Task', 'custom-codex', 'session/skill_loaded_reminder.py'],
|
|
279
279
|
['SessionStart', '*', 'custom-codex', 'session/issue_tracker_session_starter.py'],
|
|
280
280
|
['Stop', '*', '.claude', 'session/skill_loaded_reminder.py'],
|
|
281
281
|
['SessionEnd', '*', 'custom-codex', 'lifecycle/session_end_cleanup.py'],
|
|
@@ -311,8 +311,13 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
|
|
|
311
311
|
|
|
312
312
|
const migratedHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
313
313
|
const allSpawnGroups = migratedHooks.hooks.PreToolUse.filter(eachGroup =>
|
|
314
|
-
|
|
315
|
-
assert.equal(allSpawnGroups.length,
|
|
314
|
+
eachGroup.matcher === 'multi_agent_v1__spawn_agent');
|
|
315
|
+
assert.equal(allSpawnGroups.length, 1);
|
|
316
|
+
assert.equal(
|
|
317
|
+
migratedHooks.hooks.PreToolUse.flatMap(eachGroup => eachGroup.hooks)
|
|
318
|
+
.some(eachHook => String(eachHook.command).includes('skill_loaded_reminder.py')),
|
|
319
|
+
false,
|
|
320
|
+
);
|
|
316
321
|
migratedHooks.hooks.PreToolUse = migratedHooks.hooks.PreToolUse.filter(eachGroup => !allSpawnGroups.includes(eachGroup));
|
|
317
322
|
const allPackagePromptCommands = (migratedHooks.hooks.UserPromptSubmit ?? [])
|
|
318
323
|
.flatMap(eachGroup => eachGroup.hooks)
|
package/bin/install.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
SUBAGENT_MODEL_POLICY_FILE_NAME,
|
|
51
51
|
WINDOWS_PYTHON_LAUNCHER_COMMAND,
|
|
52
52
|
PYTHON_PROBE_TIMEOUT_MILLISECONDS,
|
|
53
|
-
|
|
53
|
+
CODEX_NATIVE_ROUTING_MATCHER,
|
|
54
54
|
ALL_CODEX_NATIVE_SESSION_START_HOOK_SCRIPT_NAMES,
|
|
55
55
|
ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
|
|
56
56
|
ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
|
|
@@ -1315,6 +1315,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
|
|
|
1315
1315
|
'blocking/session_title_stop_gate.py',
|
|
1316
1316
|
'session/issue_tracker_session_starter.py',
|
|
1317
1317
|
'session/deepen_first_prompt.py',
|
|
1318
|
+
'workflow/investigation_tracker_reset.py',
|
|
1318
1319
|
]);
|
|
1319
1320
|
|
|
1320
1321
|
export const MOVED_HOOK_RELATIVE_PATHS = new Set([
|
|
@@ -2283,7 +2284,7 @@ function selectCodexNativeHooksConfig(hooksConfig) {
|
|
|
2283
2284
|
...hooksConfig,
|
|
2284
2285
|
hooks: {
|
|
2285
2286
|
PreToolUse: Array.isArray(preToolUseGroups)
|
|
2286
|
-
? preToolUseGroups.filter(group =>
|
|
2287
|
+
? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
|
|
2287
2288
|
: [],
|
|
2288
2289
|
UserPromptSubmit: Array.isArray(userPromptSubmitGroups) ? userPromptSubmitGroups : [],
|
|
2289
2290
|
PostToolUse: selectCodexNativeGroupsByScriptName(
|
package/bin/install.test.mjs
CHANGED
|
@@ -1245,6 +1245,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1245
1245
|
'blocking/session_title_stop_gate.py',
|
|
1246
1246
|
'session/issue_tracker_session_starter.py',
|
|
1247
1247
|
'session/deepen_first_prompt.py',
|
|
1248
|
+
'workflow/investigation_tracker_reset.py',
|
|
1248
1249
|
]);
|
|
1249
1250
|
const shippedHooks = JSON.parse(
|
|
1250
1251
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
@@ -26,8 +26,8 @@ Run `codex-compat bridge --surface <name> --payload '<json-object>'`. The bridge
|
|
|
26
26
|
|
|
27
27
|
The package installer seeds `subagent-model-policy.json` in the agents-home
|
|
28
28
|
`rules/` directory when the file is absent. It registers one `PreToolUse` group
|
|
29
|
-
for `multi_agent_v1__spawn_agent`
|
|
30
|
-
Before adding
|
|
29
|
+
for `multi_agent_v1__spawn_agent` in `$CODEX_HOME/hooks.json`.
|
|
30
|
+
Before adding this group, the installer removes obsolete package-owned hook
|
|
31
31
|
registrations from every event. Custom hook entries and Codex's enabled or
|
|
32
32
|
disabled hook settings stay unchanged. Claude-only hook groups do not enter the
|
|
33
33
|
Codex file.
|
|
@@ -83,6 +83,7 @@ import os
|
|
|
83
83
|
import re
|
|
84
84
|
import shlex
|
|
85
85
|
import sys
|
|
86
|
+
from collections.abc import Callable, Iterable
|
|
86
87
|
from pathlib import Path
|
|
87
88
|
|
|
88
89
|
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -155,6 +156,8 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
155
156
|
EM_DASH_MESSAGE,
|
|
156
157
|
EM_DASH_PATTERN,
|
|
157
158
|
FENCED_BLOCK_PATTERN,
|
|
159
|
+
FINDING_SEPARATOR,
|
|
160
|
+
FIX_EVERY_ITEM_INSTRUCTION,
|
|
158
161
|
ADDITIONAL_CONTEXT_KEY,
|
|
159
162
|
HOOK_EVENT_NAME,
|
|
160
163
|
INLINE_CODE_PATTERN,
|
|
@@ -207,27 +210,92 @@ def sentence_word_lists(reply_text: str) -> list[list[str]]:
|
|
|
207
210
|
return [each_word_list for each_word_list in all_word_lists if each_word_list]
|
|
208
211
|
|
|
209
212
|
|
|
213
|
+
def joined_violations(all_violations: Iterable[str | None]) -> str | None:
|
|
214
|
+
"""Join every violation found into one deny reason, one violation per line.
|
|
215
|
+
|
|
216
|
+
::
|
|
217
|
+
|
|
218
|
+
[] -> None
|
|
219
|
+
["Too long."] -> "Too long."
|
|
220
|
+
["Too long.", "Em dash."] -> "Too long." and "Em dash." on two lines
|
|
221
|
+
|
|
222
|
+
None entries are skipped. A joined reason can join again at an outer
|
|
223
|
+
level and stays one violation per line, so ``numbered_violations``
|
|
224
|
+
numbers the whole reason once.
|
|
225
|
+
"""
|
|
226
|
+
all_found = [each_violation for each_violation in all_violations if each_violation]
|
|
227
|
+
return FINDING_SEPARATOR.join(all_found) if all_found else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def numbered_violations(violation: str) -> str:
|
|
231
|
+
"""Number each line of a joined deny reason that holds several violations.
|
|
232
|
+
|
|
233
|
+
::
|
|
234
|
+
|
|
235
|
+
"Too long." -> "Too long."
|
|
236
|
+
"Too long.\nEm dash." -> "1. Too long." and "2. Em dash." on two lines
|
|
237
|
+
"""
|
|
238
|
+
all_lines = violation.split(FINDING_SEPARATOR)
|
|
239
|
+
if len(all_lines) == 1:
|
|
240
|
+
return violation
|
|
241
|
+
return FINDING_SEPARATOR.join(
|
|
242
|
+
f"{each_position}. {each_line}" for each_position, each_line in enumerate(all_lines, start=1)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def collected_violations(all_checks: Iterable[Callable[[], str | None]]) -> str | None:
|
|
247
|
+
"""Run every check in order and join each violation it reports.
|
|
248
|
+
|
|
249
|
+
A check that raises ends the run. With nothing found yet the error
|
|
250
|
+
propagates, which lets the call through as an early-return gate did.
|
|
251
|
+
After a finding, the findings so far stand, as the first one did when
|
|
252
|
+
the gate never ran the later checks.
|
|
253
|
+
"""
|
|
254
|
+
all_violations: list[str | None] = []
|
|
255
|
+
for each_check in all_checks:
|
|
256
|
+
is_stopped, violation = _checked_violation(each_check, any(all_violations))
|
|
257
|
+
if is_stopped:
|
|
258
|
+
break
|
|
259
|
+
all_violations.append(violation)
|
|
260
|
+
return joined_violations(all_violations)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _checked_violation(check: Callable[[], str | None], has_finding: bool) -> tuple[bool, str | None]:
|
|
264
|
+
try:
|
|
265
|
+
return False, check()
|
|
266
|
+
except Exception:
|
|
267
|
+
if has_finding:
|
|
268
|
+
return True, None
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
|
|
210
272
|
def length_violation(reply_text: str) -> str | None:
|
|
211
|
-
"""Return the deny reason for an over-long reply, or None when it fits."""
|
|
273
|
+
"""Return the deny reason for an over-long reply and each over-long sentence, or None when it fits."""
|
|
212
274
|
all_sentences = sentence_word_lists(reply_text)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
275
|
+
return joined_violations(
|
|
276
|
+
[
|
|
277
|
+
TOO_MANY_SENTENCES_MESSAGE.format(
|
|
278
|
+
sentence_count=len(all_sentences), sentence_limit=MAXIMUM_SENTENCE_COUNT
|
|
279
|
+
)
|
|
280
|
+
if len(all_sentences) > MAXIMUM_SENTENCE_COUNT
|
|
281
|
+
else None,
|
|
282
|
+
long_sentence_violation(reply_text),
|
|
283
|
+
]
|
|
284
|
+
)
|
|
218
285
|
|
|
219
286
|
|
|
220
287
|
def long_sentence_violation(prose_text: str) -> str | None:
|
|
221
|
-
"""Return the deny reason for
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
288
|
+
"""Return the deny reason for each sentence over the word limit, or None."""
|
|
289
|
+
return joined_violations(
|
|
290
|
+
LONG_SENTENCE_MESSAGE.format(
|
|
291
|
+
word_count=len(each_sentence),
|
|
292
|
+
word_limit=MAXIMUM_WORDS_PER_SENTENCE,
|
|
293
|
+
sentence_preview=WORD_SEPARATOR.join(each_sentence[:SENTENCE_PREVIEW_WORD_COUNT])
|
|
294
|
+
+ SENTENCE_PREVIEW_SUFFIX,
|
|
295
|
+
)
|
|
296
|
+
for each_sentence in sentence_word_lists(prose_text)
|
|
297
|
+
if len(each_sentence) > MAXIMUM_WORDS_PER_SENTENCE
|
|
298
|
+
)
|
|
231
299
|
|
|
232
300
|
|
|
233
301
|
def mid_sentence_colon_violation(reply_text: str) -> str | None:
|
|
@@ -254,13 +322,13 @@ def prose_sentences(reply_text: str) -> list[str]:
|
|
|
254
322
|
|
|
255
323
|
|
|
256
324
|
def unsourced_cause_violation(reply_text: str) -> str | None:
|
|
257
|
-
"""Return the deny reason for
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
|
|
325
|
+
"""Return the deny reason for each causal sentence that cites no evidence itself, or None."""
|
|
326
|
+
return joined_violations(
|
|
327
|
+
UNSOURCED_CAUSE_MESSAGE.format(sentence=each_sentence.strip())
|
|
328
|
+
for each_sentence in prose_sentences(reply_text)
|
|
329
|
+
if CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
|
|
330
|
+
and not any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
|
|
331
|
+
)
|
|
264
332
|
|
|
265
333
|
|
|
266
334
|
def prose_outside_links(reply_text: str) -> str:
|
|
@@ -272,11 +340,11 @@ def prose_outside_links(reply_text: str) -> str:
|
|
|
272
340
|
|
|
273
341
|
|
|
274
342
|
def unlinked_pull_request_violation(reply_text: str) -> str | None:
|
|
275
|
-
"""Return the deny reason for
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
343
|
+
"""Return the deny reason for each pull request number outside a link, or None."""
|
|
344
|
+
return joined_violations(
|
|
345
|
+
UNLINKED_PULL_REQUEST_MESSAGE.format(reference=each_match.group(0))
|
|
346
|
+
for each_match in UNLINKED_PULL_REQUEST_PATTERN.finditer(prose_outside_links(reply_text))
|
|
347
|
+
)
|
|
280
348
|
|
|
281
349
|
|
|
282
350
|
def banned_words_config_path() -> Path:
|
|
@@ -303,20 +371,25 @@ def configured_banned_words() -> tuple[str, ...]:
|
|
|
303
371
|
|
|
304
372
|
|
|
305
373
|
def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) -> str | None:
|
|
306
|
-
"""Return the deny reason for
|
|
374
|
+
"""Return the deny reason for each banned word in the prose, or None."""
|
|
307
375
|
prose_text = countable_text(reply_text)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
376
|
+
return joined_violations(
|
|
377
|
+
BANNED_WORD_MESSAGE.format(banned_word=each_banned_word)
|
|
378
|
+
for each_banned_word in all_banned_words
|
|
379
|
+
if re.search(
|
|
380
|
+
BANNED_WORD_PATTERN_TEMPLATE.format(
|
|
381
|
+
word_pattern=BANNED_WORD_PART_SEPARATOR.join(
|
|
382
|
+
re.escape(each_part) for each_part in each_banned_word.split()
|
|
383
|
+
)
|
|
384
|
+
),
|
|
385
|
+
prose_text,
|
|
386
|
+
re.IGNORECASE,
|
|
311
387
|
)
|
|
312
|
-
|
|
313
|
-
if re.search(whole_word_pattern, prose_text, re.IGNORECASE):
|
|
314
|
-
return BANNED_WORD_MESSAGE.format(banned_word=each_banned_word)
|
|
315
|
-
return None
|
|
388
|
+
)
|
|
316
389
|
|
|
317
390
|
|
|
318
391
|
def unvalidated_claim_violation(reply_text: str) -> str | None:
|
|
319
|
-
"""Return the deny reason quoting
|
|
392
|
+
"""Return the deny reason quoting each sentence that guesses or admits a skipped check, or None.
|
|
320
393
|
|
|
321
394
|
A duration counts as a guess unless its sentence cites the run, job, check,
|
|
322
395
|
or pull request that measured it::
|
|
@@ -324,6 +397,7 @@ def unvalidated_claim_violation(reply_text: str) -> str | None:
|
|
|
324
397
|
flag: It should take about 40 minutes.
|
|
325
398
|
ok: The last queue build took 39 minutes, run 18239174012.
|
|
326
399
|
"""
|
|
400
|
+
all_claims: list[str] = []
|
|
327
401
|
for each_sentence in prose_sentences(reply_text):
|
|
328
402
|
prose_text = countable_text(each_sentence).replace(CURLY_APOSTROPHE, STRAIGHT_APOSTROPHE)
|
|
329
403
|
is_guess = UNVALIDATED_CLAIM_PATTERN.search(prose_text) is not None
|
|
@@ -332,12 +406,12 @@ def unvalidated_claim_violation(reply_text: str) -> str | None:
|
|
|
332
406
|
and CITED_NUMBER_PATTERN.search(each_sentence) is None
|
|
333
407
|
)
|
|
334
408
|
if is_guess or is_uncited_duration:
|
|
335
|
-
|
|
336
|
-
return
|
|
409
|
+
all_claims.append(UNVALIDATED_CLAIM_MESSAGE.format(claim=each_sentence))
|
|
410
|
+
return joined_violations(all_claims)
|
|
337
411
|
|
|
338
412
|
|
|
339
413
|
def unseen_citation_violation(reply_text: str, transcript_path: object) -> str | None:
|
|
340
|
-
"""Return the deny reason for
|
|
414
|
+
"""Return the deny reason for each cited run, job, check, or pull request no tool result shows.
|
|
341
415
|
|
|
342
416
|
Returns None when the text cites nothing or the transcript cannot be read.
|
|
343
417
|
"""
|
|
@@ -347,11 +421,17 @@ def unseen_citation_violation(reply_text: str, transcript_path: object) -> str |
|
|
|
347
421
|
result_text = successful_result_text(transcript_path)
|
|
348
422
|
if result_text is None:
|
|
349
423
|
return None
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
424
|
+
return joined_violations(
|
|
425
|
+
UNSEEN_CITATION_MESSAGE.format(citation=each_match.group(0))
|
|
426
|
+
for each_match in all_citations
|
|
427
|
+
if re.search(
|
|
428
|
+
CITED_NUMBER_IN_RESULT_TEMPLATE.format(
|
|
429
|
+
number=next(each_group for each_group in each_match.groups() if each_group)
|
|
430
|
+
),
|
|
431
|
+
result_text,
|
|
432
|
+
)
|
|
433
|
+
is None
|
|
434
|
+
)
|
|
355
435
|
|
|
356
436
|
|
|
357
437
|
def all_card_texts(
|
|
@@ -382,25 +462,25 @@ def card_length_violation(all_card_input: dict[str, object]) -> str | None:
|
|
|
382
462
|
|
|
383
463
|
|
|
384
464
|
def card_shape_violation(all_card_input: dict[str, object]) -> str | None:
|
|
385
|
-
"""Return the deny reason for
|
|
465
|
+
"""Return the deny reason for each listed or over-long card context and card sentence, or None."""
|
|
386
466
|
context_text = all_card_input.get(DECISION_CARD_CONTEXT_KEY)
|
|
467
|
+
all_violations: list[str | None] = []
|
|
387
468
|
if isinstance(context_text, str):
|
|
388
469
|
if CARD_LIST_ITEM_PATTERN.search(FENCED_BLOCK_PATTERN.sub(" ", context_text)) is not None:
|
|
389
|
-
|
|
470
|
+
all_violations.append(CARD_CONTEXT_LIST_MESSAGE)
|
|
390
471
|
context_sentence_count = len(sentence_word_lists(context_text))
|
|
391
472
|
if context_sentence_count > MAXIMUM_CARD_CONTEXT_SENTENCE_COUNT:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
473
|
+
all_violations.append(
|
|
474
|
+
CARD_CONTEXT_TOO_LONG_MESSAGE.format(
|
|
475
|
+
sentence_count=context_sentence_count,
|
|
476
|
+
sentence_limit=MAXIMUM_CARD_CONTEXT_SENTENCE_COUNT,
|
|
477
|
+
)
|
|
395
478
|
)
|
|
396
479
|
all_length_checked_texts = all_card_texts(
|
|
397
480
|
all_card_input, ALL_DECISION_CARD_LENGTH_KEYS, ALL_DECISION_OPTION_LENGTH_KEYS
|
|
398
481
|
)
|
|
399
|
-
for each_text in all_length_checked_texts
|
|
400
|
-
|
|
401
|
-
if violation is not None:
|
|
402
|
-
return violation
|
|
403
|
-
return None
|
|
482
|
+
all_violations.extend(long_sentence_violation(each_text) for each_text in all_length_checked_texts)
|
|
483
|
+
return joined_violations(all_violations)
|
|
404
484
|
|
|
405
485
|
|
|
406
486
|
def all_question_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
@@ -429,37 +509,41 @@ def active_mode_rules() -> VisualReplyRules | None:
|
|
|
429
509
|
|
|
430
510
|
|
|
431
511
|
def card_violation(card_text: str, mode_rules: VisualReplyRules | None) -> str | None:
|
|
432
|
-
"""Return the deny reason for a decision card's prose, or None."""
|
|
433
|
-
return (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
512
|
+
"""Return the deny reason for each problem in a decision card's prose, or None."""
|
|
513
|
+
return collected_violations(
|
|
514
|
+
[
|
|
515
|
+
lambda: banned_word_violation(card_text, configured_banned_words()),
|
|
516
|
+
lambda: unvalidated_claim_violation(card_text),
|
|
517
|
+
lambda: None
|
|
518
|
+
if mode_rules is None
|
|
519
|
+
else abbreviation_violation(prose_outside_links(card_text), mode_rules),
|
|
520
|
+
]
|
|
438
521
|
)
|
|
439
522
|
|
|
440
523
|
|
|
441
524
|
def reply_violation(
|
|
442
525
|
reply_text: str, transcript_path: object, mode_rules: VisualReplyRules | None
|
|
443
526
|
) -> str | None:
|
|
444
|
-
"""Return the deny reason for a reply's text, or None."""
|
|
445
|
-
|
|
446
|
-
length_violation(reply_text)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
527
|
+
"""Return the deny reason for each problem in a reply's text, or None."""
|
|
528
|
+
all_checks: list[Callable[[], str | None]] = [
|
|
529
|
+
lambda: length_violation(reply_text),
|
|
530
|
+
lambda: mid_sentence_colon_violation(reply_text),
|
|
531
|
+
lambda: em_dash_violation(reply_text),
|
|
532
|
+
lambda: unlinked_pull_request_violation(reply_text),
|
|
533
|
+
lambda: unsourced_cause_violation(reply_text),
|
|
534
|
+
lambda: banned_word_violation(reply_text, configured_banned_words()),
|
|
535
|
+
lambda: unvalidated_claim_violation(reply_text),
|
|
536
|
+
lambda: unseen_citation_violation(reply_text, transcript_path),
|
|
537
|
+
]
|
|
538
|
+
if mode_rules is not None:
|
|
539
|
+
all_checks.extend(
|
|
540
|
+
[
|
|
541
|
+
lambda: abbreviation_violation(prose_outside_links(reply_text), mode_rules),
|
|
542
|
+
lambda: second_reply_violation(transcript_path, mode_rules),
|
|
543
|
+
lambda: visual_violation(len(sentence_word_lists(reply_text)), transcript_path, mode_rules),
|
|
544
|
+
]
|
|
545
|
+
)
|
|
546
|
+
return collected_violations(all_checks)
|
|
463
547
|
|
|
464
548
|
|
|
465
549
|
def tool_violation(
|
|
@@ -467,26 +551,30 @@ def tool_violation(
|
|
|
467
551
|
) -> tuple[str, str] | None:
|
|
468
552
|
"""Return the deny reason and the checked text for one call, or None when it passes."""
|
|
469
553
|
mode_rules = active_mode_rules()
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if
|
|
473
|
-
|
|
554
|
+
anchor_violation = (
|
|
555
|
+
widget_anchor_violation(tool_name, all_tool_input)
|
|
556
|
+
if isinstance(tool_name, str) and mode_rules is not None
|
|
557
|
+
else None
|
|
558
|
+
)
|
|
559
|
+
if anchor_violation is None:
|
|
560
|
+
return _text_violation(tool_name, all_tool_input, transcript_path, mode_rules)
|
|
561
|
+
try:
|
|
562
|
+
checked = _text_violation(tool_name, all_tool_input, transcript_path, mode_rules)
|
|
563
|
+
except Exception:
|
|
564
|
+
checked = None
|
|
565
|
+
if checked is None:
|
|
566
|
+
return anchor_violation, str(all_tool_input)
|
|
567
|
+
return FINDING_SEPARATOR.join([anchor_violation, checked[0]]), checked[1]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _text_violation(
|
|
571
|
+
tool_name: object,
|
|
572
|
+
all_tool_input: dict[str, object],
|
|
573
|
+
transcript_path: object,
|
|
574
|
+
mode_rules: VisualReplyRules | None,
|
|
575
|
+
) -> tuple[str, str] | None:
|
|
474
576
|
if tool_name in (DECISION_CARD_TOOL_NAME, QUESTION_CARD_TOOL_NAME):
|
|
475
|
-
|
|
476
|
-
all_texts = all_card_texts(
|
|
477
|
-
all_tool_input, ALL_DECISION_CARD_PROSE_KEYS, ALL_DECISION_OPTION_PROSE_KEYS
|
|
478
|
-
)
|
|
479
|
-
shape_violation = card_length_violation(all_tool_input)
|
|
480
|
-
else:
|
|
481
|
-
all_texts = all_question_card_texts(all_tool_input)
|
|
482
|
-
shape_violation = None
|
|
483
|
-
card_text = CARD_TEXT_SEPARATOR.join(all_texts)
|
|
484
|
-
violation = (
|
|
485
|
-
shape_violation
|
|
486
|
-
or card_violation(card_text, mode_rules)
|
|
487
|
-
or unseen_citation_violation(card_text, transcript_path)
|
|
488
|
-
)
|
|
489
|
-
return None if violation is None else (violation, card_text)
|
|
577
|
+
return _card_text_violation(tool_name, all_tool_input, transcript_path, mode_rules)
|
|
490
578
|
if tool_name not in ALL_CHECKED_TOOL_NAMES:
|
|
491
579
|
return None
|
|
492
580
|
reply_text = all_tool_input.get(TEXT_KEY)
|
|
@@ -496,6 +584,29 @@ def tool_violation(
|
|
|
496
584
|
return None if violation is None else (violation, reply_text)
|
|
497
585
|
|
|
498
586
|
|
|
587
|
+
def _card_text_violation(
|
|
588
|
+
tool_name: object,
|
|
589
|
+
all_tool_input: dict[str, object],
|
|
590
|
+
transcript_path: object,
|
|
591
|
+
mode_rules: VisualReplyRules | None,
|
|
592
|
+
) -> tuple[str, str] | None:
|
|
593
|
+
if tool_name == DECISION_CARD_TOOL_NAME:
|
|
594
|
+
all_texts = all_card_texts(all_tool_input, ALL_DECISION_CARD_PROSE_KEYS, ALL_DECISION_OPTION_PROSE_KEYS)
|
|
595
|
+
shape_violation = card_length_violation(all_tool_input)
|
|
596
|
+
else:
|
|
597
|
+
all_texts = all_question_card_texts(all_tool_input)
|
|
598
|
+
shape_violation = None
|
|
599
|
+
card_text = CARD_TEXT_SEPARATOR.join(all_texts)
|
|
600
|
+
violation = collected_violations(
|
|
601
|
+
[
|
|
602
|
+
lambda: shape_violation,
|
|
603
|
+
lambda: card_violation(card_text, mode_rules),
|
|
604
|
+
lambda: unseen_citation_violation(card_text, transcript_path),
|
|
605
|
+
]
|
|
606
|
+
)
|
|
607
|
+
return None if violation is None else (violation, card_text)
|
|
608
|
+
|
|
609
|
+
|
|
499
610
|
def github_comment_texts(tool_name: object, all_tool_input: dict[str, object]) -> list[str]:
|
|
500
611
|
"""Return the GitHub comment texts one call would post, or an empty list.
|
|
501
612
|
|
|
@@ -569,15 +680,21 @@ def comment_length_violation(comment_text: str) -> str | None:
|
|
|
569
680
|
|
|
570
681
|
|
|
571
682
|
def github_comment_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
|
|
572
|
-
"""Return the deny reason and text for
|
|
683
|
+
"""Return the deny reason and text for every over-long GitHub comment, or None.
|
|
573
684
|
|
|
574
685
|
Fenced code blocks and collapsed ``<details>`` blocks carry no words.
|
|
575
686
|
"""
|
|
576
|
-
|
|
577
|
-
violation
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
687
|
+
all_over_long = [
|
|
688
|
+
(violation, each_comment_text)
|
|
689
|
+
for each_comment_text in github_comment_texts(tool_name, all_tool_input)
|
|
690
|
+
if (violation := comment_length_violation(each_comment_text)) is not None
|
|
691
|
+
]
|
|
692
|
+
if not all_over_long:
|
|
693
|
+
return None
|
|
694
|
+
return (
|
|
695
|
+
FINDING_SEPARATOR.join(each_violation for each_violation, _ in all_over_long),
|
|
696
|
+
CARD_TEXT_SEPARATOR.join(each_text for _, each_text in all_over_long),
|
|
697
|
+
)
|
|
581
698
|
|
|
582
699
|
|
|
583
700
|
def write_flowchart_advice(tool_name: object, all_tool_input: dict[str, object]) -> None:
|
|
@@ -607,7 +724,13 @@ def main() -> int:
|
|
|
607
724
|
write_flowchart_advice(tool_name, tool_input)
|
|
608
725
|
return ALLOW_EXIT_CODE
|
|
609
726
|
violation, reply_text = checked
|
|
610
|
-
|
|
727
|
+
numbered_violation = numbered_violations(violation)
|
|
728
|
+
instruction = RETRY_INSTRUCTION + FIX_EVERY_ITEM_INSTRUCTION
|
|
729
|
+
block_reason = (
|
|
730
|
+
numbered_violation + FINDING_SEPARATOR + instruction.lstrip()
|
|
731
|
+
if FINDING_SEPARATOR in numbered_violation
|
|
732
|
+
else numbered_violation + instruction
|
|
733
|
+
)
|
|
611
734
|
log_hook_block(
|
|
612
735
|
Path(__file__).name,
|
|
613
736
|
HOOK_EVENT_NAME,
|
|
@@ -1076,3 +1076,80 @@ def test_should_report_an_over_long_comment_text_with_the_comment_instruction()
|
|
|
1076
1076
|
def test_should_pass_a_comment_text_whose_long_part_sits_in_a_details_block() -> None:
|
|
1077
1077
|
comment_text = "Fixed.\n<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
|
|
1078
1078
|
assert reply_length_gate.comment_length_violation(comment_text) is None
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def test_joined_violations_should_put_each_violation_on_its_own_line() -> None:
|
|
1082
|
+
assert reply_length_gate.joined_violations(["Too long.", None, "Em dash.\nNo link."]) == (
|
|
1083
|
+
"Too long.\nEm dash.\nNo link."
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def test_numbered_violations_should_number_each_line_once() -> None:
|
|
1088
|
+
assert reply_length_gate.numbered_violations("Too long.\nEm dash.\nNo link.") == (
|
|
1089
|
+
"1. Too long.\n2. Em dash.\n3. No link."
|
|
1090
|
+
)
|
|
1091
|
+
assert reply_length_gate.numbered_violations("Too long.") == "Too long."
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def test_joined_violations_should_keep_one_violation_unchanged() -> None:
|
|
1095
|
+
assert reply_length_gate.joined_violations([None, "Too long."]) == "Too long."
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def test_collected_violations_should_keep_findings_made_before_a_check_raises() -> None:
|
|
1099
|
+
def broken_check() -> str | None:
|
|
1100
|
+
raise TypeError("malformed input")
|
|
1101
|
+
|
|
1102
|
+
assert reply_length_gate.collected_violations([lambda: "Too long.", broken_check]) == "Too long."
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def test_collected_violations_should_keep_findings_when_a_later_check_raises_any_error() -> None:
|
|
1106
|
+
def broken_check() -> str | None:
|
|
1107
|
+
raise RuntimeError("transcript unreadable")
|
|
1108
|
+
|
|
1109
|
+
assert reply_length_gate.collected_violations([lambda: "Too long.", broken_check]) == "Too long."
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def test_collected_violations_should_raise_when_a_check_breaks_before_any_finding() -> None:
|
|
1113
|
+
def broken_check() -> str | None:
|
|
1114
|
+
raise TypeError("malformed input")
|
|
1115
|
+
|
|
1116
|
+
with pytest.raises(TypeError):
|
|
1117
|
+
reply_length_gate.collected_violations([lambda: None, broken_check])
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def test_should_name_every_broken_rule_in_one_refusal(
|
|
1121
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
1122
|
+
) -> None:
|
|
1123
|
+
exit_code, error_text = run_gate(
|
|
1124
|
+
monkeypatch,
|
|
1125
|
+
capsys,
|
|
1126
|
+
POST_TOOL_NAME,
|
|
1127
|
+
{"text": f"See PR 7452 and PR 7460.\n{SIXTEEN_WORD_SENTENCE}\nIt failed — again."},
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
assert exit_code == reply_length_gate.BLOCK_EXIT_CODE
|
|
1131
|
+
assert "PR 7452" in error_text
|
|
1132
|
+
assert "PR 7460" in error_text
|
|
1133
|
+
assert "16 words" in error_text
|
|
1134
|
+
assert reply_length_gate.EM_DASH_MESSAGE in error_text
|
|
1135
|
+
assert error_text.endswith(reply_length_gate.FIX_EVERY_ITEM_INSTRUCTION)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def test_should_number_nested_violations_once_in_one_refusal(
|
|
1139
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
1140
|
+
) -> None:
|
|
1141
|
+
exit_code, error_text = run_gate(
|
|
1142
|
+
monkeypatch,
|
|
1143
|
+
capsys,
|
|
1144
|
+
POST_TOOL_NAME,
|
|
1145
|
+
{"text": f"One.\nTwo.\nThree.\n{SIXTEEN_WORD_SENTENCE}\n{SIXTEEN_WORD_SENTENCE}\nIt failed — again."},
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
assert exit_code == reply_length_gate.BLOCK_EXIT_CODE
|
|
1149
|
+
all_numbered_lines = [each_line for each_line in error_text.splitlines() if each_line[:1].isdigit()]
|
|
1150
|
+
assert [each_line.split(". ", 1)[0] for each_line in all_numbered_lines] == ["1", "2", "3", "4"]
|
|
1151
|
+
assert not any(each_line.split(". ", 1)[1][:1].isdigit() for each_line in all_numbered_lines)
|
|
1152
|
+
assert "Reply too long: 6 sentences" in all_numbered_lines[0]
|
|
1153
|
+
assert all_numbered_lines[1].count("16 words") == 1
|
|
1154
|
+
assert all_numbered_lines[2].count("16 words") == 1
|
|
1155
|
+
assert reply_length_gate.EM_DASH_MESSAGE in all_numbered_lines[3]
|
package/hooks/features/README.md
CHANGED
|
@@ -28,6 +28,6 @@ Each family page starts with a title and an agent-facing behavior summary. Its f
|
|
|
28
28
|
- [Conduct gates](./conduct-gates.md) covers chat replies, edit markers, step notes, checked claims, and the pull request lifecycle skill.
|
|
29
29
|
- [Session context](./session-context.md) covers skill reminders, startup guidance, and the auto mode denial quick fix.
|
|
30
30
|
- [Spawn routing](./spawn-routing.md) covers spawn readiness, pacing, and model selection.
|
|
31
|
-
- [Observability](./observability.md) covers instruction loads
|
|
31
|
+
- [Observability](./observability.md) covers instruction loads and edited files.
|
|
32
32
|
- [Read gates](./read-gates.md) covers the image size cap on Read.
|
|
33
33
|
- [Lifecycle cleanup](./lifecycle-cleanup.md) covers nested checkouts, worktree setup, and session cleanup.
|