claude-dev-env 1.73.0 → 1.75.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 (105) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +4 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  14. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  15. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  16. package/hooks/blocking/hedging_language_blocker.py +17 -23
  17. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  18. package/hooks/blocking/intent_only_ending_blocker.py +18 -26
  19. package/hooks/blocking/md_to_html_blocker.py +10 -2
  20. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  21. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  22. package/hooks/blocking/plain_language_blocker.py +6 -0
  23. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  24. package/hooks/blocking/pr_description_enforcer.py +6 -0
  25. package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
  26. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  27. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  28. package/hooks/blocking/question_to_user_enforcer.py +19 -23
  29. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  30. package/hooks/blocking/sensitive_file_protector.py +15 -1
  31. package/hooks/blocking/session_handoff_blocker.py +15 -23
  32. package/hooks/blocking/state_description_blocker.py +6 -0
  33. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  34. package/hooks/blocking/tdd_enforcer.py +6 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  36. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  37. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
  38. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  39. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  40. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  41. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  42. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  43. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  44. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  45. package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
  46. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  47. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  48. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  49. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  50. package/hooks/blocking/test_state_description_blocker.py +41 -0
  51. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  52. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  53. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  54. package/hooks/blocking/verified_commit_gate.py +11 -0
  55. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  56. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  57. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  58. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  59. package/hooks/hooks.json +10 -0
  60. package/hooks/hooks_constants/CLAUDE.md +8 -1
  61. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  62. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  63. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  64. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  65. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  66. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  67. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  68. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  69. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
  70. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
  71. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  72. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  73. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  74. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  75. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  76. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  77. package/hooks/hooks_constants/text_stripping.py +36 -0
  78. package/hooks/lifecycle/config_change_guard.py +12 -0
  79. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  80. package/hooks/validation/CLAUDE.md +1 -0
  81. package/hooks/validation/hook_format_validator.py +13 -0
  82. package/hooks/validation/mypy_validator.py +30 -1
  83. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  84. package/hooks/validation/test_hook_format_validator.py +64 -0
  85. package/hooks/validation/test_mypy_validator.py +23 -1
  86. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  87. package/hooks/workflow/auto_formatter.py +8 -5
  88. package/hooks/workflow/test_auto_formatter.py +33 -0
  89. package/package.json +1 -1
  90. package/rules/CLAUDE.md +1 -0
  91. package/rules/docstring-prose-matches-implementation.md +2 -1
  92. package/rules/package-inventory-stale-entry.md +24 -0
  93. package/rules/windows-filesystem-safe.md +2 -0
  94. package/skills/autoconverge/SKILL.md +21 -1
  95. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  96. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
  98. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  99. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  100. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  101. package/skills/autoconverge/workflow/converge.mjs +599 -606
  102. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  103. package/skills/autoconverge/workflow/render_report.py +2 -6
  104. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  105. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -10,16 +10,32 @@ const gotchasSource = readFileSync(
10
10
  join(workflowDirectory, '..', 'reference', 'gotchas.md'),
11
11
  'utf8',
12
12
  );
13
+ const skillSource = readFileSync(
14
+ join(workflowDirectory, '..', 'SKILL.md'),
15
+ 'utf8',
16
+ );
13
17
 
14
18
  function lensPromptBody(builderName) {
15
- const builderStart = convergeSource.indexOf(`function ${builderName}(`);
16
- assert.notEqual(builderStart, -1, `expected ${builderName} to exist`);
19
+ let builderStart = convergeSource.indexOf(`function ${builderName}(`);
20
+ if (builderStart === -1) {
21
+ builderStart = convergeSource.indexOf(`const ${builderName} =`);
22
+ assert.notEqual(builderStart, -1, `expected ${builderName} to exist as a function or const`);
23
+ }
17
24
  const nextBuilderMatch = /\n(?:async )?function /.exec(convergeSource.slice(builderStart + 1));
18
25
  const builderEnd =
19
26
  nextBuilderMatch === null ? convergeSource.length : builderStart + 1 + nextBuilderMatch.index;
20
27
  return convergeSource.slice(builderStart, builderEnd);
21
28
  }
22
29
 
30
+ function functionSource(functionName) {
31
+ const functionStart = convergeSource.indexOf(`function ${functionName}(`);
32
+ assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
33
+ const nextMatch = /\n(?:async )?function /.exec(convergeSource.slice(functionStart + 1));
34
+ const functionEnd =
35
+ nextMatch === null ? convergeSource.length : functionStart + 1 + nextMatch.index;
36
+ return convergeSource.slice(functionStart, functionEnd);
37
+ }
38
+
23
39
  test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
24
40
  assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
25
41
  });
@@ -29,10 +45,10 @@ test('bug-audit lens prompt no longer instructs a per-lens git fetch', () => {
29
45
  });
