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
@@ -411,8 +411,13 @@ test('the shared verdict-fence builder names the binding-hash command and the ve
411
411
  /verification_verdict_store\.py/,
412
412
  'expected the verdict-store script that computes the binding hash to be named',
413
413
  );
414
- assert.match(fenceBuilder, /```verdict/, 'expected the verdict fence to be specified');
415
- assert.match(fenceBuilder, /manifest_sha256/, 'expected the verdict fence to carry manifest_sha256');
414
+ assert.match(fenceBuilder, /buildVerdictFenceTail\(/, 'expected the fence builder to close with the shared fence tail');
415
+ const renderedFenceTail = loadVerdictFenceTailBuilder()('');
416
+ assert.match(renderedFenceTail, /```verdict/, 'expected the verdict fence to be specified');
417
+ assert.ok(
418
+ renderedFenceTail.includes(VERDICT_FENCE_JSON_LINE),
419
+ 'expected the verdict fence to carry manifest_sha256',
420
+ );
416
421
  assert.match(
417
422
  fenceBuilder,
418
423
  /gh pr view/,
@@ -425,6 +430,100 @@ test('the shared verdict-fence builder names the binding-hash command and the ve
425
430
  );
426
431
  });
427
432
 
433
+ test('the incomplete-verdict contract sentence is written once and used by both verify paths', () => {
434
+ const sentenceOccurrences = convergeSource.split(
435
+ 'name that check in prose directly above the fence rather than in findings',
436
+ ).length - 1;
437
+ assert.equal(
438
+ sentenceOccurrences,
439
+ 1,
440
+ 'expected the incomplete-verdict contract sentence to be written exactly once so the two verify paths cannot drift',
441
+ );
442
+ assert.match(
443
+ lensPromptBody('buildVerdictFenceTail'),
444
+ /VERDICT_FENCE_CONTRACT_SENTENCE/,
445
+ 'expected the shared fence tail to use the one contract sentence',
446
+ );
447
+ assert.match(
448
+ lensPromptBody('buildVerdictFenceSteps'),
449
+ /buildVerdictFenceTail\(/,
450
+ 'expected the gh-lookup fence builder to reach the contract sentence through the shared fence tail',
451
+ );
452
+ assert.match(
453
+ lensPromptBody('runVerifierTask'),
454
+ /buildVerdictFenceTail\(/,
455
+ 'expected the hardening-verify prompt to reach the contract sentence through the shared fence tail',
456
+ );
457
+ });
458
+
459
+ const VERDICT_FENCE_JSON_LINE = '{"all_pass": true, "findings": [], "manifest_sha256": "<that hash>"}';
460
+ const VERDICT_FENCE_LEAD_IN = 'END your message with a fenced verdict block exactly in this shape';
461
+
462
+ function countOccurrences(haystack, needle) {
463
+ return haystack.split(needle).length - 1;
464
+ }
465
+
466
+ function loadVerdictFenceTailBuilder() {
467
+ const sentenceMatch = /const VERDICT_FENCE_CONTRACT_SENTENCE =\s*\n?\s*'[^']*'/.exec(convergeSource);
468
+ assert.notEqual(sentenceMatch, null, 'expected VERDICT_FENCE_CONTRACT_SENTENCE to be declared as one string literal');
469
+ const builderSource = lensPromptBody('buildVerdictFenceTail');
470
+ return new Function(
471
+ `${sentenceMatch[0]}\n${builderSource}\nreturn buildVerdictFenceTail;`,
472
+ )();
473
+ }
474
+
475
+ test('the verdict fence recipe is written once — no verify path re-inlines its own copy', () => {
476
+ assert.equal(
477
+ countOccurrences(convergeSource, VERDICT_FENCE_JSON_LINE),
478
+ 1,
479
+ 'expected the verdict fence JSON skeleton to be written exactly once so no verify path can drift from the shared recipe',
480
+ );
481
+ assert.equal(
482
+ countOccurrences(convergeSource, VERDICT_FENCE_LEAD_IN),
483
+ 1,
484
+ 'expected the fence lead-in sentence to be written exactly once so no verify path can drift from the shared recipe',
485
+ );
486
+ assert.match(
487
+ lensPromptBody('buildVerdictFenceSteps'),
488
+ /buildVerdictFenceTail\(/,
489
+ 'expected the gh-lookup binding path to render its fence through the shared tail builder',
490
+ );
491
+ assert.match(
492
+ lensPromptBody('runVerifierTask'),
493
+ /buildVerdictFenceTail\(/,
494
+ 'expected the hardening-verify path to render its fence through the shared tail builder',
495
+ );
496
+ });
497
+
498
+ test('the shared fence tail renders the same fence JSON and contract sentence at every indent', () => {
499
+ const buildVerdictFenceTail = loadVerdictFenceTailBuilder();
500
+ const topLevelTail = buildVerdictFenceTail('');
501
+ const nestedTail = buildVerdictFenceTail(' ');
502
+ for (const [siteName, renderedTail] of [['top-level', topLevelTail], ['nested', nestedTail]]) {
503
+ assert.ok(
504
+ renderedTail.includes(VERDICT_FENCE_JSON_LINE),
505
+ `expected the ${siteName} fence tail to carry the verdict JSON skeleton`,
506
+ );
507
+ assert.ok(
508
+ renderedTail.includes(VERDICT_FENCE_LEAD_IN),
509
+ `expected the ${siteName} fence tail to carry the fence lead-in sentence`,
510
+ );
511
+ assert.ok(
512
+ renderedTail.includes('```verdict'),
513
+ `expected the ${siteName} fence tail to open a verdict fence`,
514
+ );
515
+ assert.ok(
516
+ renderedTail.includes('name that check in prose directly above the fence rather than in findings'),
517
+ `expected the ${siteName} fence tail to carry the one incomplete-verdict contract sentence`,
518
+ );
519
+ }
520
+ assert.equal(
521
+ nestedTail,
522
+ topLevelTail.split('\n').map((eachLine) => ` ${eachLine}`).join('\n'),
523
+ 'expected indent to be the only difference between the two rendered fence tails',
524
+ );
525
+ });
526
+
428
527
  test('the verdict-fence binding does not self-resolve a cwd via git rev-parse for the manifest hash', () => {
429
528
  const fenceBuilder = lensPromptBody('buildVerdictFenceSteps');
430
529
  assert.doesNotMatch(
@@ -1006,6 +1105,7 @@ test('the whole priming spawn-agent family is removed — every dispatcher spawn
1006
1105
 
1007
1106
  test('parseLastVerdictFence returns non-null for a verdict fence with valid JSON', () => {
1008
1107
  const parseModule = new Function(
1108
+ `${functionSource('findLastVerdictFence')}\n` +
1009
1109
  `${functionSource('parseLastVerdictFence')}\n` +
1010
1110
  'return { parseLastVerdictFence };',
1011
1111
  )();
@@ -1016,6 +1116,7 @@ test('parseLastVerdictFence returns non-null for a verdict fence with valid JSON
1016
1116
 
1017
1117
  test('parseLastVerdictFence returns null for non-string input', () => {
1018
1118
  const parseModule = new Function(
1119
+ `${functionSource('findLastVerdictFence')}\n` +
1019
1120
  `${functionSource('parseLastVerdictFence')}\n` +
1020
1121
  'return { parseLastVerdictFence };',
1021
1122
  )();
@@ -1025,6 +1126,7 @@ test('parseLastVerdictFence returns null for non-string input', () => {
1025
1126
 
1026
1127
  test('parseLastVerdictFence returns null when no verdict fence is present', () => {
1027
1128
  const parseModule = new Function(
1129
+ `${functionSource('findLastVerdictFence')}\n` +
1028
1130
  `${functionSource('parseLastVerdictFence')}\n` +
1029
1131
  'return { parseLastVerdictFence };',
1030
1132
  )();
@@ -1033,6 +1135,7 @@ test('parseLastVerdictFence returns null when no verdict fence is present', () =
1033
1135
 
1034
1136
  test('parseLastVerdictFence returns null for malformed JSON in the fence', () => {
1035
1137
  const parseModule = new Function(
1138
+ `${functionSource('findLastVerdictFence')}\n` +
1036
1139
  `${functionSource('parseLastVerdictFence')}\n` +
1037
1140
  'return { parseLastVerdictFence };',
1038
1141
  )();
@@ -255,7 +255,8 @@ test('the round-loop fix-stalled blockers survive the recovery wiring', () => {
255
255
  });
256
256
 
257
257
  const verifyObjectionModule = new Function(
258
- `${functionSource('parseLastVerdictFence')}\n` +
258
+ `${functionSource('findLastVerdictFence')}\n` +
259
+ `${functionSource('parseLastVerdictFence')}\n` +
259
260
  `${constantLine('VERIFY_OBJECTION_FALLBACK')}\n` +
260
261
  `${functionSource('renderVerifyObjectionLine')}\n` +
261
262
  `${functionSource('extractPreFenceProse')}\n` +
@@ -350,6 +351,38 @@ test('the prose reader anchors on the last CLOSED fence, not a stray unterminate
350
351
  assert.doesNotMatch(objection, /all_pass/, 'expected the verdict body never to be read back as prose');
351
352
  });
352
353
 
354
+ test('both fence readers locate the fence through the one shared locator', () => {
355
+ assert.match(
356
+ functionSource('parseLastVerdictFence'),
357
+ /findLastVerdictFence\(/,
358
+ 'expected the verdict parser to locate its fence through findLastVerdictFence',
359
+ );
360
+ assert.match(
361
+ functionSource('extractPreFenceProse'),
362
+ /findLastVerdictFence\(/,
363
+ 'expected the prose reader to locate its fence through findLastVerdictFence, not a second search',
364
+ );
365
+ assert.doesNotMatch(
366
+ functionSource('extractPreFenceProse'),
367
+ /lastIndexOf\(/,
368
+ 'expected no second fence-search procedure in the prose reader to drift from the parser',
369
+ );
370
+ });
371
+
372
+ test('the prose reader and the verdict parser land on the same fence when a later marker carries no newline', () => {
373
+ const transcript =
374
+ 'check X never showed red\n\n' +
375
+ '```verdict\n{"all_pass": false, "findings": []}\n```\n\n' +
376
+ '```verdict {"all_pass": true, "findings": []}```';
377
+ const objection = extractVerifyObjection(transcript);
378
+ assert.doesNotMatch(
379
+ objection,
380
+ /all_pass/,
381
+ 'expected the prose reader to anchor on the fence the parser read, never to slice a verdict body in as prose',
382
+ );
383
+ assert.equal(objection, 'check X never showed red');
384
+ });
385
+
353
386
  test('the prose reader skips a scaffolding-only paragraph above the fence', () => {
354
387
  const transcript =
355
388
  'check X never showed red\n\n' + '## Verdict\n\n' + '```verdict\n{"all_pass": false, "findings": []}\n```';
@@ -466,11 +466,7 @@ function runVerifierTask(task, context) {
466
466
  ` Run exactly:\n` +
467
467
  ` "C:\\Python313\\python.exe" "<REPO>/packages/claude-dev-env/hooks/blocking/verification_verdict_store.py" --manifest-hash-for-branch "${context.hardeningBranch}"\n` +
468
468
  ` (substitute the REPO path you resolved for the script path). That prints a single 64-char hex hash on stdout — capture it.\n` +
469
- ` Then END your message with a fenced verdict block exactly in this shape, on its own, carrying that hash:\n` +
470
- " ```verdict\n" +
471
- ` {"all_pass": true, "findings": [], "manifest_sha256": "<that hash>"}\n` +
472
- " ```\n" +
473
- ` Set all_pass to false when verification fails, and list every code defect you found in findings. When the verdict is incomplete because a check it rests on never showed red, set all_pass to false and name that check in prose directly above the fence rather than in findings. Always include the manifest_sha256. The verdict fence must be the last thing in your message.`,
469
+ buildVerdictFenceTail(' '),
474
470
  { label, phase: 'Converge', agentType: 'code-verifier', ...TIERS.sonnetMedium },
475
471
  )
476
472
  }
@@ -849,6 +845,29 @@ const HARDENING_COMMIT_SCHEMA = {
849
845
  required: ['hardeningPrUrl', 'summary'],
850
846
  }
851
847
 
848
+ const VERDICT_FENCE_CONTRACT_SENTENCE =
849
+ 'Set all_pass to false when verification fails, and list every code defect you found in findings. When the verdict is incomplete because a check it rests on never showed red, set all_pass to false and name that check in prose directly above the fence rather than in findings. Always include the manifest_sha256. The verdict fence must be the last thing in your message.'
850
+
851
+ /**
852
+ * Build the closing fence recipe every verify prompt ends with: the lead-in
853
+ * sentence, the ```verdict block shape carrying the binding hash, and the
854
+ * contract sentence. Each verify path resolves the binding hash its own way
855
+ * and sits at its own nesting depth, so the caller passes the indent its
856
+ * surrounding prose uses; the fence body itself sits one level deeper.
857
+ * @param {string} indent leading whitespace the caller's prose sits at
858
+ * @returns {string} the fence recipe tail for a verify prompt
859
+ */
860
+ function buildVerdictFenceTail(indent) {
861
+ const fenceIndent = `${indent} `
862
+ return (
863
+ `${indent}Then END your message with a fenced verdict block exactly in this shape, on its own, carrying that hash:\n` +
864
+ `${fenceIndent}` + "```verdict\n" +
865
+ `${fenceIndent}{"all_pass": true, "findings": [], "manifest_sha256": "<that hash>"}\n` +
866
+ `${fenceIndent}` + "```\n" +
867
+ `${fenceIndent}${VERDICT_FENCE_CONTRACT_SENTENCE}`
868
+ )
869
+ }
870
+
852
871
  /**
853
872
  * Build the verdict-fence step instructions for a verify agent, binding the
854
873
  * surface hash by branch name rather than by a self-resolved cwd. Resolving
@@ -869,11 +888,7 @@ function buildVerdictFenceSteps(prOwner, prRepo, prNumber) {
869
888
  ` b. Run exactly:\n` +
870
889
  ` "C:\\Python313\\python.exe" "<REPO>/packages/claude-dev-env/hooks/blocking/verification_verdict_store.py" --manifest-hash-for-branch "<that branch>"\n` +
871
890
  ` (substitute the REPO path you resolved for the script path, and the branch name for <that branch>). That prints a single 64-char hex hash on stdout — capture it.\n` +
872
- `Then END your message with a fenced verdict block exactly in this shape, on its own, carrying that hash:\n` +
873
- " ```verdict\n" +
874
- ` {"all_pass": true, "findings": [], "manifest_sha256": "<that hash>"}\n` +
875
- " ```\n" +
876
- ` Set all_pass to false when verification fails, and list every code defect you found in findings. When the verdict is incomplete because a check it rests on never showed red, set all_pass to false and name that check in prose directly above the fence rather than in findings. Always include the manifest_sha256. The verdict fence must be the last thing in your message.`
891
+ buildVerdictFenceTail('')
877
892
  )
878
893
  }
879
894
 
@@ -1282,23 +1297,45 @@ function normalizeShaForComparison(sha) {
1282
1297
  }
1283
1298
 
1284
1299
  /**
1285
- * Parse the LAST ```verdict ...``` fenced JSON block from a transcript.
1286
- * Guards against non-string input, iterates all fence matches for the last one,
1287
- * parses the JSON, and returns the object or null on any failure.
1300
+ * Locate the LAST ```verdict ...``` fence in a transcript: the marker, optional
1301
+ * whitespace, a required newline, then a body closed by ```. This is the ONE
1302
+ * definition of "the verdict fence" the verdict parser and the pre-fence prose
1303
+ * reader both anchor here, so they cannot land on different fences.
1304
+ *
1305
+ * ::
1306
+ *
1307
+ * ok: 'note\n\n```verdict\n{}\n```' -> body '{}\n' starting at index 6
1308
+ * flag: 'note\n\n```verdict {}```' -> null (no newline after the marker)
1309
+ *
1310
+ * A marker with no newline after it is not a fence, so a later one cannot pull
1311
+ * either reader off the fence the other reads.
1312
+ *
1288
1313
  * @param {string|null|undefined} transcript the agent transcript text
1289
- * @returns {object|null} the parsed verdict object, or null when absent or malformed
1314
+ * @returns {{bodyText: string, startIndex: number}|null} the last fence, or null when there is none
1290
1315
  */
1291
- function parseLastVerdictFence(transcript) {
1316
+ function findLastVerdictFence(transcript) {
1292
1317
  if (typeof transcript !== 'string') return null
1293
1318
  const fencePattern = /```verdict\s*\n([\s\S]*?)```/g
1294
- let lastFenceBody = null
1319
+ let lastFence = null
1295
1320
  let eachMatch
1296
1321
  while ((eachMatch = fencePattern.exec(transcript)) !== null) {
1297
- lastFenceBody = eachMatch[1]
1322
+ lastFence = { bodyText: eachMatch[1], startIndex: eachMatch.index }
1298
1323
  }
1299
- if (lastFenceBody === null) return null
1324
+ return lastFence
1325
+ }
1326
+
1327
+ /**
1328
+ * Parse the LAST ```verdict ...``` fenced JSON block from a transcript.
1329
+ * Guards against non-string input, takes the fence findLastVerdictFence locates,
1330
+ * parses the JSON, and returns the object or null on any failure.
1331
+ * @param {string|null|undefined} transcript the agent transcript text
1332
+ * @returns {object|null} the parsed verdict object, or null when absent or malformed
1333
+ */
1334
+ function parseLastVerdictFence(transcript) {
1335
+ const lastFence = findLastVerdictFence(transcript)
1336
+ if (lastFence === null) return null
1300
1337
  try {
1301
- return JSON.parse(lastFenceBody)
1338
+ return JSON.parse(lastFence.bodyText)
1302
1339
  } catch {
1303
1340
  return null
1304
1341
  }
@@ -1351,12 +1388,13 @@ function renderVerifyObjectionLine(eachFinding) {
1351
1388
  * is not a code defect, so the fence contract puts that reason here rather than
1352
1389
  * in findings; this reader is how the reason survives into the re-fix step.
1353
1390
  *
1354
- * Anchors on the same fence parseLastVerdictFence reads — the last CLOSED
1355
- * verdict fence, not the last bare marker so a stray unterminated marker after
1356
- * the real fence can never make this read the verdict body itself as prose. A
1357
- * candidate paragraph carrying only markdown scaffolding (a heading, a fence
1358
- * delimiter) is skipped, since handing the fixer a heading is worse than the
1359
- * generic fallback the caller keeps.
1391
+ * Anchors through findLastVerdictFence, the single locator parseLastVerdictFence
1392
+ * reads its JSON from, so both readers see the same fence by construction rather
1393
+ * than by two searches that happen to agree: a stray marker after the real fence
1394
+ * unterminated, or closed but carrying no newline after the marker moves
1395
+ * neither reader. A candidate paragraph carrying only markdown scaffolding (a
1396
+ * heading, a fence delimiter) is skipped, since handing the fixer a heading is
1397
+ * worse than the generic fallback the caller keeps.
1360
1398
  *
1361
1399
  * ::
1362
1400
  *
@@ -1368,15 +1406,10 @@ function renderVerifyObjectionLine(eachFinding) {
1368
1406
  * @returns {string|null} the last prose paragraph above the final fence, or null when there is none
1369
1407
  */
1370
1408
  function extractPreFenceProse(verifyTranscript) {
1371
- if (typeof verifyTranscript !== 'string') return null
1372
- const fenceMarker = '```verdict'
1373
- let lastFenceStart = verifyTranscript.lastIndexOf(fenceMarker)
1374
- while (lastFenceStart !== -1 && !verifyTranscript.slice(lastFenceStart + fenceMarker.length).includes('```')) {
1375
- lastFenceStart = verifyTranscript.lastIndexOf(fenceMarker, lastFenceStart - 1)
1376
- }
1377
- if (lastFenceStart === -1) return null
1409
+ const lastFence = findLastVerdictFence(verifyTranscript)
1410
+ if (lastFence === null) return null
1378
1411
  const proseParagraphs = verifyTranscript
1379
- .slice(0, lastFenceStart)
1412
+ .slice(0, lastFence.startIndex)
1380
1413
  .split(/\n\s*\n/)
1381
1414
  .map((eachParagraph) => eachParagraph.trim())
1382
1415
  .filter((eachParagraph) =>
@@ -22,10 +22,6 @@ WINDOWS_OS_NAME = "nt"
22
22
  WINDOWS_COMMAND_SHELL = "cmd"
23
23
  WINDOWS_COMMAND_SHELL_RUN_FLAG = "/c"
24
24
  ALL_WINDOWS_SCRIPT_SUFFIXES = (".cmd", ".bat")
25
- WINDOWS_TASKKILL_COMMAND = "taskkill"
26
- WINDOWS_TASKKILL_FORCE_FLAG = "/F"
27
- WINDOWS_TASKKILL_TREE_FLAG = "/T"
28
- WINDOWS_TASKKILL_PID_FLAG = "/PID"
29
25
 
30
26
  JSONRPC_VERSION = "2.0"
31
27
  JSONRPC_KEY_ID = "id"
@@ -40,9 +40,21 @@ import sys
40
40
  import threading
41
41
  from collections.abc import Mapping, Sequence
42
42
  from datetime import datetime, timezone
43
+ from pathlib import Path
43
44
  from typing import IO, Protocol
44
45
 
45
- from codex_review_scripts_constants.codex_usage_probe_constants import (
46
+ _shared_process_tree_scripts_directory = (
47
+ Path(__file__).resolve().parents[3] / "_shared" / "process-tree" / "scripts"
48
+ )
49
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
50
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
51
+
52
+ from process_tree_kill import ( # noqa: E402
53
+ should_start_new_session,
54
+ terminate_process_tree,
55
+ )
56
+
57
+ from codex_review_scripts_constants.codex_usage_probe_constants import ( # noqa: E402
46
58
  ALL_APP_SERVER_COMMAND_PARTS,
47
59
  ALL_WINDOWS_SCRIPT_SUFFIXES,
48
60
  APP_SERVER_TIMEOUT_SECONDS,
@@ -94,10 +106,6 @@ from codex_review_scripts_constants.codex_usage_probe_constants import (
94
106
  WINDOWS_COMMAND_SHELL,
95
107
  WINDOWS_COMMAND_SHELL_RUN_FLAG,
96
108
  WINDOWS_OS_NAME,
97
- WINDOWS_TASKKILL_COMMAND,
98
- WINDOWS_TASKKILL_FORCE_FLAG,
99
- WINDOWS_TASKKILL_PID_FLAG,
100
- WINDOWS_TASKKILL_TREE_FLAG,
101
109
  )
102
110
 
103
111
 
@@ -387,41 +395,19 @@ def _collect_server_lines(
387
395
 
388
396
 
389
397
  def _terminate_process_tree(server_process: subprocess.Popen[str]) -> None:
390
- """Stop the app-server process and any children it launched.
398
+ """Stop the app-server process and any children it launched, then reap it.
391
399
 
392
400
  On Windows the probe may wrap a ``.cmd``/``.bat`` shim as ``cmd /c``, so
393
401
  ``Popen.pid`` is ``cmd.exe``. Killing only that process leaves the real
394
- Codex app-server grandchild alive and holding stdout. ``taskkill /T``
395
- tears down the whole tree; other platforms use ``kill()``.
402
+ Codex app-server grandchild alive and holding stdout. The shared tree kill
403
+ ends the whole tree on either platform; the timed wait that follows reaps
404
+ the direct child so the enclosing ``with`` block exits without a long wait.
396
405
  """
397
- if server_process.poll() is not None:
398
- return
399
- process_id = server_process.pid
400
- if process_id is None:
401
- return
402
- if os.name == WINDOWS_OS_NAME:
403
- subprocess.run(
404
- [
405
- WINDOWS_TASKKILL_COMMAND,
406
- WINDOWS_TASKKILL_FORCE_FLAG,
407
- WINDOWS_TASKKILL_TREE_FLAG,
408
- WINDOWS_TASKKILL_PID_FLAG,
409
- str(process_id),
410
- ],
411
- check=False,
412
- stdout=subprocess.DEVNULL,
413
- stderr=subprocess.DEVNULL,
414
- )
415
- try:
416
- server_process.wait(timeout=PROCESS_TREE_WAIT_TIMEOUT_SECONDS)
417
- except subprocess.TimeoutExpired:
418
- server_process.kill()
419
- return
420
- server_process.kill()
406
+ terminate_process_tree(server_process)
421
407
  try:
422
408
  server_process.wait(timeout=PROCESS_TREE_WAIT_TIMEOUT_SECONDS)
423
409
  except subprocess.TimeoutExpired:
424
- pass
410
+ return
425
411
 
426
412
 
427
413
  def _teardown_app_server_exchange(
@@ -467,6 +453,7 @@ def _exchange_app_server_messages_via_subprocess(
467
453
  text=True,
468
454
  encoding=UTF8_ENCODING,
469
455
  shell=False,
456
+ start_new_session=should_start_new_session(),
470
457
  ) as server_process:
471
458
  server_stdin = server_process.stdin
472
459
  server_stdout = server_process.stdout
@@ -18,22 +18,29 @@ import json
18
18
  import os
19
19
  import re
20
20
  import shutil
21
- import signal
22
21
  import subprocess
22
+ import sys
23
23
  from dataclasses import dataclass
24
24
  from pathlib import Path
25
25
 
26
- from codex_review_scripts_constants.codex_usage_probe_constants import (
26
+ _shared_process_tree_scripts_directory = (
27
+ Path(__file__).resolve().parents[3] / "_shared" / "process-tree" / "scripts"
28
+ )
29
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
30
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
31
+
32
+ from process_tree_kill import ( # noqa: E402
33
+ should_start_new_session,
34
+ terminate_process_tree,
35
+ )
36
+
37
+ from codex_review_scripts_constants.codex_usage_probe_constants import ( # noqa: E402
27
38
  ALL_WINDOWS_SCRIPT_SUFFIXES,
28
39
  WINDOWS_COMMAND_SHELL,
29
40
  WINDOWS_COMMAND_SHELL_RUN_FLAG,
30
41
  WINDOWS_OS_NAME,
31
- WINDOWS_TASKKILL_COMMAND,
32
- WINDOWS_TASKKILL_FORCE_FLAG,
33
- WINDOWS_TASKKILL_PID_FLAG,
34
- WINDOWS_TASKKILL_TREE_FLAG,
35
42
  )
36
- from codex_review_scripts_constants.run_constants import (
43
+ from codex_review_scripts_constants.run_constants import ( # noqa: E402
37
44
  ALL_SHAPE_PROBE_REQUIRED_FLAGS,
38
45
  BASE_TARGET_FLAG,
39
46
  CAPTURE_STREAMS_KEYWORD,
@@ -74,61 +81,6 @@ from codex_review_scripts_constants.run_constants import (
74
81
  )
75
82
 
76
83
 
77
- def _kill_windows_process_tree(process_identifier: int) -> None:
78
- """Kill a Windows process and every descendant it started, by PID.
79
-
80
- Swallows taskkill failures so the caller can fall back to ``Popen.kill()``
81
- and a timed drain. A raised ``TimeoutExpired`` here would replace the
82
- original review-timeout exception and skip that drain path.
83
- """
84
- try:
85
- subprocess.run(
86
- [
87
- WINDOWS_TASKKILL_COMMAND,
88
- WINDOWS_TASKKILL_TREE_FLAG,
89
- WINDOWS_TASKKILL_FORCE_FLAG,
90
- WINDOWS_TASKKILL_PID_FLAG,
91
- str(process_identifier),
92
- ],
93
- stdout=subprocess.DEVNULL,
94
- stderr=subprocess.DEVNULL,
95
- check=False,
96
- timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
97
- )
98
- except (subprocess.TimeoutExpired, OSError):
99
- return
100
-
101
-
102
- def _kill_posix_process_group(process_identifier: int) -> None:
103
- """Kill a POSIX process group so no grandchild keeps the capture pipe open."""
104
- try:
105
- process_group_identifier = os.getpgid(process_identifier)
106
- os.killpg(process_group_identifier, signal.SIGKILL)
107
- except (ProcessLookupError, PermissionError):
108
- return
109
-
110
-
111
- def _terminate_process_tree(review_process: subprocess.Popen[str]) -> None:
112
- """Kill the review process and every descendant it spawned.
113
-
114
- Tree kill first (taskkill /T or killpg). When the direct child is still
115
- alive after that, fall back to ``Popen.kill()`` so ``Popen.__exit__`` never
116
- hits an unbounded wait on a surviving process.
117
- """
118
- if review_process.poll() is not None:
119
- return
120
- if os.name == WINDOWS_OS_NAME:
121
- _kill_windows_process_tree(review_process.pid)
122
- else:
123
- _kill_posix_process_group(review_process.pid)
124
- if review_process.poll() is not None:
125
- return
126
- try:
127
- review_process.kill()
128
- except ProcessLookupError:
129
- return
130
-
131
-
132
84
  def _open_codex_popen(
133
85
  all_arguments: list[str],
134
86
  all_keyword_arguments: dict[str, object],
@@ -147,7 +99,7 @@ def _open_codex_popen(
147
99
  text=bool(all_keyword_arguments.get(TEXT_MODE_KEYWORD, False)),
148
100
  encoding=stream_encoding if isinstance(stream_encoding, str) else None,
149
101
  env=process_environment if isinstance(process_environment, dict) else None,
150
- start_new_session=os.name != WINDOWS_OS_NAME,
102
+ start_new_session=should_start_new_session(),
151
103
  )
152
104
 
153
105
 
@@ -198,7 +150,7 @@ def _communicate_with_tree_kill_on_timeout(
198
150
  try:
199
151
  return review_process.communicate(timeout=timeout_seconds)
200
152
  except subprocess.TimeoutExpired:
201
- _terminate_process_tree(review_process)
153
+ terminate_process_tree(review_process)
202
154
  _drain_process_after_tree_kill(review_process)
203
155
  raise
204
156