claude-dev-env 2.3.0 → 2.4.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 (136) hide show
  1. package/CLAUDE.md +3 -2
  2. package/agents/CLAUDE.md +1 -0
  3. package/agents/code-verifier.md +3 -3
  4. package/agents/skill-writer-agent.md +84 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  7. package/docs/CLAUDE.md +1 -0
  8. package/docs/references/CLAUDE.md +1 -0
  9. package/docs/references/code-review-enforcement.md +97 -0
  10. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  11. package/hooks/blocking/CLAUDE.md +8 -1
  12. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  13. package/hooks/blocking/code_review_gate_deny.py +74 -0
  14. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  15. package/hooks/blocking/code_review_push_gate.py +140 -0
  16. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  17. package/hooks/blocking/code_review_stamp_store.py +233 -0
  18. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  19. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  20. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  21. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  22. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  23. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  24. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  25. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  26. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  27. package/hooks/blocking/config/verified_commit_constants.py +13 -9
  28. package/hooks/blocking/conftest.py +2 -0
  29. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  30. package/hooks/blocking/destructive_command_blocker.py +19 -6
  31. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  33. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  34. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  35. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  36. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  37. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  38. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  39. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  40. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  41. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  42. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  43. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  44. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  45. package/hooks/blocking/test_verification_verdict_store.py +920 -903
  46. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  47. package/hooks/blocking/verification_verdict_store.py +27 -5
  48. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  49. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  50. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  51. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  52. package/hooks/git-hooks/pre_push.py +89 -2
  53. package/hooks/git-hooks/test_pre_push.py +103 -0
  54. package/hooks/hooks.json +16 -1
  55. package/hooks/hooks_constants/CLAUDE.md +1 -0
  56. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  57. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  58. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  59. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  60. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  61. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  63. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  64. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  65. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  66. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  67. package/hooks/validators/CLAUDE.md +1 -0
  68. package/hooks/validators/mypy_integration.py +63 -50
  69. package/hooks/validators/pyproject_config_discovery.py +101 -0
  70. package/hooks/validators/ruff_integration.py +211 -23
  71. package/hooks/validators/run_all_validators.py +21 -14
  72. package/hooks/validators/test_mypy_integration.py +32 -0
  73. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  74. package/hooks/validators/test_ruff_integration.py +68 -1
  75. package/hooks/validators/test_run_all_validators.py +25 -0
  76. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  77. package/package.json +1 -1
  78. package/rules/docstring-prose-matches-implementation.md +20 -43
  79. package/rules/durable-post-artifacts.md +7 -0
  80. package/scripts/codec_forwarding_test_support.py +83 -0
  81. package/scripts/conftest.py +8 -0
  82. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  83. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
  84. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  85. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  86. package/scripts/invoke_code_review.py +550 -38
  87. package/scripts/resolve_worker_spawn.py +8 -1
  88. package/scripts/test_invoke_code_review.py +298 -4
  89. package/scripts/test_invoke_code_review_codec.py +77 -0
  90. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  91. package/skills/autoconverge/SKILL.md +9 -3
  92. package/skills/autoconverge/reference/convergence.md +2 -1
  93. package/skills/autoconverge/reference/multi-pr.md +6 -1
  94. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  95. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  96. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  98. package/skills/autoconverge/workflow/converge.mjs +24 -8
  99. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  100. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  101. package/skills/fresh-branch/CLAUDE.md +2 -0
  102. package/skills/fresh-branch/SKILL.md +2 -0
  103. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  104. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  105. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  106. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  107. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  108. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  109. package/skills/prototype/SKILL.md +86 -0
  110. package/skills/prototype/reference/honest-limitations.md +23 -0
  111. package/skills/prototype/reference/promotion-tasks.md +23 -0
  112. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  113. package/skills/prototype/scripts/conftest.py +15 -0
  114. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  115. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  116. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  117. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  118. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  119. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  120. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  121. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  122. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  123. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  124. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  125. package/skills/prototype/workflows/promotion.md +27 -0
  126. package/skills/prototype/workflows/sandbox.md +35 -0
  127. package/skills/skill-builder/CLAUDE.md +3 -3
  128. package/skills/skill-builder/SKILL.md +5 -5
  129. package/skills/skill-builder/references/CLAUDE.md +1 -1
  130. package/skills/skill-builder/references/delegation-map.md +3 -3
  131. package/skills/skill-builder/references/description-field.md +1 -1
  132. package/skills/skill-builder/references/skill-modularity.md +2 -3
  133. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  134. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  135. package/skills/skill-builder/workflows/new-skill.md +2 -2
  136. package/skills/team-advisor/SKILL.md +2 -2
@@ -178,7 +178,118 @@ test('the Codex gate prompt runs the review wrapper against the PR base branch',
178
178
  assert.match(codexPrompt, /parse_codex_findings/, 'expected findings to come from the shared parser');
179
179
  });
