pi-orchestrator-config 4.1.5 → 4.2.1

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/README.md CHANGED
@@ -12,8 +12,9 @@ Single extension that provides:
12
12
 
13
13
  | Feature | Description |
14
14
  |---------|-------------|
15
- | **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async modes) |
16
- | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`, `/async-kill`). On **acpx** parents, optional async is coerced to sync; dream/cron need `async_llm_provider` + `async_llm_model`. `cli-*` providers support async natively (see `dev-docs/cli-provider.md`) |
15
+ | **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async modes); optional `model` param for model override |
16
+ | **`list_models`** | List available models and providers for subagent model override |
17
+ | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`, `/async-kill`). On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `dev-docs/cli-provider.md`) |
17
18
  | **CLI / ACPX providers** | Optional `cli_agents` / `acpx_agents` register `cli-*` / `acpx-*` via native `createProvider` (pi ≥ 0.81): `/login cli-<agent>` or `/login acpx-<agent>`, model refresh, filter when unavailable |
18
19
  | **`/btw` command** | Quick side questions without polluting conversation history — ephemeral overlay |
19
20
  | **`/async-status` command** | Show status of background agents — select one for live output streaming |
@@ -212,6 +213,19 @@ Use python-expert to fix the type errors in src/models.py
212
213
  Run scout and planner in a chain to analyze the auth module
213
214
  ```
214
215
 
216
+ **Run agent with specific model:**
217
+
218
+ ```bash
219
+ # Discover available models
220
+ list_models(provider="litellm")
221
+
222
+ # Run agent with explicit model
223
+ subagent(agent="worker", task="...", model="litellm/claude-opus-4-6-1m")
224
+
225
+ # Parallel tasks with per-task models
226
+ subagent(tasks=[{agent: "worker", task: "...", model: "litellm/claude-opus-4-6-1m", cwd: "..."}])
227
+ ```
228
+
215
229
  ## Code Review Loop
216
230
 
217
231
  After any code change, the orchestrator runs 6 agents **in parallel** (5 reviewers + test-automator):
@@ -231,9 +245,9 @@ always completes fix/explain (5a) before the cap check; the cap only blocks re-d
231
245
  At cap, report **Not fixed** (explained why not → outstanding) vs **Fixed**
232
246
  (verification blocked by the cap — cannot re-dispatch to confirm clean). Invalid values (including non-digit
233
247
  forms like `"10.0"` / `"1e1"`) fall through to the next resolution layer / default `3`. Disable the review loop via
234
- `review_loop_enforcement: false`, not via max_cycles. The max-cycles stop is via injected orchestrator rules
235
- (LLM compliance only); commit blocking remains code-enforced — blocked unless both `status: clean` and
236
- `tests_passed: true`; hitting the cycle cap does NOT bypass it.
248
+ `review_loop_enforcement: false`, not via max_cycles. Commit blocking is code-enforced via `isCommitAllowed`:
249
+ allows commit when `isReviewClean` (`status: clean` + `tests_passed: true`, or `status: none`), or when max cycles are exhausted
250
+ (`status` is `has_findings` or `clean`, no pending reviewers, `cycle >= review_loop_max_cycles` — no `tests_passed` requirement).
237
251
 
238
252
  Staged mode (`--autorabbit`/`--autoqodo` in `/review-handler`) shares one total `review_loop_max_cycles` budget
239
253
  across both its Spec Compliance and Code Quality stages — not a separate cap per stage.
@@ -260,8 +274,8 @@ Create `.pi/pi-config-settings.json` in your project to override global defaults
260
274
  "image_model": "gemini-3-pro-image",
261
275
  "acpx_agents": ["cursor"],
262
276
  "cli_agents": ["claude", "cursor"],
263
- "async_llm_provider": "anthropic",
264
- "async_llm_model": "claude-sonnet-4-20250514",
277
+ "internal_operations_provider": "anthropic",
278
+ "internal_operations_model": "claude-sonnet-4-20250514",
265
279
  "agent_provider": "cli-cursor",
266
280
  "agent_model": "cursor:cursor-grok-4.5-high-fast",
267
281
  "agent_overrides": {
@@ -630,8 +644,10 @@ PI_PIDIFF_ENABLE=false pi
630
644
  | `git` | Version control |
631
645
  | `gh` | GitHub CLI (PRs, issues) |
632
646
  | `glab` | GitLab CLI (MRs, issues, pipelines) — install [gitlab-cli-skills](https://github.com/vince-winkintel/gitlab-cli-skills) for full agent support |
647
+ | `gcc` | C compiler — needed for building Python C extensions (e.g. ovirt-engine-sdk-python) |
633
648
  | `uv` / `uvx` | Python execution (enforced by orchestrator) |
634
649
  | `go` | Go development and code review |
650
+ | `libxml2-dev` | libxml2 headers — needed for building Python C extensions (e.g. ovirt-engine-sdk-python) |
635
651
  | `mcpl` | MCP server access (search, Jenkins, etc.) |
636
652
  | `myk-pi-tools` | PR review, release, and other CLI utilities |
637
653
  | `prek` | Pre-commit hook runner |
@@ -663,7 +679,8 @@ PI_PIDIFF_ENABLE=false pi
663
679
  | `myk-pi-tools reviews` | Review handling commands |
664
680
  | `myk-pi-tools memory` | Project memory commands — persistent per-repo learning |
665
681
  | `myk-pi-tools ai-cli` | AI CLI commands (cursor, claude, gemini) |
666
- | `myk-pi-tools coderabbit` | CodeRabbit commands |
682
+ | `myk-pi-tools coderabbit` | CodeRabbit commands (check, trigger, retry) |
683
+ | `myk-pi-tools settings get [key ...]` | Resolve settings (project → global → env → default). No args = all keys |
667
684
 
668
685
  ### What's protected
669
686
 
@@ -802,6 +819,17 @@ A standalone HTTP sidecar wrapping the Pi SDK, living under `packages/pi-sidecar
802
819
  CLI commands: `npx pi-sidecar` (start server), `npx pi-sidecar-start` (background start/stop).
