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.
Files changed (41) hide show
  1. package/CLAUDE.md +12 -0
  2. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  4. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  5. package/bin/install.mjs +1 -1
  6. package/docs/CODE_RULES.md +2 -2
  7. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  8. package/hooks/blocking/code_rules_dead_config_field.py +321 -0
  9. package/hooks/blocking/code_rules_enforcer.py +14 -0
  10. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  11. package/hooks/blocking/config/verified_commit_constants.py +15 -2
  12. package/hooks/blocking/destructive_command_blocker.py +483 -61
  13. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  14. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  15. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
  16. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  18. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  19. package/hooks/blocking/test_verification_verdict_store.py +212 -0
  20. package/hooks/blocking/test_verified_commit_gate.py +159 -0
  21. package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
  22. package/hooks/blocking/verification_verdict_store.py +240 -0
  23. package/hooks/blocking/verified_commit_gate.py +31 -9
  24. package/hooks/blocking/verifier_verdict_minter.py +46 -124
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  26. package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
  27. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  28. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  29. package/hooks/validation/mypy_validator.py +59 -7
  30. package/hooks/validation/test_mypy_validator.py +94 -0
  31. package/package.json +1 -1
  32. package/rules/orphan-css-class.md +23 -0
  33. package/skills/autoconverge/reference/gotchas.md +11 -0
  34. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
  35. package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
  36. package/skills/autoconverge/workflow/converge.mjs +392 -51
  37. package/skills/autoconverge/workflow/test_render_report.py +55 -0
  38. package/skills/doc-gist/SKILL.md +3 -2
  39. package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
  40. package/skills/doc-gist/references/examples/README.md +2 -2
  41. 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 none binds
475
- to it; None when the command may proceed.
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
- hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
491
- return f"{CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
492
- return None
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 deny unverified commit/push commands."""
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 by ``agent_id``. The hook
6
- recovers the spawning agent type from the parent transcript
7
- (``transcript_path``), where the agent's completion record carries its
8
- identity as sibling ``agentId`` and ``agentType`` keys. When that type is
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 the payload key ``agent_transcript_path``; the parent
11
- ``transcript_path`` supplies only the spawning type and never the verdict, so
12
- text printed by the main session can never mint — recomputes the live
13
- change-surface hash for the session
14
- repository, and writes the verdict bound to that hash. The companion
15
- ``verified_commit_gate.py`` (PreToolUse) then allows ``git commit`` /
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 _transcript_entries(transcript_path: str) -> list[dict]:
123
- """Parse every JSON object line of a transcript file.
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
- Args:
126
- transcript_path: Path to the parent session transcript.
127
-
128
- Returns:
129
- Each parseable object entry in transcript order; empty when the
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
- transcript_node: A JSON value drawn from a parsed transcript entry.
159
- agent_id: The stopping subagent's id from the payload.
127
+ agent_transcript_path: The stopping subagent's own transcript path from
128
+ the SubagentStop payload.
160
129
 
161
130
  Returns:
162
- The ``agentType`` of the matching mapping, or None when no nested
163
- value names this agent.
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 isinstance(transcript_node, dict):
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
- if isinstance(transcript_node, list):
175
- for each_item in transcript_node:
176
- nested_type = _agent_type_in_node(each_item, agent_id)
177
- if nested_type is not None:
178
- return nested_type
179
- return None
180
-
181
-
182
- def _agent_type_from_entries(all_entries: list[dict], agent_id: str) -> str | None:
183
- """Find the spawn record naming an agent across parent-transcript entries.
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 payload names the stopping subagent by ``agent_id``. Its spawn type
230
- lives on the agent's completion record in the parent transcript, attached
231
- as sibling ``agentId`` and ``agentType`` keys, so the type is read from
232
- that record. The read retries because the record may not be flushed at the
233
- instant the hook fires.
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 agent
240
- id is absent or no spawn record names its type.
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
- agent_id = subagent_stop_payload.get("agent_id", "")
243
- if not agent_id:
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
+ )