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.
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/conventional_pr_title_gate.py +444 -0
- package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
- package/package.json +1 -1
- package/skills/autoconverge/SKILL.md +90 -5
- package/skills/autoconverge/reference/gotchas.md +5 -5
- package/skills/autoconverge/workflow/converge.contract.test.mjs +159 -27
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +104 -7
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +62 -22
- package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
|
@@ -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
|
@@ -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
|
|
391
|
-
|
|
392
|
-
`
|
|
393
|
-
`
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
110
|
+
test('the bugbot lens waits through the Monitor tool, not a foreground sleep', () => {
|
|
111
111
|
const bugbotPrompt = lensPromptBody('runBugbotLens');
|
|
112
|
-
assert.match(bugbotPrompt, /
|
|
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
|
-
/
|
|
122
|
-
'
|
|
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
|
|
120
|
+
test('the copilot gate waits through the Monitor tool, not a foreground sleep', () => {
|
|
127
121
|
const copilotPrompt = lensPromptBody('runCopilotGate');
|
|
128
|
-
assert.match(copilotPrompt, /
|
|
129
|
-
assert.
|
|
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
|
|
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
|
-
/
|
|
145
|
-
'
|
|
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
|
|
394
|
-
'expected the commit path to
|
|
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
|
-
/
|
|
392
|
-
'expected the
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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, /
|
|
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', () => {
|