claude-dev-env 1.86.1 → 1.88.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/CLAUDE.md CHANGED
@@ -21,6 +21,10 @@ Every Markdown file I write or edit describes the system's **current** state onl
21
21
  Full banned-pattern set + enforcement: `~/.claude/rules/no-historical-clutter.md` (hook `state-description-blocker`) and `~/.claude/rules/self-contained-docs.md`.
22
22
 
23
23
  ## GOTCHAS
24
+ ALWAYS base new worktrees off of UPSTREAM origin main. Assume local is out of date.
25
+
26
+ ALWAYS start each session with a /loop 15m populate or update the task list based on remaining todos.
27
+
24
28
  When making code changes, make sure you are working in the proper worktree path for the task at hand.
25
29
 
26
30
  ## Choosing Edit vs Write
package/agents/CLAUDE.md CHANGED
@@ -16,7 +16,7 @@ Agent definition files installed into `~/.claude/agents/` by `bin/install.mjs`.
16
16
  | `docs-agent.md` | Docs Agent | Documentation authoring and maintenance |
17
17
  | `git-commit-crafter.md` | Git Commit Crafter | Stages changes, writes conventional commit messages, creates commits |
18
18
  | `plan-packet-validator.md` | Plan Packet Validator | Fresh-context validator for workflow-generated plan packets under `docs/plans/` |
19
- | `pr-description-writer.md` | PR Description Writer | Authors PR descriptions in Anthropic-style shapes; required by the `pr_description_enforcer` hook |
19
+ | `pr-description-writer.md` | PR Description Writer | Authors PR descriptions in Anthropic-style shapes that pass the `pr_description_enforcer` hook's body audit |
20
20
 
21
21
  ## Format
22
22
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pr-description-writer
3
- description: "MANDATORY agent for PR descriptions and PR comments. Enforced by the `pr_description_enforcer` PreToolUse hook on `gh pr create`, `gh pr edit`, and `gh pr comment`. Authors bodies in one of three Anthropic-derived shapes (Trivial / Standard / Heavy) so PRs match merged-PR style in `anthropics/claude-code`, `anthropics/claude-code-action`, and `anthropics/claude-code-sdk-python`."
3
+ description: "Optional agent for PR descriptions and PR comments. Authors bodies in one of three Anthropic-derived shapes (Trivial / Standard / Heavy) so PRs match merged-PR style in `anthropics/claude-code`, `anthropics/claude-code-action`, and `anthropics/claude-code-sdk-python`, and pass the `pr_description_enforcer` PreToolUse hook's body audit on `gh pr create`, `gh pr edit`, and `gh pr comment`."
4
4
  tools: Read,Grep,Glob,Bash
5
5
  model: haiku
6
6
  ---
