claude-dev-env 1.72.0 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  2. package/bin/install.mjs +73 -5
  3. package/bin/install.test.mjs +360 -4
  4. package/hooks/blocking/CLAUDE.md +3 -1
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  6. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  7. package/hooks/blocking/code_rules_docstrings.py +616 -0
  8. package/hooks/blocking/code_rules_enforcer.py +22 -0
  9. package/hooks/blocking/code_rules_shared.py +19 -0
  10. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  11. package/hooks/blocking/md_to_html_blocker.py +7 -8
  12. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  13. package/hooks/blocking/plain_language_blocker.py +51 -16
  14. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  15. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  16. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  17. package/hooks/blocking/state_description_blocker.py +75 -36
  18. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  22. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  25. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  26. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  27. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  28. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  29. package/hooks/hooks.json +9 -79
  30. package/hooks/hooks_constants/CLAUDE.md +3 -1
  31. package/hooks/hooks_constants/blocking_check_limits.py +61 -0
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  33. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  34. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  35. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  37. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  38. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  39. package/hooks/validation/mypy_validator.py +215 -17
  40. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  41. package/hooks/validation/test_mypy_validator.py +184 -1
  42. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  43. package/hooks/workflow/test_auto_formatter.py +10 -9
  44. package/package.json +1 -1
  45. package/rules/docstring-prose-matches-implementation.md +2 -1
  46. package/skills/autoconverge/SKILL.md +93 -0
  47. package/skills/autoconverge/workflow/converge.mjs +27 -2
  48. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  49. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  50. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -15,8 +15,9 @@ Read the body and the docstring side by side:
15
15
  - **Read-source / match-source unions.** A body that computes `read_names = a | b | c` (or any union of "what counts") names each union member in the prose enumeration. A union member the code applies but the prose omits is a gap.
16
16
  - **Suppressor / skip lists.** A body with several early returns that suppress the check names each suppressor in the prose.
17
17
  - **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
18
- - **Step order.** A docstring that says `A then B then C` matches the call order in the body.
18
+ - **Step order.** A docstring that says `A then B then C` matches the call order in the body. A step enumeration that names the body's linear steps also names every corrective step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`). The `check_docstring_step_enumeration_dispatch_coverage` gate blocks the branch-guarded-dispatch form of this drift — a step-enumeration docstring that omits a two-or-more-token dispatch step the body guards inside a branch — at Write/Edit time.
19
19
  - **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
20
+ - **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
20
21
  - **Companion-doc ordering and content claims.** A `SKILL.md` (or sibling `.md`) sentence that names a produced artifact and claims its order (`sorted`, `alphabetical`, `in sorted order`) or its content (`the at-risk names`, `just the current set`) matches the producer function's docstring and body for that same artifact. A producer that builds the artifact by merging stored names with new names and appending — preserving file order, not re-sorting the union — leaves a doc that still says `sorted` drifted on both counts: the order claim is wrong, and the content claim hides the merged-in prior entries. When the producer's ordering or union changes, the same change updates the companion doc. The two move together in one commit, even when the producer edit does not touch the `.md` file.
21
22
 
22
23
  When the body changes the set of behaviors it applies, the same edit updates the prose enumeration. The two move together in one commit.
@@ -23,6 +23,22 @@ the workflow journal.
23
23
  autoconverge runs it as a deterministic workflow. The two skills share the same
24
24
  helper scripts and the same convergence gate.
25
25
 
