claude-dev-env 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +12 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/bin/install.mjs +1 -1
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_config_field.py +321 -0
- package/hooks/blocking/code_rules_enforcer.py +14 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +15 -2
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verification_verdict_store.py +212 -0
- package/hooks/blocking/test_verified_commit_gate.py +159 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
- package/hooks/blocking/verification_verdict_store.py +240 -0
- package/hooks/blocking/verified_commit_gate.py +31 -9
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/orphan-css-class.md +23 -0
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
- package/skills/autoconverge/workflow/converge.mjs +392 -51
- package/skills/autoconverge/workflow/test_render_report.py +55 -0
- package/skills/doc-gist/SKILL.md +3 -2
- package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
- package/skills/doc-gist/references/examples/README.md +2 -2
- package/skills/task-build/SKILL.md +31 -0
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
import ast
|
|
16
16
|
import hashlib
|
|
17
17
|
import json
|
|
18
|
+
import re
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
20
21
|
import time
|
|
@@ -25,20 +26,35 @@ if blocking_directory not in sys.path:
|
|
|
25
26
|
sys.path.insert(0, blocking_directory)
|
|
26
27
|
|
|
27
28
|
from config.verified_commit_constants import (
|
|
29
|
+
AGENT_META_SIDECAR_SUFFIX,
|
|
30
|
+
AGENT_META_TYPE_KEY,
|
|
31
|
+
AGENT_TRANSCRIPT_GLOB,
|
|
28
32
|
CLAUDE_HOME_DIRECTORY_NAME,
|
|
29
33
|
CONFTEST_FILE_NAME,
|
|
30
34
|
DOCS_ONLY_EXTENSIONS,
|
|
31
35
|
ALL_FALLBACK_BASE_REFERENCES,
|
|
32
36
|
GIT_TIMEOUT_SECONDS,
|
|
37
|
+
MANIFEST_HASH_CLI_FLAG,
|
|
33
38
|
MINIMUM_STATUS_FIELD_COUNT,
|
|
39
|
+
MINTING_AGENT_TYPE,
|
|
34
40
|
PYTHON_EXTENSION,
|
|
35
41
|
ROOT_KEY_HEX_LENGTH,
|
|
42
|
+
SUBAGENTS_DIRECTORY_NAME,
|
|
36
43
|
TEST_FILE_PREFIX,
|
|
37
44
|
TEST_FILE_SUFFIX,
|
|
38
45
|
ALL_TOOLING_STATE_PREFIXES,
|
|
46
|
+
TRANSCRIPT_ASSISTANT_ENTRY_TYPE,
|
|
47
|
+
TRANSCRIPT_CONTENT_KEY,
|
|
48
|
+
TRANSCRIPT_CONTENT_TYPE_KEY,
|
|
49
|
+
TRANSCRIPT_ENTRY_TYPE_KEY,
|
|
50
|
+
TRANSCRIPT_MESSAGE_KEY,
|
|
51
|
+
TRANSCRIPT_TEXT_CONTENT_TYPE,
|
|
52
|
+
TRANSCRIPT_TEXT_KEY,
|
|
39
53
|
VERDICT_DIRECTORY_NAME,
|
|
54
|
+
VERDICT_FENCE_PATTERN,
|
|
40
55
|
VERDICT_JSON_INDENT,
|
|
41
56
|
VERDICT_KEY_ALL_PASS,
|
|
57
|
+
VERDICT_KEY_FINDINGS,
|
|
42
58
|
VERDICT_KEY_MANIFEST_SHA256,
|
|
43
59
|
)
|
|
44
60
|
|
|
@@ -273,6 +289,187 @@ def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict |
|
|
|
273
289
|
return verdict_record
|
|
274
290
|
|
|
275
291
|
|
|
292
|
+
def _subagents_directory_for_transcript(transcript_path: str) -> Path | None:
|
|
293
|
+
"""Locate the live session's subagents directory from a transcript path.
|
|
294
|
+
|
|
295
|
+
Handles both transcript shapes the runtime produces: a transcript already
|
|
296
|
+
inside a ``.../subagents/...`` tree resolves to its nearest ancestor named
|
|
297
|
+
``subagents``; a session transcript ``<dir>/<session-id>.jsonl`` resolves
|
|
298
|
+
to ``<dir>/<session-id>/subagents``.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
transcript_path: The live session's transcript path from the payload.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The existing subagents directory, or None when neither shape yields
|
|
305
|
+
an existing directory.
|
|
306
|
+
"""
|
|
307
|
+
if not transcript_path:
|
|
308
|
+
return None
|
|
309
|
+
transcript_file = Path(transcript_path)
|
|
310
|
+
for each_ancestor in transcript_file.parents:
|
|
311
|
+
if each_ancestor.name == SUBAGENTS_DIRECTORY_NAME and each_ancestor.is_dir():
|
|
312
|
+
return each_ancestor
|
|
313
|
+
session_subagents_directory = (
|
|
314
|
+
transcript_file.with_suffix("") / SUBAGENTS_DIRECTORY_NAME
|
|
315
|
+
)
|
|
316
|
+
if session_subagents_directory.is_dir():
|
|
317
|
+
return session_subagents_directory
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _agent_type_for_transcript(transcript_file: Path) -> str | None:
|
|
322
|
+
"""Read an agent transcript's sidecar to learn the agent type it ran as.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
transcript_file: An ``agent-*.jsonl`` transcript path.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
The ``agentType`` recorded in the ``<stem>.meta.json`` sidecar, or
|
|
329
|
+
None when the sidecar is missing, unreadable, or carries no type.
|
|
330
|
+
"""
|
|
331
|
+
sidecar_file = transcript_file.with_suffix(AGENT_META_SIDECAR_SUFFIX)
|
|
332
|
+
try:
|
|
333
|
+
sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
|
|
334
|
+
except (OSError, json.JSONDecodeError):
|
|
335
|
+
return None
|
|
336
|
+
if not isinstance(sidecar_record, dict):
|
|
337
|
+
return None
|
|
338
|
+
recorded_agent_type = sidecar_record.get(AGENT_META_TYPE_KEY)
|
|
339
|
+
return recorded_agent_type if isinstance(recorded_agent_type, str) else None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _assistant_text_blocks(transcript_file: Path) -> list[str]:
|
|
343
|
+
"""Collect every assistant text block from an agent transcript.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
transcript_file: An ``agent-*.jsonl`` transcript path.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
The text of each assistant message content block, in order; empty
|
|
350
|
+
when the file is missing, unreadable, or holds no assistant text.
|
|
351
|
+
"""
|
|
352
|
+
try:
|
|
353
|
+
transcript_lines = transcript_file.read_text(encoding="utf-8").splitlines()
|
|
354
|
+
except OSError:
|
|
355
|
+
return []
|
|
356
|
+
all_text_blocks: list[str] = []
|
|
357
|
+
for each_line in transcript_lines:
|
|
358
|
+
if not each_line.strip():
|
|
359
|
+
continue
|
|
360
|
+
try:
|
|
361
|
+
transcript_entry = json.loads(each_line)
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
continue
|
|
364
|
+
all_text_blocks.extend(_entry_text_blocks(transcript_entry))
|
|
365
|
+
return all_text_blocks
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _entry_text_blocks(transcript_entry: object) -> list[str]:
|
|
369
|
+
"""Extract assistant text from one parsed transcript entry.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
transcript_entry: One parsed JSONL transcript entry.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The text of each text content block on an assistant entry, in order;
|
|
376
|
+
empty for any other entry shape.
|
|
377
|
+
"""
|
|
378
|
+
if not isinstance(transcript_entry, dict):
|
|
379
|
+
return []
|
|
380
|
+
if transcript_entry.get(TRANSCRIPT_ENTRY_TYPE_KEY) != TRANSCRIPT_ASSISTANT_ENTRY_TYPE:
|
|
381
|
+
return []
|
|
382
|
+
message_record = transcript_entry.get(TRANSCRIPT_MESSAGE_KEY)
|
|
383
|
+
if not isinstance(message_record, dict):
|
|
384
|
+
return []
|
|
385
|
+
content_blocks = message_record.get(TRANSCRIPT_CONTENT_KEY)
|
|
386
|
+
if not isinstance(content_blocks, list):
|
|
387
|
+
return []
|
|
388
|
+
all_text_blocks: list[str] = []
|
|
389
|
+
for each_block in content_blocks:
|
|
390
|
+
if not isinstance(each_block, dict):
|
|
391
|
+
continue
|
|
392
|
+
if each_block.get(TRANSCRIPT_CONTENT_TYPE_KEY) != TRANSCRIPT_TEXT_CONTENT_TYPE:
|
|
393
|
+
continue
|
|
394
|
+
block_text = each_block.get(TRANSCRIPT_TEXT_KEY)
|
|
395
|
+
if isinstance(block_text, str):
|
|
396
|
+
all_text_blocks.append(block_text)
|
|
397
|
+
return all_text_blocks
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _last_verdict_record(all_text_blocks: list[str]) -> dict | None:
|
|
401
|
+
"""Parse the last verdict fence across an agent's assistant text blocks.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
all_text_blocks: The assistant text blocks from one transcript.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
The parsed verdict mapping when the last verdict fence carries a bool
|
|
408
|
+
``all_pass``, a list ``findings``, and a string ``manifest_sha256``;
|
|
409
|
+
otherwise None.
|
|
410
|
+
"""
|
|
411
|
+
verdict_fence_pattern = re.compile(VERDICT_FENCE_PATTERN, re.DOTALL)
|
|
412
|
+
all_fence_bodies = [
|
|
413
|
+
each_match.group(1)
|
|
414
|
+
for each_block in all_text_blocks
|
|
415
|
+
for each_match in verdict_fence_pattern.finditer(each_block)
|
|
416
|
+
]
|
|
417
|
+
if not all_fence_bodies:
|
|
418
|
+
return None
|
|
419
|
+
try:
|
|
420
|
+
verdict_record = json.loads(all_fence_bodies[-1])
|
|
421
|
+
except json.JSONDecodeError:
|
|
422
|
+
return None
|
|
423
|
+
if not isinstance(verdict_record, dict):
|
|
424
|
+
return None
|
|
425
|
+
if not isinstance(verdict_record.get(VERDICT_KEY_ALL_PASS), bool):
|
|
426
|
+
return None
|
|
427
|
+
if not isinstance(verdict_record.get(VERDICT_KEY_FINDINGS), list):
|
|
428
|
+
return None
|
|
429
|
+
if not isinstance(verdict_record.get(VERDICT_KEY_MANIFEST_SHA256), str):
|
|
430
|
+
return None
|
|
431
|
+
return verdict_record
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def workflow_verdict_covers_surface(
|
|
435
|
+
transcript_path: str, expected_manifest_sha256: str
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""Decide whether a workflow code-verifier verdict covers the live surface.
|
|
438
|
+
|
|
439
|
+
A workflow-spawned ``code-verifier`` emits its verdict as assistant text in
|
|
440
|
+
its own transcript rather than through the SubagentStop minter, so this
|
|
441
|
+
walks the live session's subagent transcripts for a ``code-verifier`` whose
|
|
442
|
+
final verdict reports ``all_pass`` true and binds to the expected manifest
|
|
443
|
+
hash.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
transcript_path: The live session's transcript path from the payload.
|
|
447
|
+
expected_manifest_sha256: Hash of the live surface manifest the verdict
|
|
448
|
+
must match exactly.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
True as soon as one ``code-verifier`` transcript carries a passing
|
|
452
|
+
verdict bound to the expected hash; False when none match or the
|
|
453
|
+
subagents directory cannot be located.
|
|
454
|
+
"""
|
|
455
|
+
subagents_directory = _subagents_directory_for_transcript(transcript_path)
|
|
456
|
+
if subagents_directory is None:
|
|
457
|
+
return False
|
|
458
|
+
for each_transcript_file in subagents_directory.rglob(AGENT_TRANSCRIPT_GLOB):
|
|
459
|
+
if _agent_type_for_transcript(each_transcript_file) != MINTING_AGENT_TYPE:
|
|
460
|
+
continue
|
|
461
|
+
verdict_record = _last_verdict_record(
|
|
462
|
+
_assistant_text_blocks(each_transcript_file)
|
|
463
|
+
)
|
|
464
|
+
if verdict_record is None:
|
|
465
|
+
continue
|
|
466
|
+
if verdict_record[VERDICT_KEY_ALL_PASS] is not True:
|
|
467
|
+
continue
|
|
468
|
+
if verdict_record[VERDICT_KEY_MANIFEST_SHA256] == expected_manifest_sha256:
|
|
469
|
+
return True
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
|
|
276
473
|
def write_verdict(
|
|
277
474
|
repo_root: str,
|
|
278
475
|
bound_manifest_sha256: str,
|
|
@@ -444,3 +641,46 @@ def is_verification_exempt_diff(repo_root: str, merge_base_sha: str) -> bool:
|
|
|
444
641
|
if not _is_python_change_docstring_only(repo_root, merge_base_sha, changed_path):
|
|
445
642
|
return False
|
|
446
643
|
return True
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _print_live_manifest_hash(repo_directory: str) -> int:
|
|
647
|
+
"""Print the live surface manifest hash for a repo, for a workflow verifier.
|
|
648
|
+
|
|
649
|
+
A workflow code-verifier runs this to learn the exact hash to bind its
|
|
650
|
+
verdict to, so stdout carries only the hash and nothing else.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
repo_directory: A directory inside the work tree to bind the verdict to.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
0 after printing the hash; nonzero with no stdout when the repo root or
|
|
657
|
+
merge base cannot be resolved.
|
|
658
|
+
"""
|
|
659
|
+
repo_root = resolve_repo_root(repo_directory)
|
|
660
|
+
if repo_root is None:
|
|
661
|
+
return 1
|
|
662
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
663
|
+
if merge_base_sha is None:
|
|
664
|
+
return 1
|
|
665
|
+
surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
|
|
666
|
+
if surface_manifest_text is None:
|
|
667
|
+
return 1
|
|
668
|
+
print(manifest_sha256(surface_manifest_text))
|
|
669
|
+
return 0
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def main() -> None:
|
|
673
|
+
"""Run the verdict-store CLI: compute the live surface-manifest hash.
|
|
674
|
+
|
|
675
|
+
Reads ``--manifest-hash <repo_root>`` from argv and prints the live
|
|
676
|
+
``manifest_sha256`` so a workflow code-verifier can bind its verdict to the
|
|
677
|
+
exact surface the gate checks. Exits nonzero with no stdout on any other
|
|
678
|
+
argument shape or when the surface cannot be resolved.
|
|
679
|
+
"""
|
|
680
|
+
if len(sys.argv) == 3 and sys.argv[1] == MANIFEST_HASH_CLI_FLAG:
|
|
681
|
+
sys.exit(_print_live_manifest_hash(sys.argv[2]))
|
|
682
|
+
sys.exit(1)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
if __name__ == "__main__":
|
|
686
|
+
main()
|
|
@@ -5,6 +5,8 @@ Fires on Bash and PowerShell tool calls. When the command carries a
|
|
|
5
5
|
targets, computes the live change-surface manifest against the merge base,
|
|
6
6
|
and allows the command only when one of these holds:
|
|
7
7
|
|
|
8
|
+
- the command carries the verification bypass marker (``# verify-skip``),
|
|
9
|
+
a manual on-the-fly override that skips the gate for that one command,
|
|
8
10
|
- the repository has no resolvable upstream base — no ``origin/HEAD``, no
|
|
9
11
|
configured tracking ref, and neither ``origin/main`` nor ``origin/master``
|
|
10
12
|
(scratch repos with no remote branch are out of scope),
|
|
@@ -47,6 +49,7 @@ from config.verified_commit_constants import (
|
|
|
47
49
|
OPTION_WITH_VALUE_STEP,
|
|
48
50
|
REPO_DIRECTORY_OPTION,
|
|
49
51
|
VALUE_TAKING_GIT_OPTIONS,
|
|
52
|
+
VERIFICATION_BYPASS_MARKER,
|
|
50
53
|
WORK_TREE_OPTION,
|
|
51
54
|
)
|
|
52
55
|
from verification_verdict_store import (
|
|
@@ -56,6 +59,7 @@ from verification_verdict_store import (
|
|
|
56
59
|
manifest_sha256,
|
|
57
60
|
resolve_merge_base,
|
|
58
61
|
resolve_repo_root,
|
|
62
|
+
workflow_verdict_covers_surface,
|
|
59
63
|
)
|
|
60
64
|
|
|
61
65
|
|
|
@@ -464,15 +468,23 @@ def gated_repo_directories(command_text: str, fallback_directory: str) -> list[s
|
|
|
464
468
|
return target_directories
|
|
465
469
|
|
|
466
470
|
|
|
467
|
-
def deny_reason_for_directory(target_directory: str) -> str | None:
|
|
471
|
+
def deny_reason_for_directory(target_directory: str, transcript_path: str) -> str | None:
|
|
468
472
|
"""Decide whether a commit/push in a directory must be blocked.
|
|
469
473
|
|
|
474
|
+
Accepts the command when a minted verdict binds to the live surface, or
|
|
475
|
+
when a workflow-spawned code-verifier emitted a passing verdict bound to
|
|
476
|
+
the same surface in its own transcript — the latter covers workflow runs,
|
|
477
|
+
where SubagentStop never fires to mint a verdict file.
|
|
478
|
+
|
|
470
479
|
Args:
|
|
471
480
|
target_directory: The directory the git command targets.
|
|
481
|
+
transcript_path: The live session's transcript path from the payload,
|
|
482
|
+
used to find a workflow code-verifier's verdict.
|
|
472
483
|
|
|
473
484
|
Returns:
|
|
474
|
-
The deny reason when the branch diff needs a verdict and
|
|
475
|
-
to it; None when the command may
|
|
485
|
+
The deny reason when the branch diff needs a verdict and neither a
|
|
486
|
+
minted nor a workflow verdict binds to it; None when the command may
|
|
487
|
+
proceed.
|
|
476
488
|
"""
|
|
477
489
|
repo_root = resolve_repo_root(target_directory)
|
|
478
490
|
if repo_root is None:
|
|
@@ -486,14 +498,21 @@ def deny_reason_for_directory(target_directory: str) -> str | None:
|
|
|
486
498
|
if surface_manifest_text is None:
|
|
487
499
|
return f"{CORRECTIVE_MESSAGE} (surface manifest failed in {repo_root})"
|
|
488
500
|
live_manifest_sha256 = manifest_sha256(surface_manifest_text)
|
|
489
|
-
if load_valid_verdict(repo_root, live_manifest_sha256) is None:
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
501
|
+
if load_valid_verdict(repo_root, live_manifest_sha256) is not None:
|
|
502
|
+
return None
|
|
503
|
+
if workflow_verdict_covers_surface(transcript_path, live_manifest_sha256):
|
|
504
|
+
return None
|
|
505
|
+
hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
|
|
506
|
+
return f"{CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
|
|
493
507
|
|
|
494
508
|
|
|
495
509
|
def main() -> None:
|
|
496
|
-
"""Read the PreToolUse payload and
|
|
510
|
+
"""Read the PreToolUse payload and decide whether to allow the command.
|
|
511
|
+
|
|
512
|
+
Allows the command without a verdict when it carries the verification
|
|
513
|
+
bypass marker (``VERIFICATION_BYPASS_MARKER``), a manual on-the-fly
|
|
514
|
+
override; otherwise denies an unverified commit or push.
|
|
515
|
+
"""
|
|
497
516
|
try:
|
|
498
517
|
pretooluse_payload = json.load(sys.stdin)
|
|
499
518
|
except json.JSONDecodeError:
|
|
@@ -503,9 +522,12 @@ def main() -> None:
|
|
|
503
522
|
command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
|
|
504
523
|
if not command_text:
|
|
505
524
|
return
|
|
525
|
+
if VERIFICATION_BYPASS_MARKER in command_text:
|
|
526
|
+
return
|
|
506
527
|
session_directory = pretooluse_payload.get("cwd", ".")
|
|
528
|
+
transcript_path = pretooluse_payload.get("transcript_path", "")
|
|
507
529
|
for each_target_directory in gated_repo_directories(command_text, session_directory):
|
|
508
|
-
deny_reason = deny_reason_for_directory(each_target_directory)
|
|
530
|
+
deny_reason = deny_reason_for_directory(each_target_directory, transcript_path)
|
|
509
531
|
if deny_reason is None:
|
|
510
532
|
continue
|
|
511
533
|
deny_payload = {
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Only this hook writes verdict files — the main session is denied writes to
|
|
4
4
|
the verdict directory, so a session cannot fabricate a passing verdict. The
|
|
5
|
-
SubagentStop payload names the stopping subagent
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
SubagentStop payload names the stopping subagent's own transcript
|
|
6
|
+
(``agent_transcript_path``), which sits beside a harness-written
|
|
7
|
+
``agent-<id>.meta.json`` sidecar naming the spawning ``agentType``. The hook
|
|
8
|
+
reads that type from the sidecar, so it resolves identically in interactive,
|
|
9
|
+
background, and worktree-switched sessions. When that type is
|
|
9
10
|
``code-verifier``, the hook pulls the verdict block out of the agent's own
|
|
10
|
-
transcript
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
``git push`` only while the work tree still matches the verified state.
|
|
11
|
+
transcript (``agent_transcript_path``); the main session writes neither that
|
|
12
|
+
transcript nor the sidecar, so text it prints can never mint — recomputes the
|
|
13
|
+
live change-surface hash for the session repository, and writes the verdict
|
|
14
|
+
bound to that hash. The companion ``verified_commit_gate.py`` (PreToolUse)
|
|
15
|
+
then allows ``git commit`` / ``git push`` only while the work tree still
|
|
16
|
+
matches the verified state.
|
|
17
17
|
|
|
18
18
|
The verifier's final message must end with a fenced block::
|
|
19
19
|
|
|
@@ -29,18 +29,13 @@ from __future__ import annotations
|
|
|
29
29
|
import json
|
|
30
30
|
import re
|
|
31
31
|
import sys
|
|
32
|
-
import time
|
|
33
32
|
from pathlib import Path
|
|
34
33
|
|
|
35
34
|
blocking_directory = str(Path(__file__).resolve().parent)
|
|
36
35
|
if blocking_directory not in sys.path:
|
|
37
36
|
sys.path.insert(0, blocking_directory)
|
|
38
37
|
|
|
39
|
-
from config.verified_commit_constants import
|
|
40
|
-
MINTING_AGENT_TYPE,
|
|
41
|
-
SPAWN_LOOKUP_ATTEMPT_COUNT,
|
|
42
|
-
SPAWN_LOOKUP_RETRY_DELAY_SECONDS,
|
|
43
|
-
)
|
|
38
|
+
from config.verified_commit_constants import MINTING_AGENT_TYPE
|
|
44
39
|
from verification_verdict_store import (
|
|
45
40
|
branch_surface_manifest,
|
|
46
41
|
manifest_sha256,
|
|
@@ -119,131 +114,58 @@ def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
|
|
|
119
114
|
return None
|
|
120
115
|
|
|
121
116
|
|
|
122
|
-
def
|
|
123
|
-
"""
|
|
117
|
+
def _agent_type_from_meta_sidecar(agent_transcript_path: str) -> str | None:
|
|
118
|
+
"""Read the spawning agentType from a subagent transcript's sidecar.
|
|
124
119
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
file is missing or holds no object lines.
|
|
131
|
-
"""
|
|
132
|
-
parsed_entries: list[dict] = []
|
|
133
|
-
try:
|
|
134
|
-
transcript_lines = (
|
|
135
|
-
Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
136
|
-
)
|
|
137
|
-
except OSError:
|
|
138
|
-
return parsed_entries
|
|
139
|
-
for each_line in transcript_lines:
|
|
140
|
-
try:
|
|
141
|
-
transcript_entry = json.loads(each_line)
|
|
142
|
-
except json.JSONDecodeError:
|
|
143
|
-
continue
|
|
144
|
-
if isinstance(transcript_entry, dict):
|
|
145
|
-
parsed_entries.append(transcript_entry)
|
|
146
|
-
return parsed_entries
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _agent_type_in_node(transcript_node: object, agent_id: str) -> str | None:
|
|
150
|
-
"""Search one parsed transcript value for a spawn record naming an agent.
|
|
151
|
-
|
|
152
|
-
Walks a transcript value and its nested mappings and sequences for a
|
|
153
|
-
mapping whose ``agentId`` equals the stopping agent and whose
|
|
154
|
-
``agentType`` is a string. Only a structured ``agentType`` key counts, so
|
|
155
|
-
a main-session text block that merely quotes the words cannot match.
|
|
120
|
+
Each subagent transcript ``agent-<id>.jsonl`` sits beside a harness-written
|
|
121
|
+
``agent-<id>.meta.json`` naming the spawning ``agentType``. Reading the type
|
|
122
|
+
from this sidecar binds it to the stopping subagent itself, so it resolves
|
|
123
|
+
identically in interactive, background, and worktree-switched sessions and
|
|
124
|
+
needs no parent-transcript scan or flush retry.
|
|
156
125
|
|
|
157
126
|
Args:
|
|
158
|
-
|
|
159
|
-
|
|
127
|
+
agent_transcript_path: The stopping subagent's own transcript path from
|
|
128
|
+
the SubagentStop payload.
|
|
160
129
|
|
|
161
130
|
Returns:
|
|
162
|
-
The ``agentType
|
|
163
|
-
|
|
131
|
+
The recorded ``agentType``, or None when the path is empty, the sidecar
|
|
132
|
+
is absent or cannot be read or parsed, it does not hold a JSON object,
|
|
133
|
+
or it names no string ``agentType``.
|
|
164
134
|
"""
|
|
165
|
-
if
|
|
166
|
-
recorded_type = transcript_node.get("agentType")
|
|
167
|
-
if transcript_node.get("agentId") == agent_id and isinstance(recorded_type, str):
|
|
168
|
-
return recorded_type
|
|
169
|
-
for each_value in transcript_node.values():
|
|
170
|
-
nested_type = _agent_type_in_node(each_value, agent_id)
|
|
171
|
-
if nested_type is not None:
|
|
172
|
-
return nested_type
|
|
135
|
+
if not agent_transcript_path:
|
|
173
136
|
return None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
Args:
|
|
186
|
-
all_entries: Parsed parent-transcript entries.
|
|
187
|
-
agent_id: The stopping subagent's id from the payload.
|
|
188
|
-
|
|
189
|
-
Returns:
|
|
190
|
-
The ``agentType`` recorded for the agent, or None when no entry's
|
|
191
|
-
spawn record names it.
|
|
192
|
-
"""
|
|
193
|
-
for each_entry in all_entries:
|
|
194
|
-
recorded_type = _agent_type_in_node(each_entry, agent_id)
|
|
195
|
-
if recorded_type is not None:
|
|
196
|
-
return recorded_type
|
|
197
|
-
return None
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _resolve_agent_type_with_retry(transcript_path: str, agent_id: str) -> str | None:
|
|
201
|
-
"""Read the parent transcript and resolve the agent's type, with retry.
|
|
202
|
-
|
|
203
|
-
The agent's completion record is not reliably flushed to the parent
|
|
204
|
-
transcript at the instant SubagentStop fires, so a single read can miss it
|
|
205
|
-
and silently mint nothing. Each attempt re-reads the transcript; a bounded
|
|
206
|
-
sleep separates attempts so a late-arriving record resolves on a later read.
|
|
207
|
-
|
|
208
|
-
Args:
|
|
209
|
-
transcript_path: Path to the parent session transcript.
|
|
210
|
-
agent_id: The stopping subagent's id from the payload.
|
|
211
|
-
|
|
212
|
-
Returns:
|
|
213
|
-
The recorded ``agentType``, or None when no attempt finds the spawn
|
|
214
|
-
record naming this agent.
|
|
215
|
-
"""
|
|
216
|
-
for each_attempt_index in range(SPAWN_LOOKUP_ATTEMPT_COUNT):
|
|
217
|
-
all_entries = _transcript_entries(transcript_path)
|
|
218
|
-
recorded_type = _agent_type_from_entries(all_entries, agent_id)
|
|
219
|
-
if recorded_type is not None:
|
|
220
|
-
return recorded_type
|
|
221
|
-
if each_attempt_index < SPAWN_LOOKUP_ATTEMPT_COUNT - 1:
|
|
222
|
-
time.sleep(SPAWN_LOOKUP_RETRY_DELAY_SECONDS)
|
|
223
|
-
return None
|
|
137
|
+
transcript_file = Path(agent_transcript_path)
|
|
138
|
+
sidecar_file = transcript_file.with_name(f"{transcript_file.stem}.meta.json")
|
|
139
|
+
try:
|
|
140
|
+
sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(sidecar_record, dict):
|
|
144
|
+
return None
|
|
145
|
+
recorded_type = sidecar_record.get("agentType")
|
|
146
|
+
return recorded_type if isinstance(recorded_type, str) else None
|
|
224
147
|
|
|
225
148
|
|
|
226
149
|
def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
|
|
227
150
|
"""Recover the spawning agent type for a SubagentStop payload.
|
|
228
151
|
|
|
229
|
-
The
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
152
|
+
The stopping subagent's own transcript (``agent_transcript_path``) sits
|
|
153
|
+
beside a harness-written ``agent-<id>.meta.json`` sidecar naming its
|
|
154
|
+
``agentType``. Reading the type from that sidecar binds it to the subagent
|
|
155
|
+
itself, so it resolves the same across interactive, background, and
|
|
156
|
+
worktree-switched sessions.
|
|
234
157
|
|
|
235
158
|
Args:
|
|
236
159
|
subagent_stop_payload: The SubagentStop hook payload.
|
|
237
160
|
|
|
238
161
|
Returns:
|
|
239
|
-
The agent type this subagent was spawned with, or None when the
|
|
240
|
-
|
|
162
|
+
The agent type this subagent was spawned with, or None when the
|
|
163
|
+
``agent_transcript_path`` is empty, the sidecar is absent or cannot be
|
|
164
|
+
read or parsed, it does not hold a JSON object, or it names no string
|
|
165
|
+
``agentType``.
|
|
241
166
|
"""
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return None
|
|
245
|
-
return _resolve_agent_type_with_retry(
|
|
246
|
-
subagent_stop_payload.get("transcript_path", ""), agent_id
|
|
167
|
+
return _agent_type_from_meta_sidecar(
|
|
168
|
+
subagent_stop_payload.get("agent_transcript_path", "")
|
|
247
169
|
)
|
|
248
170
|
|
|
249
171
|
|
|
@@ -124,6 +124,12 @@ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX: str = (
|
|
|
124
124
|
"(CODE_RULES §6; pytest builtin fixture reference "
|
|
125
125
|
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
126
126
|
)
|
|
127
|
+
UNUSED_PYTEST_FIXTURE_PARAMETER_MESSAGE_SUFFIX: str = (
|
|
128
|
+
"known pytest fixture parameter is declared but never referenced in the "
|
|
129
|
+
"function body; pytest still materializes its setup, so drop the unused "
|
|
130
|
+
"parameter (pytest builtin fixture reference "
|
|
131
|
+
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
132
|
+
)
|
|
127
133
|
ALL_LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
|
|
128
134
|
EACH_PREFIX = "each_"
|
|
129
135
|
BARE_EACH_TOKEN = "each"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Constants for the dead config-dataclass field detector in ``code_rules_enforcer``.
|
|
2
|
+
|
|
3
|
+
Lives under the hooks-tree ``hooks_constants`` package so module-level
|
|
4
|
+
UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
|
|
5
|
+
requirement and share a home with the other hook-tree configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from hooks_constants.dead_dataclass_field_constants import (
|
|
9
|
+
ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
|
|
10
|
+
WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
|
|
11
|
+
)
|
|
12
|
+
from hooks_constants.dead_module_constant_constants import (
|
|
13
|
+
CONFIG_DIRECTORY_SEGMENT,
|
|
14
|
+
DUNDER_INIT_FILENAME,
|
|
15
|
+
MAX_SCAN_ROOT_FILE_COUNT,
|
|
16
|
+
PYTHON_SOURCE_SUFFIX,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
CONFIG_CLASS_NAME_SUFFIX: str = "Config"
|
|
20
|
+
DATACLASSES_MODULE_NAME: str = "dataclasses"
|
|
21
|
+
MAX_DEAD_CONFIG_FIELD_ISSUES: int = 25
|
|
22
|
+
DEAD_CONFIG_FIELD_GUIDANCE: str = (
|
|
23
|
+
"config dataclass field is defined but read by no production module in the"
|
|
24
|
+
" enclosing package tree - remove the dead field, or read it where the value"
|
|
25
|
+
" is needed (CODE_RULES §9.8)"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ALL_REFLECTIVE_FIELD_CONSUMER_NAMES",
|
|
30
|
+
"CONFIG_CLASS_NAME_SUFFIX",
|
|
31
|
+
"CONFIG_DIRECTORY_SEGMENT",
|
|
32
|
+
"DATACLASSES_MODULE_NAME",
|
|
33
|
+
"DEAD_CONFIG_FIELD_GUIDANCE",
|
|
34
|
+
"DUNDER_INIT_FILENAME",
|
|
35
|
+
"MAX_DEAD_CONFIG_FIELD_ISSUES",
|
|
36
|
+
"MAX_SCAN_ROOT_FILE_COUNT",
|
|
37
|
+
"PYTHON_SOURCE_SUFFIX",
|
|
38
|
+
"WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME",
|
|
39
|
+
]
|
|
@@ -41,6 +41,7 @@ ALL_INTERPRETER_AND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
|
41
41
|
"su",
|
|
42
42
|
"env",
|
|
43
43
|
"xargs",
|
|
44
|
+
"parallel",
|
|
44
45
|
"awk",
|
|
45
46
|
"gawk",
|
|
46
47
|
"mawk",
|
|
@@ -66,6 +67,12 @@ ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS: frozenset[str] = frozenset(
|
|
|
66
67
|
}
|
|
67
68
|
)
|
|
68
69
|
ALL_STRING_ARGUMENT_EXECUTION_FLAGS: frozenset[str] = frozenset({"-c", "-e"})
|
|
70
|
+
FIND_PROGRAM_NAME: str = "find"
|
|
71
|
+
ALL_FIND_EXEC_ACTION_FLAGS: frozenset[str] = frozenset({"-exec", "-execdir"})
|
|
72
|
+
ALL_FIND_EXEC_ACTION_TERMINATORS: frozenset[str] = frozenset({";", "+"})
|
|
73
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE: frozenset[str] = frozenset({"-H", "-L", "-P"})
|
|
74
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE: frozenset[str] = frozenset({"-D"})
|
|
75
|
+
FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX: str = "-O"
|
|
69
76
|
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS: frozenset[str] = frozenset(
|
|
70
77
|
{
|
|
71
78
|
"echo",
|
|
@@ -176,3 +183,11 @@ ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
|
|
|
176
183
|
}
|
|
177
184
|
)
|
|
178
185
|
ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
|
|
186
|
+
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
187
|
+
{
|
|
188
|
+
"TEMP",
|
|
189
|
+
"TMP",
|
|
190
|
+
"TMPDIR",
|
|
191
|
+
"CLAUDE_JOB_DIR",
|
|
192
|
+
}
|
|
193
|
+
)
|