claude-dev-env 2.7.1 → 2.8.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 (85) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/process-tree/CLAUDE.md +41 -0
  6. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  8. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  9. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  10. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  11. package/agents/code-quality-agent.md +6 -5
  12. package/agents/deep-research.md +7 -24
  13. package/agents/docs-agent.md +1 -27
  14. package/agents/issue-tracker.md +1 -7
  15. package/agents/skill-writer-agent.md +1 -2
  16. package/agents/test_agent_frontmatter.py +309 -12
  17. package/hooks/blocking/CLAUDE.md +2 -0
  18. package/hooks/blocking/fable_spawn_gate.py +187 -0
  19. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  20. package/hooks/blocking/plain_language_blocker.py +287 -15
  21. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  22. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  23. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  25. package/hooks/git-hooks/CLAUDE.md +2 -2
  26. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  27. package/hooks/git-hooks/pre_push.py +343 -54
  28. package/hooks/git-hooks/test_pre_push.py +852 -6
  29. package/hooks/hooks.json +9 -19
  30. package/hooks/hooks_constants/CLAUDE.md +2 -0
  31. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  32. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  33. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  34. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  36. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  37. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  39. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -0
  42. package/rules/ask-user-question-required.md +26 -0
  43. package/rules/claims-as-quotes.md +65 -0
  44. package/scripts/CLAUDE.md +1 -1
  45. package/scripts/_code_review_test_support.py +6 -0
  46. package/scripts/check.ps1 +18 -5
  47. package/scripts/claude_chain_runner.py +203 -31
  48. package/scripts/codec_forwarding_test_support.py +2 -0
  49. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  50. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  51. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  52. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
  53. package/scripts/grok_headless_runner.py +16 -83
  54. package/scripts/spawn_grok_batch.py +38 -1
  55. package/scripts/test_claude_chain_runner.py +358 -0
  56. package/scripts/test_grok_headless_runner.py +18 -63
  57. package/scripts/test_invoke_code_review.py +298 -0
  58. package/scripts/test_resolve_worker_spawn.py +6 -0
  59. package/scripts/test_spawn_grok_batch.py +101 -0
  60. package/scripts/tests/CLAUDE.md +1 -0
  61. package/scripts/tests/test_grok_worker_constants.py +59 -0
  62. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  63. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  64. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  65. package/skills/autoconverge/workflow/converge.mjs +66 -33
  66. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  67. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  68. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  69. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  70. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  71. package/skills/e-code-review/SKILL.md +9 -8
  72. package/skills/e-code-review/reference/fix.md +29 -7
  73. package/skills/e-code-review/reference/loop.md +230 -14
  74. package/skills/e-code-review/reference/low.md +33 -15
  75. package/skills/e-code-review/reference/medium.md +55 -21
  76. package/skills/e-code-review/reference/xhigh.md +30 -12
  77. package/skills/fresh-branch/CLAUDE.md +5 -5
  78. package/skills/fresh-branch/SKILL.md +14 -6
  79. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  82. package/skills/grok-spawn/SKILL.md +5 -0
  83. package/skills/orchestrator/SKILL.md +4 -1
  84. package/skills/orchestrator-refresh/SKILL.md +5 -1
  85. package/skills/team-advisor/SKILL.md +4 -1
@@ -1,15 +1,20 @@
1
- """Named constants for the host-aware `/code-review` invoker.
1
+ """Named constants for the host-aware `/code-review` invoker and review loop.
2
2
 
3
3
  ::
4
4
 
5
5
  ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
6
6
  ok: ("low", "medium", "high", "xhigh", "max")
7
7
  flag: "ultra" (rejected; needs an interactive terminal)
8
+ ALL_FINDING_SEVERITIES
9
+ ok: ("blocker", "high", "medium", "low", "nit")
10
+ ALL_LOOP_TERMINALS
11
+ ok: ("clean", "nits_fixed", "advisor_blocked")
8
12
  RECORD_STAMP_FLAG
9
13
  ok: "--record-stamp"
10
14
 