803
820
 
804
821
  See [`packages/pi-sidecar/README.md`](packages/pi-sidecar/README.md) for full documentation.
822
+ See [`packages/pi-sidecar/CONSUMER-GUIDE.md`](packages/pi-sidecar/CONSUMER-GUIDE.md) for integration best practices.
823
+
824
+ ### pi-vertex-claude
825
+
826
+ Google Vertex AI Claude provider for Pi, living under `packages/pi-vertex-claude/`.
827
+
828
+ | Package | Language | Registry |
829
+ |---------|----------|----------|
830
+ | `@myk-org/pi-vertex-claude` | TypeScript | npm |
831
+
832
+ See [`packages/pi-vertex-claude/README.md`](packages/pi-vertex-claude/README.md) for full documentation.
805
833
 
806
834
  ## License
807
835
 
@@ -11,7 +11,7 @@ You are a code review specialist focused on **documentation quality, completenes
11
11
  - Execute first, explain after
12
12
  - Do NOT modify files — only review and report findings
13
13
  - If a task falls outside your domain, report it and hand off
14
- - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH...HEAD`
14
+ - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH` (includes uncommitted changes)
15
15
 
16
16
  ## Project Guidelines (MANDATORY — read before reviewing)
17
17
 
@@ -143,3 +143,13 @@ If no issues: `{"findings": []}`
143
143
  Severity values: `CRITICAL`, `WARNING`, `SUGGESTION`
144
144
 
145
145
  After writing your response, validate it is parseable JSON.
146
+
147
+ ## Prior Review Cycle
148
+
149
+ If your prompt includes a `<prior-review-cycle>` block with previous findings and responses:
150
+
151
+ - **Fixed findings** → verify the fix is correct. Do NOT re-raise if the fix addresses the issue.
152
+ - **Explained findings** → accept valid technical explanations. Only re-raise if the explanation is factually wrong (state why in the finding description field).
153
+ - **Focus on NEW issues** not covered in prior cycles.
154
+
155
+ Do NOT repeat findings that were adequately addressed. All output MUST remain JSON-only — no prose outside the JSON object.
@@ -11,7 +11,7 @@ You are a code review specialist focused on **project guidelines and style adher
11
11
  - Execute first, explain after
12
12
  - Do NOT modify files — only review and report findings
13
13
  - If a task falls outside your domain, report it and hand off
14
- - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH...HEAD`
14
+ - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH` (includes uncommitted changes)
15
15
 
16
16
  ## Review Focus
17
17
 
@@ -95,3 +95,13 @@ If no issues: `{"findings": []}`
95
95
  Severity values: `CRITICAL`, `WARNING`, `SUGGESTION`
96
96
 
97
97
  After writing your response, validate it is parseable JSON.
