claude-dev-env 1.74.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 (44) hide show
  1. package/hooks/blocking/CLAUDE.md +1 -0
  2. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
  3. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  4. package/hooks/blocking/hedging_language_blocker.py +1 -13
  5. package/hooks/blocking/intent_only_ending_blocker.py +1 -15
  6. package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
  7. package/hooks/blocking/question_to_user_enforcer.py +1 -11
  8. package/hooks/blocking/session_handoff_blocker.py +1 -15
  9. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
  10. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  11. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  12. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  13. package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
  14. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  15. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  16. package/hooks/hooks_constants/CLAUDE.md +4 -1
  17. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  18. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  19. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
  20. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
  21. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  22. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  23. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  24. package/hooks/hooks_constants/text_stripping.py +36 -0
  25. package/hooks/validation/CLAUDE.md +1 -0
  26. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  27. package/hooks/validation/test_mypy_validator.py +1 -1
  28. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  29. package/hooks/workflow/auto_formatter.py +8 -5
  30. package/hooks/workflow/test_auto_formatter.py +33 -0
  31. package/package.json +1 -1
  32. package/rules/windows-filesystem-safe.md +2 -0
  33. package/skills/autoconverge/SKILL.md +6 -3
  34. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  35. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  36. package/skills/autoconverge/workflow/converge.contract.test.mjs +308 -132
  37. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  38. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  39. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  40. package/skills/autoconverge/workflow/converge.mjs +598 -606
  41. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  42. package/skills/autoconverge/workflow/render_report.py +2 -6
  43. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  44. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -40,8 +40,9 @@ test('cleanAuditBlocker falls back to a no-result reason when the post agent die
40
40
  assert.match(message, /the post agent returned no result/);
41
41
  });
42
42
 
43
- test('postCleanAudit returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
44
- const body = functionBody('postCleanAudit');
43
+ test('the post-clean-audit task in resumeGeneralUtilityAgent returns the CLEAN_AUDIT_SCHEMA result rather than an unused transcript', () => {
44
+ const body = functionBody('resumeGeneralUtilityAgent');
45
+ assert.match(body, /task === 'post-clean-audit'/);
45
46
  assert.match(body, /schema: CLEAN_AUDIT_SCHEMA/);
46
47
  assert.doesNotMatch(body, /agent transcript \(unused\)/);
47
48
  });
@@ -58,7 +59,7 @@ test('the standards-only call site breaks with a clean-audit blocker when the po
58
59
  convergeSource.indexOf('if (isStandardsOnlyRound(findings)) {'),
59
60
  convergeSource.indexOf('if (findings.length > 0) {'),
60
61
  );
61
- assert.match(branch, /const auditResult = await postCleanAudit\(head\)/);
62
+ assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
62
63
  assert.match(branch, /if \(!auditResult\?\.posted\)/);
63
64
  assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
64
65
  assert.match(branch, /\bbreak\b/);
@@ -69,7 +70,7 @@ test('the all-clean call site breaks with a clean-audit blocker when the post do
69
70
  convergeSource.indexOf('all lenses clean on'),
70
71
  convergeSource.indexOf("if (phase === 'COPILOT') {"),
71
72
  );
72
- assert.match(branch, /const auditResult = await postCleanAudit\(head\)/);
73
+ assert.match(branch, /resumeGeneralUtilityAgent\(.*'post-clean-audit'/);
73
74
  assert.match(branch, /if \(!auditResult\?\.posted\)/);
74
75
  assert.match(branch, /blocker = cleanAuditBlocker\(head, auditResult\)/);
75
76
  assert.match(branch, /\bbreak\b/);
@@ -16,14 +16,26 @@ const skillSource = readFileSync(
16
16
  );
17
17
 
18
18
  function lensPromptBody(builderName) {
19
- const builderStart = convergeSource.indexOf(`function ${builderName}(`);
20
- 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
+ }
21
24
  const nextBuilderMatch = /\n(?:async )?function /.exec(convergeSource.slice(builderStart + 1));
22
25
  const builderEnd =
23
26
  nextBuilderMatch === null ? convergeSource.length : builderStart + 1 + nextBuilderMatch.index;
24
27
  return convergeSource.slice(builderStart, builderEnd);
25
28
  }
26
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
+
27
39
  test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
28
40
  assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
29
41
  });