30
46
 
31
47
  test('a single round-level prefetch step fetches origin/main before the parallel lenses', () => {
32
- assert.match(convergeSource, /function prefetchMainForRound\(/);
33
- const prefetchCallIndex = convergeSource.indexOf('await prefetchMainForRound(');
48
+ assert.ok(convergeSource.includes("resumeGitAgent(gitAgentId, 'prefetch-main')"));
49
+ const prefetchCallIndex = convergeSource.indexOf("resumeGitAgent(gitAgentId, 'prefetch-main')");
34
50
  const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
35
- assert.notEqual(prefetchCallIndex, -1, 'expected prefetchMainForRound to be invoked');
51
+ assert.notEqual(prefetchCallIndex, -1, 'expected prefetch to be invoked');
36
52
  assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
37
53
  assert.ok(
38
54
  prefetchCallIndex < parallelLensIndex,
@@ -69,7 +85,7 @@ test('gotchas doc states parallel lenses must avoid concurrent git operations',
69
85
  });
70
86
 
71
87
  test('repair-convergence edit step filters unresolved threads to bot authors and skips human threads', () => {
72
- const repairPrompt = lensPromptBody('repairConvergenceEdit');
88
+ const repairPrompt = functionSource('resumeCodeEditorAgent');
73
89
  assert.match(
74
90
  repairPrompt,
75
91
  /cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
@@ -83,7 +99,7 @@ test('repair-convergence edit step filters unresolved threads to bot authors and
83
99
  });
84
100
 
85
101
  test('repair-convergence edit step no longer instructs resolving every unresolved thread without an author filter', () => {
86
- const repairPrompt = lensPromptBody('repairConvergenceEdit');
102
+ const repairPrompt = functionSource('resumeCodeEditorAgent');
87
103
  assert.doesNotMatch(
88
104
  repairPrompt,
89
105
  /fetch every thread where isResolved is false/,
@@ -179,12 +195,12 @@ test('the COPILOT fix branch does not re-assign head from the fix before re-conv
179
195
  test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
180
196
  const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
181
197
  assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
182
- const resolveHeadIndex = convergeSource.indexOf('head = await resolveHead()', convergeBranchStart);
183
- assert.notEqual(resolveHeadIndex, -1, 'expected CONVERGE to re-resolve HEAD via resolveHead()');
198
+ const headResolveCallIndex = convergeSource.indexOf("resumeGitAgent(gitAgentId, 'resolve-head')", convergeBranchStart);
199
+ assert.notEqual(headResolveCallIndex, -1, 'expected CONVERGE to re-resolve HEAD via resumeGitAgent');
184
200
  });
185
201
 
186
202
  test('fix edit prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
187
- const editPrompt = lensPromptBody('applyFixesEdit');
203
+ const editPrompt = functionSource('resumeCodeEditorAgent');
188
204
  assert.match(editPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
189
205
  assert.match(
190
206
  editPrompt,
@@ -200,24 +216,16 @@ test('fix edit prompt resolves threads by PRRT thread node id looked up from the
200
216
 
201
217
  test('fix edit prompt does not pass the numeric comment id straight to resolve_thread', () => {
202
218
  assert.doesNotMatch(
203
- lensPromptBody('applyFixesEdit'),
219
+ functionSource('resumeCodeEditorAgent'),
204
220
  /then resolve that thread \(use the github MCP pull_request_review_write/,
205
221
  'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
206
222
  );
207
223
  });
208
224
 
209
- test('the fix flow spawns a code-verifier step between the edit step and the commit step', () => {
225
+ test('the fix flow spawns a fixer agent and runs fixerWithRecovery after the edit step', () => {
210
226
  const applyFixesBody = lensPromptBody('applyFixes');
211
- const editIndex = applyFixesBody.indexOf('applyFixesEdit(');
212
- const verifyIndex = applyFixesBody.indexOf('verifyFixesInWorkingTree(');
213
- const commitIndex = applyFixesBody.indexOf('commitVerifiedFixes(');
214
- assert.notEqual(editIndex, -1, 'expected applyFixes to call the edit step');
215
- assert.notEqual(verifyIndex, -1, 'expected applyFixes to call the verify step');
216
- assert.notEqual(commitIndex, -1, 'expected applyFixes to call the commit step');
217
- assert.ok(
218
- editIndex < verifyIndex && verifyIndex < commitIndex,
219
- 'expected the order edit -> verify -> commit so the verifier verdict binds the fixed working tree',
220
- );
227
+ assert.match(applyFixesBody, /spawnFixerAgent\(/, 'expected applyFixes to call spawnFixerAgent');
228
+ assert.match(applyFixesBody, /fixerWithRecovery\(/, 'expected applyFixes to call fixerWithRecovery');
221
229
  });
222
230
 
223
231
  test('the shared verdict-fence builder names the binding-hash command and the verdict fence', () => {
@@ -261,21 +269,13 @@ test('the verdict-fence binding does not self-resolve a cwd via git rev-parse fo
261
269
  });
262
270
 
263
271
  test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids edits', () => {
264
- for (const verifyFunctionName of [
265
- 'verifyFixesInWorkingTree',
266
- 'verifyRepairChanges',
267
- ]) {
272
+ for (const verifyFunctionName of ['resumeVerifierAgent']) {
268
273
  const verifyBody = lensPromptBody(verifyFunctionName);
269
274
  assert.match(
270
275
  verifyBody,
271
276
  /buildVerdictFenceSteps\(/,
272
277
  `expected ${verifyFunctionName} to call buildVerdictFenceSteps (cwd-immune branch binding)`,
273
278
  );
274
- assert.doesNotMatch(
275
- verifyBody,
276
- /VERDICT_FENCE_STEPS(?!\s*\))/,
277
- `expected ${verifyFunctionName} not to reference the removed VERDICT_FENCE_STEPS constant`,
278
- );
279
279
  assert.match(
280
280
  verifyBody,
281
281
  /agentType:\s*'code-verifier'/,
@@ -294,125 +294,91 @@ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and fo
294
294
  }
295
295
  });
296
296
 
297
- test('verifyHardeningChanges uses --manifest-hash-for-branch with the hardening branch, uses code-verifier, and forbids edits', () => {
298
- const verifyBody = lensPromptBody('verifyHardeningChanges');
297
+ test('resumeFixerAgent verify-commit path uses code-verifier and calls buildVerdictFenceSteps', () => {
298
+ const fixerBody = lensPromptBody('resumeFixerAgent');
299
+ assert.match(fixerBody, /buildVerdictFenceSteps\(/, 'expected resumeFixerAgent to call buildVerdictFenceSteps');
300
+ assert.match(fixerBody, /agentType:\s*'code-verifier'/, 'expected resumeFixerAgent to use code-verifier');
301
+ assert.match(fixerBody, /do no edits|make no edits|not edit|no file edits/i, 'expected resumeFixerAgent to forbid edits');
302
+ });
303
+
304
+ test('resumeVerifierAgent uses --manifest-hash-for-branch with the hardening branch and forbids edits', () => {
305
+ const verifyBody = lensPromptBody('resumeVerifierAgent');
299
306
  assert.match(
300
307
  verifyBody,
301
308
  /--manifest-hash-for-branch/,
302
- 'expected verifyHardeningChanges to bind by hardening branch (cwd-immune)',
303
- );
304
- assert.doesNotMatch(
305
- verifyBody,
306
- /--manifest-hash(?!-for-branch)/,
307
- 'expected verifyHardeningChanges not to use the old --manifest-hash <REPO> form',
308
- );
309
- assert.match(
310
- verifyBody,
311
- /agentType:\s*'code-verifier'/,
312
- 'expected verifyHardeningChanges to spawn the code-verifier agent type',
313
- );
314
- assert.doesNotMatch(
315
- verifyBody,
316
- /schema:/,
317
- 'expected verifyHardeningChanges to pass no schema so its verdict fence stays as assistant text',
309
+ 'expected the verifier to bind by hardening branch (cwd-immune)',
318
310
  );
319
311
  assert.match(
320
312
  verifyBody,
321
313
  /do no edits|make no edits|not edit|no file edits/i,
322
- 'expected verifyHardeningChanges to be told to make no edits',
314
+ 'expected the verifier to be told to make no edits',
323
315
  );
324
316
  });
325
317
 
326
- test('verifyFixesInWorkingTree and verifyRepairChanges pass input.owner, input.repo, input.prNumber to buildVerdictFenceSteps', () => {
327
- for (const verifyFunctionName of ['verifyFixesInWorkingTree', 'verifyRepairChanges']) {
318
+ test('resumeVerifierAgent and resumeFixerAgent pass PR coordinates to buildVerdictFenceSteps', () => {
319
+ for (const verifyFunctionName of ['resumeFixerAgent', 'resumeVerifierAgent']) {
328
320
  const verifyBody = lensPromptBody(verifyFunctionName);
329
321
  assert.match(
330
322
  verifyBody,
331
- /buildVerdictFenceSteps\(input\.owner,\s*input\.repo,\s*input\.prNumber\)/,
332
- `expected ${verifyFunctionName} to pass PR coordinates to buildVerdictFenceSteps`,
323
+ /buildVerdictFenceSteps\(/,
324
+ `expected ${verifyFunctionName} to call buildVerdictFenceSteps`,
333
325
  );
334
326
  }
335
327
  });
336
328
 
337
- test('the commit step is instructed to make no further file edits', () => {
338
- const commitBody = lensPromptBody('commitVerifiedFixes');
329
+ test('the commit path in resumeFixerAgent forbids further edits and uses clean-coder', () => {
330
+ const fixerBody = lensPromptBody('resumeFixerAgent');
339
331
  assert.match(
340
- commitBody,
332
+ fixerBody,
341
333
  /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
342
- 'expected the commit step to forbid further edits so the verified surface stays bound',
334
+ 'expected the commit path to forbid further edits',
343
335
  );
344
336
  assert.match(
345
- commitBody,
337
+ fixerBody,
346
338
  /agentType:\s*'clean-coder'/,
347
- 'expected the commit step to use clean-coder',
339
+ 'expected the commit path to use clean-coder',
348
340
  );
349
341
  });
350
342
 
351
- test('the repair flow spawns a code-verifier step between the edit step and the commit step', () => {
343
+ test('the repair flow uses resume helpers for edit, verify, and commit', () => {
352
344
  const repairBody = lensPromptBody('repairConvergence');
353
- const editIndex = repairBody.indexOf('repairConvergenceEdit(');
354
- const verifyIndex = repairBody.indexOf('verifyRepairChanges(');
355
- const commitIndex = repairBody.indexOf('commitRepairFixes(');
356
- assert.notEqual(editIndex, -1, 'expected repairConvergence to call the edit step');
357
- assert.notEqual(verifyIndex, -1, 'expected repairConvergence to call the verify step');
358
- assert.notEqual(commitIndex, -1, 'expected repairConvergence to call the commit step');
359
- assert.ok(
360
- editIndex < verifyIndex && verifyIndex < commitIndex,
361
- 'expected edit -> verify -> commit so the verifier verdict binds the repaired working tree',
362
- );
363
- assert.match(
364
- repairBody,
365
- /verdictPassed\(/,
366
- 'expected the verify verdict to gate the repair commit step',
367
- );
345
+ assert.match(repairBody, /spawnCodeEditorAgent\(/, 'expected repairConvergence to spawn a code-editor');
346
+ assert.match(repairBody, /spawnVerifierAgent\(/, 'expected repairConvergence to spawn a verifier');
347
+ assert.match(repairBody, /verdictPassed\(/, 'expected the verify verdict to gate the repair commit step');
368
348
  });
369
349
 
370
- test('the standards-deferral flow spawns a code-verifier step between the edit step and the commit step', () => {
350
+ test('the standards-deferral flow uses resume helpers for edit, verify, and commit', () => {
371
351
  const standardsBody = lensPromptBody('spawnStandardsFollowUp');
372
- const editIndex = standardsBody.indexOf('standardsFollowUpEdit(');
373
- const verifyIndex = standardsBody.indexOf('verifyHardeningChanges(');
374
- const commitIndex = standardsBody.indexOf('commitHardeningPr(');
375
- assert.notEqual(editIndex, -1, 'expected spawnStandardsFollowUp to call the edit step');
376
- assert.notEqual(verifyIndex, -1, 'expected spawnStandardsFollowUp to call the verify step');
377
- assert.notEqual(commitIndex, -1, 'expected spawnStandardsFollowUp to call the commit step');
378
- assert.ok(
379
- editIndex < verifyIndex && verifyIndex < commitIndex,
380
- 'expected edit -> verify -> commit so the verifier verdict binds the hardening working tree',
352
+ assert.match(standardsBody, /spawnCodeEditorAgent\(/, 'expected spawnStandardsFollowUp to spawn a code-editor');
353
+ assert.match(standardsBody, /spawnVerifierAgent\(/, 'expected spawnStandardsFollowUp to spawn a verifier');
354
+ assert.match(standardsBody, /verdictPassed\(/, 'expected the verify verdict to gate the hardening commit step');
355
+ });
356
+
357
+ test('repair-commit and hardening-commit paths use clean-coder and forbid edits', () => {
358
+ const codeEditorBody = lensPromptBody('resumeCodeEditorAgent');
359
+ assert.match(
360
+ codeEditorBody,
361
+ /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
362
+ 'expected the commit paths to forbid further edits',
381
363
  );
382
364
  assert.match(
383
- standardsBody,
384
- /verdictPassed\(/,
385
- 'expected the verify verdict to gate the hardening commit step',
365
+ codeEditorBody,
366
+ /agentType:\s*'clean-coder'/,
367
+ 'expected the commit paths to use clean-coder',
386
368
  );
387
369
  });
388
370
 
389
- test('the repair and hardening commit steps forbid further edits and use clean-coder', () => {
390
- for (const commitFunctionName of ['commitRepairFixes', 'commitHardeningPr']) {
391
- const commitBody = lensPromptBody(commitFunctionName);
392
- assert.match(
393
- commitBody,
394
- /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
395
- `expected ${commitFunctionName} to forbid further edits so the verified surface stays bound`,
396
- );
397
- assert.match(
398
- commitBody,
399
- /agentType:\s*'clean-coder'/,
400
- `expected ${commitFunctionName} to use clean-coder`,
401
- );
402
- }
403
- });
404
-
405
- test('the standards-deferral edit step stages the hardening change without committing', () => {
406
- const editBody = lensPromptBody('standardsFollowUpEdit');
371
+ test('the code-editor standards-edit path stages hardening without committing and uses clean-coder', () => {
372
+ const editBody = lensPromptBody('resumeCodeEditorAgent');
407
373
  assert.match(
408
374
  editBody,
409
375
  /do not commit and do not push|NO commit and NO push|Do NOT commit/i,
410
- 'expected the standards edit step to leave the hardening change uncommitted',
376
+ 'expected the standards edit path to leave the hardening change uncommitted',
411
377
  );
412
378
  assert.match(
413
379
  editBody,
414
380
  /agentType:\s*'clean-coder'/,
415
- 'expected the standards edit step to use clean-coder',
381
+ 'expected the edit paths to use clean-coder',
416
382
  );
417
383
  });
418
384
 
@@ -530,20 +496,336 @@ test('the pre-commit gate step is a shared constant that dry-runs the CODE_RULES
530
496
  );
531
497
  });
532
498
 
533
- const editStepBuilders = [
499
+ const editStepResumeHelpers = ['resumeCodeEditorAgent', 'resumeFixerAgent'];
500
+
501
+ for (const helperName of editStepResumeHelpers) {
502
+ test(`${helperName} appends the pre-commit gate step to its edit prompts`, () => {
503
+ assert.match(
504
+ functionSource(helperName),
505
+ /\+\s*PRE_COMMIT_GATE_STEP/,
506
+ `expected ${helperName} to append PRE_COMMIT_GATE_STEP to its edit-task prompts`,
507
+ );
508
+ });
509
+ }
510
+
511
+ const editStepResumeTasks = [
512
+ ['resumeCodeEditorAgent', 'fix-edit'],
513
+ ['resumeCodeEditorAgent', 'repair-edit'],
514
+ ['resumeCodeEditorAgent', 'standards-edit'],
515
+ ['resumeCodeEditorAgent', 'commit-recover'],
516
+ ];
517
+
518
+ for (const [helperName, taskName] of editStepResumeTasks) {
519
+ test(`${helperName} routes the ${taskName} task to a pre-commit-gated edit prompt`, () => {
520
+ assert.match(
521
+ functionSource(helperName),
522
+ new RegExp(`task === '${taskName}'`),
523
+ `expected ${helperName} to handle the ${taskName} task`,
524
+ );
525
+ });
526
+ }
527
+
528
+ function preambleText() {
529
+ const preambleStart = convergeSource.indexOf('const HEADLESS_SAFETY_PREAMBLE =');
530
+ assert.notEqual(preambleStart, -1, 'expected HEADLESS_SAFETY_PREAMBLE to exist');
531
+ const preambleEnd = convergeSource.indexOf('\n\nlet ', preambleStart);
532
+ return convergeSource.slice(preambleStart, preambleEnd === -1 ? undefined : preambleEnd);
533
+ }
534
+
535
+ test('preamble prescribes authoring a Python helper for variable-built or multi-step sandboxes', () => {
536
+ assert.match(
537
+ preambleText(),
538
+ /python\s+<file>\.py|python\s+<.*>\.py|author.*python.*helper|python.*helper.*sandbox|sandbox.*python.*helper/i,
539
+ 'expected the preamble to prescribe running a Python helper file for multi-step sandbox teardown',
540
+ );
541
+ });
542
+
543
+ test('preamble does not claim the standalone or both rm auto-allow paths fail closed on any $', () => {
544
+ const text = preambleText().replace(/\$\(\.\.\.\)/g, 'SUBSHELL').replace(/\s+/g, ' ');
545
+ const overstatesStandalone =
546
+ /\b(?:both|standalone|neither)\b[^.]*fail closed[^.]*any \$/i.test(text) ||
547
+ /\b(?:both|standalone|neither)\b[^.]*any \$[^.]*fail closed/i.test(text);
548
+ assert.equal(
549
+ overstatesStandalone,
550
+ false,
551
+ 'only the compound path fails closed on any $ in the target; the standalone path accepts a $-bearing target whose literal path already sits under an ephemeral root',
552
+ );
553
+ });
554
+
555
+ test('preamble does not claim $CLAUDE_JOB_DIR/tmp is blocked', () => {
556
+ assert.doesNotMatch(
557
+ preambleText(),
558
+ /CLAUDE_JOB_DIR\/tmp is NOT auto-allowed/i,
559
+ 'under an ephemeral cwd the hook auto-allows rm targeting $CLAUDE_JOB_DIR/tmp',
560
+ );
561
+ });
562
+
563
+ test('preamble scopes its rm-shape claim to the narrowest auto-allow path, not the full set', () => {
564
+ assert.doesNotMatch(
565
+ preambleText(),
566
+ /auto-allows rm only when ALL of these hold/i,
567
+ 'the hook has three rm auto-allow paths, so the preamble must not assert one narrow shape is the complete set',
568
+ );
569
+ });
570
+
571
+ test('SKILL.md does not claim the standalone or both rm auto-allow paths fail closed on any $', () => {
572
+ const text = skillSource.replace(/`/g, '').replace(/\$\(\.\.\.\)/g, 'SUBSHELL').replace(/\s+/g, ' ');
573
+ const overstatesStandalone =
574
+ /\b(?:both|standalone|neither)\b[^.]*fail closed[^.]*any \$/i.test(text) ||
575
+ /\b(?:both|standalone|neither)\b[^.]*any \$[^.]*fail closed/i.test(text);
576
+ assert.equal(
577
+ overstatesStandalone,
578
+ false,
579
+ 'only the compound path fails closed on any $ in the target; the standalone path accepts a $-bearing target whose literal path already sits under an ephemeral root',
580
+ );
581
+ });
582
+
583
+ test('SKILL.md does not claim it enforces the exact rm shape the hook auto-allows', () => {
584
+ assert.doesNotMatch(
585
+ skillSource,
586
+ /exact rm shape the hook auto-allows/i,
587
+ 'the hook has multiple rm auto-allow paths, so SKILL.md must not assert one narrow shape is the exact set',
588
+ );
589
+ });
590
+
591
+ test('preamble does not attribute the known-temp-var resolution to the standalone or compound paths', () => {
592
+ assert.doesNotMatch(
593
+ preambleText().replace(/\s+/g, ' '),
594
+ /Across these paths[\s\S]*?CLAUDE_JOB_DIR/i,
595
+ 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths do not resolve known temp variables',
596
+ );
597
+ });
598
+
599
+ test('preamble attributes the known-temp-var resolution to a third cwd-scoped auto-allow path', () => {
600
+ const text = preambleText().replace(/\s+/g, ' ');
601
+ const tempVarSentenceMatch =
602
+ /[^.]*\bTMPDIR\b[^.]*CLAUDE_JOB_DIR[^.]*\./i.exec(text);
603
+ assert.notEqual(
604
+ tempVarSentenceMatch,
605
+ null,
606
+ 'expected a sentence describing the TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR resolution',
607
+ );
608
+ assert.match(
609
+ tempVarSentenceMatch[0],
610
+ /declares? an ephemeral cwd|declared ephemeral cwd|ephemeral-cwd path|third (?:auto-allow )?path|cwd-scoped path/i,
611
+ 'expected the temp-var resolution to be tied to the cwd-scoped path that declares an ephemeral working directory, not the standalone or compound paths',
612
+ );
613
+ });
614
+
615
+ test('SKILL.md does not attribute the known-temp-var resolution to the standalone or compound paths', () => {
616
+ assert.doesNotMatch(
617
+ skillSource.replace(/\s+/g, ' '),
618
+ /Across those paths[\s\S]*?CLAUDE_JOB_DIR/i,
619
+ 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths do not resolve known temp variables',
620
+ );
621
+ });
622
+
623
+ test('SKILL.md attributes the known-temp-var resolution to the cwd-scoped auto-allow path', () => {
624
+ const tempVarSentenceMatch =
625
+ /[^.]*\bTMPDIR\b[^.]*CLAUDE_JOB_DIR[^.]*\./i.exec(skillSource.replace(/\s+/g, ' '));
626
+ assert.notEqual(
627
+ tempVarSentenceMatch,
628
+ null,
629
+ 'expected a sentence describing the TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR resolution',
630
+ );
631
+ assert.match(
632
+ tempVarSentenceMatch[0],
633
+ /declares? an ephemeral cwd|declared ephemeral cwd|ephemeral-cwd path|third (?:auto-allow )?path|cwd-scoped path/i,
634
+ 'expected the temp-var resolution to be tied to the cwd-scoped path that declares an ephemeral working directory, not the standalone or compound paths',
635
+ );
636
+ });
637
+
638
+ test('convergeAgent prepends HEADLESS_SAFETY_PREAMBLE and worktree directive on fresh spawn', () => {
639
+ const convergeAgentBody = lensPromptBody('convergeAgent');
640
+ assert.match(
641
+ convergeAgentBody,
642
+ /HEADLESS_SAFETY_PREAMBLE.*worktreeDirective/,
643
+ 'expected fresh-spawn path to prepend both preamble and worktree directive',
644
+ );
645
+ });
646
+
647
+ test('convergeAgent checks isResume before prepending the preamble', () => {
648
+ const convergeAgentBody = lensPromptBody('convergeAgent');
649
+ assert.match(convergeAgentBody, /isResume/, 'expected an isResume guard in convergeAgent');
650
+ });
651
+
652
+ const newSpawnResumeHelpers = [
653
+ { name: 'spawnGitAgent', isAsync: true },
654
+ { name: 'resumeGitAgent', isAsync: false },
655
+ { name: 'spawnFixerAgent', isAsync: true },
656
+ { name: 'resumeFixerAgent', isAsync: false },
657
+ { name: 'fixerWithRecovery', isAsync: true },
658
+ { name: 'spawnCodeEditorAgent', isAsync: true },
659
+ { name: 'resumeCodeEditorAgent', isAsync: false },
660
+ { name: 'spawnVerifierAgent', isAsync: true },
661
+ { name: 'resumeVerifierAgent', isAsync: false },
662
+ { name: 'spawnGeneralUtilityAgent', isAsync: true },
663
+ { name: 'resumeGeneralUtilityAgent', isAsync: false },
664
+ { name: 'spawnConvergenceCheckAgent', isAsync: true },
665
+ { name: 'resumeConvergenceCheckAgent', isAsync: false },
666
+ { name: 'extractVerdict', isAsync: false },
667
+ ];
668
+
669
+ for (const { name, isAsync } of newSpawnResumeHelpers) {
670
+ const prefix = isAsync ? 'async ' : '';
671
+ test(`function ${prefix}${name} exists in converge.mjs`, () => {
672
+ const needle = isAsync ? `async function ${name}(` : `function ${name}(`;
673
+ assert.ok(convergeSource.includes(needle), `expected ${name} to exist`);
674
+ });
675
+ }
676
+
677
+ const spawnHelperNames = [
678
+ 'spawnGitAgent',
679
+ 'spawnFixerAgent',
680
+ 'spawnCodeEditorAgent',
681
+ 'spawnVerifierAgent',
682
+ 'spawnGeneralUtilityAgent',
683
+ 'spawnConvergenceCheckAgent',
684
+ ];
685
+
686
+ for (const spawnName of spawnHelperNames) {
687
+ test(`${spawnName} actually spawns an agent via convergeAgent`, () => {
688
+ const spawnBody = functionSource(spawnName);
689
+ assert.match(
690
+ spawnBody,
691
+ /await convergeAgent\(/,
692
+ `expected ${spawnName} to spawn a real agent through convergeAgent rather than return a hardcoded label`,
693
+ );
694
+ });
695
+
696
+ test(`${spawnName} returns the spawned agent's runtime id`, () => {
697
+ const spawnBody = functionSource(spawnName);
698
+ assert.match(
699
+ spawnBody,
700
+ /return\s+result\?\.agentId/,
701
+ `expected ${spawnName} to return result?.agentId so resume targets the real session`,
702
+ );
703
+ assert.doesNotMatch(
704
+ spawnBody,
705
+ /return\s+['"`]/,
706
+ `expected ${spawnName} not to return a hardcoded label string`,
707
+ );
708
+ });
709
+ }
710
+
711
+ test('spawnGitAgent returns result?.agentId without a tautological ternary', () => {
712
+ const spawnBody = functionSource('spawnGitAgent');
713
+ assert.doesNotMatch(
714
+ spawnBody,
715
+ /\?\s*'git-utility'\s*:\s*'git-utility'/,
716
+ 'expected the identical-branch ternary that discards the spawn outcome to be gone',
717
+ );
718
+ });
719
+
720
+ test('resume helpers fall back to a fresh spawn when no agentId is available', () => {
721
+ const convergeAgentBody = lensPromptBody('convergeAgent');
722
+ assert.match(
723
+ convergeAgentBody,
724
+ /options\?\.resume.*length\s*>\s*0|length\s*>\s*0.*resume/s,
725
+ 'expected convergeAgent to treat a missing agentId as a fresh spawn (isResume false), restoring the preamble and worktree directive',
726
+ );
727
+ });
728
+
729
+ test('resumeGeneralUtilityAgent only handles the two tasks it is called with', () => {
730
+ const generalBody = functionSource('resumeGeneralUtilityAgent');
731
+ assert.doesNotMatch(
732
+ generalBody,
733
+ /task === 'bugbot-lens'/,
734
+ 'the live Bugbot lens is runBugbotLens; the dead bugbot-lens branch must be removed',
735
+ );
736
+ assert.doesNotMatch(
737
+ generalBody,
738
+ /Copilot can run out of usage/,
739
+ 'the live Copilot gate is runCopilotGate; the dead copilot-gate branch must be removed',
740
+ );
741
+ assert.doesNotMatch(
742
+ generalBody,
743
+ /convergence summary/,
744
+ 'the convergence-summary producer was removed; the dead branch must not return',
745
+ );
746
+ });
747
+
748
+ const orphanedHelperNames = [
534
749
  'applyFixesEdit',
535
750
  'recoverCommitBlockEdit',
536
751
  'recoverVerifyFailEdit',
752
+ 'checkConvergence',
753
+ 'markReady',
537
754
  'repairConvergenceEdit',
755
+ 'verifyRepairChanges',
756
+ 'commitRepairFixes',
757
+ 'resolveConflictsEdit',
538
758
  'standardsFollowUpEdit',
759
+ 'verifyHardeningChanges',
760
+ 'commitHardeningPr',
761
+ 'postCleanAudit',
762
+ 'spawnConvergenceSummary',
539
763
  ];
540
764
 
541
- for (const builderName of editStepBuilders) {
542
- test(`${builderName} appends the pre-commit gate step to its edit prompt`, () => {
543
- assert.match(
544
- lensPromptBody(builderName),
545
- /\+\s*PRE_COMMIT_GATE_STEP/,
546
- `expected ${builderName} to append PRE_COMMIT_GATE_STEP`,
765
+ for (const orphanName of orphanedHelperNames) {
766
+ test(`${orphanName} is removed its behavior lives in a resume helper`, () => {
767
+ assert.ok(
768
+ !convergeSource.includes(`function ${orphanName}(`),
769
+ `expected the orphaned ${orphanName} definition to be deleted (CODE_RULES 9.8)`,
547
770
  );
548
771
  });
549
772
  }
773
+
774
+ test('convergeAgent omits the preamble when options.resume is a non-empty string', () => {
775
+ const convergeAgentBody = lensPromptBody('convergeAgent');
776
+ assert.match(
777
+ convergeAgentBody,
778
+ /isResume\s*\?\s*prompt/,
779
+ 'expected the resume path to pass prompt through without the preamble',
780
+ );
781
+ });
782
+
783
+ test('parseLastVerdictFence returns non-null for a verdict fence with valid JSON', () => {
784
+ const parseModule = new Function(
785
+ `${functionSource('parseLastVerdictFence')}\n` +
786
+ 'return { parseLastVerdictFence };',
787
+ )();
788
+ const result = parseModule.parseLastVerdictFence('```verdict\n{"all_pass":true,"findings":[],"manifest_sha256":"abc"}\n```');
789
+ assert.notEqual(result, null);
790
+ assert.equal(result.all_pass, true);
791
+ });
792
+
793
+ test('parseLastVerdictFence returns null for non-string input', () => {
794
+ const parseModule = new Function(
795
+ `${functionSource('parseLastVerdictFence')}\n` +
796
+ 'return { parseLastVerdictFence };',
797
+ )();
798
+ assert.equal(parseModule.parseLastVerdictFence(null), null);
799
+ assert.equal(parseModule.parseLastVerdictFence(undefined), null);
800
+ });
801
+
802
+ test('parseLastVerdictFence returns null when no verdict fence is present', () => {
803
+ const parseModule = new Function(
804
+ `${functionSource('parseLastVerdictFence')}\n` +
805
+ 'return { parseLastVerdictFence };',
806
+ )();
807
+ assert.equal(parseModule.parseLastVerdictFence('plain text with no fence'), null);
808
+ });
809
+
810
+ test('parseLastVerdictFence returns null for malformed JSON in the fence', () => {
811
+ const parseModule = new Function(
812
+ `${functionSource('parseLastVerdictFence')}\n` +
813
+ 'return { parseLastVerdictFence };',
814
+ )();
815
+ assert.equal(parseModule.parseLastVerdictFence('```verdict\nnot json\n```'), null);
816
+ });
817
+
818
+ test('verdictPassed calls parseLastVerdictFence', () => {
819
+ const verdictBody = lensPromptBody('verdictPassed');
820
+ assert.match(verdictBody, /parseLastVerdictFence\(/, 'expected verdictPassed to call the shared parser');
821
+ });
822
+
823
+ test('extractVerifyObjection calls parseLastVerdictFence', () => {
824
+ const objectionBody = lensPromptBody('extractVerifyObjection');
825
+ assert.match(objectionBody, /parseLastVerdictFence\(/, 'expected extractVerifyObjection to call the shared parser');
826
+ });
827
+
828
+ test('extractVerdict calls parseLastVerdictFence', () => {
829
+ const verdictBody = lensPromptBody('extractVerdict');
830
+ assert.match(verdictBody, /parseLastVerdictFence\(/, 'expected extractVerdict to call the shared parser');
831
+ });