98
+
99
+ ## Prior Review Cycle
100
+
101
+ If your prompt includes a `<prior-review-cycle>` block with previous findings and responses:
102
+
103
+ - **Fixed findings** → verify the fix is correct. Do NOT re-raise if the fix addresses the issue.
104
+ - **Explained findings** → accept valid technical explanations. Only re-raise if the explanation is factually wrong (state why in the finding description field).
105
+ - **Focus on NEW issues** not covered in prior cycles.
106
+
107
+ Do NOT repeat findings that were adequately addressed. All output MUST remain JSON-only — no prose outside the JSON object.
@@ -11,7 +11,7 @@ You are a code review specialist focused on **general code quality and maintaina
11
11
  - Execute first, explain after
12
12
  - Do NOT modify files — only review and report findings
13
13
  - If a task falls outside your domain, report it and hand off
14
- - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH...HEAD`
14
+ - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH` (includes uncommitted changes)
15
15
 
16
16
  ## Project Guidelines (MANDATORY — read before reviewing)
17
17
 
@@ -140,3 +140,13 @@ If no issues: `{"findings": []}`
140
140
  Severity values: `CRITICAL`, `WARNING`, `SUGGESTION`
141
141
 
142
142
  After writing your response, validate it is parseable JSON.
143
+
144
+ ## Prior Review Cycle
145
+
146
+ If your prompt includes a `<prior-review-cycle>` block with previous findings and responses:
147
+
148
+ - **Fixed findings** → verify the fix is correct. Do NOT re-raise if the fix addresses the issue.
149
+ - **Explained findings** → accept valid technical explanations. Only re-raise if the explanation is factually wrong (state why in the finding description field).
150
+ - **Focus on NEW issues** not covered in prior cycles.
151
+
152
+ Do NOT repeat findings that were adequately addressed. All output MUST remain JSON-only — no prose outside the JSON object.
@@ -11,7 +11,7 @@ You are a code review specialist focused on **bugs, logic errors, and security v
11
11
  - Execute first, explain after
12
12
  - Do NOT modify files — only review and report findings
13
13
  - If a task falls outside your domain, report it and hand off
14
- - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH...HEAD`
14
+ - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH` (includes uncommitted changes)
15
15
 
16
16
  ## Review Focus
17
17
 
@@ -105,3 +105,13 @@ If no issues: `{"findings": []}`
105
105
  Severity values: `CRITICAL`, `WARNING`, `SUGGESTION`
106
106
 
107
107
  After writing your response, validate it is parseable JSON.
108
+
109
+ ## Prior Review Cycle
110
+
111
+ If your prompt includes a `<prior-review-cycle>` block with previous findings and responses:
112
+
113
+ - **Fixed findings** → verify the fix is correct. Do NOT re-raise if the fix addresses the issue.
114
+ - **Explained findings** → accept valid technical explanations. Only re-raise if the explanation is factually wrong (state why in the finding description field).
115
+ - **Focus on NEW issues** not covered in prior cycles.
116
+
117
+ Do NOT repeat findings that were adequately addressed. All output MUST remain JSON-only — no prose outside the JSON object.
@@ -11,7 +11,7 @@ You are a code review specialist focused on **spec compliance — alignment betw
11
11
  - Execute first, explain after
12
12
  - Do NOT modify files — only review and report findings
13
13
  - If a task falls outside your domain, report it and hand off