26
+ ## Run scope: one PR or several
27
+
28
+ Decide the scope from how many PRs the user named, then follow that path:
29
+
30
+ 1. **One PR** → the single-PR run described below (`workflow/converge.mjs`): one
31
+ worktree, one workflow launch, one teardown.
32
+ 2. **Several PRs** → the [Multiple PRs](#multiple-prs) run
33
+ (`workflow/converge_multi.mjs`): one worktree per PR and a single workflow
34
+ launch that drives every PR's converge run in parallel, then one teardown per
35
+ PR.
36
+
37
+ The single-PR sections (Requirements, Pre-flight, Run the workflow, Teardown)
38
+ each describe one converge run. The Multiple PRs section reuses them once per PR
39
+ and adds only what fanning out needs: a per-PR worktree and a per-PR teardown
40
+ loop.
41
+
26
42
  ## Requirements
27
43
 
28
44
  Scan the tool list at the top of this conversation for the literal string
@@ -270,10 +286,87 @@ suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
270
286
  - **Convergence check:** `check_convergence.py` is the authoritative gate; on a
271
287
  full pass the workflow marks `draft=false`.
272
288
 
289
+ ## Multiple PRs
290
+
291
+ The multi-PR run drives several draft PRs to ready in one launch:
292
+ `workflow/converge_multi.mjs` fans out one `converge.mjs` child run per PR with
293
+ `parallel()`, and every child is pinned to its own PR's worktree through the
294
+ `repoPath` it receives, so the children never share a checkout. Each child run is
295
+ the exact single-PR convergence loop — same rounds, same reuse pass, same Copilot
296
+ gate, same convergence check — one per PR at once. The children share the run's
297
+ concurrency cap, so the fan-out self-throttles rather than spawning every PR's
298
+ lenses at the same instant.
299
+
300
+ ### Multi-PR pre-flight (main session)
301
+
302
+ `EnterWorktree` puts the session on one branch only, so the multi-PR path gives
303
+ each PR its own checkout with `git worktree add`. For each PR the user named:
304
+
305
+ 1. **Resolve PR scope** as the single-PR pre-flight step 2 does: capture `owner`,
306
+ `repo`, `prNumber`, and `headRefName`; confirm the PR is a draft, and mark it
307
+ draft (`gh pr ready <n> --repo <o>/<r> --undo`) when it is already ready so the
308
+ loop owns the ready transition.
309
+ 2. **Create a worktree on the PR's head ref** and capture its absolute path. From
310
+ inside the PR's repository checkout:
311
+ `git worktree add <abs worktree path> <headRefName>` (run `git fetch origin
312
+ <headRefName>` first when the ref is not local). Put each PR's worktree under a
313
+ path carrying its PR number so the fan-out keeps them distinct. Confirm
314
+ `git -C <abs worktree path> rev-parse --abbrev-ref HEAD` equals the head ref
315
+ and its `HEAD` equals the PR head SHA.
316
+ 3. **Verify each worktree is the PR's repo (strict pre-flight):**
317
+ `python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode strict`,
318
+ run with that worktree as the working directory. A non-zero exit prints a
319
+ `PREFLIGHT_OUTCOME` line and an `ABORT` line: report it and drop that PR from
320
+ the run rather than aborting every PR.
321
+ 4. **Grant project permissions once per repository** — the single-PR pre-flight
322
+ step 4 grant covers every worktree of the same repo, so run it one time for
323
+ the repo the PRs live in.
324
+
325
+ ### Launch the multi-PR workflow
326
+
327
+ Call the `Workflow` tool against the fan-out script, passing the absolute path of
328
+ `converge.mjs` and one entry per PR:
329
+
330
+ ```
331
+ Workflow({
332
+ scriptPath: "<this skill dir>/workflow/converge_multi.mjs",
333
+ args: {
334
+ convergeScriptPath: "<this skill dir>/workflow/converge.mjs",
335
+ prs: [
336
+ { owner: "<O>", repo: "<R>", prNumber: <N1>, repoPath: "<abs worktree 1>", bugbotDisabled: false },
337
+ { owner: "<O>", repo: "<R>", prNumber: <N2>, repoPath: "<abs worktree 2>", bugbotDisabled: false }
338
+ ]
339
+ }
340
+ })
341
+ ```
342
+
343
+ `convergeScriptPath` is the absolute path to `workflow/converge.mjs` in this same
344
+ skill directory; each `repoPath` is the absolute path of the worktree that PR is
345
+ checked out in. The workflow runs in the background and notifies this session on
346
+ completion; watch live progress with `/workflows`, where each PR's child run
347
+ appears under its own group.
348
+
349
+ The workflow returns `{ converged, prCount, convergedCount, results, blocker }`,
350
+ where `results` is one record per PR carrying
351
+ `{ owner, repo, prNumber, converged, rounds, finalSha, blocker }`. The top-level
352
+ `converged` is true only when every PR converged.
353
+
354
+ ### Multi-PR teardown (on workflow completion)
355
+
356
+ Run the single-PR [Teardown](#teardown-on-workflow-completion) once per entry in
357
+ `results`, using that PR's `owner`, `repo`, `prNumber`, and `finalSha`, and its
358
+ own worktree as the working directory. Build and publish a PR's closing report
359
+ only for a PR whose `converged` is true; for a PR that returned a blocker, skip
360
+ its report and carry the blocker into the final summary. Revoke project
361
+ permissions once per repository after every PR's teardown. Then print one summary
362
+ report — a line per PR as
363
+ `#<prNumber>: <converged | blocked> — rounds <N>, final <finalSha>[, blocker <blocker>]`.
364
+
273
365
  ## Folder map
274
366
 
275
367
  - `SKILL.md` — this hub.
276
368
  - `workflow/converge.mjs` — the convergence workflow script.
369
+ - `workflow/converge_multi.mjs` — the multi-PR fan-out driver: one `converge.mjs` child run per PR in parallel, each pinned to its PR worktree via `repoPath`.
277
370
  - `workflow/aggregate_runs.py` — merges every autoconverge journal for a PR into one journal and returns its deduped findings, fix summaries, round count, and final SHA.
278
371
  - `workflow/convergence_summary.py` — builds the convergence-summary agent prompt over a PR's merged findings.
279
372
  - `workflow/render_report.py` — builds the closing convergence insights HTML report, taking the summary from `--summary-file`.
@@ -36,16 +36,40 @@ const HEADLESS_SAFETY_PREAMBLE =
36
36
  '- Keep scratch files and cleanup inside the OS temp dir or $CLAUDE_JOB_DIR/tmp (auto-allowed as ephemeral); never target a repository or worktree path with rm -rf.\n' +
37
37
  '- If a step appears to require a real destructive command, use a non-destructive equivalent or report it as a blocker instead of running it.\n\n'
38
38
 
39
+ let activeRepoPath = null
40
+
41
+ /**
42
+ * Build the per-agent worktree directive for a path-scoped run.
43
+ *
44
+ * A multi-PR parent run drives several converge children from one shared
45
+ * working directory, so each child pins its own agents to the worktree its PR
46
+ * is checked out in; without that pin every child's git, gh, diff, edit,
47
+ * commit, and test commands would run in the shared launch directory rather
48
+ * than the PR's own checkout. The parent hands the worktree path in as
49
+ * input.repoPath, which sets activeRepoPath. A single-PR run carries no
50
+ * repoPath, so this returns an empty string and every agent keeps its own
51
+ * working directory — behavior identical to a run with no path scoping.
52
+ * @param {string|null} repoPath the PR worktree absolute path, or null for the single-PR default
53
+ * @returns {string} the worktree directive to prepend, or an empty string when repoPath is null
54
+ */
55
+ const worktreeDirective = (repoPath) =>
56
+ repoPath
57
+ ? `WORKTREE — this PR is checked out at ${repoPath}. Unless a step explicitly names a different repository directory (for example an environment-hardening repo checkout, which you cd into exactly as that step directs), run every git, gh, diff, edit, commit, push, and test command for this PR in that worktree: cd "${repoPath}" before any such command, and resolve repository roots from there.\n\n`
58
+ : ''
59
+
39
60
  /**
40
61
  * Spawn a workflow agent with the headless-safety preamble prepended to its
41
62
  * prompt. Every agent in this convergence loop runs unattended, so each one is
42
- * routed through here to inherit the same no-confirmation-prompt guidance.
63
+ * routed through here to inherit the same no-confirmation-prompt guidance. On a
64
+ * path-scoped run the worktree directive is prepended too, so every agent runs
65
+ * in the PR's own worktree (activeRepoPath); on a single-PR run that directive
66
+ * is empty and the agent keeps its own working directory.
43
67
  * @param {string} prompt the agent's role-specific instruction body
44
68
  * @param {object} options the agent() options (label, phase, schema, agentType, model)
45
69
  * @returns {Promise<*>} the agent() result
46
70
  */
47
71
  const convergeAgent = (prompt, options) =>
48
- agent(`${HEADLESS_SAFETY_PREAMBLE}${prompt}`, options)
72
+ agent(`${HEADLESS_SAFETY_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
49
73
 
50
74
  const PRE_COMMIT_GATE_STEP =
51
75
  `\n\nFINAL STEP — pre-commit gate check (do NOT commit): before your turn ends, prove your working-tree changes CAN be committed by dry-running the CODE_RULES commit gate that gates git commit (precommit_code_rules_gate). From inside the checkout that holds your changes, resolve its root with git rev-parse --show-toplevel, stage your changes with git add -A, then run exactly:\n` +
@@ -696,6 +720,7 @@ if (runInput.blocker) {
696
720
  return { converged: false, rounds: 0, finalSha: null, blocker: runInput.blocker }
697
721
  }
698
722
  const input = runInput.input
723
+ activeRepoPath = typeof input.repoPath === 'string' && input.repoPath ? input.repoPath : null
699
724
  const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNumber} (https://github.com/${input.owner}/${input.repo}/pull/${input.prNumber})`
700
725
 
701
726
  /**
@@ -0,0 +1,47 @@
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 sliceBetween(startNeedle, endNeedle) {
11
+ const sliceStart = convergeSource.indexOf(startNeedle);
12
+ assert.notEqual(sliceStart, -1, `expected ${startNeedle} to exist`);
13
+ const sliceEnd = convergeSource.indexOf(endNeedle, sliceStart + startNeedle.length);
14
+ assert.notEqual(sliceEnd, -1, `expected ${endNeedle} to exist after ${startNeedle}`);
15
+ return convergeSource.slice(sliceStart, sliceEnd);
16
+ }
17
+
18
+ const productionModule = new Function(
19
+ `${sliceBetween('const worktreeDirective =', '\nconst convergeAgent =')}\n` +
20
+ 'return { worktreeDirective };',
21
+ )();
22
+ const { worktreeDirective } = productionModule;
23
+
24
+ test('a single-PR run (no repoPath) produces an empty worktree directive', () => {
25
+ assert.equal(worktreeDirective(null), '');
26
+ });
27
+
28
+ test('a path-scoped run pins every agent to the PR worktree by absolute path', () => {
29
+ const directive = worktreeDirective('/worktrees/pr-398');
30
+ assert.match(directive, /\/worktrees\/pr-398/);
31
+ assert.match(directive, /cd /);
32
+ assert.match(directive, /git, gh, diff, edit, commit, push, and test/);
33
+ });
34
+
35
+ test('a path-scoped run defers to a step that names a different repository directory', () => {
36
+ assert.match(worktreeDirective('/worktrees/pr-398'), /different repository directory/i);
37
+ });
38
+
39
+ test('convergeAgent prepends the worktree directive for the active repo path', () => {
40
+ const agentDefinition = sliceBetween('const convergeAgent =', '\nconst PRE_COMMIT_GATE_STEP');
41
+ assert.match(agentDefinition, /worktreeDirective\(activeRepoPath\)/);
42
+ assert.match(agentDefinition, /HEADLESS_SAFETY_PREAMBLE/);
43
+ });
44
+
45
+ test('the run binds activeRepoPath from input.repoPath after the input is parsed', () => {
46
+ assert.match(convergeSource, /activeRepoPath = typeof input\.repoPath === 'string'/);
47
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Autoconverge multi-PR fan-out workflow driver.
3
+ *
4
+ * SINGLE-FILE CONTRACT — keep this file self-contained. The Workflow runtime
5
+ * wraps this body in a function (so top-level await and return work) and rejects
6
+ * static import statements, and `export const meta` must be the first statement.
7
+ * This driver fans out one converge.mjs child run per PR with parallel(); the
8
+ * converge.mjs child uses only agent()/parallel() (never workflow()), so the
9
+ * one-level workflow() nesting limit holds.
10
+ */
11
+
12
+ export const meta = {
13
+ name: 'autoconverge-multi',
14
+ description: 'Drive several draft PRs to convergence in one run: fan out one autoconverge converge.mjs child per PR in parallel, each pinned to its own checked-out worktree via repoPath, then report every PR\'s outcome together.',
15
+ whenToUse: 'Launched by the /autoconverge skill when the user names more than one PR to converge at once; the single-PR path launches workflow/converge.mjs directly.',
16
+ phases: [
17
+ { title: 'Converge all', detail: 'One converge.mjs child run per PR, all in parallel; each child is pinned to its own PR worktree through repoPath' },
18
+ ],
19
+ }
20
+
21
+ /**
22
+ * Normalize the workflow args global into a parsed object.
23
+ *
24
+ * The Workflow runtime may deliver args as a JSON-encoded string or as an
25
+ * object; a string is parsed and an object passes through unchanged. A non-JSON
26
+ * or empty string yields null so a malformed payload becomes a structured
27
+ * blocker rather than aborting the run.
28
+ * @param {string|object} rawArgs the workflow args global (JSON string or object)
29
+ * @returns {object|null} the parsed args, or null when a string payload fails to parse
30
+ */
31
+ function normalizeMultiInput(rawArgs) {
32
+ if (typeof rawArgs !== 'string') return rawArgs
33
+ try {
34
+ return JSON.parse(rawArgs)
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Decide whether one PR entry carries every coordinate a child run needs.
42
+ *
43
+ * A child converge run needs the PR's owner, repo, and number to address its
44
+ * GitHub calls, and the absolute worktree path the PR is checked out in to pin
45
+ * its agents there.
46
+ * @param {object} prEntry one element of the args.prs array
47
+ * @returns {boolean} true when owner, repo, prNumber, and a non-empty string repoPath are all present
48
+ */
49
+ function isUsablePrEntry(prEntry) {
50
+ return (
51
+ prEntry != null &&
52
+ Boolean(prEntry.owner) &&
53
+ Boolean(prEntry.repo) &&
54
+ Boolean(prEntry.prNumber) &&
55
+ typeof prEntry.repoPath === 'string' &&
56
+ Boolean(prEntry.repoPath)
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Validate the normalized multi-PR input into usable coordinates or a blocker.
62
+ *
63
+ * A fan-out run needs the absolute converge.mjs script path and a non-empty list
64
+ * of PR entries that each carry owner, repo, prNumber, and the absolute worktree
65
+ * path the PR is checked out in. A payload that fails to parse, a non-string
66
+ * convergeScriptPath, a missing or empty prs list, or any entry missing a
67
+ * coordinate yields a blocker the top-level run reports as
68
+ * {converged:false, blocker} rather than throwing on a missing field.
69
+ * @param {string|object} rawArgs the workflow args global (JSON string or object)
70
+ * @returns {{input: object|null, blocker: string|null}} usable coordinates or a blocker
71
+ */
72
+ function classifyMultiInput(rawArgs) {
73
+ const candidate = normalizeMultiInput(rawArgs)
74
+ if (candidate == null) {
75
+ return {
76
+ input: null,
77
+ blocker: 'invalid run coordinates: the workflow args did not parse into an object',
78
+ }
79
+ }
80
+ if (typeof candidate.convergeScriptPath !== 'string' || !candidate.convergeScriptPath) {
81
+ return {
82
+ input: null,
83
+ blocker:
84
+ 'invalid run coordinates: convergeScriptPath (absolute path to converge.mjs) is required',
85
+ }
86
+ }
87
+ if (!Array.isArray(candidate.prs) || candidate.prs.length === 0) {
88
+ return {
89
+ input: null,
90
+ blocker: 'invalid run coordinates: prs must be a non-empty array of PR entries',
91
+ }
92
+ }
93
+ const unusableEntryCount = candidate.prs.filter(
94
+ (eachEntry) => !isUsablePrEntry(eachEntry),
95
+ ).length
96
+ if (unusableEntryCount > 0) {
97
+ return {
98
+ input: null,
99
+ blocker: `invalid run coordinates: ${unusableEntryCount} PR entry/entries missing owner, repo, prNumber, or repoPath`,
100
+ }
101
+ }
102
+ return { input: candidate, blocker: null }
103
+ }
104
+
105
+ const multiInput = classifyMultiInput(args)
106
+ if (multiInput.blocker) {
107
+ return { converged: false, prCount: 0, convergedCount: 0, results: [], blocker: multiInput.blocker }
108
+ }
109
+ const input = multiInput.input
110
+
111
+ phase('Converge all')
112
+ log(`autoconverge multi-PR: driving ${input.prs.length} PR(s) to ready in parallel`)
113
+
114
+ const childResults = await parallel(
115
+ input.prs.map((eachPr) => async () => {
116
+ const childOutcome = await workflow(
117
+ { scriptPath: input.convergeScriptPath },
118
+ {
119
+ owner: eachPr.owner,
120
+ repo: eachPr.repo,
121
+ prNumber: eachPr.prNumber,
122
+ repoPath: eachPr.repoPath,
123
+ bugbotDisabled: Boolean(eachPr.bugbotDisabled),
124
+ },
125
+ )
126
+ return {
127
+ owner: eachPr.owner,
128
+ repo: eachPr.repo,
129
+ prNumber: eachPr.prNumber,
130
+ converged: Boolean(childOutcome && childOutcome.converged),
131
+ rounds: childOutcome && childOutcome.rounds !== undefined ? childOutcome.rounds : null,
132
+ finalSha: childOutcome && childOutcome.finalSha !== undefined ? childOutcome.finalSha : null,
133
+ blocker: childOutcome && childOutcome.blocker !== undefined ? childOutcome.blocker : null,
134
+ }
135
+ }),
136
+ )
137
+
138
+ const results = childResults.map((eachResult, eachIndex) =>
139
+ eachResult === null
140
+ ? {
141
+ owner: input.prs[eachIndex].owner,
142
+ repo: input.prs[eachIndex].repo,
143
+ prNumber: input.prs[eachIndex].prNumber,
144
+ converged: false,
145
+ rounds: null,
146
+ finalSha: null,
147
+ blocker: 'child run threw or was skipped before returning an outcome',
148
+ }
149
+ : eachResult,
150
+ )
151
+
152
+ const convergedCount = results.filter((eachResult) => eachResult.converged).length
153
+ log(`autoconverge multi-PR done: ${convergedCount}/${results.length} PR(s) converged`)
154
+
155
+ return {
156
+ converged: convergedCount === results.length,
157
+ prCount: results.length,
158
+ convergedCount,
159
+ results,
160
+ blocker: null,
161
+ }
@@ -0,0 +1,100 @@
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 multiSource = readFileSync(join(workflowDirectory, 'converge_multi.mjs'), 'utf8');
9
+
10
+ function sourceSliceBetween(startNeedle, endNeedle) {
11
+ const sliceStart = multiSource.indexOf(startNeedle);
12
+ assert.notEqual(sliceStart, -1, `expected ${startNeedle} to exist`);
13
+ const sliceEnd = multiSource.indexOf(endNeedle, sliceStart + startNeedle.length);
14
+ assert.notEqual(sliceEnd, -1, `expected ${endNeedle} to exist after ${startNeedle}`);
15
+ return multiSource.slice(sliceStart, sliceEnd);
16
+ }
17
+
18
+ const productionModule = new Function(
19
+ `${sourceSliceBetween('function normalizeMultiInput(', '\nconst multiInput =')}\n` +
20
+ 'return { normalizeMultiInput, isUsablePrEntry, classifyMultiInput };',
21
+ )();
22
+ const { normalizeMultiInput, classifyMultiInput } = productionModule;
23
+
24
+ const SCRIPT_PATH = '/abs/skills/autoconverge/workflow/converge.mjs';
25
+
26
+ function validEntry(prNumber) {
27
+ return {
28
+ owner: 'JonEcho',
29
+ repo: 'python-automation',
30
+ prNumber,
31
+ repoPath: `/worktrees/pr-${prNumber}`,
32
+ };
33
+ }
34
+
35
+ function validArgs() {
36
+ return { convergeScriptPath: SCRIPT_PATH, prs: [validEntry(398), validEntry(402)] };
37
+ }
38
+
39
+ test('an object payload passes through unchanged', () => {
40
+ const parsed = validArgs();
41
+ assert.deepEqual(normalizeMultiInput(parsed), parsed);
42
+ });
43
+
44
+ test('a JSON-encoded string payload is parsed into coordinates', () => {
45
+ assert.deepEqual(normalizeMultiInput(JSON.stringify(validArgs())), validArgs());
46
+ });
47
+
48
+ test('a non-JSON string returns null rather than throwing', () => {
49
+ assert.equal(normalizeMultiInput('not json at all'), null);
50
+ });
51
+
52
+ test('valid coordinates classify with no blocker and keep every PR entry', () => {
53
+ const classified = classifyMultiInput(validArgs());
54
+ assert.equal(classified.blocker, null);
55
+ assert.equal(classified.input.prs.length, 2);
56
+ });
57
+
58
+ test('a missing convergeScriptPath is blocked', () => {
59
+ const classified = classifyMultiInput({ prs: [validEntry(398)] });
60
+ assert.equal(classified.input, null);
61
+ assert.match(classified.blocker, /convergeScriptPath/);
62
+ });
63
+
64
+ test('an empty prs array is blocked', () => {
65
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [] });
66
+ assert.equal(classified.input, null);
67
+ assert.match(classified.blocker, /non-empty array/);
68
+ });
69
+
70
+ test('a missing prs list is blocked', () => {
71
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH });
72
+ assert.equal(classified.input, null);
73
+ assert.match(classified.blocker, /non-empty array/);
74
+ });
75
+
76
+ test('a non-array prs value is blocked', () => {
77
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: 'nope' });
78
+ assert.equal(classified.input, null);
79
+ assert.match(classified.blocker, /non-empty array/);
80
+ });
81
+
82
+ test('an entry missing prNumber is blocked', () => {
83
+ const badEntry = { owner: 'JonEcho', repo: 'python-automation', repoPath: '/w/x' };
84
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [badEntry] });
85
+ assert.equal(classified.input, null);
86
+ assert.match(classified.blocker, /missing/);
87
+ });
88
+
89
+ test('an entry missing repoPath is blocked', () => {
90
+ const badEntry = { owner: 'JonEcho', repo: 'python-automation', prNumber: 398 };
91
+ const classified = classifyMultiInput({ convergeScriptPath: SCRIPT_PATH, prs: [badEntry] });
92
+ assert.equal(classified.input, null);
93
+ assert.match(classified.blocker, /missing/);
94
+ });
95
+
96
+ test('a null payload is blocked', () => {
97
+ const classified = classifyMultiInput('not json at all');
98
+ assert.equal(classified.input, null);
99
+ assert.match(classified.blocker, /did not parse/);
100
+ });