claude-dev-env 1.81.0 → 1.83.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 (74) hide show
  1. package/CLAUDE.md +16 -13
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/README.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
  7. package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
  8. package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
  11. package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
  12. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
  13. package/hooks/blocking/CLAUDE.md +3 -0
  14. package/hooks/blocking/code_rules_constants_config.py +159 -1
  15. package/hooks/blocking/code_rules_docstrings.py +312 -9
  16. package/hooks/blocking/code_rules_enforcer.py +29 -0
  17. package/hooks/blocking/code_rules_imports_logging.py +867 -1
  18. package/hooks/blocking/code_rules_naming_collection.py +141 -0
  19. package/hooks/blocking/code_rules_string_magic.py +68 -0
  20. package/hooks/blocking/conventional_pr_title_gate.py +444 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  22. package/hooks/blocking/reviewer_spawn_gate.py +182 -0
  23. package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
  24. package/hooks/blocking/state_description_blocker.py +96 -5
  25. package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
  26. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
  28. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
  29. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
  30. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
  31. package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
  32. package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
  33. package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
  34. package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
  35. package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
  36. package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
  37. package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
  38. package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
  39. package/hooks/blocking/test_state_description_blocker.py +135 -0
  40. package/hooks/hooks.json +10 -0
  41. package/hooks/hooks_constants/CLAUDE.md +4 -1
  42. package/hooks/hooks_constants/blocking_check_limits.py +43 -0
  43. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
  44. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
  45. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  46. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
  47. package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
  48. package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
  49. package/package.json +1 -1
  50. package/rules/CLAUDE.md +4 -1
  51. package/rules/claude-md-orphan-file.md +5 -0
  52. package/rules/docstring-prose-matches-implementation.md +10 -1
  53. package/rules/env-var-table-code-drift.md +5 -0
  54. package/rules/es-exe-file-search.md +17 -0
  55. package/rules/no-historical-clutter.md +12 -1
  56. package/rules/orphan-css-class.md +5 -0
  57. package/rules/package-inventory-stale-entry.md +10 -0
  58. package/rules/paired-test-coverage.md +5 -0
  59. package/rules/plain-illustrative-docstrings.md +5 -0
  60. package/rules/verify-before-asking.md +7 -0
  61. package/rules/verify-runtime-state.md +40 -0
  62. package/rules/windows-filesystem-safe.md +8 -0
  63. package/rules/workers-done-before-complete.md +33 -0
  64. package/rules/workflow-substitution-slots.md +5 -0
  65. package/skills/CLAUDE.md +1 -1
  66. package/skills/autoconverge/SKILL.md +96 -5
  67. package/skills/autoconverge/reference/gotchas.md +5 -5
  68. package/skills/autoconverge/workflow/converge.contract.test.mjs +228 -27
  69. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +154 -21
  70. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
  71. package/skills/autoconverge/workflow/converge.mjs +153 -49
  72. package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
  73. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
  74. package/skills/everything-search/SKILL.md +5 -0
@@ -1,3 +1,8 @@
1
+ ---
2
+ paths:
3
+ - "**/*.workflow.js"
4
+ ---
5
+
1
6
  # Workflow Substitution Slot Rule
2
7
 
3
8
  In a `.workflow.js` agent-prompt template, every per-call or per-iteration value an agent must fill in is marked with the angle-bracket convention — `<plate.svg>`, `<object.svg>`, `<glow_hex>`, `cand_<i>`. A bare token such as `cand_i` reads as a fixed literal, so an agent can create one literal directory named `cand_i` and overwrite it across every iteration of a loop, collapsing an N-iteration gate into a single run.
