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.
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +616 -0
- package/hooks/blocking/code_rules_enforcer.py +22 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +61 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/autoconverge/SKILL.md +93 -0
- package/skills/autoconverge/workflow/converge.mjs +27 -2
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- 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
|
+
});
|