11
15
  Effort tokens re-export the hooks enforcement constants (single source).
12
- Scalar flags, JSON keys, and mint-loop messages live here for the invoker.
16
+ Scalar flags, JSON keys, mint-loop messages, finding severity vocabulary,
17
+ reviewed-head counting, and loop terminal resolution live here.
13
18
  """
14
19
 
15
20
  from __future__ import annotations
@@ -17,6 +22,7 @@ from __future__ import annotations
17
22
  import importlib.util
18
23
  import os
19
24
  import sys
25
+ from collections.abc import Mapping, Sequence
20
26
  from pathlib import Path
21
27
  from types import ModuleType
22
28
 
@@ -223,3 +229,398 @@ STAMP_STORE_RECORD_CLEAN_STAMP_NAME: str = "record_clean_stamp"
223
229
 
224
230
  STAMP_STORE_RESOLVE_REPO_ROOT_NAME: str = "resolve_repo_root"
225
231
  """Attribute name of the repo-root resolver on the stamp store module."""
232
+
233
+ SEVERITY_BLOCKER: str = "blocker"
234
+ """Finding severity for a release-blocking defect."""
235
+
236
+ SEVERITY_HIGH: str = "high"
237
+ """Finding severity for a high-impact defect that is not a release blocker."""
238
+
239
+ SEVERITY_MEDIUM: str = "medium"
240
+ """Finding severity for a moderate maintainer-action defect."""
241
+
242
+ SEVERITY_LOW: str = "low"
243
+ """Finding severity for a low-impact non-nit defect."""
244
+
245
+ SEVERITY_NIT: str = "nit"
246
+ """Finding severity for a mechanical clarity, format, or typo fix only."""
247
+
248
+ ALL_FINDING_SEVERITIES: tuple[str, ...] = (
249
+ SEVERITY_BLOCKER,
250
+ SEVERITY_HIGH,
251
+ SEVERITY_MEDIUM,
252
+ SEVERITY_LOW,
253
+ SEVERITY_NIT,
254
+ )
255
+ """Frozen severity vocabulary every retained finding must use."""
256
+
257
+ VERDICT_CONFIRMED: str = "CONFIRMED"
258
+ """Verification verdict when the trigger and wrong outcome are named."""
259
+
260
+ VERDICT_PLAUSIBLE: str = "PLAUSIBLE"
261
+ """Verification verdict when the mechanism is real and the trigger is uncertain."""
262
+
263
+ VERDICT_REFUTED: str = "REFUTED"
264
+ """Verification verdict when the candidate is factually wrong or guarded."""
265
+
266
+ ALL_VERIFICATION_VERDICTS: tuple[str, ...] = (
267
+ VERDICT_CONFIRMED,
268
+ VERDICT_PLAUSIBLE,
269
+ VERDICT_REFUTED,
270
+ )
271
+ """Frozen verification-verdict vocabulary from the medium verify phase."""
272
+
273
+ ALL_RETAINED_VERIFICATION_VERDICTS: tuple[str, ...] = (
274
+ VERDICT_CONFIRMED,
275
+ VERDICT_PLAUSIBLE,
276
+ )
277
+ """Verdicts that keep a candidate in the retained findings list."""
278
+
279
+ TERMINAL_CLEAN: str = "clean"
280
+ """Loop terminal when a reviewed head retains zero findings."""
281
+
282
+ TERMINAL_NITS_FIXED: str = "nits_fixed"
283
+ """Loop terminal when every retained finding is a fixed nit after gates."""
284
+
285
+ TERMINAL_ADVISOR_BLOCKED: str = "advisor_blocked"
286
+ """Loop terminal when classification needs an advisor that cannot be reached."""
287
+
288
+ ALL_LOOP_TERMINALS: tuple[str, ...] = (
289
+ TERMINAL_CLEAN,
290
+ TERMINAL_NITS_FIXED,
291
+ TERMINAL_ADVISOR_BLOCKED,
292
+ )
293
+ """Frozen set of review-loop terminal statuses."""
294
+
295
+ FINDING_FIELD_SEVERITY: str = "severity"
296
+ """Structured finding field that holds one of ``ALL_FINDING_SEVERITIES``."""
297
+
298
+ FINDING_FIELD_VERDICT: str = "verdict"
299
+ """Structured finding field that holds a verification verdict."""
300
+
301
+ RESULT_KEY_TERMINAL: str = "terminal"
302
+ """JSON result key naming the review-loop terminal status."""
303
+
304
+ RESULT_KEY_DRAFT_PRESERVED: str = "draft_preserved"
305
+ """JSON result key holding whether the pull request stays draft."""
306
+
307
+ RESULT_KEY_REVIEWED_HEAD_COUNT: str = "reviewed_head_count"
308
+ """JSON result key holding how many distinct heads the loop reviewed."""
309
+
310
+ RESULT_KEY_SURVIVING_FINDINGS: str = "surviving_findings"
311
+ """JSON result key holding structured findings that remain at terminal."""
312
+
313
+
314
+ def is_known_finding_severity(severity: str) -> bool:
315
+ """Return whether ``severity`` is one of the frozen five tokens.
316
+
317
+ ::
318
+
319
+ is_known_finding_severity("nit") # ok: True
320
+ is_known_finding_severity("P1") # flag: False
321
+ is_known_finding_severity("") # flag: False
322
+
323
+ Args:
324
+ severity: Candidate severity token from a structured finding.
325
+
326
+ Returns:
327
+ True when ``severity`` is in ``ALL_FINDING_SEVERITIES``.
328
+ """
329
+ return severity in ALL_FINDING_SEVERITIES
330
+
331
+
332
+ def is_nit_finding_severity(severity: str) -> bool:
333
+ """Return whether ``severity`` is exactly the nit token.
334
+
335
+ ::
336
+
337
+ is_nit_finding_severity("nit") # ok: True
338
+ is_nit_finding_severity("low") # flag: False
339
+
340
+ Args:
341
+ severity: Candidate severity token from a structured finding.
342
+
343
+ Returns:
344
+ True when ``severity`` equals ``SEVERITY_NIT``.
345
+ """
346
+ return severity == SEVERITY_NIT
347
+
348
+
349
+ def is_retained_verification_verdict(verdict: str) -> bool:
350
+ """Return whether ``verdict`` keeps a finding in the retained set.
351
+
352
+ ::
353
+
354
+ is_retained_verification_verdict("CONFIRMED") # ok: True
355
+ is_retained_verification_verdict("REFUTED") # flag: False
356
+
357
+ Args:
358
+ verdict: Verification vote from the medium verify phase.
359
+
360
+ Returns:
361
+ True when ``verdict`` is CONFIRMED or PLAUSIBLE.
362
+ """
363
+ return verdict in ALL_RETAINED_VERIFICATION_VERDICTS
364
+
365
+
366
+ def finding_carries_severity_and_verdict(
367
+ *,
368
+ severity: object,
369
+ verdict: object,
370
+ ) -> bool:
371
+ """Return whether one retained finding carries both required fields.
372
+
373
+ ::
374
+
375
+ finding_carries_severity_and_verdict(
376
+ severity="high", verdict="CONFIRMED"
377
+ ) # ok: True
378
+ finding_carries_severity_and_verdict(
379
+ severity="high", verdict=None
380
+ ) # flag: False
381
+
382
+ A retained finding needs a known severity and a retained verification
383
+ verdict. Missing either field, or a REFUTED verdict, fails the contract.
384
+
385
+ Args:
386
+ severity: Severity token from a structured finding, or missing value.
387
+ verdict: Verification verdict from a structured finding, or missing.
388
+
389
+ Returns:
390
+ True when severity and retained verdict are both present and valid.
391
+ """
392
+ if not isinstance(severity, str) or not isinstance(verdict, str):
393
+ return False
394
+ if not is_known_finding_severity(severity):
395
+ return False
396
+ return is_retained_verification_verdict(verdict)
397
+
398
+
399
+ def all_findings_carry_severity_and_verdict(
400
+ all_findings: Sequence[Mapping[str, object]],
401
+ ) -> bool:
402
+ """Return whether every finding carries severity and a retained verdict.
403
+
404
+ ::
405
+
406
+ all_findings_carry_severity_and_verdict([]) # ok: True
407
+ all_findings_carry_severity_and_verdict(
408
+ [{"severity": "nit", "verdict": "CONFIRMED"}]
409
+ ) # ok: True
410
+
411
+ Args:
412
+ all_findings: Structured findings retained after verification.
413
+
414
+ Returns:
415
+ True when every finding passes ``finding_carries_severity_and_verdict``.
416
+ """
417
+ for each_finding in all_findings:
418
+ if not finding_carries_severity_and_verdict(
419
+ severity=each_finding.get(FINDING_FIELD_SEVERITY),
420
+ verdict=each_finding.get(FINDING_FIELD_VERDICT),
421
+ ):
422
+ return False
423
+ return True
424
+
425
+
426
+ def has_unclassified_finding(all_findings: Sequence[Mapping[str, object]]) -> bool:
427
+ """Return whether any finding lacks a known severity token.
428
+
429
+ ::
430
+
431
+ has_unclassified_finding([{"verdict": "CONFIRMED"}]) # ok: True
432
+ has_unclassified_finding(
433
+ [{"severity": "nit", "verdict": "CONFIRMED"}]
434
+ ) # flag: False
435
+
436
+ Args:
437
+ all_findings: Structured findings under terminal evaluation.
438
+
439
+ Returns:
440
+ True when any finding omits severity or uses an unknown token.
441
+ """
442
+ for each_finding in all_findings:
443
+ severity = each_finding.get(FINDING_FIELD_SEVERITY)
444
+ if not isinstance(severity, str):
445
+ return True
446
+ if not is_known_finding_severity(severity):
447
+ return True
448
+ return False
449
+
450
+
451
+ def has_non_nit_finding(all_findings: Sequence[Mapping[str, object]]) -> bool:
452
+ """Return whether any finding carries a known non-nit severity.
453
+
454
+ ::
455
+
456
+ has_non_nit_finding([{"severity": "high"}]) # ok: True
457
+ has_non_nit_finding([{"severity": "nit"}]) # flag: False
458
+
459
+ Args:
460
+ all_findings: Structured findings under terminal evaluation.
461
+
462
+ Returns:
463
+ True when any finding's severity is known and not ``nit``.
464
+ """
465
+ for each_finding in all_findings:
466
+ severity = each_finding.get(FINDING_FIELD_SEVERITY)
467
+ if not isinstance(severity, str):
468
+ continue
469
+ if not is_known_finding_severity(severity):
470
+ continue
471
+ if not is_nit_finding_severity(severity):
472
+ return True
473
+ return False
474
+
475
+
476
+ def is_nits_only_findings(all_findings: Sequence[Mapping[str, object]]) -> bool:
477
+ """Return whether every finding is a classified nit and at least one exists.
478
+
479
+ ::
480
+
481
+ is_nits_only_findings([{"severity": "nit"}]) # ok: True
482
+ is_nits_only_findings([]) # flag: False
483
+ is_nits_only_findings(
484
+ [{"severity": "nit"}, {"severity": "high"}]
485
+ ) # flag: False
486
+
487
+ An empty list is clean, not nits-only. Unclassified findings fail the
488
+ nits-only check so they route to classification before a terminal.
489
+
490
+ Args:
491
+ all_findings: Structured findings under terminal evaluation.
492
+
493
+ Returns:
494
+ True when the list is non-empty, fully classified, and all nits.
495
+ """
496
+ if not all_findings:
497
+ return False
498
+ if has_unclassified_finding(all_findings):
499
+ return False
500
+ return not has_non_nit_finding(all_findings)
501
+
502
+
503
+ def record_reviewed_head(
504
+ all_reviewed_head_shas: tuple[str, ...],
505
+ head_sha: str,
506
+ ) -> tuple[str, ...]:
507
+ """Append ``head_sha`` once when it is a new distinct head.
508
+
509
+ ::
510
+
511
+ record_reviewed_head((), "aaa") # ok: ("aaa",)
512
+ record_reviewed_head(("aaa",), "aaa") # ok: ("aaa",) re-review
513
+ record_reviewed_head(("a", "b", "c"), "d") # ok: ("a", "b", "c", "d")
514
+
515
+ A re-review of the same head does not increment the count. There is no
516
+ head count limit — every new head is recorded.
517
+
518
+ Args:
519
+ all_reviewed_head_shas: Ordered distinct heads already reviewed.
520
+ head_sha: Git head under review for this pass.
521
+
522
+ Returns:
523
+ The prior tuple, or the prior tuple plus ``head_sha`` when new.
524
+ """
525
+ if head_sha in all_reviewed_head_shas:
526
+ return all_reviewed_head_shas
527
+ return all_reviewed_head_shas + (head_sha,)
528
+
529
+
530
+ def resolve_review_loop_terminal(
531
+ *,
532
+ all_findings: Sequence[Mapping[str, object]],
533
+ reviewed_head_count: int,
534
+ is_gates_passed: bool,
535
+ is_nits_applied: bool,
536
+ is_advisor_unreachable: bool = False,
537
+ ) -> str | None:
538
+ """Resolve the review-loop terminal, or None when the loop continues.
539
+
540
+ ::
541
+
542
+ resolve_review_loop_terminal(
543
+ all_findings=(), reviewed_head_count=1,
544
+ is_gates_passed=True, is_nits_applied=False,
545
+ ) # ok: "clean"
546
+ resolve_review_loop_terminal(
547
+ all_findings=(), reviewed_head_count=1,
548
+ is_gates_passed=False, is_nits_applied=False,
549
+ ) # flag: None (gates still open)
550
+ resolve_review_loop_terminal(
551
+ all_findings=[{"severity": "high", "verdict": "CONFIRMED"}],
552
+ reviewed_head_count=5, is_gates_passed=True, is_nits_applied=False,
553
+ ) # flag: None (open non-nit work continues)
554
+
555
+ Empty findings return clean only when gates pass. Nits-only findings
556
+ return nits_fixed after each finding carries severity and a retained
557
+ verdict, the nits are applied, and gates pass. An unreachable advisor
558
+ needed for classification returns advisor_blocked. Open non-nit work
559
+ returns None so the caller re-enters. There is no head-count stop.
560
+
561
+ Args:
562
+ all_findings: Structured findings retained for this head.
563
+ reviewed_head_count: Distinct heads reviewed so far, including this one.
564
+ is_gates_passed: Whether required checks passed for this decision.
565
+ is_nits_applied: Whether every nit on the target was fixed.
566
+ is_advisor_unreachable: Whether classification needs a missing advisor.
567
+
568
+ Returns:
569
+ One of ``ALL_LOOP_TERMINALS``, or None when the loop continues.
570
+ """
571
+ _ = reviewed_head_count
572
+ if is_advisor_unreachable and has_unclassified_finding(all_findings):
573
+ return TERMINAL_ADVISOR_BLOCKED
574
+ if not all_findings:
575
+ if is_gates_passed:
576
+ return TERMINAL_CLEAN
577
+ return None
578
+ if (
579
+ is_nits_only_findings(all_findings)
580
+ and all_findings_carry_severity_and_verdict(all_findings)
581
+ and is_nits_applied
582
+ and is_gates_passed
583
+ ):
584
+ return TERMINAL_NITS_FIXED
585
+ return None
586
+
587
+
588
+ def encode_review_loop_terminal_result(
589
+ *,
590
+ terminal: str,
591
+ all_surviving_findings: Sequence[Mapping[str, object]],
592
+ reviewed_head_count: int,
593
+ is_draft_preserved: bool,
594
+ ) -> dict[str, object]:
595
+ """Serialize a review-loop terminal for hand-off and reporting.
596
+
597
+ ::
598
+
599
+ encode_review_loop_terminal_result(
600
+ terminal="advisor_blocked",
601
+ all_surviving_findings=[{"severity": "high", "verdict": "CONFIRMED"}],
602
+ reviewed_head_count=2,
603
+ is_draft_preserved=False,
604
+ )["draft_preserved"] # ok: True (blocked terminals force draft)
605
+
606
+ ``advisor_blocked`` keeps the pull request draft even when the caller
607
+ passes a false draft flag. Other terminals use the caller's flag.
608
+ Every terminal uses the same JSON shape.
609
+
610
+ Args:
611
+ terminal: One of ``ALL_LOOP_TERMINALS``.
612
+ all_surviving_findings: Findings still open at the terminal.
613
+ reviewed_head_count: Distinct heads reviewed before this terminal.
614
+ is_draft_preserved: Whether the pull request stays in draft.
615
+
616
+ Returns:
617
+ A JSON-ready mapping with terminal, draft flag, head count, findings.
618
+ """
619
+ is_blocked_terminal = terminal == TERMINAL_ADVISOR_BLOCKED
620
+ should_preserve_draft = is_blocked_terminal or is_draft_preserved
621
+ return {
622
+ RESULT_KEY_TERMINAL: terminal,
623
+ RESULT_KEY_DRAFT_PRESERVED: should_preserve_draft,
624
+ RESULT_KEY_REVIEWED_HEAD_COUNT: reviewed_head_count,
625
+ RESULT_KEY_SURVIVING_FINDINGS: list(all_surviving_findings),
626
+ }
@@ -215,8 +215,8 @@ LAUNCH_FAILURE_STDERR_PREFIX: str = "failed to launch: "
215
215
  KILL_GRACE_TIMEOUT_SECONDS: int = 10
216
216
  """Seconds to wait for a killed process to reap its pipes before giving up on its streams.