package/skills/CLAUDE.md CHANGED
@@ -42,7 +42,7 @@ Skills install to `~/.claude/skills/<skill-name>/` via `packages/claude-dev-env/
42
42
  - `research-mode` — activates anti-hallucination discipline for a session
43
43
  - `recall` — retrieves facts from memory files
44
44
  - `remember` — saves a decision, gotcha, or architectural choice to the Obsidian vault
45
- - `everything-search` — file-system search via the Everything MCP tool
45
+ - `everything-search` — file-system search via the Everything `es.exe` CLI
46
46
  - `caveman` — trims noise from a draft artifact
47
47
 
48
48
  **Session and workflow management**
@@ -131,7 +131,12 @@ no agent spawned. The workflow runs in the background and notifies this session
131
131
  on completion. Watch live progress with `/workflows`.
132
132
 
133
133
  The workflow returns
134
- `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote }`.
134
+ `{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote, deferredPrs }`.
135
+ `deferredPrs` is the list of draft environment-hardening PRs the standards-deferral
136
+ path opened this run, each as `{ owner, repo, prNumber, copilotDisabled, bugbotDisabled }`.
137
+ The two flags carry this run's Copilot and Bugbot availability, so the next generation
138
+ skips a reviewer that is down or out of quota without re-probing. This list is the seed the
139
+ [self-closing loop](#self-closing-loop-converge-the-deferred-prs) converges next.
135
140
 
136
141
  ## Budget-aware round boundaries
137
142
 
@@ -387,10 +392,13 @@ checked out in. The workflow runs in the background and notifies this session on
387
392
  completion; watch live progress with `/workflows`, where each PR's child run
388
393
  appears under its own group.
389
394
 
390
- The workflow returns `{ converged, prCount, convergedCount, results, blocker }`,
391
- where `results` is one record per PR carrying
392
- `{ owner, repo, prNumber, converged, rounds, finalSha, blocker }`. The top-level
393
- `converged` is true only when every PR converged.
395
+ The workflow returns
396
+ `{ converged, prCount, convergedCount, results, allDeferredPrs, blocker }`, where
397
+ `results` is one record per PR carrying
398
+ `{ owner, repo, prNumber, converged, rounds, finalSha, blocker, deferredPrs }`.
399
+ Each record's `deferredPrs` is that PR's own list of draft hardening PRs, and
400
+ `allDeferredPrs` is every record's `deferredPrs` flattened into one list. The
401
+ top-level `converged` is true only when every PR converged.
394
402
 
395
403
  ### Multi-PR teardown (on workflow completion)
396
404
 
@@ -403,6 +411,89 @@ permissions once per repository after every PR's teardown. Then print one summar
403
411
  report — a line per PR as
404
412
  `#<prNumber>: <converged | blocked> — rounds <N>, final <finalSha>[, blocker <blocker>]`.
405
413
 