180
180
 
181
- test('the CODEX phase stamps codexCleanAt on clean and clears it on skip/down', () => {
181
+ test('the Codex gate prompt passes an explicit generous timeout so a slow-but-healthy review is not cut short', () => {
182
+ const codexPrompt = functionBody('runCodexGate');
183
+ assert.match(
184
+ codexPrompt,
185
+ /timeout_seconds=\$\{CONFIG\.codexReviewTimeoutSeconds\}/,
186
+ 'expected the driver to pass CONFIG.codexReviewTimeoutSeconds, overriding the too-small wrapper default',
187
+ );
188
+ });
189
+
190
+ test('CONFIG.codexReviewTimeoutSeconds exceeds the observed real Codex runtime', () => {
191
+ const match = convergeSource.match(/codexReviewTimeoutSeconds:\s*(\d+)/);
192
+ assert.notEqual(match, null, 'expected CONFIG.codexReviewTimeoutSeconds to be defined');
193
+ const budgetSeconds = Number(match[1]);
194
+ assert.ok(
195
+ budgetSeconds >= 900,
196
+ `expected a budget comfortably above the ~683s measured Codex runtime, got ${budgetSeconds}`,
197
+ );
198
+ });
199
+
200
+ test('the Codex gate prompt runs the review in the background rather than the default foreground timeout', () => {
201
+ const codexPrompt = functionBody('runCodexGate');
202
+ assert.match(
203
+ codexPrompt,
204
+ /run_in_background/i,
205
+ 'expected the review driver to run in the background — the foreground tool timeout caps below the review runtime and would kill a healthy review',
206
+ );
207
+ assert.match(
208
+ codexPrompt,
209
+ /result file/i,
210
+ 'expected the driver to persist its outcome to a result file the agent reads once the background run finishes',
211
+ );
212
+ });
213
+
214
+ test('the Codex gate prompt waits for the background review inside the same turn with a bounded Monitor loop', () => {
215
+ const codexPrompt = functionBody('runCodexGate');
216
+ assert.match(codexPrompt, /Monitor tool/, 'expected a same-turn Monitor wait for the background review');
217
+ assert.match(codexPrompt, /until-loop/, 'expected a bounded until-loop that re-checks the result file');
218
+ assert.match(codexPrompt, /codexReviewPollIntervalSeconds/, 'expected a named poll interval in the wait protocol');
219
+ assert.match(codexPrompt, /codexReviewMaxPolls/, 'expected a named attempt budget in the wait protocol');
220
+ assert.match(
221
+ codexPrompt,
222
+ /Do NOT end this turn/i,
223
+ 'expected an explicit instruction against ending the schema-bearing turn to await the background run',
224
+ );
225
+ });
226
+
227
+ test('the Codex background-review poll budget covers the review timeout', () => {
228
+ const intervalMatch = convergeSource.match(/codexReviewPollIntervalSeconds:\s*(\d+)/);
229
+ const pollsMatch = convergeSource.match(/codexReviewMaxPolls:\s*(\d+)/);
230
+ const timeoutMatch = convergeSource.match(/codexReviewTimeoutSeconds:\s*(\d+)/);
231
+ assert.notEqual(intervalMatch, null, 'expected codexReviewPollIntervalSeconds in CONFIG');
232
+ assert.notEqual(pollsMatch, null, 'expected codexReviewMaxPolls in CONFIG');
233
+ assert.notEqual(timeoutMatch, null, 'expected codexReviewTimeoutSeconds in CONFIG');
234
+ const intervalSeconds = Number(intervalMatch[1]);
235
+ const maxPolls = Number(pollsMatch[1]);
236
+ const timeoutSeconds = Number(timeoutMatch[1]);
237
+ assert.ok(
238
+ intervalSeconds * maxPolls >= timeoutSeconds,
239
+ `expected the poll budget ${intervalSeconds * maxPolls}s to cover the ${timeoutSeconds}s review timeout`,
240
+ );
241
+ });
242
+
243
+ test('the Codex gate prompt returns codex_down when the background poll budget is spent', () => {
244
+ const codexPrompt = functionBody('runCodexGate');
245
+ assert.match(
246
+ codexPrompt,
247
+ /full poll budget is spent with no result file[\s\S]*?down:true/,
248
+ 'expected a spent-budget branch that returns the codex_down schema shape',
249
+ );
250
+ });
251
+
252
+ test('meta Codex gate phase describes codex_down as a non-blocking skip that logs a note', () => {
253
+ const phaseStart = convergeSource.indexOf("title: 'Codex gate'");
254
+ const phaseEnd = convergeSource.indexOf("title: 'Finalize'");
255
+ assert.notEqual(phaseStart, -1, 'expected a Codex gate phase in meta');
256
+ const codexPhaseDetail = convergeSource.slice(phaseStart, phaseEnd);
257
+ assert.match(codexPhaseDetail, /codex_down/, 'expected the phase detail to name codex_down');
258
+ assert.match(
259
+ codexPhaseDetail,
260
+ /codex_down, the gate skips without blocking/,
261
+ 'expected codex_down to be described as a non-blocking skip',
262
+ );
263
+ assert.match(
264
+ codexPhaseDetail,
265
+ /logged as a note/,
266
+ 'expected the codex_down skip to be logged as a note so the bypass stays visible',
267
+ );
268
+ assert.doesNotMatch(
269
+ codexPhaseDetail,
270
+ /holds the PR as a draft with a codex-down blocker/,
271
+ 'codex_down must not be described as holding the PR draft with a blocker',
272
+ );
273
+ });
274
+
275
+ test('CODEX_SCHEMA down.description describes codex_down as a skipped, bypassed gate recorded as a note', () => {
276
+ const schemaStart = convergeSource.indexOf('const CODEX_SCHEMA =');
277
+ const schemaEnd = convergeSource.indexOf('const COPILOT_VERIFY_VERDICTS =');
278
+ assert.notEqual(schemaStart, -1, 'expected CODEX_SCHEMA to exist');
279
+ const schemaSource = convergeSource.slice(schemaStart, schemaEnd);
280
+ const downLineStart = schemaSource.indexOf('down: {');
281
+ const downLine = schemaSource.slice(downLineStart, schemaSource.indexOf('\n', downLineStart));
282
+ assert.match(downLine, /codex_down/, 'expected the down description to name codex_down');
283
+ assert.match(downLine, /skipped and bypassed/, 'expected codex_down to be described as a skipped, bypassed gate');
284
+ assert.match(downLine, /recorded as a note/, 'expected codex_down to be recorded as a note');
285
+ assert.doesNotMatch(
286
+ downLine,
287
+ /holds the PR as a draft with a codex-down blocker/,
288
+ 'the down description must not claim codex_down holds the PR draft as a blocker',
289
+ );
290
+ });
291
+
292
+ test('the CODEX phase stamps codexCleanAt on clean and clears it on an opt-out, usage, or down skip', () => {
182
293
  const codexPhaseStart = convergeSource.indexOf("if (phase === 'CODEX') {");
183
294
  const finalizePhaseStart = convergeSource.indexOf("if (phase === 'FINALIZE') {", codexPhaseStart);
184
295
  assert.notEqual(codexPhaseStart, -1, 'expected a CODEX phase block');
@@ -192,6 +303,36 @@ test('the CODEX phase stamps codexCleanAt on clean and clears it on skip/down',
192
303
  assert.match(codexPhase, /kind === 'down'[\s\S]*codexDown = true[\s\S]*codexCleanAt = null/);
193
304
  });
194
305
 
306
+ test('the CODEX phase skips a genuine codex_down gracefully, recording a note and advancing to mark-ready', () => {
307
+ const codexPhaseStart = convergeSource.indexOf("if (phase === 'CODEX') {");
308
+ const finalizePhaseStart = convergeSource.indexOf("if (phase === 'FINALIZE') {", codexPhaseStart);
309
+ const codexPhase = convergeSource.slice(codexPhaseStart, finalizePhaseStart);
310
+ const downBranchStart = codexPhase.indexOf("codexOutcome.kind === 'down'");
311
+ const downBranchEnd = codexPhase.indexOf("codexOutcome.kind === 'fix'", downBranchStart);
312
+ assert.notEqual(downBranchStart, -1, 'expected a codex_down branch in the CODEX phase');
313
+ assert.notEqual(downBranchEnd, -1, 'expected a fix branch after the down branch');
314
+ const downBranch = codexPhase.slice(downBranchStart, downBranchEnd);
315
+ assert.match(downBranch, /codexNote =/, 'a genuine codex_down records a transparent note');
316
+ assert.match(downBranch, /codexDown = true/, 'the down branch sets the bypass flag so mark-ready skips the required Codex gate');
317
+ assert.match(downBranch, /codexCleanAt = null/, 'the down branch clears any codex-clean stamp');
318
+ assert.match(
319
+ downBranch,
320
+ /phase = 'FINALIZE'/,
321
+ 'a genuine codex_down advances to mark-ready with the gate bypassed rather than blocking the run',
322
+ );
323
+ assert.doesNotMatch(downBranch, /blocker =/, 'a genuine codex_down must not record a blocker');
324
+ assert.doesNotMatch(downBranch, /\bbreak\b/, 'the down branch continues rather than breaking the loop');
325
+ });
326
+
327
+ test('the workflow return object includes codexNote so the final report surfaces the Codex skip', () => {
328
+ const assembleStart = convergeSource.indexOf('const assembleResult =');
329
+ assert.notEqual(assembleStart, -1, 'expected assembleResult to exist');
330
+ const assembleEnd = convergeSource.indexOf('})', assembleStart);
331
+ assert.notEqual(assembleEnd, -1, 'expected the assembleResult object to close');
332
+ const assembleSource = convergeSource.slice(assembleStart, assembleEnd);
333
+ assert.match(assembleSource, /codexNote,/, 'expected codexNote among the returned notes');
334
+ });
335
+
195
336
  test('the CODEX phase routes findings through applyFixes and re-enters CONVERGE', () => {
196
337
  const codexPhaseStart = convergeSource.indexOf("if (phase === 'CODEX') {");
197
338
  const finalizePhaseStart = convergeSource.indexOf("if (phase === 'FINALIZE') {", codexPhaseStart);
@@ -267,8 +408,8 @@ test('runConvergenceCheck tells the finalize agent to persist codex_clean_at for
267
408
  test('CONFIG expands home for the codex-review scripts directory', () => {
268
409
  assert.match(
269
410
  convergeSource,
270
- /const homeDirectory = \(process\.env\.HOME \|\| process\.env\.USERPROFILE/,
271
- 'expected home to be resolved from process.env before CONFIG path use',
411
+ /const homeDirectory = \([\s\S]*?homeDirectoryPayload\.homeDirectory/,
412
+ 'expected home to be sourced from the workflow args payload (the sandbox exposes no process)',
272
413
  );
273
414
  assert.match(
274
415
  convergeSource,
@@ -282,6 +423,25 @@ test('CONFIG expands home for the codex-review scripts directory', () => {
282
423
  );
283
424
  });
284
425
 
426
+ test('no process global is read unguarded (the workflow sandbox exposes no process)', () => {
427
+ const unguardedProcessLines = convergeSource
428
+ .split('\n')
429
+ .filter((sourceLine) => sourceLine.includes('process.') && !sourceLine.includes('typeof process'));
430
+ assert.deepEqual(
431
+ unguardedProcessLines,
432
+ [],
433
+ 'every process.* read must sit on a line guarded by typeof process; the Bun workflow sandbox has no process global',
434
+ );
435
+ });
436
+
437
+ test('the home directory is sourced from the workflow args payload', () => {
438
+ assert.match(
439
+ convergeSource,
440
+ /const homeDirectoryPayload = normalizeRunInput\(args\)/,
441
+ 'expected homeDirectory to read args (the only channel into the process-less sandbox), parsed via normalizeRunInput',
442
+ );
443
+ });
444
+
285
445
  test('meta lists the Codex gate phase between Copilot and Finalize', () => {
286
446
  const copilotPhaseIndex = convergeSource.indexOf("title: 'Copilot gate'");
287
447
  const codexPhaseIndex = convergeSource.indexOf("title: 'Codex gate'");
@@ -294,6 +454,18 @@ test('SKILL.md names Codex among the terminal confirmation gates', () => {
294
454
  assert.match(skillSource, /Codex/i);
295
455
  });
296
456
 
457
+ test('SKILL.md documents codexNote in the workflow return shape', () => {
458
+ const returnShapeMatch = skillSource.match(/`\{ converged, rounds, finalSha, blocker,[^`]*\}`/);
459
+ assert.ok(returnShapeMatch, 'expected the documented return shape literal in SKILL.md');
460
+ assert.match(returnShapeMatch[0], /codexNote/);
461
+ });
462
+
463
+ test('SKILL.md final-report template surfaces the codexNote so a bypassed Codex gate stays visible', () => {
464
+ const reportBlockMatch = skillSource.match(/\/autoconverge exit:[\s\S]*?```/);
465
+ assert.ok(reportBlockMatch, 'expected the final-report template block in SKILL.md');
466
+ assert.match(reportBlockMatch[0], /Codex: <codexNote>/);
467
+ });
468
+
297
469
  test('stop-conditions.md documents the Codex gate skip paths as non-blockers', () => {
298
470
  assert.match(stopConditionsSource, /Codex/i);
299
471
  assert.match(stopConditionsSource, /codex/i);
@@ -815,6 +815,25 @@ test('preamble attributes the known-temp-var resolution to a third cwd-scoped au
815
815
  );
816
816
  });
817
817
 
818
+ test('preamble forbids bash rm on a repo-path build cache and points cleanup at Remove-Item', () => {
819
+ const text = preambleText().replace(/\s+/g, ' ');
820
+ assert.match(
821
+ text,
822
+ /\.mypy_cache|\.pytest_cache|__pycache__/,
823
+ 'expected the preamble to name a gitignored build cache agents must not rm',
824
+ );
825
+ assert.match(
826
+ text,
827
+ /never delete it|leave it in place/i,
828
+ 'expected the preamble to tell agents to leave a gitignored cache rather than rm it',
829
+ );
830
+ assert.match(
831
+ text,
832
+ /Remove-Item -Recurse -Force -Confirm:\$false/,
833
+ 'expected the preamble to point a genuine delete at the PowerShell Remove-Item form',
834
+ );
835
+ });
836
+
818
837
  test('SKILL.md does not attribute the known-temp-var resolution to the standalone or compound paths', () => {
819
838
  assert.doesNotMatch(
820
839
  skillSource.replace(/\s+/g, ' '),
@@ -18,17 +18,28 @@ export const meta = {
18
18
  { title: 'Converge', detail: 'A deterministic static sweep runs first each round; then code-review, bug-audit, and self-review run in parallel on the same HEAD; one clean-coder applies all fixes; the loop repeats until every lens is clean on a stable HEAD' },
19
19
  { title: 'Bugbot gate', detail: 'A terminal confirmation gate that runs once the internal lenses are clean; when Bugbot is disabled or unavailable it spawns no agent and passes, otherwise it fetches Bugbot\'s verdict and routes any finding back into Converge' },
20
20
  { title: 'Copilot gate', detail: 'When the quota pre-check reports Copilot out of premium-request quota or unavailable, skip the gate with no agent spawned; otherwise request a Copilot review and poll up to the configured cap, then route findings by tier — self-healing findings flow back into Converge, and each code-concern finding goes to its own verifier agent that must execute a check against HEAD: a confirmed finding (defect reproduced by a run command) joins the fix round carrying its repro, a refuted finding (correct behavior demonstrated by a run command) is replied-to and resolved with the check evidence, and only inconclusive findings return blocker user-review for the orchestrator to gate on rather than auto-fixing or marking the PR ready; when Copilot is down or out of quota log a notice and proceed with the gate bypassed' },
21
- { title: 'Codex gate', detail: 'A conditional-required terminal gate that runs after Bugbot and Copilot: when CLAUDE_REVIEWS_DISABLED lists codex, when weekly usage is at or below the shared probe threshold (or null), or when the wrapper classifies codex_down, the gate skips without blocking; when usage is above the threshold it runs the codex-review wrapper against the PR base branch, routes findings into the fix path, and on a clean pass records the codex-clean HEAD for the convergence check' },
21
+ { title: 'Codex gate', detail: 'A conditional-required terminal gate that runs after Bugbot and Copilot: when CLAUDE_REVIEWS_DISABLED lists codex, when weekly usage is at or below the shared probe threshold (or null), or when the wrapper classifies codex_down, the gate skips without blocking (a codex_down skip is logged as a note so the bypass stays visible); when usage is above the threshold it runs the codex-review wrapper against the PR base branch, routes findings into the fix path, and on a clean pass records the codex-clean HEAD for the convergence check' },
22
22
  { title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
23
23
  ],
24
24
  }
25
25
 
26
- const homeDirectory = (process.env.HOME || process.env.USERPROFILE || '').replace(/\\/g, '/')
26
+ const homeDirectoryPayload = normalizeRunInput(args)
27
+ const homeDirectoryFromEnv = typeof process !== 'undefined' && process.env ? process.env.HOME || process.env.USERPROFILE || '' : ''
28
+ const homeDirectory = (
29
+ (homeDirectoryPayload && typeof homeDirectoryPayload.homeDirectory === 'string'
30
+ ? homeDirectoryPayload.homeDirectory
31
+ : '') ||
32
+ homeDirectoryFromEnv ||
33
+ ''
34
+ ).replace(/\\/g, '/')
27
35
 
28
36
  const CONFIG = {
29
37
  maxIterations: 20,
30
38
  maxConsecutiveNoLensRounds: 3,
31
39
  copilotMaxPolls: 8,
40
+ codexReviewTimeoutSeconds: 2400,
41
+ codexReviewPollIntervalSeconds: 120,
42
+ codexReviewMaxPolls: 21,
32
43
  sharedScripts: '$HOME/.claude/skills/pr-converge/scripts',
33
44
  prLoopScripts: '$HOME/.claude/_shared/pr-loop/scripts',
34
45
  codexScripts: `${homeDirectory}/.claude/skills/codex-review/scripts`,
@@ -49,7 +60,7 @@ const HEADLESS_EDIT_PREAMBLE =
49
60
  '- Never place a destructive-command literal inside a Bash command — not in echo, not in a heredoc, and not as an argument to python -c, node -e, or awk. To exercise or verify destructive_command_blocker (or any hook) behavior, run the committed test suite, e.g. python -m pytest <test_file>, which passes the command strings as in-language data rather than as a shell command.\n' +
50
61
  '- When a commit message, or a PR / issue / review-comment body, must describe destructive-command behavior, write that text to a file and pass it by path (git commit -F <file>, gh ... --body-file <file>); never inline it with git commit -m or gh ... -b, where the literal lands in the Bash command and stalls you.\n' +
51
62
  '- Keep scratch files and cleanup inside the OS temp dir; never target a repository or worktree path.\n' +
52
- '- rm shape rules — the hook grants several rm auto-allow paths. The simplest one accepts a standalone Bash call whose target resolves inside the ephemeral namespace (/tmp, /temp, the OS temp root, or the run worktree); a compound path accepts an rm joined with benign reporting segments when every rm target is an absolute ephemeral path. Both of those paths fail closed on $(...) command substitution and on backtick subshells. The compound path additionally fails closed on any $ in the target — including $CLAUDE_JOB_DIR. The standalone path declines a $-bearing target only when the literal path is not already under an ephemeral root, so it does not by itself stop a $VAR that expands inside an ephemeral root. A third, broad path matches only when the command itself declares an ephemeral working directory (it cds into one, or runs under one): that cwd-scoped path resolves the target against the declared cwd, fails closed on $(...) , backticks, and unknown variables, and resolves the known temporary variables TEMP, TMP, TMPDIR, and CLAUDE_JOB_DIR to the OS temp root, so under that declared ephemeral cwd a bare $CLAUDE_JOB_DIR/tmp/<name> target and a relative target after a cd are auto-allowed. Even so, prefer a Python helper for any cleanup whose path is variable-built or whose setup/teardown spans multiple steps: author the helper file and run it as python <file>.py, which keeps every destructive literal out of a Bash command string entirely and never depends on which auto-allow path matches.\n' +
63
+ '- rm shape rules — the hook grants several rm auto-allow paths. The simplest one accepts a standalone Bash call whose target resolves inside the ephemeral namespace (/tmp, /temp, the OS temp root, or the run worktree); a compound path accepts an rm joined with benign reporting segments when every rm target is an absolute ephemeral path. Both of those paths fail closed on $(...) command substitution and on backtick subshells. The compound path additionally fails closed on any $ in the target — including $CLAUDE_JOB_DIR. The standalone path declines a $-bearing target only when the literal path is not already under an ephemeral root, so it does not by itself stop a $VAR that expands inside an ephemeral root. A third, broad path matches only when the command itself declares an ephemeral working directory (it cds into one, or runs under one): that cwd-scoped path resolves the target against the declared cwd, fails closed on $(...) , backticks, and unknown variables, and resolves the known temporary variables TEMP, TMP, TMPDIR, and CLAUDE_JOB_DIR to the OS temp root, so under that declared ephemeral cwd a bare $CLAUDE_JOB_DIR/tmp/<name> target and a relative target after a cd are auto-allowed. Outside those ephemeral paths, run no bash rm at all: an rm on a repo or worktree path — most often a build cache like .mypy_cache, .pytest_cache, or __pycache__ left by ruff, mypy, or pytest — is not auto-allowed and stalls you on a prompt no human can answer. A gitignored build cache is harmless, so never delete it — leave it in place. For any genuine delete outside the ephemeral namespace, use the PowerShell tool (Remove-Item -Recurse -Force -Confirm:$false <absolute path>), which the blocker never sees because it watches only the Bash tool. For any cleanup whose path is variable-built or whose setup/teardown spans multiple steps, author a Python helper and run it as python <file>.py, which keeps every destructive literal out of a Bash command string entirely.\n' +
53
64
  '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n' +
54
65
  'WAITS AND POLLS — foreground sleep is blocked in this headless harness: a bare Bash "sleep N" or a PowerShell "Start-Sleep" is denied, and a wait you move to a background process — then end your turn to await it — never resumes, because a schema-bearing agent runs for a single turn. Therefore:\n' +
55
66
  '- Perform every required delay or poll-interval wait inside this same turn by pairing the Monitor tool with a bounded until-loop: the Monitor tool streams its events to you while you keep working, and the until-loop re-runs the step condition on the interval the step names, up to the attempt budget the step names, exiting the moment the condition holds or the budget is spent — consume the Monitor notifications as they arrive rather than ending your turn to await them. Never end your turn to wait for something to finish.\n' +
@@ -561,7 +572,7 @@ function runConvergenceCheck(context) {
561
572
  if (context.copilotDown) reviewerOptOutTokens.push('copilot')
562
573
  if (context.codexDown) reviewerOptOutTokens.push('codex')
563
574
  if (context.bugteamPostBlocked) reviewerOptOutTokens.push('bugteam')
564
- const jobDirName = process.env.CLAUDE_JOB_DIR
575
+ const jobDirName = typeof process !== 'undefined' && process.env ? process.env.CLAUDE_JOB_DIR : undefined
565
576
  const needsCodexCleanExportFallback = Boolean(context.codexCleanAt) && !jobDirName
566
577
  if (needsCodexCleanExportFallback && !reviewerOptOutTokens.includes('codex')) {
567
578
  reviewerOptOutTokens.push('codex')
@@ -692,7 +703,7 @@ const CODEX_SCHEMA = {
692
703
  properties: {
693
704
  sha: { type: 'string', description: 'PR HEAD SHA this gate evaluated' },
694
705
  clean: { type: 'boolean', description: 'true when Codex reported no findings on sha, or when the gate skipped without requiring a review' },
695
- down: { type: 'boolean', description: 'true when Codex is down (wrapper classifies codex_down) or opted out via the codex token — the gate is bypassed' },
706
+ down: { type: 'boolean', description: 'true when the wrapper classifies codex_down (the gate is skipped and bypassed, recorded as a note) or when Codex is opted out via the codex token — the gate is bypassed' },
696
707
  skipped: { type: 'boolean', description: 'true when the gate did not run the review wrapper (opt-out token or usage at/below the shared threshold)' },
697
708
  skipReason: {
698
709
  type: 'string',
@@ -2072,9 +2083,11 @@ function runCodexGate(head) {
2072
2083
  `3. Resolve the PR base branch (the review target is HEAD vs base, never an invented commit range). Run exactly:\n` +
2073
2084
  ` gh api repos/${input.owner}/${input.repo}/pulls/${input.prNumber} --jq .base.ref\n` +
2074
2085
  ` Capture the printed base branch name.\n\n` +
2075
- `4. Run the codex-review wrapper against that base. From the PR worktree root, run a short Python driver that imports the skill scripts (add "${CONFIG.codexScripts}" to sys.path) and:\n` +
2086
+ `4. Run the codex-review wrapper against that base. A real Codex review routinely runs about 11-12 minutes — longer than the default 2-minute tool timeout and longer than the 10-minute foreground tool ceiling — so it MUST run in the background, or the tool timeout kills it mid-review and it is misreported as codex_down. From the PR worktree root, write a short Python driver to a temp file that imports the skill scripts (add "${CONFIG.codexScripts}" to sys.path) and:\n` +
2076
2087
  ` - creates a temp run-state directory under the OS temp dir\n` +
2077
- ` - calls run_codex_review.run_codex_review(repository_directory=<repo root Path>, run_state_directory=<temp Path>, base_branch=<base ref from step 3>)\n` +
2088
+ ` - calls run_codex_review.run_codex_review(repository_directory=<repo root Path>, run_state_directory=<temp Path>, base_branch=<base ref from step 3>, timeout_seconds=${CONFIG.codexReviewTimeoutSeconds})\n` +
2089
+ ` - writes the outcome (outcome_class, exit_code, and the agent_message when completed) as JSON to a result file under the OS temp dir, then exits\n` +
2090
+ ` Run that driver with the Bash tool in the background (run_in_background: true), never in the foreground — the foreground tool timeout caps below the review runtime and would kill a healthy review. Do NOT end this turn to await the background run — a schema-bearing turn that ends never resumes, so the result would never be collected. Wait for the background run to finish inside this same turn per the WAITS AND POLLS rule: pair the Monitor tool with a bounded until-loop that re-checks whether the result file has been written, on a ${CONFIG.codexReviewPollIntervalSeconds}-second interval up to ${CONFIG.codexReviewMaxPolls} attempts (interval x attempts covers the ${CONFIG.codexReviewTimeoutSeconds}-second review budget), consuming the Monitor notifications as they arrive; arm the Monitor with persistent:true (or a timeout_ms of at least interval x attempts) so it is not timed out before the review lands. If the full poll budget is spent with no result file written, return {sha:${'`'}${head}${'`'}, clean:false, down:true, skipped:false, skipReason:'', findings:[]} and stop. When the background run finishes, read the result file and map its outcome:\n` +
2078
2091
  ` - when outcome_class is codex_down (or classify_codex_run on a non-zero exit reports codex_down) -> return {sha:${'`'}${head}${'`'}, clean:false, down:true, skipped:false, skipReason:'', findings:[]} and stop\n` +
2079
2092
  ` - when outcome_class is completed: parse findings with parse_codex_findings.parse_codex_findings(agent_message)\n` +
2080
2093
  ` - empty findings -> return {sha:${'`'}${head}${'`'}, clean:true, down:false, skipped:false, skipReason:'', findings:[]}\n` +
@@ -2536,6 +2549,7 @@ let copilotDown = input.copilotDisabled || false
2536
2549
  let codexDown = false
2537
2550
  let codexCleanAt = null
2538
2551
  let copilotNote = null
2552
+ let codexNote = null
2539
2553
  let standardsNote = null
2540
2554
  let cleanAuditNote = null
2541
2555
  let hasStandardsFollowUpFiled = false
@@ -2549,6 +2563,7 @@ const assembleResult = (outcomeFields) => ({
2549
2563
  ...outcomeFields,
2550
2564
  standardsNote,
2551
2565
  copilotNote,
2566
+ codexNote,
2552
2567
  cleanAuditNote,
2553
2568
  reuseNote,
2554
2569
  deferredPrs,
@@ -2840,7 +2855,8 @@ while (iterations < CONFIG.maxIterations) {
2840
2855
  continue
2841
2856
  }
2842
2857
  if (codexOutcome.kind === 'down') {
2843
- log('Codex gate: Codex classified codex_down — bypassing the terminal Codex gate and proceeding to mark-ready with the gate bypassed.')
2858
+ codexNote = `Codex was unreachable this run (the codex-review wrapper classified codex_down — a Codex CLI error, or a review that ran past its timeout budget). The terminal Codex gate was skipped with the gate bypassed so a Codex outage does not hinder convergence; Codex runs again on the next convergence.`
2859
+ log(`Codex gate: ${codexNote}`)
2844
2860
  codexDown = true
2845
2861
  codexCleanAt = null
2846
2862
  phase = 'FINALIZE'
@@ -109,11 +109,14 @@ function classifyMultiInput(rawArgs) {
109
109
  * fan-out honors it: bugbotDisabled skips the Bugbot phase, and copilotDisabled
110
110
  * short-circuits the Copilot quota gate when the account is out of premium
111
111
  * requests. An entry that omits an opt-out defaults it to false so the child
112
- * runs that phase.
112
+ * runs that phase. homeDirectory is a run-level value forwarded to every child
113
+ * so the child can build its codex-review scripts path: the workflow sandbox
114
+ * exposes no process global, so the child reads home from its args, not env.
113
115
  * @param {object} prEntry one validated element of the args.prs array
116
+ * @param {string} homeDirectory the run-level home directory for every child
114
117
  * @returns {object} the args object passed to the converge.mjs child run
115
118
  */
116
- function childRunInput(prEntry) {
119
+ function childRunInput(prEntry, homeDirectory) {
117
120
  return {
118
121
  owner: prEntry.owner,
119
122
  repo: prEntry.repo,
@@ -121,6 +124,7 @@ function childRunInput(prEntry) {
121
124
  repoPath: prEntry.repoPath,
122
125
  bugbotDisabled: Boolean(prEntry.bugbotDisabled),
123
126
  copilotDisabled: Boolean(prEntry.copilotDisabled),
127
+ homeDirectory,
124
128
  }
125
129
  }
126
130
 
@@ -137,7 +141,7 @@ const childResults = await parallel(
137
141
  input.prs.map((eachPr) => async () => {
138
142
  const childOutcome = await workflow(
139
143
  { scriptPath: input.convergeScriptPath },
140
- childRunInput(eachPr),
144
+ childRunInput(eachPr, input.homeDirectory),
141
145
  )
142
146
  return {
143
147
  owner: eachPr.owner,
@@ -138,6 +138,11 @@ test('childRunInput defaults bugbotDisabled to false when the entry omits it', (
138
138
  assert.equal(childRunInput(validEntry(101)).bugbotDisabled, false);
139
139
  });
140
140
 
141
+ test('childRunInput forwards the run-level home directory to the child run', () => {
142
+ const childArgs = childRunInput(validEntry(101), '/home/dev');
143
+ assert.equal(childArgs.homeDirectory, '/home/dev');
144
+ });
145
+
141
146
  test('the malformed-input blocker return carries an empty allDeferredPrs list', async () => {
142
147
  const blockerOutcome = await runMultiWorkflowBody(
143
148
  'not json at all',
@@ -8,5 +8,7 @@ Creates a new branch from fresh-fetched `origin/main` inside an isolated worktre
8
8
  |---|---|
9
9
  | `SKILL.md` | Phases, checklist, execute-vs-read for the CLI, gotchas |
10
10
  | `scripts/create_fresh_branch.py` | Deterministic CLI: fetch base, `git worktree add -b`, JSON stdout |
11
+ | `scripts/fresh_branch_git_commands.py` | Git command helpers: fetch, ref checks, `git worktree add -b --no-track` |
11
12
  | `scripts/test_create_fresh_branch.py` | Behavioral tests with temporary git repos |
13
+ | `scripts/test_fresh_branch_git_commands.py` | Behavioral tests for the git command helpers |
12
14
  | `scripts/fresh_branch_scripts_constants/` | Constants package (`fresh_branch_cli_constants`) for the CLI |
@@ -113,7 +113,9 @@ Further edits for the new branch belong in `worktree_path`, not in the caller's
113
113
  | `SKILL.md` | This hub: phases, checklist, constraints, gotchas |
114
114
  | `CLAUDE.md` | Package map for agents browsing the skill folder |
115
115
  | `scripts/create_fresh_branch.py` | **Execute** — deterministic fetch + worktree branch CLI |
116
+ | `scripts/fresh_branch_git_commands.py` | Git command helpers behind the CLI (fetch, ref checks, worktree add) |
116
117
  | `scripts/test_create_fresh_branch.py` | Real-repo tests for agent/path/CLI behavior |
118
+ | `scripts/test_fresh_branch_git_commands.py` | Real-repo tests for the git command helpers |
117
119
  | `scripts/fresh_branch_scripts_constants/` | Named constants for the CLI |
118
120
 
119
121
  ## Folder map