claude-dev-env 1.81.0 → 1.82.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.
@@ -0,0 +1,58 @@
1
+ """Configuration constants for the conventional_pr_title_gate PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe"})
6
+ PR_SUBCOMMAND_TOKEN: str = "pr"
7
+ ALL_PR_TITLE_SUBCOMMAND_VERBS: frozenset[str] = frozenset({"create", "edit"})
8
+ GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT: int = 3
9
+
10
+ BASH_TOOL_NAME: str = "Bash"
11
+
12
+ TITLE_LONG_FLAG: str = "--title"
13
+ TITLE_SHORT_FLAG: str = "-t"
14
+ REPO_LONG_FLAG: str = "--repo"
15
+ REPO_SHORT_FLAG: str = "-R"
16
+
17
+ WORKFLOWS_DIRECTORY_RELATIVE_PATH: str = ".github/workflows"
18
+ ALL_WORKFLOW_FILE_GLOB_PATTERNS: tuple[str, ...] = ("*.yml", "*.yaml")
19
+
20
+ ALL_SEMANTIC_TITLE_CI_MARKERS: tuple[str, ...] = (
21
+ "semantic-pull-request",
22
+ "action-semantic-pull-request",
23
+ "semantic_pull_request",
24
+ )
25
+
26
+ ALL_CONVENTIONAL_COMMIT_TYPES: tuple[str, ...] = (
27
+ "feat",
28
+ "fix",
29
+ "chore",
30
+ "docs",
31
+ "refactor",
32
+ "perf",
33
+ "ci",
34
+ "style",
35
+ "test",
36
+ "build",
37
+ "revert",
38
+ )
39
+
40
+ CONVENTIONAL_COMMIT_TITLE_PATTERN: re.Pattern[str] = re.compile(
41
+ r"^(?:" + "|".join(ALL_CONVENTIONAL_COMMIT_TYPES) + r")(?:\([^)]+\))?!?: .+"
42
+ )
43
+
44
+ SEMANTIC_ACTION_TYPES_INPUT_PATTERN: re.Pattern[str] = re.compile(r"^\s*types\s*:")
45
+
46
+ SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN: re.Pattern[str] = re.compile(r"[{,]\s*types\s*:")
47
+
48
+ YAML_LIST_ITEM_PREFIX: str = "- "
49
+
50
+ CORRECTIVE_MESSAGE: str = (
51
+ "BLOCKED [conventional-pr-title]: this repository's CI validates PR titles "
52
+ "against Conventional Commits, and the --title value here does not match. "
53
+ "Required shape: type(scope)!: description, where the scope and the "
54
+ "breaking-change marker (!) are optional. Allowed types: "
55
+ f"{', '.join(ALL_CONVENTIONAL_COMMIT_TYPES)}.\n\n"
56
+ "Example: feat(hooks): add the conventional PR title gate\n\n"
57
+ "Fix the --title value and retry."
58
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.81.0",
3
+ "version": "1.82.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -131,7 +131,10 @@ 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 }` — the seed the
137
+ [self-closing loop](#self-closing-loop-converge-the-deferred-prs) converges next.
135
138
 
136
139
  ## Budget-aware round boundaries
137
140
 
@@ -387,10 +390,13 @@ checked out in. The workflow runs in the background and notifies this session on
387
390
  completion; watch live progress with `/workflows`, where each PR's child run
388
391
  appears under its own group.
389
392
 
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.
393
+ The workflow returns
394
+ `{ converged, prCount, convergedCount, results, allDeferredPrs, blocker }`, where
395
+ `results` is one record per PR carrying
396
+ `{ owner, repo, prNumber, converged, rounds, finalSha, blocker, deferredPrs }`.
397
+ Each record's `deferredPrs` is that PR's own list of draft hardening PRs, and
398
+ `allDeferredPrs` is every record's `deferredPrs` flattened into one list. The
399
+ top-level `converged` is true only when every PR converged.
394
400
 
395
401
  ### Multi-PR teardown (on workflow completion)
396
402
 
@@ -403,6 +409,85 @@ permissions once per repository after every PR's teardown. Then print one summar
403
409
  report — a line per PR as
404
410
  `#<prNumber>: <converged | blocked> — rounds <N>, final <finalSha>[, blocker <blocker>]`.
405
411
 
412
+ ## Self-closing loop: converge the deferred PRs
413
+
414
+ Every run leaves work behind. When a round holds only code-standard findings, the
415
+ standards-deferral path opens a draft environment-hardening PR and reports it in
416
+ `deferredPrs`. That PR is itself a draft that needs to reach ready, and its own
417
+ convergence can defer more standard findings, opening more hardening PRs. The
418
+ self-closing loop drives that chain to the ground: after teardown, the
419
+ orchestrator (this session, which launched the workflow) converges the deferred
420
+ PRs, then the PRs their runs defer, and so on until a generation opens none.
421
+
422
+ This loop runs by default at the end of every autoconverge run — single-PR and
423
+ multi-PR alike. It stops only when a generation's deferred-PR list comes back
424
+ empty.
425
+
426
+ ### Seed the loop
427
+
428
+ Collect the first generation of deferred PRs from the run that just finished:
429
+
430
+ - A single-PR run seeds from its `deferredPrs`.
431
+ - A multi-PR run seeds from its `allDeferredPrs`.
432
+
433
+ When the seed list is empty, the loop is already done — the run deferred nothing,
434
+ so there is nothing to converge. Report that and stop.
435
+
436
+ ### Each generation
437
+
438
+ Given a non-empty list of deferred PRs `{ owner, repo, prNumber }` (a generation
439
+ may span more than one repository — a hardening PR lands in whichever repo owns
440
+ the surface that blocks the deferred class, so `JonEcho/llm-settings` for hooks
441
+ and `jl-cmd/claude-code-config` for rules and skills both appear):
442
+
443
+ 1. **Check out each deferred PR.** Run the
444
+ [Multi-PR pre-flight](#multi-pr-pre-flight-main-session) once per deferred PR.
445
+ Because a deferred PR can live in a repo the first run never touched, first
446
+ find a local checkout of that PR's repo; when none exists, clone it under the
447
+ session temp dir, then add the per-PR worktree on the PR's head ref. Drop any
448
+ PR whose strict pre-flight fails rather than stopping the whole generation.
449
+ Grant project permissions once per repository the generation spans.
450
+ 2. **Converge the generation.** Launch `workflow/converge_multi.mjs` with one
451
+ entry per checked-out deferred PR, exactly as the
452
+ [multi-PR launch](#launch-the-multi-pr-workflow) describes.
453
+ 3. **Tear down.** Run the [multi-PR teardown](#multi-pr-teardown-on-workflow-completion)
454
+ over the generation's `results`, and revoke project permissions once per
455
+ repository.
456
+ 4. **Take the next seed.** The generation's `allDeferredPrs` is the next
457
+ generation's seed list. When it is empty, the loop is done.
458
+
459
+ Repeat from step 1 with each new seed. The depth is unbounded: the loop keeps
460
+ opening generations until one converges every deferred PR without deferring
461
+ anything new.
462
+
463
+ ### Report each generation
464
+
465
+ Log one line as each generation finishes, so a watcher sees the chain close:
466
+
467
+ ```
468
+ Self-closing generation <k>: <converged>/<total> deferred PR(s) converged, <new> new deferred PR(s) opened
469
+ ```
470
+
471
+ When the loop ends, print a final line naming the generation count and the total
472
+ deferred PRs converged across every generation.
473
+
474
+ ### Conventional titles on deferred PRs
475
+
476
+ Each hardening PR the loop opens targets a repo whose CI validates the PR title
477
+ as a Conventional Commit. The commit step's prompt directs the agent to title
478
+ the hardening PR as a Conventional Commit — a type prefix, an optional scope,
479
+ then a colon and a short summary — so a deferred PR carries a conforming title
480
+ (`feat(hooks): …`, `chore(rules): …`) before it exists. That prompt is where the
481
+ conforming title is enforced.
482
+
483
+ The `conventional_pr_title_gate` hook is a best-effort backstop on that title,
484
+ not the guarantee. It blocks a `gh pr create` with a non-conforming `--title`
485
+ only for a repo whose semantic-pull-request workflow leaves the action's
486
+ `types:` input at the default Conventional Commits list. For a repo that pins
487
+ its own explicit `types:` list — which the main target repo does in
488
+ `.github/workflows/pr-check.yml` — the hook fails open and lets the title
489
+ through, and the CI title check on GitHub has the final say.
490
+
406
491
  ## Folder map
407
492
 
408
493
  - `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
@@ -107,42 +107,28 @@ test('repair-convergence edit step no longer instructs resolving every unresolve
107
107
  );
108
108
  });
109
109
 
110
- test('bugbot lens delay instructions are shell-agnostic with PowerShell as an alternative', () => {
110
+ test('the bugbot lens waits through the Monitor tool, not a foreground sleep', () => {
111
111
  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
- );
112
+ assert.match(bugbotPrompt, /Monitor tool/, 'expected the bugbot poll to wait via the Monitor tool');
119
113
  assert.doesNotMatch(
120
114
  bugbotPrompt,
121
- /wait 8 seconds(?!,)/,
122
- 'the vague "wait 8 seconds" phrasing must carry a concrete command',
115
+ /sleep 60|sleep 8|Start-Sleep/,
116
+ 'expected no foreground sleep in the bugbot poll delays',
123
117
  );
124
118
  });
125
119
 
126
- test('copilot gate delay instruction is shell-agnostic with PowerShell as an alternative', () => {
120
+ test('the copilot gate waits through the Monitor tool, not a foreground sleep', () => {
127
121
  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
- );
122
+ assert.match(copilotPrompt, /Monitor tool/, 'expected the copilot poll to wait via the Monitor tool');
123
+ assert.doesNotMatch(copilotPrompt, /sleep 360|Start-Sleep/, 'expected no foreground sleep in the copilot poll delay');
134
124
  });
135
125
 
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
- );
126
+ test('gotchas doc describes the reviewer wait as a Monitor poll, not a foreground sleep', () => {
127
+ assert.match(gotchasSource, /Monitor tool/, 'expected the gotcha to name the Monitor-based reviewer wait');
142
128
  assert.doesNotMatch(
143
129
  gotchasSource,
144
- /a single PowerShell\s*`?Start-Sleep`?\s*loop/i,
145
- 'PowerShell Start-Sleep must be an alternative, not the sole mechanism',
130
+ /shell-agnostic/i,
131
+ 'the reviewer wait is a Monitor poll, not a shell-agnostic sleep loop',
146
132
  );
147
133
  });
148
134
 
@@ -390,8 +376,40 @@ test('spawnStandardsFollowUp reports whether a hardening PR opened on every path
390
376
  );
391
377
  assert.match(
392
378
  body,
393
- /hardeningPrOpened:\s*true/,
394
- 'expected the commit path to return hardeningPrOpened:true',
379
+ /const hardeningPrOpened =\s*typeof commitResult\?\.hardeningPrUrl === 'string' && commitResult\.hardeningPrUrl\.length > 0/,
380
+ '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',
381
+ );
382
+ });
383
+
384
+ test('spawnStandardsFollowUp reports the deferred PR identity on every path', () => {
385
+ const body = lensPromptBody('spawnStandardsFollowUp');
386
+ const nullDeferred = body.match(/deferredPr:\s*null/g) || [];
387
+ assert.ok(
388
+ nullDeferred.length >= 2,
389
+ 'expected both skip paths (no hardening staged, verify failed) to return deferredPr:null',
390
+ );
391
+ assert.match(
392
+ body,
393
+ /parseDeferredPr\(commitResult\?\.hardeningPrUrl\)/,
394
+ 'expected the commit path to parse the deferred PR identity from the commit step hardeningPrUrl',
395
+ );
396
+ assert.doesNotMatch(
397
+ body,
398
+ /hardeningPrOpened:\s*deferredPr !== null/,
399
+ 'expected the commit path to keep hardeningPrOpened separate from the parsed deferredPr so an unparseable URL still latches the guard',
400
+ );
401
+ });
402
+
403
+ test('the workflow return objects carry the accumulated deferredPrs list', () => {
404
+ const converged = convergeSource.match(/deferredPrs/g) || [];
405
+ assert.ok(
406
+ converged.length >= 4,
407
+ 'expected deferredPrs to be declared, pushed at both deferral call sites, and returned',
408
+ );
409
+ assert.match(
410
+ convergeSource,
411
+ /if \(standardsOutcome\?\.deferredPr\) deferredPrs\.push\(standardsOutcome\.deferredPr\)/,
412
+ 'expected each deferral call site to accumulate the deferred PR into deferredPrs',
395
413
  );
396
414
  });
397
415
 
@@ -758,3 +776,117 @@ test('extractVerifyObjection calls parseLastVerdictFence', () => {
758
776
  const objectionBody = lensPromptBody('extractVerifyObjection');
759
777
  assert.match(objectionBody, /parseLastVerdictFence\(/, 'expected extractVerifyObjection to call the shared parser');
760
778
  });
779
+
780
+ test('the headless preamble routes waits through the Monitor tool and forbids ending a turn to await work', () => {
781
+ const preambleStart = convergeSource.indexOf('const HEADLESS_SAFETY_PREAMBLE =');
782
+ assert.notEqual(preambleStart, -1, 'expected a HEADLESS_SAFETY_PREAMBLE definition');
783
+ const preambleEnd = convergeSource.indexOf('\n\nlet activeRepoPath', preambleStart);
784
+ assert.notEqual(preambleEnd, -1, 'expected the preamble to end before activeRepoPath');
785
+ const preamble = convergeSource.slice(preambleStart, preambleEnd);
786
+ assert.match(preamble, /foreground sleep is blocked/i, 'expected the preamble to state foreground sleep is blocked');
787
+ assert.match(preamble, /Monitor tool/, 'expected the preamble to route waits through the Monitor tool');
788
+ assert.match(preamble, /StructuredOutput/, 'expected the preamble to require a schema agent to always call StructuredOutput');
789
+ assert.match(preamble, /never end your turn to wait/i, 'expected the preamble to forbid ending a turn to await background work');
790
+ });
791
+
792
+ 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', () => {
793
+ const blockStart = convergeSource.indexOf('const HEADLESS_SAFETY_PREAMBLE =');
794
+ assert.ok(blockStart !== -1, 'expected a wait-safety preamble to exist');
795
+ const blockEnd = convergeSource.indexOf('\n\nlet activeRepoPath', blockStart);
796
+ assert.ok(blockEnd !== -1, 'expected the preamble to end before activeRepoPath');
797
+ const preamble = convergeSource.slice(blockStart, blockEnd);
798
+ assert.doesNotMatch(
799
+ preamble,
800
+ /never move a wait to a background process/i,
801
+ 'the Monitor tool is itself a background monitor, so the preamble must avoid forbidding a wait that moves to a background process',
802
+ );
803
+ assert.doesNotMatch(
804
+ preamble,
805
+ /return to you when the condition holds/i,
806
+ 'the Monitor tool streams notifications across turns rather than returning in-turn, so the preamble must avoid describing an in-turn synchronous return',
807
+ );
808
+ assert.match(
809
+ preamble,
810
+ /until-loop/i,
811
+ 'expected the preamble to describe the wait as a bounded until-loop paired with the Monitor tool',
812
+ );
813
+ assert.match(
814
+ preamble,
815
+ /notifications? as they arrive/i,
816
+ 'expected the preamble to say Monitor notifications are consumed as they arrive',
817
+ );
818
+ });
819
+
820
+ test('the background-wait clause gives "await" an explicit object, not a dangling "await never resumes"', () => {
821
+ const text = preambleText();
822
+ assert.doesNotMatch(
823
+ text,
824
+ /end your turn to await never resumes/i,
825
+ 'expected the background-wait clause to name what is awaited, not read as "await never resumes" with no object',
826
+ );
827
+ assert.match(
828
+ text,
829
+ /end your turn to await it/i,
830
+ 'expected the background-wait clause to name the wait as the object of "await"',
831
+ );
832
+ });
833
+
834
+ test('the wait guidance names the full down-result schema, not a bare down flag', () => {
835
+ const text = preambleText();
836
+ assert.doesNotMatch(
837
+ text,
838
+ /Copilot gate, down: true\)/,
839
+ 'expected the down signal to name the full schema, not a bare down flag that reads as a partial object',
840
+ );
841
+ assert.match(
842
+ text,
843
+ /down:true, findings:\[\]/,
844
+ 'expected the wait guidance to name the full down-result fields',
845
+ );
846
+ });
847
+
848
+ test('no agent prompt instructs a foreground sleep as the poll delay', () => {
849
+ assert.doesNotMatch(
850
+ convergeSource,
851
+ /delay each (?:attempt|iteration|retry) with "sleep/,
852
+ 'expected no poll directive to instruct a foreground sleep as the between-attempt delay',
853
+ );
854
+ assert.doesNotMatch(
855
+ convergeSource,
856
+ /Start-Sleep -Seconds/,
857
+ 'expected no agent prompt to instruct a foreground PowerShell Start-Sleep',
858
+ );
859
+ });
860
+
861
+ test('the wait guidance sizes the Monitor timeout_ms above the 300s default', () => {
862
+ const text = preambleText();
863
+ assert.match(text, /timeout_ms/, 'names the timeout_ms parameter the wait rule tells the agent to set');
864
+ assert.match(text, /persistent/, 'offers persistent as an alternative to a sized timeout_ms');
865
+ assert.match(text, /300000|300 second/, 'names the 300000 default that truncates a long wait');
866
+ assert.match(
867
+ text,
868
+ /interval[\s\S]{0,60}attempt|interval-times-attempts/i,
869
+ 'sizes timeout_ms to the interval-times-attempts span the step names',
870
+ );
871
+ });
872
+
873
+ test('the Monitor ceiling the guidance names covers the longest interval-times-attempts wait the steps prescribe', () => {
874
+ const cap = /(\d{7})\s*(?:ms|millisecond)/i.exec(preambleText());
875
+ assert.ok(cap !== null, 'the guidance names a seven-digit Monitor ceiling in milliseconds');
876
+ const ceiling = Number(cap[1]);
877
+
878
+ const copilot = lensPromptBody('runCopilotGate');
879
+ const bugbot = lensPromptBody('runBugbotLens');
880
+ const apart = Number(/(\d+)\s*seconds apart/.exec(copilot)[1]);
881
+ const polls = Number(/copilotMaxPolls:\s*(\d+)/.exec(convergeSource)[1]);
882
+ const seconds = Number(/every\s*(\d+)\s*seconds/.exec(bugbot)[1]);
883
+ const iterations = Number(/up to\s*(\d+)\s*iterations/.exec(bugbot)[1]);
884
+ const spans = [apart * polls * 1000, seconds * iterations * 1000];
885
+
886
+ for (const span of spans) {
887
+ assert.ok(
888
+ span <= ceiling,
889
+ `a ${span}ms wait span exceeds the ${ceiling}ms Monitor ceiling the guidance names`,
890
+ );
891
+ }
892
+ });
@@ -388,8 +388,8 @@ test('openStandardsFollowUpOnce gates spawnStandardsFollowUp behind the run-once
388
388
  );
389
389
  assert.match(
390
390
  onceBody,
391
- /return wasStandardsHardeningPrOpened/,
392
- 'expected the skip path to return the cached hardening outcome',
391
+ /hardeningPrOpened: wasStandardsHardeningPrOpened/,
392
+ 'expected the helper to return the cached hardening outcome as hardeningPrOpened',
393
393
  );
394
394
  });
395
395
 
@@ -431,14 +431,18 @@ function extractCallableSource(functionName) {
431
431
  return convergeSource.slice(declarationStart, index);
432
432
  }
433
433
 
434
- function loadStandardsFollowUpRuntime(recordedCalls, standardsEditResult) {
434
+ const parseableHardeningCommitResult = { hardeningPrUrl: 'https://github.com/owner/repo/pull/7', summary: 'opened' };
435
+
436
+ function loadStandardsFollowUpRuntime(recordedCalls, standardsEditResult, hardeningCommitResult = parseableHardeningCommitResult) {
435
437
  const runtimeSource =
436
438
  'let hasStandardsFollowUpFiled = false;\n' +
437
439
  'let wasStandardsHardeningPrOpened = false;\n' +
438
440
  "let standardsFollowUpIssueUrl = '';\n" +
439
441
  'async function runCodeEditorTask(taskName, context) {\n' +
440
442
  ' recordedCalls.push({ task: taskName, context });\n' +
441
- " return taskName === 'standards-edit' ? standardsEditResult : {};\n" +
443
+ " if (taskName === 'standards-edit') return standardsEditResult;\n" +
444
+ " if (taskName === 'hardening-commit') return hardeningCommitResult;\n" +
445
+ ' return {};\n' +
442
446
  '}\n' +
443
447
  'async function runVerifierTask() {\n' +
444
448
  ' return { passed: true };\n' +
@@ -450,6 +454,7 @@ function loadStandardsFollowUpRuntime(recordedCalls, standardsEditResult) {
450
454
  `${extractCallableSource('collectFindingThreadIds')}\n` +
451
455
  `${extractCallableSource('findingsCarryThreads')}\n` +
452
456
  `${extractCallableSource('shouldOpenStandardsFollowUp')}\n` +
457
+ `${extractCallableSource('parseDeferredPr')}\n` +
453
458
  `${extractCallableSource('spawnStandardsFollowUp')}\n` +
454
459
  `${extractCallableSource('resolveStandardsThreadsForBatch')}\n` +
455
460
  `${extractCallableSource('openStandardsFollowUpOnce')}\n` +
@@ -457,12 +462,45 @@ function loadStandardsFollowUpRuntime(recordedCalls, standardsEditResult) {
457
462
  ' openStandardsFollowUpOnce,\n' +
458
463
  ' guards: () => ({ hasStandardsFollowUpFiled, wasStandardsHardeningPrOpened, standardsFollowUpIssueUrl }),\n' +
459
464
  '};';
460
- return new Function('recordedCalls', 'standardsEditResult', runtimeSource)(
465
+ return new Function('recordedCalls', 'standardsEditResult', 'hardeningCommitResult', runtimeSource)(
461
466
  recordedCalls,
462
467
  standardsEditResult,
468
+ hardeningCommitResult,
463
469
  );
464
470
  }
465
471
 
472
+ function loadParseDeferredPr() {
473
+ return new Function(`${extractCallableSource('parseDeferredPr')}\nreturn parseDeferredPr;`)();
474
+ }
475
+
476
+ test('parseDeferredPr parses a full canonical hardening PR URL into its coordinates', () => {
477
+ const parseDeferredPr = loadParseDeferredPr();
478
+ assert.deepEqual(
479
+ parseDeferredPr('https://github.com/jl-cmd/claude-code-config/pull/824'),
480
+ { owner: 'jl-cmd', repo: 'claude-code-config', prNumber: 824 },
481
+ );
482
+ });
483
+
484
+ test('parseDeferredPr accepts a trailing slash, query string, or fragment on the PR URL', () => {
485
+ const parseDeferredPr = loadParseDeferredPr();
486
+ assert.equal(parseDeferredPr('https://github.com/owner/repo/pull/7/').prNumber, 7);
487
+ assert.equal(parseDeferredPr('https://github.com/owner/repo/pull/7?w=1').prNumber, 7);
488
+ assert.equal(parseDeferredPr('https://github.com/owner/repo/pull/7#issuecomment-42').prNumber, 7);
489
+ });
490
+
491
+ test('parseDeferredPr rejects a PR URL embedded in surrounding log text so it never parses the wrong number', () => {
492
+ const parseDeferredPr = loadParseDeferredPr();
493
+ assert.equal(
494
+ parseDeferredPr('opened https://github.com/owner/repo/pull/7 then https://github.com/owner/repo/pull/9'),
495
+ null,
496
+ );
497
+ });
498
+
499
+ test('parseDeferredPr rejects a deep-linked pull path so a non-canonical URL parses no coordinate', () => {
500
+ const parseDeferredPr = loadParseDeferredPr();
501
+ assert.equal(parseDeferredPr('https://github.com/owner/repo/pull/7/files'), null);
502
+ });
503
+
466
504
  test('a second standards-only round never re-opens a hardening PR after the first round opened one but failed to file the issue', async () => {
467
505
  const recordedCalls = [];
468
506
  const issueFailedHardeningStaged = {
@@ -482,8 +520,8 @@ test('a second standards-only round never re-opens a hardening PR after the firs
482
520
  1,
483
521
  'expected the hardening PR to be committed exactly once even when the follow-up issue filing must retry on the second round',
484
522
  );
485
- assert.equal(firstRoundHardeningPr, true, 'expected the first round to open the hardening PR');
486
- assert.equal(secondRoundHardeningPr, true, 'expected the second round to report the hardening PR as opened for this run');
523
+ assert.equal(firstRoundHardeningPr.hardeningPrOpened, true, 'expected the first round to open the hardening PR');
524
+ assert.equal(secondRoundHardeningPr.hardeningPrOpened, true, 'expected the second round to report the hardening PR as opened for this run');
487
525
  assert.equal(
488
526
  runtime.guards().wasStandardsHardeningPrOpened,
489
527
  true,
@@ -496,6 +534,65 @@ test('a second standards-only round never re-opens a hardening PR after the firs
496
534
  );
497
535
  });
498
536
 
537
+ test('a hardening-commit that opens a PR but returns an unparseable URL still latches the run-once guard', async () => {
538
+ const recordedCalls = [];
539
+ const issueFailedHardeningStaged = {
540
+ issueUrl: '',
541
+ hardeningEdited: true,
542
+ hardeningRepoPath: '/tmp/hardening',
543
+ hardeningBranch: 'harden-standards',
544
+ };
545
+ const unparseableUrlHardeningCommitResult = { hardeningPrUrl: 'draft-hardening-pr-opened', summary: 'opened' };
546
+ const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFailedHardeningStaged, unparseableUrlHardeningCommitResult);
547
+
548
+ const firstRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'converge-round');
549
+ const secondRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'copilot');
550
+
551
+ const hardeningCommitCalls = recordedCalls.filter((call) => call.task === 'hardening-commit').length;
552
+ assert.equal(
553
+ hardeningCommitCalls,
554
+ 1,
555
+ 'expected the non-empty-URL commit to latch the guard so a second round opens no duplicate hardening PR',
556
+ );
557
+ assert.equal(firstRoundHardeningPr.hardeningPrOpened, true, 'expected a non-empty (though unparseable) URL to report the hardening PR as opened');
558
+ assert.equal(firstRoundHardeningPr.deferredPr, null, 'expected the unparseable URL to contribute no deferred coordinate');
559
+ assert.equal(secondRoundHardeningPr.hardeningPrOpened, true, 'expected the second round to report the hardening PR as opened for this run');
560
+ assert.equal(
561
+ runtime.guards().wasStandardsHardeningPrOpened,
562
+ true,
563
+ 'expected the hardening guard to latch even though the returned URL never parsed',
564
+ );
565
+ });
566
+
567
+ test('a hardening-commit that opens no PR (empty hardeningPrUrl) leaves the run-once guard clear so a later round retries the open', async () => {
568
+ const recordedCalls = [];
569
+ const issueFailedHardeningStaged = {
570
+ issueUrl: '',
571
+ hardeningEdited: true,
572
+ hardeningRepoPath: '/tmp/hardening',
573
+ hardeningBranch: 'harden-standards',
574
+ };
575
+ const noPrHardeningCommitResult = { hardeningPrUrl: '', summary: 'no PR opened' };
576
+ const runtime = loadStandardsFollowUpRuntime(recordedCalls, issueFailedHardeningStaged, noPrHardeningCommitResult);
577
+
578
+ const firstRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'converge-round');
579
+ const secondRoundHardeningPr = await runtime.openStandardsFollowUpOnce('sha1', [{ file: 'a.py', line: 1 }], 'copilot');
580
+
581
+ const hardeningCommitCalls = recordedCalls.filter((call) => call.task === 'hardening-commit').length;
582
+ assert.equal(
583
+ hardeningCommitCalls,
584
+ 2,
585
+ 'expected the empty-URL commit (no PR opened) to leave the guard clear so a later round retries the open',
586
+ );
587
+ assert.equal(firstRoundHardeningPr.hardeningPrOpened, false, 'expected an empty URL to report no hardening PR opened');
588
+ assert.equal(secondRoundHardeningPr.hardeningPrOpened, false, 'expected the retry round to still report no hardening PR opened');
589
+ assert.equal(
590
+ runtime.guards().wasStandardsHardeningPrOpened,
591
+ false,
592
+ 'expected the hardening guard to stay clear when no PR opened so the open keeps retrying',
593
+ );
594
+ });
595
+
499
596
  test('a later standards-only round resolves its own review threads after the follow-up issue was already filed', async () => {
500
597
  const recordedCalls = [];
501
598
  const issueFiledNoHardening = {
@@ -31,7 +31,7 @@ test('isMergeConflicting reports a conflict only when the check returned conflic
31
31
  assert.equal(isMergeConflicting({ conflicting: false }), false);
32
32
  });
33
33
 
34
- test('the git agent handles merge-conflict checks with shell-agnostic polling', () => {
34
+ test('the git agent handles merge-conflict checks with a Monitor-based poll', () => {
35
35
  const gitBody = functionBody('runGitTask');
36
36
  assert.match(gitBody, /mergeable/, 'expected the git agent to read the PR mergeable field');
37
37
  assert.match(
@@ -40,7 +40,7 @@ test('the git agent handles merge-conflict checks with shell-agnostic polling',
40
40
  'expected the git agent merge check to be read-only',
41
41
  );
42
42
  assert.match(gitBody, /MERGE_CONFLICT_SCHEMA/, 'expected the git agent to return MERGE_CONFLICT_SCHEMA');
43
- assert.match(gitBody, /sleep 5|Start-Sleep/, 'expected a shell-agnostic poll delay');
43
+ assert.match(gitBody, /Monitor tool/, 'expected a Monitor-based poll delay, not a foreground sleep');
44
44
  });
45
45
 
46
46
  test('runCodeEditorTask conflict-edit path rebases onto origin/main and makes no push', () => {