217
217
 
218
- Gates the drain that follows a kill. ``PROCESS_TREE_KILL_TIMEOUT_SECONDS``
219
- gates the kill command itself, so the two bound different operations.
218
+ Gates the drain that follows a kill. The shared process-tree helper carries its
219
+ own bound on the kill command, so the two bound different operations.
220
220
  """
221
221
 
222
222
  MAXIMUM_WORKER_TIMEOUT_SECONDS: int = 5400
@@ -235,28 +235,6 @@ MAXIMUM_WORKER_TIMEOUT_ERROR_TEMPLATE: str = (
235
235
  )
236
236
  """Rejection message for an over-ceiling timeout, shared by the batch parse and the runner."""
237
237
 
238
- WINDOWS_OS_NAME: str = "nt"
239
- """``os.name`` value that selects the Windows branch of the process-tree kill."""
240
-
241
- WINDOWS_TASKKILL_COMMAND: str = "taskkill"
242
- """Windows command that ends a process by id."""
243
-
244
- WINDOWS_TASKKILL_TREE_FLAG: str = "/T"
245
- """``taskkill`` flag that extends the kill to every descendant process."""
246
-
247
- WINDOWS_TASKKILL_FORCE_FLAG: str = "/F"
248
- """``taskkill`` flag that forces termination rather than requesting it."""
249
-
250
- WINDOWS_TASKKILL_PID_FLAG: str = "/PID"
251
- """``taskkill`` flag that names the target process id."""
252
-
253
- PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
254
- """Seconds allowed for the tree-kill command itself before it is abandoned.
255
-
256
- Gates the kill command. ``KILL_GRACE_TIMEOUT_SECONDS`` gates the drain that
257
- follows a kill, so the two bound different operations.
258
- """
259
-
260
238
  MIN_WORKER_TIMEOUT_SECONDS: int = 1
261
239
  """Minimum accepted worker timeout_seconds, in seconds.