14
- - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH...HEAD`
14
+ - Get the diff with `git diff origin/$PI_REVIEW_BASE_BRANCH` (includes uncommitted changes)
15
15
  - You MUST run `gh pr view` and `gh issue view` commands every time — even if you think you already have the data from a prior turn. Prior turn data is STALE.
16
16
  - If `$PI_HAS_PR` is `false`, this is a pre-push review — no PR exists yet. Follow the reduced flow in Step 1.
17
17
 
@@ -162,3 +162,13 @@ If no issues: `{"findings": []}`
162
162
  Severity values: `SUGGESTION`
163
163
 
164
164
  After writing your response, validate it is parseable JSON.
165
+
166
+ ## Prior Review Cycle
167
+
168
+ If your prompt includes a `<prior-review-cycle>` block with previous findings and responses:
169
+
170
+ - **Fixed findings** → verify the fix is correct. Do NOT re-raise if the fix addresses the issue.
171
+ - **Explained findings** → accept valid technical explanations. Only re-raise if the explanation is factually wrong (state why in the finding description field).
172
+ - **Focus on NEW issues** not covered in prior cycles.
173
+
174
+ Do NOT repeat findings that were adequately addressed. All output MUST remain JSON-only — no prose outside the JSON object.
@@ -13,9 +13,44 @@ You are a Git Expert responsible for all local git operations and version contro
13
13
  - Do NOT ask for confirmation — execute directly
14
14
  - If a task falls outside your domain, report it and hand off
15
15
 
16
+ ## Review Loop Enforcement
17
+
18
+ {{SETTINGS:review_loop_enforcement,review_loop_max_cycles}}
19
+
20
+ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
21
+
22
+ 1. Read `.pi/data/pi-config-review-state.json` (use the `read` tool, NOT `cat`/`grep`) and check the state.
23
+
24
+ 2. **BLOCK the commit unless one of the allow cases in step 4 holds.**
25
+ Common block reasons: `status` is `"needs_review"` / `"in_progress"`,
26
+ `status` is `"clean"` with `tests_passed: false` (does **not** block the max-cycle path),
27
+ `reviewers_pending` is not empty,
28
+ or `has_findings` but `cycle` still below `review_loop_max_cycles`.
29
+
30
+ 3. When blocking, report: "⛔ Review loop incomplete (status: X, tests_passed: Y). Run the review loop before committing." and STOP — do NOT proceed with the commit.
31
+
32
+ 4. **Only commit when:**
33
+ - Status is `"clean"` with `tests_passed: true`, OR
34
+ - Status is `"none"` (no review tracking), OR
35
+ - Max cycles exhausted: status is `"has_findings"`, `"clean"`, or `"needs_review"`,
36
+ `reviewers_pending` is empty, and `cycle` >= `review_loop_max_cycles`. No `tests_passed` requirement.
37
+
38
+ ## Project Settings
39
+
40
+ {{SETTINGS:dco,commit_trailer,use_worktrees,allow_push_to_protected_branches}}
41
+
42
+ - If `dco` is `true`: add `--signoff` to `git commit`. Skip if already present.
43
+ - If `commit_trailer` is a string (e.g. `"Assisted-by"`): append trailer to the commit message.
44
+ Format: `TRAILER: PI (MODEL) <noreply@pi.dev>` using `$PI_MODEL` for model. Skip if already present.
45
+ If the value contains commas, use the first name.
46
+ - If `use_worktrees` is `true`: BLOCK `git checkout` (except `git checkout -- <file>`) and `git switch`.
47
+ Use `git worktree add .worktrees/NAME -b BRANCH DEFAULT_BRANCH` instead
48
+ (detect default branch via `git symbolic-ref refs/remotes/origin/HEAD`, fallback to `main`).
49
+ - If `allow_push_to_protected_branches` is `true`: commits and pushes to main/master are allowed.
50
+
16
51
  ## Protection Rules
17
52
 
18
- - NEVER commit or push to main/master branch
53
+ - NEVER commit or push to main/master branch (unless `allow_push_to_protected_branches` is `true` — see Project Settings)
19
54
  - NEVER commit to already-merged branches
20
55
  - NEVER use `--no-verify` flag
21
56
  - Branch prefixes: `feature/`, `fix/`, `hotfix/`, `refactor/`
@@ -38,7 +73,7 @@ Format rules:
38
73
  - First line: Clear, concise title (50 chars or less)
39
74
  - Blank line separator
40
75
  - Body: Detailed explanation if needed
41
- - NO attribution — no Claude/AI signatures whatsoever
76
+ - NO ad-hoc AI signatures. When `commit_trailer` is configured, append that trailer only (see Project Settings)
42
77
 
43
78
  ## Standard Workflows
44
79
 
@@ -51,7 +86,7 @@ Format rules:
51
86
 
52
87
  **Create branch and push:**
53
88
 
54
- 1. `git checkout -b branch-name`
89
+ 1. `git checkout -b branch-name` (if `use_worktrees` is enabled: `git worktree add .worktrees/NAME -b BRANCH DEFAULT_BRANCH`)
55
90
  2. Verify changes committed
56
91
  3. `git push -u origin branch-name`
57
92
 
@@ -27,6 +27,14 @@ This agent does NOT run tests. When tests are required (e.g., before creating a
27
27
  1. Ask orchestrator: "Have all tests passed?"
28
28
  2. If NO/UNKNOWN: "Please delegate to test-runner, then call me again"
29
29
 
30
+ ## Project Settings
31
+
32
+ {{SETTINGS:comment_signature}}
33
+
34
+ - If `comment_signature` is `true`: append a signature to every PR/issue comment body.
35
+ Format: `\n\n---\n*Assisted-by: PI (<model>)*` using `$PI_MODEL` for model.
36
+ If `$PI_COMMENT_SIGNATURE` is set, use: `\n\n---\n*$PI_COMMENT_SIGNATURE*`.
37
+
30
38
  ## Core Operations
31
39
 
32
40
  ### Pull Requests
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { createRequire } from "node:module";
10
+ import { fileURLToPath } from "node:url";
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
 
@@ -16,12 +17,23 @@ import { discoverAgents } from "./agents.js";
16
17
  import { resolveAgentModelProvider } from "./resolve-agent-model.js";
17
18
  import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
18
19
  import { addReviewerPending, recordReviewerResult, countFindings, readReviewState, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
19
- import { getMainBranch } from "./git-helpers.js";
20
+ import {
21
+ getMainBranch,
22
+ } from "./git-helpers.js";
20
23
  import { waitForResultFiles } from "./async-wait.js";
21
24
  import { openAsyncStatusOverlay } from "./async-status-ui.js";
22
25
  export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
23
26
  import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
24
27
  import { setSlot } from "./status-bar.js";
28
+ import { getSetting } from "./project-settings.js";
29
+ import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
30
+
31
+ const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
32
+ fs.readFileSync(
33
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "settings-keys.json"),
34
+ "utf-8",
35
+ ),
36
+ );
25
37
 
26
38
  // ── Constants ────────────────────────────────────────────────────────────
27
39
 
@@ -101,7 +113,7 @@ export function registerAsyncAgents(
101
113
  pi: ExtensionAPI,
102
114
  terminalNotify: (title: string, body: string) => void,
103
115
  ): {
104
- spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean }) => { id: string; error?: string; model?: string };
116
+ spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string };
105
117
  killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
106
118
  getAsyncJobs: () => Array<{ id: string; agent: string; name?: string; task: string; status: string; startedAt: number }>;
107
119
  } {
@@ -193,7 +205,7 @@ export function registerAsyncAgents(
193
205
  job.updatedAt = Date.now();
194
206
  if (job.agent.startsWith("code-reviewer-")) {
195
207
  const findings = countFindings(typeof data.output === "string" ? data.output : "");
196
- try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch {}
208
+ try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
197
209
  }
198
210
  try { fs.unlinkSync(resultFilePath); } catch {}
199
211
  } catch {
@@ -202,7 +214,7 @@ export function registerAsyncAgents(
202
214
  job.durationMs = Date.now() - job.startedAt;
203
215
  job.updatedAt = Date.now();
204
216
  if (job.agent.startsWith("code-reviewer-")) {
205
- try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
217
+ try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
206
218
  }
207
219
  }
208
220
  } else {
@@ -212,7 +224,7 @@ export function registerAsyncAgents(
212
224
  job.updatedAt = Date.now();
213
225
  // Record killed reviewer as having 0 findings — prevents permanent commit block
214
226
  if (job.agent.startsWith("code-reviewer-")) {
215
- try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch { /* best-effort */ }
227
+ try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
216
228
  }
217
229
  }
218
230
  // Check if this completes a group — deliver remaining siblings' results
@@ -260,7 +272,6 @@ export function registerAsyncAgents(
260
272
  // Ingest any unprocessed result files — zombie/kill paths may trigger delivery
261
273
  // before processResultFile() has read all group members' outputs.
262
274
  // Wait up to 2s total (not per-job) for all missing result files.
263
- const lateIngestedIds = new Set<string>();
264
275
  // Wait for non-failed, non-fireAndForget jobs that haven't been ingested yet
265
276
  const waitJobs = groupJobs.filter(j => j.output === undefined && !j.fireAndForget && j.status !== "failed");
266
277
  if (waitJobs.length > 0) {
@@ -277,21 +288,33 @@ export function registerAsyncAgents(
277
288
  j.exitCode = data.exitCode;
278
289
  j.durationMs = data.durationMs;
279
290
  if (data.success !== undefined) j.status = data.success ? "complete" : "failed";
280
- lateIngestedIds.add(j.id);
281
291
  asyncLog(`deliverGroupResults: late-ingested result for ${j.id}`);
282
292
  } catch (e: any) { asyncLog(`deliverGroupResults: late-ingest failed for ${j.id}: ${e?.message}`); }
283
293
  }
284
294
 
285
- // Track reviewer completions for late-ingested code-reviewer jobs only
286
- // (jobs already processed by processResultFile were tracked there)
295
+ // Track reviewer completions for ALL code-reviewer jobs in the group.
296
+ // processResultFile may have already called recordReviewerResult for some,
297
+ // but recordReviewerResult is idempotent (skips if reviewer already reported).
287
298
  for (const j of groupJobs) {
288
- if (!j.agent.startsWith("code-reviewer-") || !lateIngestedIds.has(j.id)) continue;
299
+ if (!j.agent.startsWith("code-reviewer-")) continue;
289
300
  try {
290
301
  const output = typeof j.output === "string" ? j.output : "";
291
302
  const findings = countFindings(output);
292
303
  // -1 means invalid JSON output — treat conservatively as having findings
293
304
  recordReviewerResult(jobCwd(j), j.agent, findings < 0 ? 1 : findings);
294
- } catch { /* best-effort */ }
305
+ } catch (e: any) { asyncLog(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); }
306
+ }
307
+
308
+ // Track test agent completions for grouped test-automator/test-runner
309
+ for (const j of groupJobs) {
310
+ if (j.agent !== "test-automator" && j.agent !== "test-runner") continue;
311
+ try {
312
+ if (j.status === "complete") {
313
+ markTestsPassed(jobCwd(j));
314
+ } else if (j.status === "failed") {
315
+ markTestsFailed(jobCwd(j));
316
+ }
317
+ } catch (e: any) { asyncLog(`markTests failed for ${j.agent}: ${e?.message}`); }
295
318
  }
296
319
 
297
320
  // Skip delivery if ALL jobs in group are fire-and-forget
@@ -364,8 +387,16 @@ export function registerAsyncAgents(
364
387
  const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
365
388
  const job = asyncState.jobs.get(data.id);
366
389
  asyncLog(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
367
- if (!job) return;
368
- if (job.delivered) return; // Already delivered to user
390
+ if (!job) {
391
+ // Orphan result file — job not in current session. Clean up to prevent re-delivery on reload.
392
+ try { fs.unlinkSync(resultPath); } catch (e: any) { asyncLog(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
393
+ return;
394
+ }
395
+ if (job.delivered) {
396
+ // Already delivered — clean up stale file
397
+ try { fs.unlinkSync(resultPath); } catch (e: any) { asyncLog(`stale cleanup failed: ${resultPath}: ${e?.message}`); }
398
+ return;
399
+ }
369
400
  // Skip if already ingested (grouped jobs stay on disk until group delivers)
370
401
  if (job.output !== undefined) return;
371
402
 
@@ -387,7 +418,7 @@ export function registerAsyncAgents(
387
418
  const findings = countFindings(output);
388
419
  // -1 means invalid JSON output — treat conservatively as having findings
389
420
  recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings);
390
- } catch { /* best-effort */ }
421
+ } catch (e: any) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
391
422
  // Clear reviewer session if context usage > 80% to prevent overflow on next cycle
392
423
  // Use model context window from the agent's effective model.
393
424
  // If agent uses a custom model, we pass its context window via the config.
@@ -513,7 +544,7 @@ export function registerAsyncAgents(
513
544
  task: string,
514
545
  cwd: string,
515
546
  agents: AgentConfig[],
516
- options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean },
547
+ options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } },
517
548
  ): { id: string; error?: string; model?: string } {
518
549
  const agent = agents.find(a => a.name === agentName);
519
550
  if (!agent) return { id: "", error: `Unknown agent: "${agentName}"` };
@@ -536,6 +567,7 @@ export function registerAsyncAgents(
536
567
  if (!parentStartTime) {
537
568
  console.debug("[async-agents] WARNING: could not read /proc/self/stat starttime after 3 attempts");
538
569
  }
570
+ const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd, options?.explicit);
539
571
  fs.writeFileSync(path.join(workerDir, "session.json"), JSON.stringify({
540
572
  resultsDir: ASYNC_RESULTS_DIR,
541
573
  fireAndForget: options?.fireAndForget || false,
@@ -572,14 +604,18 @@ export function registerAsyncAgents(
572
604
  : agentName + ':' + task.slice(0, 100);
573
605
  const deterministicSessionId = `async-${agentName}-${djb2Hash(sessionIdSource).toString(36)}`;
574
606
  piArgs.push("--session-id", deterministicSessionId);
575
- const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd);
576
607
  if (effectiveModel) piArgs.push("--model", effectiveModel);
577
608
  if (effectiveProvider) piArgs.push("--provider", effectiveProvider);
578
609
  if (agent.tools?.length) piArgs.push("--tools", agent.tools.join(","));
579
610
 
580
611
  if (agent.systemPrompt?.trim()) {
581
612
  const promptPath = path.join(workerDir, "system-prompt.md");
582
- fs.writeFileSync(promptPath, agent.systemPrompt, { mode: 0o600 });
613
+ const resolvedPrompt = substituteSettingsPlaceholders(
614
+ agent.systemPrompt,
615
+ (key) => getSetting(cwd, key as any),
616
+ Object.keys(SETTINGS_KEYS),
617
+ );
618
+ fs.writeFileSync(promptPath, resolvedPrompt, { mode: 0o600 });
583
619
  piArgs.push("--append-system-prompt", promptPath);
584
620
  }
585
621
 
@@ -664,14 +700,8 @@ export function registerAsyncAgents(
664
700
  }
665
701
  }
666
702
 
667
- const proc = spawn(process.execPath, spawnArgs, {
668
- cwd,
669
- stdio: "ignore",
670
- windowsHide: true,
671
- env: spawnEnv,
672
- });
673
-
674
- // Track the job
703
+ // Register job BEFORE spawn — child can finish and write result file before
704
+ // jobs.set runs; watcher/poller would then treat it as orphan and delete it.
675
705
  const job: AsyncJob = {
676
706
  id,
677
707
  agent: agentName,
@@ -692,6 +722,13 @@ export function registerAsyncAgents(
692
722
  };
693
723
  asyncState.jobs.set(id, job);
694
724
 
725
+ const proc = spawn(process.execPath, spawnArgs, {
726
+ cwd,
727
+ stdio: "ignore",
728
+ windowsHide: true,
729
+ env: spawnEnv,
730
+ });
731
+
695
732
  // addReviewerPending already called above (before piArgs construction)
696
733
 
697
734
  updateAsyncWidget();
@@ -891,7 +928,7 @@ export function registerAsyncAgents(
891
928
  job.updatedAt = Date.now();
892
929
  // Record killed reviewer as having 0 findings — prevents permanent commit block
893
930
  if (job.agent.startsWith("code-reviewer-")) {
894
- try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch { /* best-effort */ }
931
+ try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
895
932
  }
896
933
  // Check if this completes a group — deliver remaining siblings' results
897
934
  if (job.groupId) {
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { asStringArray, getSetting } from "./project-settings.js";
15
15
 
16
- export interface AsyncLlmSidecar {
16
+ export interface InternalOpsProvider {
17
17
  provider: string;
18
18
  model: string;
19
19
  }
@@ -61,9 +61,9 @@ export function supportsAsyncLlm(
61
61
  * Sidecar model for must-async LLM work (dream, fireAndForget) when parent is acpx.
62
62
  * Both provider and model must be set. Returns null if incomplete or sidecar is acpx-*.
63
63
  */
64
- export function getAsyncLlmSidecar(cwd: string): AsyncLlmSidecar | null {
65
- const provider = getSetting(cwd, "async_llm_provider");
66
- const model = getSetting(cwd, "async_llm_model");
64
+ export function getInternalOpsProvider(cwd: string): InternalOpsProvider | null {
65
+ const provider = getSetting(cwd, "internal_operations_provider");
66
+ const model = getSetting(cwd, "internal_operations_model");
67
67
  if (
68
68
  typeof provider === "string" &&
69
69
  provider.trim() &&
@@ -80,7 +80,7 @@ export function getAsyncLlmSidecar(cwd: string): AsyncLlmSidecar | null {
80
80
  export type AsyncDispatchDecision =
81
81
  | { action: "keep-async" }
82
82
  | { action: "coerce-sync"; note: string }
83
- | { action: "sidecar-async"; sidecar: AsyncLlmSidecar; note: string }
83
+ | { action: "sidecar-async"; sidecar: InternalOpsProvider; note: string }
84
84
  | { action: "skip"; note: string };
85
85
 
86
86
  /**
@@ -106,20 +106,20 @@ export function decideAsyncLlmDispatch(opts: {
106
106
  if (asyncOk) {
107
107
  return { action: "keep-async" };
108
108
  }
109
- const sidecar = getAsyncLlmSidecar(opts.cwd);
109
+ const sidecar = getInternalOpsProvider(opts.cwd);
110
110
  if (opts.mustAsync) {
111
111
  if (!sidecar) {
112
112
  return {
113
113
  action: "skip",
114
114
  note:
115
115
  "Skipped must-async LLM work: parent is acpx and " +
116
- "async_llm_provider/async_llm_model are not set (or sidecar is acpx-*).",
116
+ "internal_operations_provider/internal_operations_model are not set (or sidecar is acpx-*).",
117
117
  };
118
118
  }
119
119
  return {
120
120
  action: "sidecar-async",
121
121
  sidecar,
122
- note: `Using async_llm sidecar ${sidecar.provider}/${sidecar.model} (parent is acpx).`,
122
+ note: `Using internal ops provider ${sidecar.provider}/${sidecar.model} (parent is acpx).`,
123
123
  };
124
124
  }
125
125
  return {
@@ -544,7 +544,7 @@ export function registerCron(
544
544
  console.debug(`[cron] ${dispatch.note} (task #${task.id})`);
545
545
  try {
546
546
  state.lastCtx?.ui?.notify?.(
547
- `Cron #${task.id} skipped: set async_llm_provider/async_llm_model for acpx`,
547
+ `Cron #${task.id} skipped: set internal_operations_provider/internal_operations_model for acpx`,
548
548
  "warning",
549
549
  );
550
550
  } catch { /* stale UI */ }
@@ -87,7 +87,7 @@ export function registerDreaming(
87
87
  dreamingLog("warn", dreamDispatch.note || "dream skipped (no async LLM path)");
88
88
  try {
89
89
  lastCtx?.ui?.notify?.(
90
- "Dream skipped: set async_llm_provider and async_llm_model for acpx sessions",
90
+ "Dream skipped: set internal_operations_provider and internal_operations_model for acpx sessions",
91
91
  "warning",
92
92
  );
93
93
  } catch { /* stale UI */ }
@@ -27,7 +27,7 @@ import {
27
27
  runGit,
28
28
  } from "./git-helpers.js";
29
29
  import { spawnSync } from "node:child_process";
30
- import { markNeedsReview, isReviewClean, readReviewState, statePath, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
30
+ import { markNeedsReview, isCommitAllowed, readReviewState, statePath, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
31
31
  import {
32
32
  checkPythonPipBlock,
33
33
  checkRemoteExecBlock,
@@ -82,8 +82,7 @@ async function handleCommitTrailer(command: string, event: any, ctx: any, gitCwd
82
82
  const trailerSetting = getSetting(gitCwd, "commit_trailer");
83
83
  if (typeof trailerSetting !== "string") return undefined;
84
84
 
85
- const modelId = (ctx as any).model?.id;
86
- if (!modelId) return undefined;
85
+ const modelId = (process.env.PI_MODEL || (ctx as any).model?.id || "unknown").replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim() || "unknown";
87
86
 
88
87
  const piIdentity = `PI (${modelId}) <noreply@pi.dev>`;
89
88
  let trailerName: string;
@@ -362,7 +361,7 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
362
361
  cachedTrailerSelection = null;
363
362
  // Set comment signature env var for CLI tools to read
364
363
  if (getSetting(ctx.cwd, "comment_signature")) {
365
- const modelId = (ctx as any).model?.id || "unknown";
364
+ const modelId = (process.env.PI_MODEL || (ctx as any).model?.id || "unknown").replace(/[^a-zA-Z0-9._:@/ -]/g, "").trim() || "unknown";
366
365
  process.env.PI_COMMENT_SIGNATURE = `Assisted-by: PI (${modelId})`;
367
366
  } else {
368
367
  delete process.env.PI_COMMENT_SIGNATURE;
@@ -518,7 +517,7 @@ export function registerEnforcement(pi: ExtensionAPI, inContainer?: boolean): vo
518
517
  // Use resolveEffectiveCwd to detect the worktree from cd commands (e.g., cd .worktrees/issue-622 && git commit)
519
518
  if (process.env.PI_AGENT_NAME === "git-expert" && hasGitSub(command, "commit")) {
520
519
  const commitCwd = resolveEffectiveCwd(command, ctx.cwd);
521
- if (getSetting(ctx.cwd, "review_loop_enforcement") && !isReviewClean(commitCwd)) {
520
+ if (getSetting(ctx.cwd, "review_loop_enforcement") && !isCommitAllowed(commitCwd)) {
522
521
  const state = readReviewState(commitCwd);
523
522
  const testInfo = state.status === "clean" && !state.tests_passed ? " Tests have not passed yet — run tests before committing." : "";
524
523
  const reviewAdvice = state.status !== "clean" ? " Fix findings and re-run all reviewers until 0 comments." : "";