package/bin/install.mjs CHANGED
@@ -147,6 +147,7 @@ const INSTALL_GROUPS = {
147
147
  core: {
148
148
  description: 'Development standards, hooks, agents, commands',
149
149
  skills: [
150
+ 'advisor', 'advisor-refresh',
150
151
  'anthropic-plan', 'everything-search',
151
152
  'pr-review-responder',
152
153
  'recall', 'remember', 'task-build', 'verified-build'
@@ -81,7 +81,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
81
81
  | `package_inventory_stale_blocker.py` | PreToolUse (Write) | A new production code file created in a directory whose `README.md`/`CLAUDE.md` inventory (or a parent skill's `SKILL.md` Layout table mapping the `scripts/` subdirectory) names two or more sibling files but no entry for the new file |
82
82
  | `plain_language_blocker.py` | PreToolUse (Write/Edit/AskUserQuestion) | Heavy or jargon words in user-facing prose |
83
83
  | `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
84
- | `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit` without a PR-description-writer-authored body |
84
+ | `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit`/`comment` bodies that fail the Anthropic claude-code style audit |
85
85
  | `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
86
86
  | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
87
87
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
@@ -178,7 +178,7 @@ def main() -> None:
178
178
  pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
179
179
  denial_reason = (
180
180
  f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
181
- f"Use the pr-description-writer agent to author the body in Anthropic claude-code style. "
181
+ f"Rewrite the body yourself in Anthropic claude-code style. "
182
182
  f"Guide:{pr_guide_reference}"
183
183
  )
184
184
  denial_payload = {
@@ -76,8 +76,10 @@ def _read_strike_count() -> int:
76
76
  def _increment_strike_count() -> int:
77
77
  payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
78
78
  raw_count = payload.get("strikes", 0)
79
- is_valid_integer = isinstance(raw_count, int) and not isinstance(raw_count, bool)
80
- starting_count = max(raw_count, 0) if is_valid_integer else 0
79
+ if isinstance(raw_count, int) and not isinstance(raw_count, bool):
80
+ starting_count = max(raw_count, 0)
81
+ else:
82
+ starting_count = 0
81
83
  new_count = starting_count + 1
82
84
  _atomic_write_json(READABILITY_STATE_FILE, {"strikes": new_count})
83
85
  return new_count
@@ -87,31 +89,27 @@ def _reset_strike_count() -> None:
87
89
  _atomic_write_json(READABILITY_STATE_FILE, {"strikes": 0})
88
90
 
89
91
 
92
+ def _resolve_integer_field(
93
+ all_payload_fields: dict[str, object], field_name: str, default_value: int
94
+ ) -> int:
95
+ raw_value = all_payload_fields.get(field_name, default_value)
96
+ if isinstance(raw_value, int) and not isinstance(raw_value, bool):
97
+ return raw_value
98
+ return default_value
99
+
100
+
90
101
  def _load_readability_thresholds() -> ReadabilityThresholds:
91
102
  payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
92
- flesch_min_value = payload.get("flesch_min", DEFAULT_READABILITY_THRESHOLDS.flesch_min)
93
- max_sentence_value = payload.get(
94
- "max_sentence_words", DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
95
- )
96
- avg_sentence_value = payload.get(
97
- "avg_sentence_words", DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
98
- )
99
- flesch_is_int = isinstance(flesch_min_value, int) and not isinstance(flesch_min_value, bool)
100
- max_is_int = isinstance(max_sentence_value, int) and not isinstance(max_sentence_value, bool)
101
- avg_is_int = isinstance(avg_sentence_value, int) and not isinstance(avg_sentence_value, bool)
102
- resolved_flesch = (
103
- flesch_min_value if flesch_is_int else DEFAULT_READABILITY_THRESHOLDS.flesch_min
104
- )
105
- resolved_max = (
106
- max_sentence_value if max_is_int else DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
107
- )
108
- resolved_avg = (
109
- avg_sentence_value if avg_is_int else DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
110
- )
111
103
  return ReadabilityThresholds(
112
- flesch_min=resolved_flesch,
113
- max_sentence_words=resolved_max,
114
- avg_sentence_words=resolved_avg,
104
+ flesch_min=_resolve_integer_field(
105
+ payload, "flesch_min", DEFAULT_READABILITY_THRESHOLDS.flesch_min
106
+ ),
107
+ max_sentence_words=_resolve_integer_field(
108
+ payload, "max_sentence_words", DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
109
+ ),
110
+ avg_sentence_words=_resolve_integer_field(
111
+ payload, "avg_sentence_words", DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
112
+ ),
115
113
  )
116
114
 
117
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.86.1",
3
+ "version": "1.88.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/CLAUDE.md CHANGED
@@ -26,6 +26,8 @@ Skills install to `~/.claude/skills/<skill-name>/` via `packages/claude-dev-env/
26
26
  - `implement` — structured implementation from an existing plan packet
27
27
  - `bdd-protocol` — BDD depth: Example Mapping, scenario quality, outside-in layout
28
28
  - `verified-build` — build + test loop that gates on a verifier verdict
29
+ - `advisor` — turns the session into the advisor-orchestrator: it spawns executor subagents to do the code edits and test runs, and answers a blocked executor with a plan, correction, or stop
30
+ - `advisor-refresh` — sub-skill fired by the `/advisor` loop to re-assert the executor-advisor discipline mid-run
29
31
 
30
32
  **PR review and convergence**
31
33
  - `autoconverge` — autonomous single-run workflow that drives a PR to ready
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: advisor
3
+ description: >-
4
+ Turns the session into the advisor-orchestrator — the user's sole interface,
5
+ which spawns and resumes executor subagents (clean-coder and the like) to do
6
+ all the code editing and every build or test run. When an executor hits a
7
+ blocker it consults the advisor, which replies with one of three brief
8
+ signals — a plan, a correction, or a stop. The advisor orchestrates and
9
+ advises but never edits code or runs tests itself. Caps consultations per
10
+ task (default 5), reuses warm agents before spawning new ones, and
11
+ re-asserts the discipline every 20 minutes through the /advisor-refresh
12
+ loop. Adapts Anthropic's advisor strategy to Claude Code. Triggers:
13
+ '/advisor', 'advisor strategy', 'run with an advisor', 'executor-advisor
14
+ mode'.
15
+ ---
16
+
17
+ # Advisor Strategy
18
+
19
+ ## Principle
20
+
21
+ A cost-effective executor runs the primary work — it calls tools, reads
22
+ results, and iterates — while a stronger model advises only at the hard
23
+ decisions. Source: Anthropic's advisor strategy,
24
+ https://claude.com/blog/the-advisor-strategy.
25
+
26
+ Under this skill the session is the advisor-orchestrator. In Claude Code the
27
+ user always talks to the session and never to a subagent, so the session is
28
+ the user's sole interface: all user-facing communication flows through it. It
29
+ spawns and resumes executor subagents — `clean-coder` and the like — and those
30
+ executors do every bit of the execution: the code edits, the build runs, the
31
+ test runs. The advisor drives the plan and answers the executors when they get
32
+ stuck.
33
+
34
+ This is the advisor-lite shape: the blog's pure pairing keeps the advisor
35
+ tool-less and hidden from the user, but Claude Code routes every user message
36
+ through the session, so the advisor here stays the user's interface while
37
+ keeping execution out of its own hands. Consultations are capped (default 5
38
+ per task) — the same guard the API's `max_uses` gives a tool.
39
+
40
+ ## Gotchas
41
+
42
+ - **Double invocation duplicates the reminder loop.** A second `/advisor` in
43
+ the same session schedules a second refresh loop. Check whether the loop is
44
+ already running before you schedule one (see the invocation guard in
45
+ Process step 1).
46
+ - **The advisor never executes.** The moment it edits a file or runs the tests
47
+ itself, the pairing breaks and the executor's warm context is wasted. Hand
48
+ every code edit and every build or test run to an executor; keep the
49
+ advisor's own tool use to orchestration and light verification reads.
50
+ - **Resuming an unnamed background agent needs its agentId.** A background
51
+ spawn returns an `agentId` (format `a...-...`); keep it so `SendMessage` can
52
+ reach that agent later. A named agent is reachable by name.
53
+ - **Consultations past the cap signal a scoping problem.** When five
54
+ consultations do not clear the blocker, the task needs re-scoping or a
55
+ hand-off to the user — not a sixth round of advice.
56
+
57
+
58
+ ## Process
59
+
60
+ 1. **Invocation guard (once per session).** Before scheduling anything, check
61
+ whether the refresh loop is already running this session. If it is, skip
62
+ straight to orchestration — do not schedule a second loop.
63
+
64
+ 2. **Register the discipline reminder.** By default, schedule it with
65
+ `ScheduleWakeup` at `delaySeconds: 1200`, prompt `/advisor-refresh`, where
66
+ each refresh re-schedules the next one — a 1200-second wakeup costs one
67
+ prompt-cache miss per firing and nothing more (see Gotchas). The loop
68
+ mechanism (`/loop 20m /advisor-refresh`) is the escape hatch when
69
+ `ScheduleWakeup` is not available. Either way the reminder is the
70
+ enforcement surface: each firing re-asserts the discipline while the run is
71
+ in flight.
72
+
73
+ 3. **Orchestrate the task.** Hold the plan and the user conversation. Spawn or
74
+ resume executor subagents (for example `clean-coder`) to do every code edit
75
+ and every build or test run, and keep driving while they work. Your own
76
+ tool use stays orchestration and light verification reads. Keep your task list updated religiously.
77
+
78
+ 4. **Executors consult at a hard decision.** Each executor's spawn prompt tells
79
+ it to stop and message you — with the task, what it tried, and the exact
80
+ blocker (plus any short code excerpt that helps) — when one of these holds:
81
+ - It has tried the same problem twice or more and it still fails.
82
+ - A decision changes the deliverable's scope or a contract that is hard to
83
+ reverse.
84
+ - Two constraints conflict and it cannot satisfy both.
85
+ - It is unsure whether to stop or keep going.
86
+
87
+ 5. **Answer with one signal.** On a consultation, reply with exactly one
88
+ signal, brief (about 400 to 700 tokens):
89
+ - **PLAN** — a different approach, as concrete ordered steps the executor
90
+ can run. When a warm agent fits the plan, name which one to resume.
91
+ - **CORRECTION** — the executor's approach is right, one thing is wrong;
92
+ name the wrong step and the fix.
93
+ - **STOP** — no path satisfies the task as assigned; say why so it can be
94
+ reported upward.
95
+ The executor resumes the moment your reply lands.
96
+
97
+ A worked consultation:
98
+
99
+ ```
100
+ Executor → advisor
101
+ Task: add a retry to the upload client.
102
+ Tried: wrapped upload() in a three-attempt loop; the second attempt
103
+ double-posts.
104
+ Blocker: the server takes no idempotency key, so a retry after a timeout
105
+ creates a duplicate record.
106
+
107
+ Advisor → executor
108
+ CORRECTION — the retry loop is right; the missing piece is a stable request
109
+ id. Generate one client-side on the first attempt and send the same id on
110
+ every retry, so the server treats the retries as one request.
111
+ ```
112
+
113
+ For a decision the advisor itself cannot settle, it may spawn the tool-less
114
+ `code-advisor` agent for a second opinion — an optional escalation, not a
115
+ required step.
116
+
117
+ ## Agent reuse (non-negotiable)
118
+
119
+ - **Resume before you spawn, always.** A warm agent carries its context and
120
+ cached tokens; a fresh spawn starts cold and pays to rebuild both.
121
+ `SendMessage` with an agent's name or `agentId` resumes a running or a
122
+ completed agent with its context intact — verified live in this environment.
123
+ Prefer that path every time an existing agent holds relevant context.
124
+ - **Spawn a fresh agent only when** no existing agent holds relevant context,
125
+ or a genuine task switch needs a clean context.
126
+ - **Name the agent to resume.** When you answer with a PLAN and a warm agent
127
+ fits, say which agent to resume and where.
128
+
129
+ ## Constraints
130
+
131
+ - One `/advisor` per session; the invocation guard blocks a second reminder
132
+ loop.
133
+ - The advisor orchestrates and advises but never edits code or runs a build or
134
+ test itself — executors do that.
135
+ - Consultations are capped at five per task by default. At the cap, re-scope
136
+ or hand off; do not keep answering.
137
+ - Reuse a warm agent over a cold spawn whenever one holds relevant context.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: advisor-refresh
3
+ description: >-
4
+ Fired by the /advisor loop reminder about every 20 minutes to re-assert the
5
+ advisor discipline mid-run. A compressed restatement of /advisor: orchestrate
6
+ rather than execute, answer a blocked executor with a plan, correction, or
7
+ stop, and reuse warm agents before spawning new ones. Triggers:
8
+ '/advisor-refresh'.
9
+ ---
10
+
11
+ # Advisor Refresh
12
+
13
+ 1. **You are the advisor.** Orchestrate and hold the user conversation; spawn
14
+ executor subagents to do all the work — every code edit and build or test
15
+ run.
16
+ 2. **An executor blocked twice on the same thing?** Answer it with one signal
17
+ — a plan, a correction, or a stop — brief. Never take over the edit or the
18
+ tests yourself.
19
+ 3. **Resume before you spawn.** `SendMessage` an existing agent by name or
20
+ `agentId` to reuse its warm context; prefer that over a cold spawn.
21
+ 4. **Fresh spawn only for a genuine task switch.** No tool compacts or clears a
22
+ subagent's context, so a clean context comes from a fresh spawn — never tell
23
+ an agent to compact.
24
+ 5. **Re-schedule the next refresh** (about 1200 seconds out) when the loop
25
+ mechanism needs each firing to queue the following one.
@@ -324,7 +324,10 @@ out of a Bash command string entirely and independent of which auto-allow path
324
324
  matches.
325
325
 
326
326
  - **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
327
- the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
327
+ the current HEAD, full `origin/main...HEAD` diff. The preflight step fetches
328
+ `origin/main` once for the round and enumerates the diff (changed-file list plus
329
+ diffstat); each lens receives that list and reads only the files it needs rather
330
+ than re-deriving the diff, forming its own review judgment. Dedup findings; one
328
331
  `clean-coder` applies the round's fixes per the `pr-fix-protocol` skill
329
332
  (`../pr-fix-protocol/SKILL.md`) — fix, reply, resolve — landing every fix in
330
333
  one commit per round, which the workflow journal records; re-verify next
@@ -344,8 +347,12 @@ matches.
344
347
  out-of-usage notice (the requester hit their quota) on the HEAD, or surfaces no
345
348
  review at all after the configured cap — the gate logs a notice and the run marks the PR
346
349
  ready with the Copilot gate bypassed. `copilotNote` records the bypass.
347
- - **Convergence check:** `check_convergence.py` is the authoritative gate; on a
348
- full pass the workflow marks `draft=false`.
350
+ - **Convergence check:** `check_convergence.py` is the authoritative gate; one
351
+ agent runs it and, on a full pass, marks `draft=false` in the same turn. Each
352
+ spawned agent runs on the model tier its role needs — opus/medium for the review
353
+ lenses and the code-editing fix steps, sonnet/medium for the verify, commit, and
354
+ recovery steps, haiku/low for the mechanical probes (preflight, Copilot gate,
355
+ CLEAN-audit post, convergence check).
349
356
 
350
357
  ## Multiple PRs
351
358
 
@@ -46,9 +46,14 @@ tracks CONVERGE passes only and is never the cap.
46
46
 
47
47
  **CONVERGE** (one round = one parallel sweep):
48
48
 
49
- 1. Resolve the current PR HEAD SHA.
49
+ 1. Resolve the current PR HEAD SHA. The same preflight step also fetches
50
+ `origin/main` once for the round and enumerates the diff — the
51
+ `git diff --name-status origin/main...HEAD` changed-file list and the
52
+ `git diff --stat` diffstat — and carries both into the round.
50
53
  2. Run three lenses in parallel on that HEAD, each over the full
51
- `origin/main...HEAD` diff:
54
+ `origin/main...HEAD` diff. Each lens receives the preflight's changed-file
55
+ list and diffstat and reads only the files it needs from that list rather than
56
+ re-deriving the diff; each lens forms its own review judgment.
52
57
  - **Bugbot lens** — drive Cursor Bugbot to a verdict on HEAD (trigger and
53
58
  poll its CI check run when needed) and return its findings, or mark itself
54
59
  down when Bugbot is opted out or unreachable.
@@ -89,9 +94,24 @@ tracks CONVERGE passes only and is never the cap.
89
94
 
90
95
  **Convergence check**:
91
96
 
92
- - Run `check_convergence.py`. A full pass marks the PR ready (`draft=false`) and
93
- ends the run. A failure returns to CONVERGE so the next round addresses the
94
- failing gate.
97
+ - One agent runs `check_convergence.py` and, on a full pass, marks the PR ready
98
+ (`draft=false`) in the same turn, ending the run. A failure returns to CONVERGE
99
+ so the next round addresses the failing gate; the repair path re-runs this same
100
+ check, and only a passing check marks the PR ready.
101
+
102
+ ## Model tiers
103
+
104
+ Each spawned agent runs on the model and effort its role needs, so the run spends
105
+ the strongest model only where judgment is dense:
106
+
107
+ - **opus / medium** — the review lenses (Bugbot, code-review, bug-audit, reuse)
108
+ and the code-editing steps that fix findings (fix-edit, conflict-edit,
109
+ repair-edit, standards-edit).
110
+ - **sonnet / medium** — the verify steps, the commit steps, and the recovery
111
+ edits that clear a commit-gate or verdict rejection.
112
+ - **haiku / low** — the mechanical steps: the preflight git-and-availability
113
+ probe, the Copilot gate, the CLEAN-audit post, and the convergence check that
114
+ marks the PR ready.
95
115
 
96
116
  ## Full-diff rule
97
117
 
@@ -58,7 +58,7 @@ function buildRunGeneralUtilityTask(convergeAgentStub) {
58
58
  'CONFIG',
59
59
  'prCoordinates',
60
60
  'CLEAN_AUDIT_SCHEMA',
61
- 'READY_SCHEMA',
61
+ 'TIERS',
62
62
  'describeStandardsDeferral',
63
63
  'describeNotRunLens',
64
64
  'serializeOneLineJson',
@@ -70,7 +70,7 @@ function buildRunGeneralUtilityTask(convergeAgentStub) {
70
70
  { prLoopScripts: 'x' },
71
71
  'coords',
72
72
  {},
73
- {},
73
+ { haikuLow: { model: 'haiku', effort: 'low' } },
74
74
  describeStandardsDeferral,
75
75
  describeNotRunLens,
76
76
  serializeOneLineJson,
@@ -36,6 +36,14 @@ function functionSource(functionName) {
36
36
  return convergeSource.slice(functionStart, functionEnd);
37
37
  }
38
38
 
39
+ function docstringBefore(functionName) {
40
+ const functionStart = convergeSource.indexOf(`function ${functionName}(`);
41
+ assert.notEqual(functionStart, -1, `expected ${functionName} to exist`);
42
+ const docStart = convergeSource.lastIndexOf('/**', functionStart);
43
+ assert.notEqual(docStart, -1, `expected a JSDoc block before ${functionName}`);
44
+ return convergeSource.slice(docStart, functionStart);
45
+ }
46
+
39
47
  test('code-review lens prompt no longer instructs a per-lens git fetch', () => {
40
48
  assert.doesNotMatch(lensPromptBody('runCodeReviewLens'), /git fetch origin main/);
41
49
  });
@@ -60,10 +68,36 @@ test('the merged preflight-git task fetches origin/main once before the parallel
60
68
  assert.match(gitTaskBody, /PREFLIGHT_GIT_SCHEMA/, 'expected the merged task to return the {sha, conflicting, fetched, copilot, bugbot} schema');
61
69
  });
62
70
 
63
- test('the merged preflight-git agent runs on haiku at low effort', () => {
71
+ test('the merged preflight-git agent spreads the haikuLow tier', () => {
64
72
  const gitTaskBody = functionSource('runGitTask');
65
- assert.match(gitTaskBody, /model: 'haiku'/, 'expected the git-utility agent to run on the cheapest model');
66
- assert.match(gitTaskBody, /effort: 'low'/, 'expected the git-utility agent to run at low effort');
73
+ assert.match(
74
+ gitTaskBody,
75
+ /\.\.\.TIERS\.haikuLow/,
76
+ 'expected the git-utility agent to spread the single-sourced haikuLow tier rather than inline model/effort literals',
77
+ );
78
+ assert.doesNotMatch(
79
+ gitTaskBody,
80
+ /model: 'haiku', effort: 'low'/,
81
+ 'expected no inline haikuLow literals duplicating the TIERS definition',
82
+ );
83
+ });
84
+
85
+ test('the preflight-git prompt states the correct number of read-only steps it enumerates', () => {
86
+ const body = functionSource('runGitTask');
87
+ assert.match(
88
+ body,
89
+ /Run five read-only preflight steps/,
90
+ 'expected the stated step count to match the five enumerated STEP blocks',
91
+ );
92
+ assert.match(body, /STEP 5 —/, 'expected the fifth step to be enumerated');
93
+ assert.doesNotMatch(body, /Run four read-only preflight steps/);
94
+ });
95
+
96
+ test('the runGitTask docstring names the diff enumeration and its changedFiles/diffstat return fields', () => {
97
+ const doc = docstringBefore('runGitTask');
98
+ assert.match(doc, /diff|changed-file|enumerat/i, 'expected the docstring to name the diff enumeration operation');
99
+ assert.match(doc, /changedFiles/, 'expected the docstring return shape to name changedFiles');
100
+ assert.match(doc, /diffstat/, 'expected the docstring return shape to name diffstat');
67
101
  });
68
102
 
69
103
  test('the reviewer-availability probe rides the merged preflight-git spawn, not a separate agent', () => {
@@ -98,7 +132,7 @@ test('the Bugbot lens is not spawned pre-spawn when the shared gate reports Bugb
98
132
  const lensArray = convergeSource.slice(parallelLensIndex, lensArrayEnd);
99
133
  assert.match(
100
134
  lensArray,
101
- /isBugbotDownPreSpawn \? Promise\.resolve\(\{ sha: head, clean: true, down: true, notSpawned: true, findings: \[\] \}\) : runBugbotLens\(head\)/,
135
+ /isBugbotDownPreSpawn \? Promise\.resolve\(\{ sha: head, clean: true, down: true, notSpawned: true, findings: \[\] \}\) : runBugbotLens\(head, reviewerAvailability\)/,
102
136
  'expected the Bugbot lens slot to substitute a resolved down placeholder instead of spawning runBugbotLens when isBugbotDownPreSpawn is true',
103
137
  );
104
138
  });
@@ -263,7 +297,7 @@ test('the COPILOT fix branch does not re-assign head from the fix before re-conv
263
297
  test('the CONVERGE branch refreshes HEAD via the merged preflight-git task only when the threaded head is invalidated', () => {
264
298
  const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
265
299
  assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
266
- const invalidGuardIndex = convergeSource.indexOf('if (!isResolvedHeadUsable(head))', convergeBranchStart);
300
+ const invalidGuardIndex = convergeSource.indexOf('if (!isResolvedHeadUsable(head)', convergeBranchStart);
267
301
  const headRefreshCallIndex = convergeSource.indexOf("runGitTask('preflight-git')", convergeBranchStart);
268
302
  assert.notEqual(invalidGuardIndex, -1, 'expected CONVERGE to gate the refresh on an invalidated head');
269
303
  assert.notEqual(headRefreshCallIndex, -1, 'expected CONVERGE to refresh HEAD via the merged preflight-git task');
@@ -273,6 +307,19 @@ test('the CONVERGE branch refreshes HEAD via the merged preflight-git task only
273
307
  );
274
308
  });
275
309
 
310
+ test('the CONVERGE branch refreshes preflight when the availability enumeration was computed against a different SHA than the head under review', () => {
311
+ const convergeBranchStart = convergeSource.indexOf("if (phase === 'CONVERGE')");
312
+ assert.notEqual(convergeBranchStart, -1, 'expected the CONVERGE branch to exist');
313
+ const guardIndex = convergeSource.indexOf('if (!isResolvedHeadUsable(head)', convergeBranchStart);
314
+ assert.notEqual(guardIndex, -1, 'expected the refresh guard to exist');
315
+ const guardLine = convergeSource.slice(guardIndex, convergeSource.indexOf('\n', guardIndex));
316
+ assert.match(
317
+ guardLine,
318
+ /reviewerAvailability\?\.sha !== head/,
319
+ 'expected the refresh guard to fire when the reviewerAvailability changed-file enumeration was computed against a SHA other than the current head, so a rebase or reuse push that advances HEAD before round 1 hands the lenses the diff for the head they actually review',
320
+ );
321
+ });
322
+
276
323
  test('each fix push, each lens-retry, and the convergence repair invalidate the threaded head so the next CONVERGE entry refreshes it', () => {
277
324
  const invalidationMatches = convergeSource.match(/^ +head = null$/gm) || [];
278
325
  assert.equal(
@@ -830,8 +877,18 @@ for (const { name, isAsync } of taskDispatchers) {
830
877
  });
831
878
  }
832
879
 
833
- test('runGeneralUtilityTask only handles the two tasks it is called with', () => {
880
+ test('runGeneralUtilityTask only handles the post-clean-audit task it is called with', () => {
834
881
  const generalBody = functionSource('runGeneralUtilityTask');
882
+ assert.match(
883
+ generalBody,
884
+ /task === 'post-clean-audit'/,
885
+ 'expected runGeneralUtilityTask to handle the post-clean-audit task',
886
+ );
887
+ assert.doesNotMatch(
888
+ generalBody,
889
+ /task === 'mark-ready'/,
890
+ 'the mark-ready step is merged into the FINALIZE convergence check (runConvergenceCheck), so its branch must be removed here',
891
+ );
835
892
  assert.doesNotMatch(
836
893
  generalBody,
837
894
  /task === 'bugbot-lens'/,
@@ -1041,3 +1098,116 @@ test('the Monitor ceiling the guidance names covers the longest interval-times-a
1041
1098
  );
1042
1099
  }
1043
1100
  });
1101
+
1102
+ function optionsLineForLabel(label) {
1103
+ const labelIndex = convergeSource.indexOf(`label: '${label}'`);
1104
+ assert.notEqual(labelIndex, -1, `expected an agent options object labeled ${label}`);
1105
+ const lineStart = convergeSource.lastIndexOf('\n', labelIndex);
1106
+ const lineEnd = convergeSource.indexOf('\n', labelIndex);
1107
+ return convergeSource.slice(lineStart, lineEnd);
1108
+ }
1109
+
1110
+ test('the TIERS map defines the opus/sonnet/haiku model-effort tiers', () => {
1111
+ assert.match(convergeSource, /opusMedium:\s*\{ model: 'opus', effort: 'medium' \}/);
1112
+ assert.match(convergeSource, /sonnetMedium:\s*\{ model: 'sonnet', effort: 'medium' \}/);
1113
+ assert.match(convergeSource, /haikuLow:\s*\{ model: 'haiku', effort: 'low' \}/);
1114
+ });
1115
+
1116
+ test('every convergeAgent spawn options object carries a model and effort tier', () => {
1117
+ const optionObjects = convergeSource.match(/\{ label[^\n]*\}/g) || [];
1118
+ assert.ok(
1119
+ optionObjects.length >= 20,
1120
+ `expected to find the per-spawn options objects, found ${optionObjects.length}`,
1121
+ );
1122
+ for (const optionObject of optionObjects) {
1123
+ const hasTier = optionObject.includes('...TIERS.');
1124
+ const hasInlineModelEffort = /model:\s*'/.test(optionObject) && /effort:\s*'/.test(optionObject);
1125
+ assert.ok(
1126
+ hasTier || hasInlineModelEffort,
1127
+ `expected the spawn options to carry a model and effort tier: ${optionObject}`,
1128
+ );
1129
+ }
1130
+ });
1131
+
1132
+ test('the four review lenses run on the opusMedium tier', () => {
1133
+ for (const label of ['lens:bugbot', 'lens:code-review', 'lens:bug-audit', 'lens:reuse']) {
1134
+ assert.match(
1135
+ optionsLineForLabel(label),
1136
+ /\.\.\.TIERS\.opusMedium/,
1137
+ `expected ${label} to run on the opusMedium tier`,
1138
+ );
1139
+ }
1140
+ });
1141
+
1142
+ test('the copilot gate runs on the haikuLow tier', () => {
1143
+ assert.match(optionsLineForLabel('copilot-gate'), /\.\.\.TIERS\.haikuLow/);
1144
+ });
1145
+
1146
+ test('the merged finalize convergence check runs on the haikuLow tier', () => {
1147
+ assert.match(functionSource('runConvergenceCheck'), /\.\.\.TIERS\.haikuLow/);
1148
+ });
1149
+
1150
+ test('the preflight-git task returns the changed-file list and diffstat for the lenses', () => {
1151
+ const body = functionSource('runGitTask');
1152
+ assert.match(body, /git diff --name-status origin\/main\.\.\.HEAD/);
1153
+ assert.match(body, /git diff --stat origin\/main\.\.\.HEAD/);
1154
+ assert.match(body, /changedFiles/);
1155
+ assert.match(body, /diffstat/);
1156
+ const schemaStart = convergeSource.indexOf('const PREFLIGHT_GIT_SCHEMA =');
1157
+ const schemaEnd = convergeSource.indexOf('\n}', schemaStart);
1158
+ const schema = convergeSource.slice(schemaStart, schemaEnd);
1159
+ assert.match(schema, /changedFiles:/);
1160
+ assert.match(schema, /diffstat:/);
1161
+ assert.match(schema, /required:[^\n]*'changedFiles'[^\n]*'diffstat'/);
1162
+ });
1163
+
1164
+ test('each per-round lens and the reuse lens inject the preflight changed-file context', () => {
1165
+ for (const builder of ['runBugbotLens', 'runCodeReviewLens', 'runAuditLens', 'runReuseAuditPass']) {
1166
+ assert.match(
1167
+ lensPromptBody(builder),
1168
+ /renderLensDiffContext\(preflightResult\)/,
1169
+ `expected ${builder} to inject the changed-file context from the preflight result`,
1170
+ );
1171
+ }
1172
+ });
1173
+
1174
+ test('renderLensDiffContext reuses the changed-file list when present and falls back to self-enumeration otherwise', () => {
1175
+ const renderLensDiffContext = new Function(
1176
+ `${functionSource('renderLensDiffContext')}\nreturn renderLensDiffContext;`,
1177
+ )();
1178
+ const withList = renderLensDiffContext({ changedFiles: 'M\ta.py', diffstat: ' a.py | 2 +-' });
1179
+ assert.match(withList, /Changed files/);
1180
+ assert.match(withList, /a\.py/);
1181
+ const fallback = renderLensDiffContext(null);
1182
+ assert.match(fallback, /git diff --name-only/, 'expected a missing file list to fall back to self-enumeration');
1183
+ });
1184
+
1185
+ test('the merged FINALIZE check runs check_convergence then marks the PR ready on pass in one agent', () => {
1186
+ const body = functionSource('runConvergenceCheck');
1187
+ assert.match(body, /check_convergence\.py/, 'expected the merged check to run check_convergence.py');
1188
+ assert.match(body, /gh pr ready/, 'expected the merged check to mark the PR ready on the passing path');
1189
+ assert.match(body, /schema: FINALIZE_SCHEMA/, 'expected the merged check to return the {pass, failures, ready} schema');
1190
+ assert.match(body, /context\.copilotDown/, 'expected the merged check to carry the copilotDown opt-out context');
1191
+ });
1192
+
1193
+ test('FINALIZE_SCHEMA carries pass, failures, and ready', () => {
1194
+ const schemaStart = convergeSource.indexOf('const FINALIZE_SCHEMA =');
1195
+ assert.notEqual(schemaStart, -1, 'expected FINALIZE_SCHEMA to exist');
1196
+ const schema = convergeSource.slice(schemaStart, convergeSource.indexOf('\n}', schemaStart));
1197
+ for (const field of ['pass', 'failures', 'ready']) {
1198
+ assert.match(schema, new RegExp(`${field}:`), `expected FINALIZE_SCHEMA to carry ${field}`);
1199
+ }
1200
+ });
1201
+
1202
+ test('the FINALIZE phase drives the merged check and reads ready from its result without a separate mark-ready spawn', () => {
1203
+ const finalizeStart = convergeSource.indexOf("if (phase === 'FINALIZE') {");
1204
+ assert.notEqual(finalizeStart, -1, 'expected a FINALIZE phase block');
1205
+ const finalizeBody = convergeSource.slice(finalizeStart, finalizeStart + 1000);
1206
+ assert.match(finalizeBody, /runConvergenceCheck\(\{ head, bugbotDown, copilotDown \}\)/);
1207
+ assert.match(finalizeBody, /classifyReadyOutcome\(finalizeResult\)/);
1208
+ assert.doesNotMatch(
1209
+ finalizeBody,
1210
+ /runGeneralUtilityTask\('mark-ready'/,
1211
+ 'the separate mark-ready spawn is merged into the FINALIZE convergence check',
1212
+ );
1213
+ });