262
240
 
@@ -396,6 +374,32 @@ WORKER_SPEC_IS_REPO_ONLY_KEY: str = "is_repo_only"
396
374
  WORKER_SPEC_AGENT_NAME_KEY: str = "agent_name"
397
375
  """JSON key for one worker's optional agent definition name."""
398
376
 
377
+ ALL_KNOWN_WORKER_SPEC_KEYS: frozenset[str] = frozenset(
378
+ {
379
+ WORKER_SPEC_ROLE_NAME_KEY,
380
+ WORKER_SPEC_PROMPT_PARTS_KEY,
381
+ WORKER_SPEC_CWD_KEY,
382
+ WORKER_SPEC_TOOL_PROFILE_KEY,
383
+ WORKER_SPEC_TIMEOUT_KEY,
384
+ WORKER_SPEC_IS_REPO_ONLY_KEY,
385
+ WORKER_SPEC_AGENT_NAME_KEY,
386
+ }
387
+ )
388
+ """Every JSON key a batch worker entry accepts.
389
+
390
+ A worker entry carrying any other key fails to load, so a key the launcher
391
+ would drop is named to the operator rather than passing for a setting that
392
+ took effect.
393
+ """
394
+
395
+ WORKER_SPEC_KEY_JOIN_SEPARATOR: str = ", "
396
+ """Separator between key names listed in an unknown-worker-key error message."""
397
+
398
+ UNKNOWN_WORKER_KEY_ERROR_TEMPLATE: str = (
399
+ "unknown worker key(s): {unknown_keys}; accepted keys: {accepted_keys}"
400
+ )
401
+ """Message raised when a worker entry carries a key outside the accepted set."""
402
+
399
403
  CLI_BATCH_SPEC_FLAG: str = "--spec"