@@ -33,10 +45,10 @@ test('bug-audit lens prompt no longer instructs a per-lens git fetch', () => {
33
45
  });
34
46
 
35
47
  test('a single round-level prefetch step fetches origin/main before the parallel lenses', () => {
36
- assert.match(convergeSource, /function prefetchMainForRound\(/);
37
- const prefetchCallIndex = convergeSource.indexOf('await prefetchMainForRound(');
48
+ assert.ok(convergeSource.includes("resumeGitAgent(gitAgentId, 'prefetch-main')"));
49
+ const prefetchCallIndex = convergeSource.indexOf("resumeGitAgent(gitAgentId, 'prefetch-main')");
38
50
  const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
39
- assert.notEqual(prefetchCallIndex, -1, 'expected prefetchMainForRound to be invoked');
51
+ assert.notEqual(prefetchCallIndex, -1, 'expected prefetch to be invoked');
40
52
  assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
41
53
  assert.ok(
42
54
  prefetchCallIndex < parallelLensIndex,
@@ -73,7 +85,7 @@ test('gotchas doc states parallel lenses must avoid concurrent git operations',
73
85
  });
74
86
 
75
87
  test('repair-convergence edit step filters unresolved threads to bot authors and skips human threads', () => {
76
- const repairPrompt = lensPromptBody('repairConvergenceEdit');
88
+ const repairPrompt = functionSource('resumeCodeEditorAgent');
77
89
  assert.match(
78
90
  repairPrompt,
79
91
  /cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
@@ -87,7 +99,7 @@ test('repair-convergence edit step filters unresolved threads to bot authors and
87
99
  });
88
100
 
89
101
  test('repair-convergence edit step no longer instructs resolving every unresolved thread without an author filter', () => {
90
- const repairPrompt = lensPromptBody('repairConvergenceEdit');
102
+ const repairPrompt = functionSource('resumeCodeEditorAgent');
91
103
  assert.doesNotMatch(
92
104
  repairPrompt,
93
105
  /fetch every thread where isResolved is false/,
@@ -183,12 +195,12 @@ test('the COPILOT fix branch does not re-assign head from the fix before re-conv
183
195
  test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
184
196
  const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
185
197
  assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
186
- const resolveHeadIndex = convergeSource.indexOf('head = await resolveHead()', convergeBranchStart);
187
- 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');
188
200
  });
189
201
 
190
202
  test('fix edit prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
191
- const editPrompt = lensPromptBody('applyFixesEdit');
203
+ const editPrompt = functionSource('resumeCodeEditorAgent');
192
204
  assert.match(editPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
193
205
  assert.match(
194
206
  editPrompt,
@@ -204,24 +216,16 @@ test('fix edit prompt resolves threads by PRRT thread node id looked up from the
204
216
 
205
217
  test('fix edit prompt does not pass the numeric comment id straight to resolve_thread', () => {
206
218
  assert.doesNotMatch(
207
- lensPromptBody('applyFixesEdit'),
219
+ functionSource('resumeCodeEditorAgent'),
208
220
  /then resolve that thread \(use the github MCP pull_request_review_write/,
209
221
  'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
210
222
  );
211
223
  });
212
224
 
213
- 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', () => {
214
226
  const applyFixesBody = lensPromptBody('applyFixes');
215
- const editIndex = applyFixesBody.indexOf('applyFixesEdit(');
216
- const verifyIndex = applyFixesBody.indexOf('verifyFixesInWorkingTree(');
217
- const commitIndex = applyFixesBody.indexOf('commitVerifiedFixes(');
218
- assert.notEqual(editIndex, -1, 'expected applyFixes to call the edit step');
219
- assert.notEqual(verifyIndex, -1, 'expected applyFixes to call the verify step');
220
- assert.notEqual(commitIndex, -1, 'expected applyFixes to call the commit step');
221
- assert.ok(
222
- editIndex < verifyIndex && verifyIndex < commitIndex,
223
- 'expected the order edit -> verify -> commit so the verifier verdict binds the fixed working tree',
224
- );
227
+ assert.match(applyFixesBody, /spawnFixerAgent\(/, 'expected applyFixes to call spawnFixerAgent');
228
+ assert.match(applyFixesBody, /fixerWithRecovery\(/, 'expected applyFixes to call fixerWithRecovery');
225
229
  });
226
230
 
227
231
  test('the shared verdict-fence builder names the binding-hash command and the verdict fence', () => {
@@ -265,21 +269,13 @@ test('the verdict-fence binding does not self-resolve a cwd via git rev-parse fo
265
269
  });
266
270
 
267
271
  test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and forbids edits', () => {
268
- for (const verifyFunctionName of [
269
- 'verifyFixesInWorkingTree',
270
- 'verifyRepairChanges',
271
- ]) {
272
+ for (const verifyFunctionName of ['resumeVerifierAgent']) {
272
273
  const verifyBody = lensPromptBody(verifyFunctionName);
273
274
  assert.match(
274
275
  verifyBody,
275
276
  /buildVerdictFenceSteps\(/,
276
277
  `expected ${verifyFunctionName} to call buildVerdictFenceSteps (cwd-immune branch binding)`,
277
278
  );
278
- assert.doesNotMatch(
279
- verifyBody,
280
- /VERDICT_FENCE_STEPS(?!\s*\))/,
281
- `expected ${verifyFunctionName} not to reference the removed VERDICT_FENCE_STEPS constant`,
282
- );
283
279
  assert.match(
284
280
  verifyBody,
285
281
  /agentType:\s*'code-verifier'/,
@@ -298,125 +294,91 @@ test('every verify step calls buildVerdictFenceSteps, uses code-verifier, and fo
298
294
  }
299
295
  });
300
296
 
301
- test('verifyHardeningChanges uses --manifest-hash-for-branch with the hardening branch, uses code-verifier, and forbids edits', () => {
302
- 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');
303
306
  assert.match(
304
307
  verifyBody,
305
308
  /--manifest-hash-for-branch/,
306
- 'expected verifyHardeningChanges to bind by hardening branch (cwd-immune)',
307
- );
308
- assert.doesNotMatch(
309
- verifyBody,
310
- /--manifest-hash(?!-for-branch)/,
311
- 'expected verifyHardeningChanges not to use the old --manifest-hash <REPO> form',
312
- );
313
- assert.match(
314
- verifyBody,
315
- /agentType:\s*'code-verifier'/,
316
- 'expected verifyHardeningChanges to spawn the code-verifier agent type',
317
- );
318
- assert.doesNotMatch(
319
- verifyBody,
320
- /schema:/,
321
- '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)',
322
310
  );
323
311
  assert.match(
324
312
  verifyBody,
325
313
  /do no edits|make no edits|not edit|no file edits/i,
326
- 'expected verifyHardeningChanges to be told to make no edits',
314
+ 'expected the verifier to be told to make no edits',
327
315
  );
328
316
  });
329
317
 
330
- test('verifyFixesInWorkingTree and verifyRepairChanges pass input.owner, input.repo, input.prNumber to buildVerdictFenceSteps', () => {
331
- for (const verifyFunctionName of ['verifyFixesInWorkingTree', 'verifyRepairChanges']) {
318
+ test('resumeVerifierAgent and resumeFixerAgent pass PR coordinates to buildVerdictFenceSteps', () => {
319
+ for (const verifyFunctionName of ['resumeFixerAgent', 'resumeVerifierAgent']) {
332
320
  const verifyBody = lensPromptBody(verifyFunctionName);
333
321
  assert.match(
334
322
  verifyBody,
335
- /buildVerdictFenceSteps\(input\.owner,\s*input\.repo,\s*input\.prNumber\)/,
336
- `expected ${verifyFunctionName} to pass PR coordinates to buildVerdictFenceSteps`,
323
+ /buildVerdictFenceSteps\(/,
324
+ `expected ${verifyFunctionName} to call buildVerdictFenceSteps`,
337
325
  );
338
326
  }
339
327
  });
340
328
 
341
- test('the commit step is instructed to make no further file edits', () => {
342
- const commitBody = lensPromptBody('commitVerifiedFixes');
329
+ test('the commit path in resumeFixerAgent forbids further edits and uses clean-coder', () => {
330
+ const fixerBody = lensPromptBody('resumeFixerAgent');
343
331
  assert.match(
344
- commitBody,
332
+ fixerBody,
345
333
  /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
346
- 'expected the commit step to forbid further edits so the verified surface stays bound',
334
+ 'expected the commit path to forbid further edits',
347
335
  );
348
336
  assert.match(
349
- commitBody,
337
+ fixerBody,
350
338
  /agentType:\s*'clean-coder'/,
351
- 'expected the commit step to use clean-coder',
339
+ 'expected the commit path to use clean-coder',
352
340
  );
353
341
  });
354
342
 
355
- 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', () => {
356
344
  const repairBody = lensPromptBody('repairConvergence');
357
- const editIndex = repairBody.indexOf('repairConvergenceEdit(');
358
- const verifyIndex = repairBody.indexOf('verifyRepairChanges(');
359
- const commitIndex = repairBody.indexOf('commitRepairFixes(');
360
- assert.notEqual(editIndex, -1, 'expected repairConvergence to call the edit step');
361
- assert.notEqual(verifyIndex, -1, 'expected repairConvergence to call the verify step');
362
- assert.notEqual(commitIndex, -1, 'expected repairConvergence to call the commit step');
363
- assert.ok(
364
- editIndex < verifyIndex && verifyIndex < commitIndex,
365
- 'expected edit -> verify -> commit so the verifier verdict binds the repaired working tree',
366
- );
367
- assert.match(
368
- repairBody,
369
- /verdictPassed\(/,
370
- 'expected the verify verdict to gate the repair commit step',
371
- );
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');
372
348
  });
373
349
 
374
- 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', () => {
375
351
  const standardsBody = lensPromptBody('spawnStandardsFollowUp');
376
- const editIndex = standardsBody.indexOf('standardsFollowUpEdit(');
377
- const verifyIndex = standardsBody.indexOf('verifyHardeningChanges(');
378
- const commitIndex = standardsBody.indexOf('commitHardeningPr(');
379
- assert.notEqual(editIndex, -1, 'expected spawnStandardsFollowUp to call the edit step');
380
- assert.notEqual(verifyIndex, -1, 'expected spawnStandardsFollowUp to call the verify step');
381
- assert.notEqual(commitIndex, -1, 'expected spawnStandardsFollowUp to call the commit step');
382
- assert.ok(
383
- editIndex < verifyIndex && verifyIndex < commitIndex,
384
- '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',
385
363
  );
386
364
  assert.match(
387
- standardsBody,
388
- /verdictPassed\(/,
389
- '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',
390
368
  );
391
369
  });
392
370
 
393
- test('the repair and hardening commit steps forbid further edits and use clean-coder', () => {
394
- for (const commitFunctionName of ['commitRepairFixes', 'commitHardeningPr']) {
395
- const commitBody = lensPromptBody(commitFunctionName);
396
- assert.match(
397
- commitBody,
398
- /no (?:further |additional )?(?:file )?edits|do not edit|make no edits/i,
399
- `expected ${commitFunctionName} to forbid further edits so the verified surface stays bound`,
400
- );
401
- assert.match(
402
- commitBody,
403
- /agentType:\s*'clean-coder'/,
404
- `expected ${commitFunctionName} to use clean-coder`,
405
- );
406
- }
407
- });
408
-
409
- test('the standards-deferral edit step stages the hardening change without committing', () => {
410
- 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');
411
373
  assert.match(
412
374
  editBody,
413
375
  /do not commit and do not push|NO commit and NO push|Do NOT commit/i,
414
- 'expected the standards edit step to leave the hardening change uncommitted',
376
+ 'expected the standards edit path to leave the hardening change uncommitted',
415
377
  );
416
378
  assert.match(
417
379
  editBody,
418
380
  /agentType:\s*'clean-coder'/,
419
- 'expected the standards edit step to use clean-coder',
381
+ 'expected the edit paths to use clean-coder',
420
382
  );
421
383
  });
422
384
 
@@ -534,20 +496,31 @@ test('the pre-commit gate step is a shared constant that dry-runs the CODE_RULES
534
496
  );
535
497
  });
536
498
 
537
- const editStepBuilders = [
538
- 'applyFixesEdit',
539
- 'recoverCommitBlockEdit',
540
- 'recoverVerifyFailEdit',
541
- 'repairConvergenceEdit',
542
- 'standardsFollowUpEdit',
543
- ];
499
+ const editStepResumeHelpers = ['resumeCodeEditorAgent', 'resumeFixerAgent'];
544
500
 
545
- for (const builderName of editStepBuilders) {
546
- test(`${builderName} appends the pre-commit gate step to its edit prompt`, () => {
501
+ for (const helperName of editStepResumeHelpers) {
502
+ test(`${helperName} appends the pre-commit gate step to its edit prompts`, () => {
547
503
  assert.match(
548
- lensPromptBody(builderName),
504
+ functionSource(helperName),
549
505
  /\+\s*PRE_COMMIT_GATE_STEP/,
550
- `expected ${builderName} to append 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`,
551
524
  );
552
525
  });
553
526
  }
@@ -567,11 +540,15 @@ test('preamble prescribes authoring a Python helper for variable-built or multi-
567
540
  );
568
541
  });
569
542
 
570
- test('preamble does not claim any $ in the rm target makes the gate fail closed', () => {
571
- assert.doesNotMatch(
572
- preambleText(),
573
- /any\s+\$[^\n]*fail closed/i,
574
- 'the hook resolves known temp variables (TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR), so a bare $ does not always fail closed',
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',
575
552
  );
576
553
  });
577
554
 
@@ -591,11 +568,15 @@ test('preamble scopes its rm-shape claim to the narrowest auto-allow path, not t
591
568
  );
592
569
  });
593
570
 
594
- test('SKILL.md does not claim any $ in the rm target makes the gate fail closed', () => {
595
- assert.doesNotMatch(
596
- skillSource,
597
- /any\s+`?\$`?[^\n]*fail closed/i,
598
- 'the hook resolves known temp variables (TEMP/TMP/TMPDIR/CLAUDE_JOB_DIR), so a bare $ does not always fail closed',
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',
599
580
  );
600
581
  });
601
582
 
@@ -611,7 +592,7 @@ test('preamble does not attribute the known-temp-var resolution to the standalon
611
592
  assert.doesNotMatch(
612
593
  preambleText().replace(/\s+/g, ' '),
613
594
  /Across these paths[\s\S]*?CLAUDE_JOB_DIR/i,
614
- 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths fail closed on any $',
595
+ 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths do not resolve known temp variables',
615
596
  );
616
597
  });
617
598
 
@@ -635,7 +616,7 @@ test('SKILL.md does not attribute the known-temp-var resolution to the standalon
635
616
  assert.doesNotMatch(
636
617
  skillSource.replace(/\s+/g, ' '),
637
618
  /Across those paths[\s\S]*?CLAUDE_JOB_DIR/i,
638
- 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths fail closed on any $',
619
+ 'the temp-var resolution lives only in the broad cwd-scoped path; the standalone and compound paths do not resolve known temp variables',
639
620
  );
640
621
  });
641
622
 
@@ -653,3 +634,198 @@ test('SKILL.md attributes the known-temp-var resolution to the cwd-scoped auto-a
653
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',
654
635
  );
655
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 = [
749
+ 'applyFixesEdit',
750
+ 'recoverCommitBlockEdit',
751
+ 'recoverVerifyFailEdit',
752
+ 'checkConvergence',
753
+ 'markReady',
754
+ 'repairConvergenceEdit',
755
+ 'verifyRepairChanges',
756
+ 'commitRepairFixes',
757
+ 'resolveConflictsEdit',
758
+ 'standardsFollowUpEdit',
759
+ 'verifyHardeningChanges',
760
+ 'commitHardeningPr',
761
+ 'postCleanAudit',
762
+ 'spawnConvergenceSummary',
763
+ ];
764
+
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)`,
770
+ );
771
+ });
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
+ });