414
+ ## Self-closing loop: converge the deferred PRs
415
+
416
+ Every run leaves work behind. When a round holds only code-standard findings, the
417
+ standards-deferral path opens a draft environment-hardening PR and reports it in
418
+ `deferredPrs`. That PR is itself a draft that needs to reach ready, and its own
419
+ convergence can defer more standard findings, opening more hardening PRs. The
420
+ self-closing loop drives that chain to the ground: after teardown, the
421
+ orchestrator (this session, which launched the workflow) converges the deferred
422
+ PRs, then the PRs their runs defer, and so on until a generation opens none.
423
+
424
+ This loop runs by default at the end of every autoconverge run — single-PR and
425
+ multi-PR alike. It stops only when a generation's deferred-PR list comes back
426
+ empty.
427
+
428
+ ### Seed the loop
429
+
430
+ Collect the first generation of deferred PRs from the run that just finished:
431
+
432
+ - A single-PR run seeds from its `deferredPrs`.
433
+ - A multi-PR run seeds from its `allDeferredPrs`.
434
+
435
+ When the seed list is empty, the loop is already done — the run deferred nothing,
436
+ so there is nothing to converge. Report that and stop.
437
+
438
+ ### Each generation
439
+
440
+ Given a non-empty list of deferred PRs `{ owner, repo, prNumber, copilotDisabled, bugbotDisabled }`
441
+ (a generation may span more than one repository — a hardening PR lands in whichever repo owns
442
+ the surface that blocks the deferred class, so `JonEcho/llm-settings` for hooks
443
+ and `jl-cmd/claude-code-config` for rules and skills both appear):
444
+
445
+ 1. **Check out each deferred PR.** Run the
446
+ [Multi-PR pre-flight](#multi-pr-pre-flight-main-session) once per deferred PR.
447
+ Because a deferred PR can live in a repo the first run never touched, first
448
+ find a local checkout of that PR's repo; when none exists, clone it under the
449
+ session temp dir, then add the per-PR worktree on the PR's head ref. Drop any
450
+ PR whose strict pre-flight fails rather than stopping the whole generation.
451
+ Grant project permissions once per repository the generation spans.
452
+ 2. **Converge the generation.** Launch `workflow/converge_multi.mjs` with one
453
+ entry per checked-out deferred PR, exactly as the
454
+ [multi-PR launch](#launch-the-multi-pr-workflow) describes. Each child run
455
+ re-checks Copilot and Bugbot availability every round through the workflow's own
456
+ pre-spawn probe, so a reviewer that is down or out of quota is never spawned in any
457
+ generation; the `copilotDisabled`/`bugbotDisabled` flags each deferred PR carries
458
+ seed that check for the first round.
459
+ 3. **Tear down.** Run the [multi-PR teardown](#multi-pr-teardown-on-workflow-completion)
460
+ over the generation's `results`, and revoke project permissions once per
461
+ repository.
462
+ 4. **Take the next seed.** The generation's `allDeferredPrs` is the next
463
+ generation's seed list. When it is empty, the loop is done.
464
+
465
+ Repeat from step 1 with each new seed. The depth is unbounded: the loop keeps
466
+ opening generations until one converges every deferred PR without deferring
467
+ anything new.
468
+
469
+ ### Report each generation
470
+
471
+ Log one line as each generation finishes, so a watcher sees the chain close:
472
+
473
+ ```
474
+ Self-closing generation <k>: <converged>/<total> deferred PR(s) converged, <new> new deferred PR(s) opened
475
+ ```
476
+
477
+ When the loop ends, print a final line naming the generation count and the total
478
+ deferred PRs converged across every generation.
479
+
480
+ ### Conventional titles on deferred PRs
481
+
482
+ Each hardening PR the loop opens targets a repo whose CI validates the PR title
483
+ as a Conventional Commit. The commit step's prompt directs the agent to title
484
+ the hardening PR as a Conventional Commit — a type prefix, an optional scope,
485
+ then a colon and a short summary — so a deferred PR carries a conforming title
486
+ (`feat(hooks): …`, `chore(rules): …`) before it exists. That prompt is where the
487
+ conforming title is enforced.
488
+
489
+ The `conventional_pr_title_gate` hook is a best-effort backstop on that title,
490
+ not the guarantee. It blocks a `gh pr create` with a non-conforming `--title`
491
+ only for a repo whose semantic-pull-request workflow leaves the action's
492
+ `types:` input at the default Conventional Commits list. For a repo that pins
493
+ its own explicit `types:` list — which the main target repo does in
494
+ `.github/workflows/pr-check.yml` — the hook fails open and lets the title
495
+ through, and the CI title check on GitHub has the final say.
496
+
406
497
  ## Folder map
407
498
 
408
499
  - `SKILL.md` — this hub.
@@ -3,11 +3,11 @@
3
3
  Hard-won lessons for the autoconverge workflow. Append a bullet each time a run
4
4
  fails in a new way.
5
5
 
6
- - **The workflow script cannot sleep.** `Date.now`, `Math.random`, and timers
7
- are unavailable in the script body, and a foreground sleep is blocked. Every
8
- reviewer wait lives inside an `agent`'s own poll loop a shell-agnostic `sleep`
9
- loop (PowerShell `Start-Sleep` is an allowed alternative), or `gh` check
10
- polling. Never try to wait in the script itself.
6
+ - **The workflow script cannot sleep, and neither can an agent's foreground shell.** `Date.now`, `Math.random`, and timers
7
+ are unavailable in the script body, and a foreground `sleep` / `Start-Sleep` is blocked in the headless harness. Every
8
+ reviewer wait lives inside an `agent`'s own poll loop, and that loop waits with the Monitor tool inside the same turn —
9
+ never a foreground sleep, and never backgrounding a wait and ending the turn to await it, which
10
+ leaves a schema-bearing agent with no `StructuredOutput` call. Never try to wait in the script itself.
11
11
 
12
12
  - **Workflow agents start blank.** Spawned agents do not inherit CLAUDE.md,
13
13
  rules, or this skill's context. Each agent prompt is self-contained: it names
@@ -56,6 +56,75 @@ test('a single round-level prefetch step fetches origin/main before the parallel
56
56
  );
57
57
  });
58
58
 
59
+ test('the CONVERGE round spawns a single shared reviewer-availability probe before the parallel lenses', () => {
60
+ const prefetchCallIndex = convergeSource.indexOf("runGitTask('prefetch-main')");
61
+ const probeCallIndex = convergeSource.indexOf('reviewerAvailability = await runReviewerAvailabilityCheck()');
62
+ const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
63
+ assert.notEqual(probeCallIndex, -1, 'expected the CONVERGE round to spawn the shared reviewer-availability probe');
64
+ assert.ok(
65
+ prefetchCallIndex < probeCallIndex && probeCallIndex < parallelLensIndex,
66
+ 'expected the probe to run after the round prefetch and before the parallel lenses spawn',
67
+ );
68
+ const probeBody = functionSource('runReviewerAvailabilityCheck');
69
+ assert.match(probeBody, /reviewer_availability\.py/);
70
+ assert.match(probeBody, /--reviewer copilot/);
71
+ assert.match(probeBody, /--reviewer bugbot/);
72
+ assert.match(probeBody, /schema:\s*REVIEWER_AVAILABILITY_SCHEMA/);
73
+ });
74
+
75
+ test('the Bugbot lens is not spawned pre-spawn when the shared gate reports Bugbot down', () => {
76
+ const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
77
+ assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
78
+ const isBugbotDownPreSpawnIndex = convergeSource.indexOf('const isBugbotDownPreSpawn = resolveReviewerDown(');
79
+ assert.notEqual(isBugbotDownPreSpawnIndex, -1, 'expected a pre-spawn Bugbot-down decision computed before the lens array');
80
+ assert.ok(
81
+ isBugbotDownPreSpawnIndex < parallelLensIndex,
82
+ 'expected the pre-spawn Bugbot-down decision to be computed before the parallel lens array',
83
+ );
84
+ const lensArrayEnd = convergeSource.indexOf('])', parallelLensIndex);
85
+ const lensArray = convergeSource.slice(parallelLensIndex, lensArrayEnd);
86
+ assert.match(
87
+ lensArray,
88
+ /isBugbotDownPreSpawn \? Promise\.resolve\(\{ sha: head, clean: true, down: true, findings: \[\] \}\) : runBugbotLens\(head\)/,
89
+ 'expected the Bugbot lens slot to substitute a resolved down placeholder instead of spawning runBugbotLens when isBugbotDownPreSpawn is true',
90
+ );
91
+ });
92
+
93
+ test('the post-round bugbotDown verdict treats a dead Bugbot lens as down, not fail-open', () => {
94
+ const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
95
+ assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
96
+ const lensArrayEnd = convergeSource.indexOf('])', parallelLensIndex);
97
+ const bugbotDownAssignIndex = convergeSource.indexOf('bugbotDown = ', lensArrayEnd);
98
+ assert.notEqual(bugbotDownAssignIndex, -1, 'expected the post-round bugbotDown assignment after the lens array');
99
+ const bugbotDownAssignLineEnd = convergeSource.indexOf('\n', bugbotDownAssignIndex);
100
+ const bugbotDownAssignLine = convergeSource.slice(bugbotDownAssignIndex, bugbotDownAssignLineEnd);
101
+ assert.match(
102
+ bugbotDownAssignLine,
103
+ /bugbotDown = lenses\[0\] == null \? true : resolveReviewerDown\(lenses\[0\], input\.bugbotDisabled \|\| false\)/,
104
+ 'expected a dead (null) Bugbot lens result to report bugbotDown:true — resolveReviewerDown alone is fail-open on null, which would wrongly report bugbotDown:false for a lens agent that actually spawned and died, leaving check_convergence.py to look for a bugbot review that was never posted',
105
+ );
106
+ });
107
+
108
+ test('both reviewer trigger commands carry the CLAUDE_REVIEWER_GATE=autoconverge sentinel', () => {
109
+ assert.match(
110
+ convergeSource,
111
+ /const REVIEWER_GATE_SENTINEL = 'CLAUDE_REVIEWER_GATE=autoconverge '/,
112
+ 'expected the shared sentinel constant to exist',
113
+ );
114
+ const copilotPrompt = lensPromptBody('runCopilotGate');
115
+ assert.match(
116
+ copilotPrompt,
117
+ /\$\{REVIEWER_GATE_SENTINEL\}gh api --method POST[^\n]*requested_reviewers/,
118
+ 'expected the Copilot requested_reviewers trigger to carry the sentinel prefix',
119
+ );
120
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
121
+ assert.match(
122
+ bugbotPrompt,
123
+ /\$\{REVIEWER_GATE_SENTINEL\}python "\$\{CONFIG\.sharedScripts\}\/post_fix_reply\.py"[^\n]*--body "bugbot run"/,
124
+ 'expected the Bugbot rerun-comment trigger to carry the sentinel prefix',
125
+ );
126
+ });
127
+
59
128
  test('bugbot lens preamble does not blanket-instruct passing --owner/--repo to every script', () => {
60
129
  const bugbotPrompt = lensPromptBody('runBugbotLens');
61
130
  assert.doesNotMatch(
@@ -107,42 +176,28 @@ test('repair-convergence edit step no longer instructs resolving every unresolve
107
176
  );
108
177
  });
109
178
 
110
- test('bugbot lens delay instructions are shell-agnostic with PowerShell as an alternative', () => {
179
+ test('the bugbot lens waits through the Monitor tool, not a foreground sleep', () => {
111
180
  const bugbotPrompt = lensPromptBody('runBugbotLens');
112
- assert.match(bugbotPrompt, /sleep 60/, 'expected a shell-agnostic 60-second poll delay');
113
- assert.match(bugbotPrompt, /sleep 8/, 'expected a concrete 8-second delay command');
114
- assert.match(
115
- bugbotPrompt,
116
- /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
117
- 'expected PowerShell to be named only as an allowed alternative',
118
- );
181
+ assert.match(bugbotPrompt, /Monitor tool/, 'expected the bugbot poll to wait via the Monitor tool');
119
182
  assert.doesNotMatch(
120
183
  bugbotPrompt,
121
- /wait 8 seconds(?!,)/,
122
- 'the vague "wait 8 seconds" phrasing must carry a concrete command',
184
+ /sleep 60|sleep 8|Start-Sleep/,
185
+ 'expected no foreground sleep in the bugbot poll delays',
123
186
  );
124
187
  });
125
188
 
126
- test('copilot gate delay instruction is shell-agnostic with PowerShell as an alternative', () => {
189
+ test('the copilot gate waits through the Monitor tool, not a foreground sleep', () => {
127
190
  const copilotPrompt = lensPromptBody('runCopilotGate');
128
- assert.match(copilotPrompt, /sleep 360/, 'expected a shell-agnostic 360-second poll delay');
129
- assert.match(
130
- copilotPrompt,
131
- /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
132
- 'expected PowerShell to be named only as an allowed alternative',
133
- );
191
+ assert.match(copilotPrompt, /Monitor tool/, 'expected the copilot poll to wait via the Monitor tool');
192
+ assert.doesNotMatch(copilotPrompt, /sleep 360|Start-Sleep/, 'expected no foreground sleep in the copilot poll delay');
134
193
  });
135
194
 
136
- test('gotchas doc describes the reviewer wait as shell-agnostic', () => {
137
- assert.match(
138
- gotchasSource,
139
- /\bsleep\b/i,
140
- 'expected the wait guidance to name a shell-agnostic sleep',
141
- );
195
+ test('gotchas doc describes the reviewer wait as a Monitor poll, not a foreground sleep', () => {
196
+ assert.match(gotchasSource, /Monitor tool/, 'expected the gotcha to name the Monitor-based reviewer wait');
142
197
  assert.doesNotMatch(
143
198
  gotchasSource,
144
- /a single PowerShell\s*`?Start-Sleep`?\s*loop/i,
145
- 'PowerShell Start-Sleep must be an alternative, not the sole mechanism',
199
+ /shell-agnostic/i,
200
+ 'the reviewer wait is a Monitor poll, not a shell-agnostic sleep loop',
146
201
  );
147
202
  });
148
203
 
@@ -390,8 +445,40 @@ test('spawnStandardsFollowUp reports whether a hardening PR opened on every path
390
445
  );
391
446
  assert.match(
392
447
  body,
393
- /hardeningPrOpened:\s*true/,
394
- 'expected the commit path to return hardeningPrOpened:true',
448
+ /const hardeningPrOpened =\s*typeof commitResult\?\.hardeningPrUrl === 'string' && commitResult\.hardeningPrUrl\.length > 0/,
449
+ 'expected the commit path to derive hardeningPrOpened from a non-empty hardeningPrUrl, so a PR that opened with an unparseable URL still latches the guard while an empty URL (no PR opened) leaves it clear to retry',
450
+ );
451
+ });
452
+
453
+ test('spawnStandardsFollowUp reports the deferred PR identity on every path', () => {
454
+ const body = lensPromptBody('spawnStandardsFollowUp');
455
+ const nullDeferred = body.match(/deferredPr:\s*null/g) || [];
456
+ assert.ok(
457
+ nullDeferred.length >= 2,
458
+ 'expected both skip paths (no hardening staged, verify failed) to return deferredPr:null',
459
+ );
460
+ assert.match(
461
+ body,
462
+ /parseDeferredPr\(commitResult\?\.hardeningPrUrl\)/,
463
+ 'expected the commit path to parse the deferred PR identity from the commit step hardeningPrUrl',
464
+ );
465
+ assert.doesNotMatch(
466
+ body,
467
+ /hardeningPrOpened:\s*deferredPr !== null/,
468
+ 'expected the commit path to keep hardeningPrOpened separate from the parsed deferredPr so an unparseable URL still latches the guard',
469
+ );
470
+ });
471
+
472
+ test('the workflow return objects carry the accumulated deferredPrs list', () => {
473
+ const converged = convergeSource.match(/deferredPrs/g) || [];
474
+ assert.ok(
475
+ converged.length >= 4,
476
+ 'expected deferredPrs to be declared, pushed at both deferral call sites, and returned',
477
+ );
478
+ assert.match(
479
+ convergeSource,
480
+ /if \(standardsOutcome\?\.deferredPr\) deferredPrs\.push\(standardsOutcome\.deferredPr\)/,
481
+ 'expected each deferral call site to accumulate the deferred PR into deferredPrs',
395
482
  );
396
483
  });
397
484
 
@@ -758,3 +845,117 @@ test('extractVerifyObjection calls parseLastVerdictFence', () => {
758
845
  const objectionBody = lensPromptBody('extractVerifyObjection');
759
846
  assert.match(objectionBody, /parseLastVerdictFence\(/, 'expected extractVerifyObjection to call the shared parser');
760
847
  });
848
+
849
+ test('the headless preamble routes waits through the Monitor tool and forbids ending a turn to await work', () => {
850
+ const preambleStart = convergeSource.indexOf('const HEADLESS_SAFETY_PREAMBLE =');
851
+ assert.notEqual(preambleStart, -1, 'expected a HEADLESS_SAFETY_PREAMBLE definition');
852
+ const preambleEnd = convergeSource.indexOf('\n\nlet activeRepoPath', preambleStart);
853
+ assert.notEqual(preambleEnd, -1, 'expected the preamble to end before activeRepoPath');
854
+ const preamble = convergeSource.slice(preambleStart, preambleEnd);
855
+ assert.match(preamble, /foreground sleep is blocked/i, 'expected the preamble to state foreground sleep is blocked');
856
+ assert.match(preamble, /Monitor tool/, 'expected the preamble to route waits through the Monitor tool');
857
+ assert.match(preamble, /StructuredOutput/, 'expected the preamble to require a schema agent to always call StructuredOutput');
858
+ assert.match(preamble, /never end your turn to wait/i, 'expected the preamble to forbid ending a turn to await background work');
859
+ });
860
+
861
+ test('the preamble describes the Monitor wait by its real contract: a bounded until-loop consuming notifications as they arrive, rather than an in-turn synchronous return', () => {
862
+ const blockStart = convergeSource.indexOf('const HEADLESS_SAFETY_PREAMBLE =');
863
+ assert.ok(blockStart !== -1, 'expected a wait-safety preamble to exist');
864
+ const blockEnd = convergeSource.indexOf('\n\nlet activeRepoPath', blockStart);
865
+ assert.ok(blockEnd !== -1, 'expected the preamble to end before activeRepoPath');
866
+ const preamble = convergeSource.slice(blockStart, blockEnd);
867
+ assert.doesNotMatch(
868
+ preamble,
869
+ /never move a wait to a background process/i,
870
+ 'the Monitor tool is itself a background monitor, so the preamble must avoid forbidding a wait that moves to a background process',
871
+ );
872
+ assert.doesNotMatch(
873
+ preamble,
874
+ /return to you when the condition holds/i,
875
+ 'the Monitor tool streams notifications across turns rather than returning in-turn, so the preamble must avoid describing an in-turn synchronous return',
876
+ );
877
+ assert.match(
878
+ preamble,
879
+ /until-loop/i,
880
+ 'expected the preamble to describe the wait as a bounded until-loop paired with the Monitor tool',
881
+ );
882
+ assert.match(
883
+ preamble,
884
+ /notifications? as they arrive/i,
885
+ 'expected the preamble to say Monitor notifications are consumed as they arrive',
886
+ );
887
+ });
888
+
889
+ test('the background-wait clause gives "await" an explicit object, not a dangling "await never resumes"', () => {
890
+ const text = preambleText();
891
+ assert.doesNotMatch(
892
+ text,
893
+ /end your turn to await never resumes/i,
894
+ 'expected the background-wait clause to name what is awaited, not read as "await never resumes" with no object',
895
+ );
896
+ assert.match(
897
+ text,
898
+ /end your turn to await it/i,
899
+ 'expected the background-wait clause to name the wait as the object of "await"',
900
+ );
901
+ });
902
+
903
+ test('the wait guidance names the full down-result schema, not a bare down flag', () => {
904
+ const text = preambleText();
905
+ assert.doesNotMatch(
906
+ text,
907
+ /Copilot gate, down: true\)/,
908
+ 'expected the down signal to name the full schema, not a bare down flag that reads as a partial object',
909
+ );
910
+ assert.match(
911
+ text,
912
+ /down:true, findings:\[\]/,
913
+ 'expected the wait guidance to name the full down-result fields',
914
+ );
915
+ });
916
+
917
+ test('no agent prompt instructs a foreground sleep as the poll delay', () => {
918
+ assert.doesNotMatch(
919
+ convergeSource,
920
+ /delay each (?:attempt|iteration|retry) with "sleep/,
921
+ 'expected no poll directive to instruct a foreground sleep as the between-attempt delay',
922
+ );
923
+ assert.doesNotMatch(
924
+ convergeSource,
925
+ /Start-Sleep -Seconds/,
926
+ 'expected no agent prompt to instruct a foreground PowerShell Start-Sleep',
927
+ );
928
+ });
929
+
930
+ test('the wait guidance sizes the Monitor timeout_ms above the 300s default', () => {
931
+ const text = preambleText();
932
+ assert.match(text, /timeout_ms/, 'names the timeout_ms parameter the wait rule tells the agent to set');
933
+ assert.match(text, /persistent/, 'offers persistent as an alternative to a sized timeout_ms');
934
+ assert.match(text, /300000|300 second/, 'names the 300000 default that truncates a long wait');
935
+ assert.match(
936
+ text,
937
+ /interval[\s\S]{0,60}attempt|interval-times-attempts/i,
938
+ 'sizes timeout_ms to the interval-times-attempts span the step names',
939
+ );
940
+ });
941
+
942
+ test('the Monitor ceiling the guidance names covers the longest interval-times-attempts wait the steps prescribe', () => {
943
+ const cap = /(\d{7})\s*(?:ms|millisecond)/i.exec(preambleText());
944
+ assert.ok(cap !== null, 'the guidance names a seven-digit Monitor ceiling in milliseconds');
945
+ const ceiling = Number(cap[1]);
946
+
947
+ const copilot = lensPromptBody('runCopilotGate');
948
+ const bugbot = lensPromptBody('runBugbotLens');
949
+ const apart = Number(/(\d+)\s*seconds apart/.exec(copilot)[1]);
950
+ const polls = Number(/copilotMaxPolls:\s*(\d+)/.exec(convergeSource)[1]);
951
+ const seconds = Number(/every\s*(\d+)\s*seconds/.exec(bugbot)[1]);
952
+ const iterations = Number(/up to\s*(\d+)\s*iterations/.exec(bugbot)[1]);
953
+ const spans = [apart * polls * 1000, seconds * iterations * 1000];
954
+
955
+ for (const span of spans) {
956
+ assert.ok(
957
+ span <= ceiling,
958
+ `a ${span}ms wait span exceeds the ${ceiling}ms Monitor ceiling the guidance names`,
959
+ );
960
+ }
961
+ });