claude-dev-env 1.52.0 → 1.53.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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claude-dev-env": "bin/install.mjs"
8
8
  },
9
9
  "scripts": {
10
- "test": "node --test bin/*.test.mjs"
10
+ "test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\""
11
11
  },
12
12
  "files": [
13
13
  "bin/",
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: autoconverge
3
+ description: >-
4
+ Drives one draft PR to convergence in a single autonomous workflow run.
5
+ Each round runs Cursor Bugbot, a code-review pass, and a bug-audit in
6
+ parallel on the same HEAD, dedups findings, applies every fix in one
7
+ commit, re-verifies, then clears a Copilot wait-gate and a closing
8
+ convergence check before marking the PR ready. Use when the user says
9
+ '/autoconverge', 'autoconverge this PR', 'converge this PR in one run',
10
+ 'run the converge workflow', or 'drive the PR to ready autonomously'.
11
+ ---
12
+
13
+ # Autoconverge
14
+
15
+ One launch drives the whole loop to convergence. The `/autoconverge` skill
16
+ resolves PR scope, enters a worktree, grants project permissions, then hands
17
+ the loop to the **`converge.mjs` workflow**, which runs every round and every
18
+ reviewer wait inside one background pass — no ticks, no `ScheduleWakeup`, no
19
+ state file. State lives in the workflow's own variables; resume is handled by
20
+ the workflow journal.
21
+
22
+ `pr-converge` paces the same four-reviewer loop across `ScheduleWakeup` ticks;
23
+ autoconverge runs it as a deterministic workflow. The two skills share the same
24
+ helper scripts and the same convergence gate.
25
+
26
+ ## Requirements
27
+
28
+ Scan the tool list at the top of this conversation for the literal string
29
+ `Workflow`. If it is absent, report `autoconverge requires the Workflow tool;
30
+ aborting` and stop. The workflow also needs the `gh` CLI authenticated for the
31
+ PR's owner.
32
+
33
+ ## Pre-flight (main session)
34
+
35
+ 1. **Enter a worktree.** Call `EnterWorktree` with no arguments before any
36
+ `gh`, `git`, file read, or edit. `gh`/`git` Bash calls do not auto-isolate,
37
+ so this is mandatory. If it fails, report and stop.
38
+
39
+ 2. **Resolve PR scope.** When the user passed a PR URL or number, parse owner,
40
+ repo, and number from it. Otherwise read the current branch's PR:
41
+ `gh pr view --json number,headRefName,url,isDraft,baseRefName`. Capture
42
+ `owner`, `repo`, `prNumber`. Confirm the PR is a draft; if it is already
43
+ ready, mark it draft first (`gh pr ready <n> --repo <o>/<r> --undo`) so the
44
+ loop owns the ready transition.
45
+
46
+ 3. **Grant project permissions.**
47
+ `python "$HOME/.claude/skills/bugteam/scripts/grant_project_claude_permissions.py"`
48
+
49
+ ## Run the workflow
50
+
51
+ Call the `Workflow` tool against the colocated script:
52
+
53
+ ```
54
+ Workflow({
55
+ scriptPath: "<this skill dir>/workflow/converge.mjs",
56
+ args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false }
57
+ })
58
+ ```
59
+
60
+ `scriptPath` is the absolute path to `workflow/converge.mjs` inside this skill's
61
+ own directory (on this install,
62
+ `<home>/.claude/skills/autoconverge/workflow/converge.mjs`). Set
63
+ `bugbotDisabled: true` only when the user has opted Cursor Bugbot out for the
64
+ run; otherwise the workflow detects an opt-out or an unreachable Bugbot on its
65
+ own. The workflow runs in the background and notifies this session on
66
+ completion. Watch live progress with `/workflows`.
67
+
68
+ The workflow returns
69
+ `{ converged, rounds, finalSha, blocker }`.
70
+
71
+ ## Teardown (on workflow completion)
72
+
73
+ 1. **When `converged` is true:** rewrite the PR description and clean the
74
+ working tree — see
75
+ [`bugteam/reference/teardown-publish-permissions.md` § Step 4 and § Step 4.5](../bugteam/reference/teardown-publish-permissions.md).
76
+ The workflow already marked the PR ready.
77
+
78
+ 2. **Always revoke project permissions** (including on a blocker exit):
79
+ `python "$HOME/.claude/skills/bugteam/scripts/revoke_project_claude_permissions.py"`
80
+
81
+ 3. **Print the final report:**
82
+
83
+ ```
84
+ /autoconverge exit: <converged | blocked>
85
+ Rounds: <N>
86
+ Final commit: <finalSha>
87
+ Blocker: <blocker> # only when blocked
88
+ ```
89
+
90
+ ## What the workflow does each round
91
+
92
+ See [`reference/convergence.md`](reference/convergence.md) for the full round
93
+ shape and the exact convergence definition, and
94
+ [`reference/stop-conditions.md`](reference/stop-conditions.md) for every way the
95
+ run ends short of ready. Hard-won failure lessons live in
96
+ [`reference/gotchas.md`](reference/gotchas.md).
97
+
98
+ - **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
99
+ the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
100
+ `clean-coder` applies all fixes in a single commit, pushes, replies to and
101
+ resolves any bot threads; re-verify next round on the new HEAD. When all
102
+ three are clean on a stable HEAD, post the CLEAN bugteam audit artifact.
103
+ - **Copilot gate:** request a Copilot review, poll up to three times; findings
104
+ route back into Converge, a no-show after the cap is a blocker.
105
+ - **Convergence check:** `check_convergence.py` is the authoritative gate; on a
106
+ full pass the workflow marks `draft=false`.
107
+
108
+ ## Folder map
109
+
110
+ - `SKILL.md` — this hub.
111
+ - `workflow/converge.mjs` — the convergence workflow script.
112
+ - `reference/` — convergence definition, stop conditions, gotchas.
@@ -0,0 +1,84 @@
1
+ # Convergence — round shape and the ready definition
2
+
3
+ ## The round loop
4
+
5
+ The workflow holds three states and moves between them until the PR is ready or
6
+ a blocker ends the run. A single iteration counter increments on every pass
7
+ through any phase and caps the whole run at 20 loop iterations; the round counter
8
+ tracks CONVERGE passes only and is never the cap.
9
+
10
+ **CONVERGE** (one round = one parallel sweep):
11
+
12
+ 1. Resolve the current PR HEAD SHA.
13
+ 2. Run three lenses in parallel on that HEAD, each over the full
14
+ `origin/main...HEAD` diff:
15
+ - **Bugbot lens** — drive Cursor Bugbot to a verdict on HEAD (trigger and
16
+ poll its CI check run when needed) and return its findings, or mark itself
17
+ down when Bugbot is opted out or unreachable.
18
+ - **Code-review lens** — a correctness-focused review pass (`code-quality-agent`)
19
+ that reports findings without editing.
20
+ - **Bug-audit lens** — the bug-audit (`code-quality-agent`) applying the
21
+ shared A–P rubric from `bugteam/reference/audit-contract.md`, reporting
22
+ findings without editing.
23
+ 3. Dedup findings across the three lenses by file, line, and title. A collision
24
+ keeps the most severe duplicate's severity (P0 > P1 > P2), unions the detail
25
+ text, and collects every distinct bot thread id so the fix lens resolves all
26
+ colliding threads.
27
+ 4. **Any findings** → one `clean-coder` applies every fix in a single test-first
28
+ commit, pushes, then replies to and resolves each finding that carries a
29
+ GitHub review thread. A round progresses when the fix lens lands a push that
30
+ moves HEAD, or when every finding was already addressed so no code change is
31
+ needed yet each finding thread is still resolved (the fix lens reports
32
+ `resolvedWithoutCommit` and the run re-converges on the unchanged HEAD). A
33
+ round whose fix lens reports neither a moved-HEAD push nor a full
34
+ thread-resolution ends the run with a fix-stalled blocker. The next round
35
+ re-verifies on the current HEAD.
36
+ 5. **Zero findings on a stable HEAD** → post the CLEAN bugteam audit artifact
37
+ for that HEAD, then move to the Copilot gate.
38
+
39
+ **COPILOT** gate:
40
+
41
+ - Request a Copilot review on HEAD (skipping a duplicate request), then poll up
42
+ to three times, 360 seconds apart.
43
+ - Copilot findings → fix them and return to CONVERGE on the new HEAD.
44
+ - Copilot clean or approved → move to the convergence check.
45
+ - No review after three polls → blocker.
46
+
47
+ **Convergence check**:
48
+
49
+ - Run `check_convergence.py`. A full pass marks the PR ready (`draft=false`) and
50
+ ends the run. A failure returns to CONVERGE so the next round addresses the
51
+ failing gate.
52
+
53
+ ## Full-diff rule
54
+
55
+ Every lens, every round, reviews the full `origin/main...HEAD` diff — every file
56
+ the PR touches. A lens that scopes to recent commits, a single file, or a
57
+ bugbot-flagged path does not satisfy the round; its clean verdict is not a clean.
58
+
59
+ ## The ready definition
60
+
61
+ `check_convergence.py` is the single source of truth for readiness. It re-derives
62
+ every condition from GitHub and marks the PR ready only when all of these hold on
63
+ the current HEAD:
64
+
65
+ 1. Bugbot CI check run is completed with a success or neutral conclusion
66
+ (bypassed when Bugbot is opted out or proved unreachable this run).
67
+ 2. The Bugbot review body on HEAD reports no findings (checked when a Bugbot
68
+ review is present).
69
+ 3. A CLEAN bugteam audit review sits on HEAD.
70
+ 4. The Copilot review on HEAD is clean or approved.
71
+ 5. Zero unresolved bot review threads anywhere on the PR — counting Cursor,
72
+ Claude, and Copilot authored threads where `isResolved` is false (`isOutdated`
73
+ threads are excluded by the gate, but the fix lens still verifies and resolves
74
+ them during the round).
75
+ 6. The PR is mergeable (`mergeable` true and `mergeable_state` clean).
76
+ 7. No requested reviewers are still pending.
77
+
78
+ ## Audit-trail design
79
+
80
+ Bugbot and Copilot post their own review threads, which the fix lens replies to
81
+ and resolves. The bug-audit lens keeps its findings in memory across the round
82
+ and posts only the terminal CLEAN bugteam review once every lens is clean on a
83
+ stable HEAD — that single artifact is what gate 3 reads. This keeps thread churn
84
+ to the threads the bots raise themselves.
@@ -0,0 +1,47 @@
1
+ # Gotchas
2
+
3
+ Hard-won lessons for the autoconverge workflow. Append a bullet each time a run
4
+ fails in a new way.
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.
11
+
12
+ - **Workflow agents start blank.** Spawned agents do not inherit CLAUDE.md,
13
+ rules, or this skill's context. Each agent prompt is self-contained: it names
14
+ the exact scripts and flags, the full `origin/main...HEAD` diff scope, and the
15
+ return contract.
16
+
17
+ - **Only the fix lens writes.** The three converge lenses read and report; they
18
+ never edit, commit, or push. Because only the serial fix step pushes, the
19
+ parallel sweep needs no worktree isolation.
20
+
21
+ - **Fetch origin/main once before the parallel lenses.** The code-review and
22
+ bug-audit lenses both diff against `origin/main`. Concurrent `git fetch` calls
23
+ contend on the worktree `.git` lock and fail intermittently, so the workflow
24
+ runs a single serial `git fetch origin main` (the `prefetch-main` step) at the
25
+ start of each round and the parallel lenses run no git fetch of their own —
26
+ they diff against the already-current ref.
27
+
28
+ - **The CLEAN bugteam artifact is HEAD-specific.** `check_convergence.py` reads
29
+ the bugteam review on the current HEAD. Any push moves HEAD and invalidates a
30
+ prior artifact, so post it only when a round is fully clean, and post a fresh
31
+ one after any later fix round.
32
+
33
+ - **Bot login fields differ by endpoint.** `get_reviews` returns `.user.login`
34
+ (an object), while `get_review_comments` returns `.author` (a string). Match
35
+ bot logins with case-insensitive substring tests, not strict equality.
36
+
37
+ - **`scriptPath` takes a real path, not a shell variable.** `$HOME` expands in a
38
+ Bash command but not in the `Workflow` tool's `scriptPath` argument. Pass the
39
+ expanded absolute path to `workflow/converge.mjs`.
40
+
41
+ - **Tilde paths fail on Windows Git Bash.** Inside shell calls, use `$HOME/.claude/...`,
42
+ not `~/.claude/...`; a tilde resolves to the wrong home and gives a
43
+ file-not-found error that looks like a script failure.
44
+
45
+ - **`gh` token drift across accounts.** When a run touches more than one GitHub
46
+ account, pin the token with `--user <login>`; `gh auth token` alone can return
47
+ another account's token after a switch.
@@ -0,0 +1,59 @@
1
+ # Stop conditions
2
+
3
+ The workflow ends one of two ways: converged (PR marked ready) or blocked. A
4
+ blocker exit returns `{ converged: false, rounds, finalSha, blocker }`, and the
5
+ skill still runs teardown (revoke permissions, final report).
6
+
7
+ ## Blockers (end the run short of ready)
8
+
9
+ - **Copilot no-show** — Copilot surfaces no review on the current HEAD after
10
+ three polls (360 seconds apart). `blocker` names the Copilot timeout.
11
+ - **Iteration cap** — 20 loop iterations pass without a full convergence-check
12
+ pass. The iteration counter increments on every pass through any phase, so a
13
+ convergence-check gate that no round can clear (for example a `mergeable_state`
14
+ stuck at `blocked`, `behind`, or `unknown` that a rebase does not fix) and a
15
+ Copilot gate agent that keeps dying and retrying on the same HEAD both reach
16
+ the cap this way. `blocker` reports `iteration cap reached`.
17
+ - **Fix stalled** — the fix lens reports no push (`pushed: false`) without
18
+ resolving every finding thread, returns a SHA equal to the prior HEAD on a
19
+ case-folded common prefix (a full or abbreviated SHA of the unchanged commit
20
+ both count), or returns null for a round's findings. HEAD did not move and the
21
+ threads were not all resolved, so the next round would re-raise the same
22
+ findings. The run ends with a `blocker` that names the finding count and the
23
+ stalled HEAD. A round whose every finding carries no GitHub thread
24
+ (`replyToCommentId: null` on each) and whose fix reports
25
+ `resolvedWithoutCommit: true` is also a stall: it moves no code and resolves no
26
+ thread, so re-converging on the unchanged HEAD would loop the same finding to
27
+ the iteration cap. The `blocker` names the in-memory finding count and the
28
+ stalled HEAD. An all-stale round that makes no commit but resolves every
29
+ finding thread (`resolvedWithoutCommit: true` with at least one thread-bearing
30
+ finding) is not a stall — the run re-converges on the unchanged HEAD and
31
+ reaches the Copilot and convergence gates.
32
+ - **Mark-ready failed** — the convergence check passes but the mark-ready step
33
+ cannot confirm the PR left draft state (`gh pr ready` errored, or the draft
34
+ re-query still reports true). The workflow does not report `converged: true`;
35
+ the run ends with a `blocker` naming the failed ready transition.
36
+
37
+ ## Not a blocker (the run continues)
38
+
39
+ - **Bugbot down** — when Cursor Bugbot is opted out, or never produces a check
40
+ run or review after the lens poll budget, the Bugbot lens returns `down: true`.
41
+ The run continues, and the convergence check runs with `--bugbot-down` so its
42
+ Bugbot gate is bypassed.
43
+ - **A lens agent dies** — when one parallel lens returns null (a terminal agent
44
+ failure), the round proceeds on the surviving lenses. A real defect it would
45
+ have caught surfaces in a later round or at the convergence check. A dead
46
+ Bugbot lens (null result) counts as down for that HEAD, so the convergence
47
+ check runs with `--bugbot-down` rather than demanding a Bugbot verdict the
48
+ dead agent never produced.
49
+ - **Every lens agent dies** — when all three parallel lenses return null in the
50
+ same round, the round is a failure, not a clean: the workflow posts no CLEAN
51
+ bugteam artifact and does not advance to the Copilot gate. It re-resolves HEAD
52
+ and retries on the next round, still bounded by the iteration cap.
53
+
54
+ ## User stop
55
+
56
+ Stopping the background workflow (`TaskStop`, or the user halting the run) ends
57
+ it where it stands. Re-launching `/autoconverge` starts a fresh run; the
58
+ workflow journal allows resuming the prior run from its last completed step with
59
+ `Workflow({ scriptPath, resumeFromRunId })`.
@@ -0,0 +1,177 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
+ const gotchasSource = readFileSync(
10
+ join(workflowDirectory, '..', 'reference', 'gotchas.md'),
11
+ 'utf8',
12
+ );
13
+
14
+ function lensPromptBody(builderName) {
15
+ const builderStart = convergeSource.indexOf(`function ${builderName}(`);
16
+ assert.notEqual(builderStart, -1, `expected ${builderName} to exist`);
17
+ const nextBuilderStart = convergeSource.indexOf('\nfunction ', builderStart + 1);
18
+ const builderEnd = nextBuilderStart === -1 ? convergeSource.length : nextBuilderStart;
19
+ return convergeSource.slice(builderStart, builderEnd);
20
+ }
21
+
22
+ test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
23
+ assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
24
+ });
25
+
26
+ test('bug-audit lens prompt no longer instructs a per-lens git fetch', () => {
27
+ assert.doesNotMatch(lensPromptBody('runAuditLens'), /git fetch origin main/);
28
+ });
29
+
30
+ test('a single round-level prefetch step fetches origin/main before the parallel lenses', () => {
31
+ assert.match(convergeSource, /function prefetchMainForRound\(/);
32
+ const prefetchCallIndex = convergeSource.indexOf('await prefetchMainForRound(');
33
+ const parallelLensIndex = convergeSource.indexOf('const lenses = await parallel(');
34
+ assert.notEqual(prefetchCallIndex, -1, 'expected prefetchMainForRound to be invoked');
35
+ assert.notEqual(parallelLensIndex, -1, 'expected the parallel lens block to exist');
36
+ assert.ok(
37
+ prefetchCallIndex < parallelLensIndex,
38
+ 'expected the round prefetch to run before the parallel lenses spawn',
39
+ );
40
+ });
41
+
42
+ test('bugbot lens preamble does not blanket-instruct passing --owner/--repo to every script', () => {
43
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
44
+ assert.doesNotMatch(
45
+ bugbotPrompt,
46
+ /use the existing scripts; pass --owner/,
47
+ 'the blanket clause breaks reviews_disabled.py, which accepts only --reviewer',
48
+ );
49
+ });
50
+
51
+ test('bugbot lens invokes reviews_disabled.py with only --reviewer', () => {
52
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
53
+ const reviewsDisabledIndex = bugbotPrompt.indexOf('reviews_disabled.py');
54
+ assert.notEqual(reviewsDisabledIndex, -1, 'expected reviews_disabled.py invocation');
55
+ const invocationLineEnd = bugbotPrompt.indexOf('\\n', reviewsDisabledIndex);
56
+ const invocationLine = bugbotPrompt.slice(reviewsDisabledIndex, invocationLineEnd);
57
+ assert.match(invocationLine, /--reviewer bugbot/);
58
+ assert.doesNotMatch(
59
+ invocationLine,
60
+ /--owner|--repo/,
61
+ 'reviews_disabled.py argparse rejects --owner/--repo with SystemExit(2)',
62
+ );
63
+ });
64
+
65
+ test('gotchas doc states parallel lenses must avoid concurrent git operations', () => {
66
+ assert.doesNotMatch(gotchasSource, /cannot race on git state/);
67
+ assert.match(gotchasSource, /fetch.*once.*before/i);
68
+ });
69
+
70
+ test('repair-convergence filters unresolved threads to bot authors and skips human threads', () => {
71
+ const repairPrompt = lensPromptBody('repairConvergence');
72
+ assert.match(
73
+ repairPrompt,
74
+ /cursor.*claude.*copilot|copilot.*cursor.*claude|claude.*cursor.*copilot/is,
75
+ 'expected the bot-author allowlist (Cursor/Claude/Copilot) to be named',
76
+ );
77
+ assert.match(
78
+ repairPrompt,
79
+ /skip.*human|human.*skip/is,
80
+ 'expected an explicit instruction to skip human reviewer threads',
81
+ );
82
+ });
83
+
84
+ test('repair-convergence no longer instructs resolving every unresolved thread without an author filter', () => {
85
+ const repairPrompt = lensPromptBody('repairConvergence');
86
+ assert.doesNotMatch(
87
+ repairPrompt,
88
+ /fetch every thread where isResolved is false/,
89
+ 'the unfiltered instruction could resolve human reviewer threads',
90
+ );
91
+ });
92
+
93
+ test('bugbot lens delay instructions are shell-agnostic with PowerShell as an alternative', () => {
94
+ const bugbotPrompt = lensPromptBody('runBugbotLens');
95
+ assert.match(bugbotPrompt, /sleep 60/, 'expected a shell-agnostic 60-second poll delay');
96
+ assert.match(bugbotPrompt, /sleep 8/, 'expected a concrete 8-second delay command');
97
+ assert.match(
98
+ bugbotPrompt,
99
+ /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
100
+ 'expected PowerShell to be named only as an allowed alternative',
101
+ );
102
+ assert.doesNotMatch(
103
+ bugbotPrompt,
104
+ /wait 8 seconds(?!,)/,
105
+ 'the vague "wait 8 seconds" phrasing must carry a concrete command',
106
+ );
107
+ });
108
+
109
+ test('copilot gate delay instruction is shell-agnostic with PowerShell as an alternative', () => {
110
+ const copilotPrompt = lensPromptBody('runCopilotGate');
111
+ assert.match(copilotPrompt, /sleep 360/, 'expected a shell-agnostic 360-second poll delay');
112
+ assert.match(
113
+ copilotPrompt,
114
+ /Start-Sleep[\s\S]*alternative|alternative[\s\S]*Start-Sleep/i,
115
+ 'expected PowerShell to be named only as an allowed alternative',
116
+ );
117
+ });
118
+
119
+ test('gotchas doc describes the reviewer wait as shell-agnostic', () => {
120
+ assert.match(
121
+ gotchasSource,
122
+ /\bsleep\b/i,
123
+ 'expected the wait guidance to name a shell-agnostic sleep',
124
+ );
125
+ assert.doesNotMatch(
126
+ gotchasSource,
127
+ /a single PowerShell\s*`?Start-Sleep`?\s*loop/i,
128
+ 'PowerShell Start-Sleep must be an alternative, not the sole mechanism',
129
+ );
130
+ });
131
+
132
+ function finalizeRepairBranch() {
133
+ const repairCallIndex = convergeSource.indexOf('await repairConvergence(');
134
+ assert.notEqual(repairCallIndex, -1, 'expected the FINALIZE repair call to exist');
135
+ const transitionIndex = convergeSource.indexOf("phase = 'CONVERGE'", repairCallIndex);
136
+ assert.notEqual(transitionIndex, -1, 'expected a CONVERGE transition after the repair call');
137
+ const branchEnd = convergeSource.indexOf('continue', transitionIndex) + 'continue'.length;
138
+ return convergeSource.slice(repairCallIndex, branchEnd);
139
+ }
140
+
141
+ test('the FINALIZE repair branch does not re-assign head from the repair before re-converging', () => {
142
+ assert.doesNotMatch(
143
+ finalizeRepairBranch(),
144
+ /head\s*=\s*repair/,
145
+ 'the next CONVERGE pass re-resolves HEAD from GitHub, so assigning the repair SHA here is dead',
146
+ );
147
+ });
148
+
149
+ test('the CONVERGE branch re-resolves HEAD from GitHub on every entry', () => {
150
+ const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
151
+ assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
152
+ const resolveHeadIndex = convergeSource.indexOf('head = await resolveHead()', convergeBranchStart);
153
+ assert.notEqual(resolveHeadIndex, -1, 'expected CONVERGE to re-resolve HEAD via resolveHead()');
154
+ });
155
+
156
+ test('fix prompt resolves threads by PRRT thread node id looked up from the comment databaseId', () => {
157
+ const fixPrompt = lensPromptBody('applyFixes');
158
+ assert.match(fixPrompt, /PRRT/, 'expected the thread node id form (PRRT_...) to be named');
159
+ assert.match(
160
+ fixPrompt,
161
+ /databaseId/,
162
+ 'expected the GraphQL lookup matching comment databaseId to be named',
163
+ );
164
+ assert.match(
165
+ fixPrompt,
166
+ /not the numeric comment id/,
167
+ 'expected an explicit guard against passing the numeric comment id to resolve_thread',
168
+ );
169
+ });
170
+
171
+ test('fix prompt does not pass the numeric comment id straight to resolve_thread', () => {
172
+ assert.doesNotMatch(
173
+ lensPromptBody('applyFixes'),
174
+ /then resolve that thread \(use the github MCP pull_request_review_write/,
175
+ 'resolve_thread and resolveReviewThread require a PRRT_... thread node id, not the comment id',
176
+ );
177
+ });
@@ -0,0 +1,107 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const workflowDirectory = dirname(fileURLToPath(import.meta.url));
8
+ const convergeSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
9
+
10
+ function functionBody(functionName) {
11
+ const functionStart = convergeSource.indexOf(`function ${functionName}(`);
12
+ assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
13
+ const nextFunctionStart = convergeSource.indexOf('\nfunction ', functionStart + 1);
14
+ const functionEnd = nextFunctionStart === -1 ? convergeSource.length : nextFunctionStart;
15
+ return convergeSource.slice(functionStart, functionEnd);
16
+ }
17
+
18
+ function constantLine(constantName) {
19
+ const constantSource = readFileSync(join(workflowDirectory, 'converge.mjs'), 'utf8');
20
+ const matchedLine = constantSource
21
+ .split('\n')
22
+ .find((eachLine) => eachLine.trimStart().startsWith(`const ${constantName} =`));
23
+ assert.ok(matchedLine, `expected ${constantName} to be declared`);
24
+ return matchedLine;
25
+ }
26
+
27
+ const productionModule = new Function(
28
+ `${constantLine('SHA_COMPARISON_PREFIX_LENGTH')}\n` +
29
+ `${functionBody('normalizeShaForComparison')}\n` +
30
+ `${functionBody('collectFindingThreadIds')}\n` +
31
+ `${functionBody('detectFixProgress')}\n` +
32
+ 'return { detectFixProgress, collectFindingThreadIds };',
33
+ )();
34
+
35
+ const { detectFixProgress, collectFindingThreadIds } = productionModule;
36
+
37
+ const PRIOR_HEAD = 'abcdef0123456789abcdef0123456789abcdef01';
38
+ const MOVED_HEAD = 'fedcba9876543210fedcba9876543210fedcba98';
39
+
40
+ function nullThreadFinding() {
41
+ return { file: 'converge.mjs', line: 278, severity: 'P2', title: 'stale', detail: 'x', replyToCommentId: null };
42
+ }
43
+
44
+ function threadBearingFinding() {
45
+ return { file: 'converge.mjs', line: 278, severity: 'P2', title: 'stale', detail: 'x', replyToCommentId: 4242 };
46
+ }
47
+
48
+ test('a resolvedWithoutCommit round whose findings carry no thread id does not count as progress', () => {
49
+ const allNullThreadFindings = [nullThreadFinding(), nullThreadFinding()];
50
+ const hadThreadBearingFinding = allNullThreadFindings.some(
51
+ (eachFinding) => collectFindingThreadIds(eachFinding).length > 0,
52
+ );
53
+ const fixProgress = detectFixProgress(
54
+ { newSha: PRIOR_HEAD, pushed: false, resolvedWithoutCommit: true, summary: 'judged stale' },
55
+ PRIOR_HEAD,
56
+ hadThreadBearingFinding,
57
+ );
58
+ assert.equal(fixProgress.progressed, false);
59
+ });
60
+
61
+ test('a resolvedWithoutCommit round with at least one thread-bearing finding still counts as progress', () => {
62
+ const mixedFindings = [nullThreadFinding(), threadBearingFinding()];
63
+ const hadThreadBearingFinding = mixedFindings.some(
64
+ (eachFinding) => collectFindingThreadIds(eachFinding).length > 0,
65
+ );
66
+ const fixProgress = detectFixProgress(
67
+ { newSha: PRIOR_HEAD, pushed: false, resolvedWithoutCommit: true, summary: 'resolved threads' },
68
+ PRIOR_HEAD,
69
+ hadThreadBearingFinding,
70
+ );
71
+ assert.equal(fixProgress.progressed, true);
72
+ assert.equal(fixProgress.newSha, PRIOR_HEAD);
73
+ });
74
+
75
+ test('a pushed fix that moved HEAD still progresses regardless of thread-bearing findings', () => {
76
+ const fixProgress = detectFixProgress(
77
+ { newSha: MOVED_HEAD, pushed: true, resolvedWithoutCommit: false, summary: 'committed' },
78
+ PRIOR_HEAD,
79
+ false,
80
+ );
81
+ assert.equal(fixProgress.progressed, true);
82
+ assert.equal(fixProgress.newSha, MOVED_HEAD);
83
+ });
84
+
85
+ test('a dead fix agent never progresses', () => {
86
+ const fixProgress = detectFixProgress(null, PRIOR_HEAD, true);
87
+ assert.equal(fixProgress.progressed, false);
88
+ assert.equal(fixProgress.newSha, PRIOR_HEAD);
89
+ });
90
+
91
+ test('the converge call site sets a fix-stalled blocker when an all-null-thread resolvedWithoutCommit round cannot progress', () => {
92
+ const convergeBranch = convergeSource.slice(
93
+ convergeSource.indexOf("const fixResult = await applyFixes(head, findings, 'converge-round')"),
94
+ convergeSource.indexOf("if (!roundOutcome.roundClean)"),
95
+ );
96
+ assert.match(convergeBranch, /collectFindingThreadIds/);
97
+ assert.match(convergeBranch, /fix stalled/i);
98
+ });
99
+
100
+ test('the copilot call site sets a fix-stalled blocker when an all-null-thread resolvedWithoutCommit round cannot progress', () => {
101
+ const copilotBranch = convergeSource.slice(
102
+ convergeSource.indexOf("const fixResult = await applyFixes(head, copilotOutcome.findings, 'copilot')"),
103
+ convergeSource.indexOf("phase = 'CONVERGE'\n continue\n }\n phase = 'FINALIZE'"),
104
+ );
105
+ assert.match(copilotBranch, /collectFindingThreadIds/);
106
+ assert.match(copilotBranch, /fix stalled/i);
107
+ });