400
404
  """CLI flag that points the batch launcher at a JSON batch specification file."""
401
405
 
@@ -25,14 +25,24 @@ Import ``run_headless_worker`` for the outcome object::
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- import os
29
- import signal
30
28
  import subprocess
29
+ import sys
31
30
  import uuid
32
31
  from dataclasses import dataclass
33
32
  from pathlib import Path
34
33
 
35
- from dev_env_scripts_constants.grok_worker_constants import (
34
+ _shared_process_tree_scripts_directory = (
35
+ Path(__file__).resolve().parents[1] / "_shared" / "process-tree" / "scripts"
36
+ )
37
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
38
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
39
+
40
+ from process_tree_kill import ( # noqa: E402
41
+ should_start_new_session,
42
+ terminate_process_tree,
43
+ )
44
+
45
+ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
36
46
  AGENT_FLAG,
37
47
  ALL_AUTH_FAILURE_SIGNATURES,
38
48
  ALL_USAGE_LIMIT_SIGNATURES,
@@ -65,21 +75,14 @@ from dev_env_scripts_constants.grok_worker_constants import (
65
75
  OUTPUT_FORMAT_FLAG,
66
76
  OUTPUT_FORMAT_JSON,
67
77
  PROCESS_TREE_KILL_ATTEMPT_LIMIT,
68
- PROCESS_TREE_KILL_TIMEOUT_SECONDS,
69
78
  PROMPT_FILE_FLAG,
70
79
  TIMEOUT_RETURN_CODE,
71
80
  UTF8_DECODE_ERRORS,
72
81
  UTF8_ENCODING,
73
- WINDOWS_OS_NAME,
74
- WINDOWS_TASKKILL_COMMAND,
75
- WINDOWS_TASKKILL_FORCE_FLAG,
76
- WINDOWS_TASKKILL_PID_FLAG,
77
- WINDOWS_TASKKILL_TREE_FLAG,
78
82
  WORKER_SPEC_TIMEOUT_KEY,
79
83
  )
80
84
 
81
85
  runner_popen = subprocess.Popen
82
- runner_subprocess_run = subprocess.run
83
86
 
84
87
 
85
88
  class WorkerTimeoutOutOfBoundsError(ValueError):
@@ -204,76 +207,6 @@ def _resolve_returncode(process: subprocess.Popen[str]) -> int:
204
207
  return TIMEOUT_RETURN_CODE
205
208
 
206
209
 
207
- def _kill_windows_process_tree(process_identifier: int) -> None:
208
- """End a Windows process and every descendant it started, by process id.
209
-
210
- Swallows taskkill failures so the caller still falls back to
211
- ``Popen.kill()`` and a timed drain.
212
- """
213
- try:
214
- runner_subprocess_run(
215
- [
216
- WINDOWS_TASKKILL_COMMAND,
217
- WINDOWS_TASKKILL_TREE_FLAG,
218
- WINDOWS_TASKKILL_FORCE_FLAG,
219
- WINDOWS_TASKKILL_PID_FLAG,
220
- str(process_identifier),
221
- ],
222
- stdout=subprocess.DEVNULL,
223
- stderr=subprocess.DEVNULL,
224
- check=False,
225
- timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
226
- )
227
- except (subprocess.TimeoutExpired, OSError):
228
- return
229
-
230
-
231
- def _kill_posix_process_group(process_identifier: int) -> None:
232
- """End a POSIX process group so no grandchild keeps the capture pipe open.
233
-
234
- Reached only through the caller's ``os.name`` branch, so the process-group
235
- calls run on the platforms that define them.
236
-
237
- A process id that is already reaped raises ``OSError``; this swallows it and
238
- returns, so the caller falls back to ``Popen.kill()``.
239
- """
240
- try:
241
- process_group_identifier = os.getpgid(process_identifier) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
242
- os.killpg(process_group_identifier, signal.SIGKILL) # type: ignore[attr-defined] # POSIX-only, reached via the caller's os.name branch
243
- except OSError:
244
- return
245
-
246
-
247
- def _kill_process_tree_by_identifier(process_identifier: int) -> None:
248
- """Issue the platform's tree kill for one process id, with no liveness check."""
249
- if os.name == WINDOWS_OS_NAME:
250
- _kill_windows_process_tree(process_identifier)
251
- return
252
- _kill_posix_process_group(process_identifier)
253
-
254
-
255
- def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
256
- """End the worker process and every descendant it spawned.
257
-
258
- ::
259
-
260
- tree kill (taskkill /T or killpg) ok: grandchildren die, pipes close
261
- Popen.kill() alone flag: grandchildren outlive the worker
262
-
263
- Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
264
- so the caller never waits on a live process.
265
- """
266
- if process.poll() is not None:
267
- return
268
- _kill_process_tree_by_identifier(process.pid)
269
- if process.poll() is not None:
270
- return
271
- try:
272
- process.kill()
273
- except ProcessLookupError:
274
- return
275
-
276
-
277
210
  def require_timeout_within_bounds(timeout_seconds: int | None) -> None:
278
211
  """Refuse a timeout that is missing, below the floor, or above the ceiling.
279
212
 
@@ -332,7 +265,7 @@ def _kill_and_drain_within_attempt_limit(
332
265
 
333
266
  A tree kill that returns without taking leaves the drain waiting on a live
334
267
  pipe, so a timed-out drain is followed by another kill-and-drain round.
335
- ``_terminate_process_tree`` re-issues the kill only while the worker
268
+ ``terminate_process_tree`` re-issues the kill only while the worker
336
269
  process is still alive; once it has exited, the next round is a second
337
270
  drain window for the descendants still holding the pipe open.
338
271
 
@@ -344,7 +277,7 @@ def _kill_and_drain_within_attempt_limit(
344
277
  """
345
278
  attempts_made = 0
346
279
  while attempts_made < PROCESS_TREE_KILL_ATTEMPT_LIMIT:
347
- _terminate_process_tree(process)
280
+ terminate_process_tree(process)
348
281
  all_captured_streams = _drain_after_kill(process)
349
282
  if all_captured_streams is not None:
350
283
  return all_captured_streams
@@ -424,7 +357,7 @@ def _invoke_process(
424
357
  text=True,
425
358
  encoding=UTF8_ENCODING,
426
359
  errors=UTF8_DECODE_ERRORS,
427
- start_new_session=os.name != WINDOWS_OS_NAME,
360
+ start_new_session=should_start_new_session(),
428
361
  )
429
362
  except FileNotFoundError:
430
363
  return _missing_binary_outcome()