pi-orchestrator-config 4.2.0 → 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 +26 -8
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +38 -3
- package/agents/github-expert.md +8 -0
- package/extensions/orchestrator/async-agents.ts +63 -26
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +1 -1
- package/extensions/orchestrator/dreaming.ts +1 -1
- package/extensions/orchestrator/enforcement.ts +4 -5
- package/extensions/orchestrator/index.ts +55 -0
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +13 -0
- package/extensions/orchestrator/project-settings.ts +118 -111
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-ui.ts +56 -14
- package/extensions/orchestrator/rule-placeholders.ts +28 -2
- package/extensions/orchestrator/subagent-tool.ts +53 -10
- package/extensions/pidiff/pidiff.ts +36 -4
- package/package.json +3 -2
- package/rules/10-agent-routing.md +11 -0
- package/rules/20-code-review-loop.md +14 -9
- package/settings-keys.json +102 -0
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { Type } from "typebox";
|
|
17
18
|
import { registerAskUser } from "./ask-user.js";
|
|
18
19
|
import { registerAsyncAgents } from "./async-agents.js";
|
|
19
20
|
import { registerBtw } from "./btw.js";
|
|
@@ -97,4 +98,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
98
|
registerPreferenceExtractor(pi);
|
|
98
99
|
registerMemoryTools(pi);
|
|
99
100
|
registerSessionSearch(pi);
|
|
101
|
+
|
|
102
|
+
// ── list_models tool — LLM-callable model discovery ──
|
|
103
|
+
pi.registerTool({
|
|
104
|
+
name: "list_models",
|
|
105
|
+
label: "List Models",
|
|
106
|
+
description: "List available models and providers. Use when the user asks to run an agent with a specific model, or when you need to discover available models for the subagent model parameter. Returns provider/model pairs that can be passed to subagent(model=\"provider/model-id\").",
|
|
107
|
+
parameters: Type.Object({
|
|
108
|
+
provider: Type.Optional(Type.String({ description: "Filter by provider name (e.g., 'litellm', 'cli-cursor', 'google')" })),
|
|
109
|
+
}),
|
|
110
|
+
async execute(_callId, params, _signal, _onUpdate, ctx) {
|
|
111
|
+
let models: any[];
|
|
112
|
+
try {
|
|
113
|
+
const registry = ctx.modelRegistry;
|
|
114
|
+
if (!registry) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text" as const, text: "Model registry not available." }],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
models = registry.getAvailable?.() || registry.getAll?.() || [];
|
|
120
|
+
} catch (e: any) {
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text" as const, text: `Failed to list models: ${e?.message || e}` }],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Filter out acpx-* providers — they cannot work in subagent children
|
|
126
|
+
// (PI_SUBAGENT_CHILD=1 skips acpx registration)
|
|
127
|
+
const filtered = (params.provider
|
|
128
|
+
? models.filter((m: any) => m.provider === params.provider)
|
|
129
|
+
: models
|
|
130
|
+
).filter((m: any) => !(m.provider || "").startsWith("acpx-"));
|
|
131
|
+
if (filtered.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
content: [{ type: "text" as const, text: params.provider ? `No models found for provider "${params.provider}".` : "No models available." }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Group by provider for readability
|
|
137
|
+
const byProvider = new Map<string, string[]>();
|
|
138
|
+
for (const m of filtered) {
|
|
139
|
+
const list = byProvider.get(m.provider) || [];
|
|
140
|
+
list.push(m.id);
|
|
141
|
+
byProvider.set(m.provider, list);
|
|
142
|
+
}
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
for (const [prov, ids] of byProvider) {
|
|
145
|
+
lines.push(`## ${prov}`);
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
lines.push(`- ${prov}/${id}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
});
|
|
100
155
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Parse 'provider/model-id' into separate provider and model. Plain 'model-id' returns undefined provider. */
|
|
2
|
+
export function parseModelOverride(modelStr: string | undefined): { model?: string; provider?: string } | undefined {
|
|
3
|
+
if (!modelStr) return undefined;
|
|
4
|
+
const trimmed = modelStr.trim();
|
|
5
|
+
if (!trimmed) return undefined;
|
|
6
|
+
const slashIdx = trimmed.indexOf("/");
|
|
7
|
+
if (slashIdx > 0) {
|
|
8
|
+
const provider = trimmed.slice(0, slashIdx).trim();
|
|
9
|
+
const model = trimmed.slice(slashIdx + 1).trim();
|
|
10
|
+
if (!provider || !model) return undefined;
|
|
11
|
+
return { provider, model };
|
|
12
|
+
}
|
|
13
|
+
return { model: trimmed };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Merge task-level model override with top-level explicit fallback. Task fields win. */
|
|
17
|
+
export function mergeModelOverride(
|
|
18
|
+
taskModel: string | undefined,
|
|
19
|
+
explicit: { model?: string; provider?: string } | undefined,
|
|
20
|
+
): { model?: string; provider?: string } | undefined {
|
|
21
|
+
const taskParsed = parseModelOverride(taskModel);
|
|
22
|
+
if (taskParsed) {
|
|
23
|
+
return {
|
|
24
|
+
model: taskParsed.model || explicit?.model,
|
|
25
|
+
provider: taskParsed.provider || explicit?.provider,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return explicit;
|
|
29
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import { getSetting } from "./project-settings.js";
|
|
10
11
|
import { resolveWorktreeRoot, getProjectDataDir } from "./utils.js";
|
|
11
12
|
|
|
12
13
|
type StateTransitionCallback = (state: ReviewState) => void;
|
|
@@ -254,6 +255,18 @@ export function isReviewClean(cwd: string): boolean {
|
|
|
254
255
|
return true;
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
/** Check if commit is allowed — clean review, no tracking, or max cycles exhausted. */
|
|
259
|
+
export function isCommitAllowed(cwd: string): boolean {
|
|
260
|
+
if (isReviewClean(cwd)) return true;
|
|
261
|
+
const state = readReviewState(cwd);
|
|
262
|
+
// Allow commit when max review cycles exhausted — cap reached, no reviewers running
|
|
263
|
+
if ((state.status === "has_findings" || state.status === "clean" || state.status === "needs_review") && state.reviewers_pending.length === 0) {
|
|
264
|
+
const maxCycles = getSetting(cwd, "review_loop_max_cycles");
|
|
265
|
+
if (state.cycle >= maxCycles) return true;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
257
270
|
/** Mark that tests have passed. Only meaningful when review status is being tracked. */
|
|
258
271
|
export function markTestsPassed(cwd: string): void {
|
|
259
272
|
withStateLock(cwd, (state) => {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 2. Global ~/.pi/pi-config-settings.json (fallback for all projects)
|
|
7
7
|
* 3. Env var (PI_COMMIT_TRAILER, PI_USE_WORKTREES, PI_DREAM_INTERVAL_HOURS, PI_DCO,
|
|
8
8
|
* ACPX_AGENTS, CLI_AGENTS, PI_PIDASH_ENABLE, PI_PIDIFF_ENABLE, PI_PIDASH_PORT, PI_IMAGE_MODEL,
|
|
9
|
-
*
|
|
9
|
+
* PI_INTERNAL_OPERATIONS_PROVIDER, PI_INTERNAL_OPERATIONS_MODEL, PI_REVIEW_LOOP_MAX_CYCLES)
|
|
10
10
|
* 4. Default (dream_interval_hours defaults to 3; acpx_agents/cli_agents to []; pidash_enable/pidiff_enable to true; pidash_port to 19190;
|
|
11
11
|
* review_loop_max_cycles to 3)
|
|
12
12
|
*
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
20
|
-
import { join } from "node:path";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
21
22
|
import { homedir } from "node:os";
|
|
22
23
|
import { resolveRepoRoot } from "./utils.js";
|
|
23
24
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -40,9 +41,9 @@ interface ProjectSettings {
|
|
|
40
41
|
pidash_port?: number;
|
|
41
42
|
image_model?: string;
|
|
42
43
|
/** Provider for detached LLM async children when parent is acpx (must-async / dream). */
|
|
43
|
-
|
|
44
|
+
internal_operations_provider?: string;
|
|
44
45
|
/** Model id for detached LLM async children when parent is acpx. */
|
|
45
|
-
|
|
46
|
+
internal_operations_model?: string;
|
|
46
47
|
/** Max review-loop cycles injected into the rules prompt. Integer 1-10 only. */
|
|
47
48
|
review_loop_max_cycles?: number;
|
|
48
49
|
/** Default provider for all subagents. */
|
|
@@ -53,8 +54,52 @@ interface ProjectSettings {
|
|
|
53
54
|
agent_overrides?: Record<string, { provider?: string | null; model?: string | null }>;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** Key definition from settings-keys.json — single source of truth for env names + defaults. */
|
|
58
|
+
interface SettingsKeyDef {
|
|
59
|
+
type: string;
|
|
60
|
+
env?: string;
|
|
61
|
+
default: unknown;
|
|
62
|
+
min?: number;
|
|
63
|
+
max?: number;
|
|
64
|
+
strict_digits?: boolean;
|
|
65
|
+
per_key_resolution?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
const SETTINGS_FILENAME = "pi-config-settings.json";
|
|
57
69
|
|
|
70
|
+
const SETTINGS_KEYS: Record<string, SettingsKeyDef> = JSON.parse(
|
|
71
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "settings-keys.json"), "utf-8"),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/** Dev-time check: every ProjectSettings field must have a JSON definition. */
|
|
75
|
+
const PROJECT_SETTINGS_KEYS: (keyof ProjectSettings)[] = [
|
|
76
|
+
"commit_trailer",
|
|
77
|
+
"allow_push_to_protected_branches",
|
|
78
|
+
"use_worktrees",
|
|
79
|
+
"dream_interval_hours",
|
|
80
|
+
"dco",
|
|
81
|
+
"comment_signature",
|
|
82
|
+
"review_loop_enforcement",
|
|
83
|
+
"orchestrator_edit_write_block",
|
|
84
|
+
"acpx_agents",
|
|
85
|
+
"cli_agents",
|
|
86
|
+
"pidash_enable",
|
|
87
|
+
"pidiff_enable",
|
|
88
|
+
"pidash_port",
|
|
89
|
+
"image_model",
|
|
90
|
+
"internal_operations_provider",
|
|
91
|
+
"internal_operations_model",
|
|
92
|
+
"review_loop_max_cycles",
|
|
93
|
+
"agent_provider",
|
|
94
|
+
"agent_model",
|
|
95
|
+
"agent_overrides",
|
|
96
|
+
];
|
|
97
|
+
for (const key of PROJECT_SETTINGS_KEYS) {
|
|
98
|
+
if (!(key in SETTINGS_KEYS)) {
|
|
99
|
+
throw new Error(`[project-settings] settings-keys.json missing key: ${key}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
58
103
|
function getSettingsPath(cwd: string): string {
|
|
59
104
|
return join(resolveRepoRoot(cwd), ".pi", SETTINGS_FILENAME);
|
|
60
105
|
}
|
|
@@ -83,11 +128,11 @@ function parseSettingsFile(filePath: string): ProjectSettings {
|
|
|
83
128
|
if (typeof raw.image_model === "string" && raw.image_model.trim()) {
|
|
84
129
|
result.image_model = raw.image_model.trim();
|
|
85
130
|
}
|
|
86
|
-
if (typeof raw.
|
|
87
|
-
result.
|
|
131
|
+
if (typeof raw.internal_operations_provider === "string" && raw.internal_operations_provider.trim()) {
|
|
132
|
+
result.internal_operations_provider = raw.internal_operations_provider.trim();
|
|
88
133
|
}
|
|
89
|
-
if (typeof raw.
|
|
90
|
-
result.
|
|
134
|
+
if (typeof raw.internal_operations_model === "string" && raw.internal_operations_model.trim()) {
|
|
135
|
+
result.internal_operations_model = raw.internal_operations_model.trim();
|
|
91
136
|
}
|
|
92
137
|
const parsedMaxCycles = parseReviewLoopMaxCycles(raw.review_loop_max_cycles);
|
|
93
138
|
if (parsedMaxCycles !== undefined) {
|
|
@@ -302,67 +347,34 @@ export function getSetting(cwd: string, key: "pidash_enable"): boolean;
|
|
|
302
347
|
export function getSetting(cwd: string, key: "pidiff_enable"): boolean;
|
|
303
348
|
export function getSetting(cwd: string, key: "pidash_port"): number;
|
|
304
349
|
export function getSetting(cwd: string, key: "image_model"): string;
|
|
305
|
-
export function getSetting(cwd: string, key: "
|
|
306
|
-
export function getSetting(cwd: string, key: "
|
|
350
|
+
export function getSetting(cwd: string, key: "internal_operations_provider"): string;
|
|
351
|
+
export function getSetting(cwd: string, key: "internal_operations_model"): string;
|
|
307
352
|
export function getSetting(cwd: string, key: "review_loop_max_cycles"): number;
|
|
308
353
|
export function getSetting(cwd: string, key: "agent_provider"): string;
|
|
309
354
|
export function getSetting(cwd: string, key: "agent_model"): string;
|
|
310
355
|
export function getSetting(cwd: string, key: "agent_overrides"): Record<string, { provider?: string | null; model?: string | null }>;
|
|
311
356
|
export function getSetting(cwd: string, key: string): boolean | string | number | string[] | Record<string, { provider?: string | null; model?: string | null }> {
|
|
312
357
|
const settings = getSettings(cwd);
|
|
358
|
+
const def = SETTINGS_KEYS[key];
|
|
359
|
+
if (!def) return false;
|
|
313
360
|
|
|
361
|
+
// Special cases — unique parsing beyond simple settings → env → default
|
|
314
362
|
switch (key) {
|
|
315
363
|
case "commit_trailer": {
|
|
316
364
|
if (settings.commit_trailer !== undefined) return settings.commit_trailer;
|
|
317
|
-
const envStr = process.env.
|
|
365
|
+
const envStr = def.env ? process.env[def.env] : undefined;
|
|
318
366
|
if (envStr !== undefined && envStr !== "") {
|
|
319
367
|
if (["true", "1", "yes", "on"].includes(envStr.toLowerCase())) return true;
|
|
320
368
|
if (["false", "0", "no", "off"].includes(envStr.toLowerCase())) return false;
|
|
321
369
|
return envStr; // treat as custom trailer string
|
|
322
370
|
}
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
case "allow_push_to_protected_branches": {
|
|
326
|
-
if (settings.allow_push_to_protected_branches !== undefined) return settings.allow_push_to_protected_branches;
|
|
327
|
-
const env = parseBoolEnv("PI_ALLOW_PUSH_TO_PROTECTED_BRANCHES");
|
|
328
|
-
if (env !== undefined) return env;
|
|
329
|
-
return false; // default: block pushes to protected branches
|
|
330
|
-
}
|
|
331
|
-
case "use_worktrees": {
|
|
332
|
-
if (settings.use_worktrees !== undefined) return settings.use_worktrees;
|
|
333
|
-
const env = parseBoolEnv("PI_USE_WORKTREES");
|
|
334
|
-
if (env !== undefined) return env;
|
|
335
|
-
return false; // default: disabled
|
|
336
|
-
}
|
|
337
|
-
case "dream_interval_hours": {
|
|
338
|
-
if (settings.dream_interval_hours !== undefined) return settings.dream_interval_hours;
|
|
339
|
-
const env = parseNumEnv("PI_DREAM_INTERVAL_HOURS");
|
|
340
|
-
if (env !== undefined) return env;
|
|
341
|
-
return 3; // default: 3 hours
|
|
342
|
-
}
|
|
343
|
-
case "dco": {
|
|
344
|
-
if (settings.dco !== undefined) return settings.dco;
|
|
345
|
-
const env = parseBoolEnv("PI_DCO");
|
|
346
|
-
if (env !== undefined) return env;
|
|
347
|
-
return false; // default: disabled
|
|
371
|
+
return def.default as boolean;
|
|
348
372
|
}
|
|
349
|
-
case "
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (settings.review_loop_enforcement !== undefined) return settings.review_loop_enforcement;
|
|
355
|
-
const env = parseBoolEnv("PI_REVIEW_LOOP_ENFORCEMENT");
|
|
356
|
-
if (env !== undefined) return env;
|
|
357
|
-
return false; // default: disabled (opt-in)
|
|
358
|
-
}
|
|
359
|
-
case "orchestrator_edit_write_block": {
|
|
360
|
-
if (settings.orchestrator_edit_write_block !== undefined) return settings.orchestrator_edit_write_block;
|
|
361
|
-
return false; // default: disabled
|
|
362
|
-
}
|
|
363
|
-
case "acpx_agents": {
|
|
364
|
-
if (projectSettingsFileHasKey(cwd, "acpx_agents")) {
|
|
365
|
-
const projectValue = loadProjectSettings(cwd).acpx_agents;
|
|
373
|
+
case "acpx_agents":
|
|
374
|
+
case "cli_agents": {
|
|
375
|
+
const agentKey = key as "acpx_agents" | "cli_agents";
|
|
376
|
+
if (projectSettingsFileHasKey(cwd, agentKey)) {
|
|
377
|
+
const projectValue = loadProjectSettings(cwd)[agentKey];
|
|
366
378
|
// Only override when the raw value parsed as a valid type (string or array).
|
|
367
379
|
// Invalid types (number, object, etc.) are skipped by parseSettingsFile,
|
|
368
380
|
// leaving projectValue undefined — fall through to global/env.
|
|
@@ -370,44 +382,27 @@ export function getSetting(cwd: string, key: string): boolean | string | number
|
|
|
370
382
|
return parseAgentNameList(projectValue);
|
|
371
383
|
}
|
|
372
384
|
}
|
|
373
|
-
const globalAgents = loadGlobalSettings()
|
|
385
|
+
const globalAgents = loadGlobalSettings()[agentKey];
|
|
374
386
|
if (globalAgents !== undefined) {
|
|
375
387
|
return parseAgentNameList(globalAgents);
|
|
376
388
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return [];
|
|
382
|
-
}
|
|
383
|
-
case "cli_agents": {
|
|
384
|
-
if (projectSettingsFileHasKey(cwd, "cli_agents")) {
|
|
385
|
-
const projectValue = loadProjectSettings(cwd).cli_agents;
|
|
386
|
-
if (projectValue !== undefined) {
|
|
387
|
-
return parseAgentNameList(projectValue);
|
|
389
|
+
if (def.env) {
|
|
390
|
+
const env = process.env[def.env];
|
|
391
|
+
if (env !== undefined && env !== "") {
|
|
392
|
+
return parseAgentNameList(env);
|
|
388
393
|
}
|
|
389
394
|
}
|
|
390
|
-
|
|
391
|
-
if (globalAgents !== undefined) {
|
|
392
|
-
return parseAgentNameList(globalAgents);
|
|
393
|
-
}
|
|
394
|
-
const env = process.env.CLI_AGENTS;
|
|
395
|
-
if (env !== undefined && env !== "") {
|
|
396
|
-
return parseAgentNameList(env);
|
|
397
|
-
}
|
|
398
|
-
return [];
|
|
399
|
-
}
|
|
400
|
-
case "pidash_enable": {
|
|
401
|
-
if (settings.pidash_enable !== undefined) return settings.pidash_enable;
|
|
402
|
-
const disabled = parseDisabledEnv("PI_PIDASH_ENABLE");
|
|
403
|
-
if (disabled !== undefined) return !disabled;
|
|
404
|
-
return true;
|
|
395
|
+
return parseAgentNameList(def.default as string | string[] | undefined);
|
|
405
396
|
}
|
|
397
|
+
case "pidash_enable":
|
|
406
398
|
case "pidiff_enable": {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
399
|
+
const enableKey = key as "pidash_enable" | "pidiff_enable";
|
|
400
|
+
if (settings[enableKey] !== undefined) return settings[enableKey]!;
|
|
401
|
+
if (def.env) {
|
|
402
|
+
const disabled = parseDisabledEnv(def.env);
|
|
403
|
+
if (disabled !== undefined) return !disabled;
|
|
404
|
+
}
|
|
405
|
+
return def.default as boolean;
|
|
411
406
|
}
|
|
412
407
|
case "pidash_port": {
|
|
413
408
|
if (settings.pidash_port !== undefined) {
|
|
@@ -417,39 +412,51 @@ export function getSetting(cwd: string, key: string): boolean | string | number
|
|
|
417
412
|
}
|
|
418
413
|
// Invalid value in merged settings — fall through to env/default
|
|
419
414
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (settings.image_model !== undefined) return settings.image_model;
|
|
426
|
-
const env = process.env.PI_IMAGE_MODEL;
|
|
427
|
-
return env !== undefined && env !== "" ? env : "";
|
|
428
|
-
}
|
|
429
|
-
case "async_llm_provider": {
|
|
430
|
-
if (settings.async_llm_provider !== undefined) return settings.async_llm_provider;
|
|
431
|
-
const env = process.env.PI_ASYNC_LLM_PROVIDER;
|
|
432
|
-
return env !== undefined && env !== "" ? env.trim() : "";
|
|
433
|
-
}
|
|
434
|
-
case "async_llm_model": {
|
|
435
|
-
if (settings.async_llm_model !== undefined) return settings.async_llm_model;
|
|
436
|
-
const env = process.env.PI_ASYNC_LLM_MODEL;
|
|
437
|
-
return env !== undefined && env !== "" ? env.trim() : "";
|
|
415
|
+
if (def.env) {
|
|
416
|
+
const envPort = parsePortEnv(def.env);
|
|
417
|
+
if (envPort !== undefined) return envPort;
|
|
418
|
+
}
|
|
419
|
+
return def.default as number;
|
|
438
420
|
}
|
|
439
421
|
case "review_loop_max_cycles": {
|
|
440
422
|
if (settings.review_loop_max_cycles !== undefined) return settings.review_loop_max_cycles;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
423
|
+
if (def.env) {
|
|
424
|
+
const env = parseReviewLoopMaxCyclesEnv(def.env);
|
|
425
|
+
if (env !== undefined) return env;
|
|
426
|
+
}
|
|
427
|
+
return def.default as number;
|
|
444
428
|
}
|
|
445
|
-
case "
|
|
446
|
-
return settings.
|
|
429
|
+
case "agent_overrides": {
|
|
430
|
+
return settings.agent_overrides || (def.default as Record<string, { provider?: string | null; model?: string | null }>);
|
|
447
431
|
}
|
|
448
|
-
|
|
449
|
-
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Generic handler — simple bool / number / string keys driven by settings-keys.json
|
|
435
|
+
const merged = settings[key as keyof ProjectSettings];
|
|
436
|
+
switch (def.type) {
|
|
437
|
+
case "bool": {
|
|
438
|
+
if (merged !== undefined) return merged as boolean;
|
|
439
|
+
if (def.env) {
|
|
440
|
+
const env = parseBoolEnv(def.env);
|
|
441
|
+
if (env !== undefined) return env;
|
|
442
|
+
}
|
|
443
|
+
return def.default as boolean;
|
|
450
444
|
}
|
|
451
|
-
case "
|
|
452
|
-
return
|
|
445
|
+
case "number": {
|
|
446
|
+
if (merged !== undefined) return merged as number;
|
|
447
|
+
if (def.env) {
|
|
448
|
+
const env = parseNumEnv(def.env);
|
|
449
|
+
if (env !== undefined) return env;
|
|
450
|
+
}
|
|
451
|
+
return def.default as number;
|
|
452
|
+
}
|
|
453
|
+
case "string": {
|
|
454
|
+
if (merged !== undefined) return (merged as string) || (def.default as string);
|
|
455
|
+
if (def.env) {
|
|
456
|
+
const env = process.env[def.env];
|
|
457
|
+
if (env !== undefined && env !== "") return env.trim();
|
|
458
|
+
}
|
|
459
|
+
return def.default as string;
|
|
453
460
|
}
|
|
454
461
|
default:
|
|
455
462
|
return false;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve effective model/provider for an agent.
|
|
3
|
-
*
|
|
3
|
+
* Each field resolved independently through priority chain:
|
|
4
|
+
* explicit > agent_overrides[name] > agent frontmatter > agent_provider/agent_model setting > parent
|
|
4
5
|
* null in overrides = skip settings, use parent directly.
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -11,22 +12,32 @@ export function resolveAgentModelProvider(
|
|
|
11
12
|
agent: { model?: string; provider?: string },
|
|
12
13
|
parentModelId: string | undefined,
|
|
13
14
|
parentProvider: string | undefined,
|
|
14
|
-
cwd: string
|
|
15
|
+
cwd: string,
|
|
16
|
+
explicit?: { model?: string; provider?: string },
|
|
15
17
|
): { model: string | undefined; provider: string | undefined } {
|
|
16
18
|
const overrides = getSetting(cwd, "agent_overrides");
|
|
17
19
|
const override = overrides[agentName];
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// Resolve each field independently through the priority chain:
|
|
22
|
+
// explicit > agent_overrides[name] > agent frontmatter > agent_provider/agent_model setting > parent
|
|
23
|
+
// null in overrides = skip settings, use parent directly.
|
|
24
|
+
function resolveModel(): string | undefined {
|
|
25
|
+
if (explicit?.model) return explicit.model;
|
|
26
|
+
if (override) {
|
|
27
|
+
if (override.model === null) return parentModelId;
|
|
28
|
+
if (override.model) return override.model;
|
|
29
|
+
}
|
|
30
|
+
return agent.model || getSetting(cwd, "agent_model") || parentModelId || undefined;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
function resolveProvider(): string | undefined {
|
|
34
|
+
if (explicit?.provider) return explicit.provider;
|
|
35
|
+
if (override) {
|
|
36
|
+
if (override.provider === null) return parentProvider;
|
|
37
|
+
if (override.provider) return override.provider;
|
|
38
|
+
}
|
|
39
|
+
return agent.provider || getSetting(cwd, "agent_provider") || parentProvider || undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { model: resolveModel(), provider: resolveProvider() };
|
|
32
43
|
}
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
11
|
-
import { onStateTransition, readReviewState, type ReviewState } from "./pi-config-review-state.js";
|
|
11
|
+
import { onStateTransition, readReviewState, markNeedsReview, resetReviewState, type ReviewState } from "./pi-config-review-state.js";
|
|
12
12
|
import { ICON_REVIEW_CLEAN, ICON_REVIEW_NEEDED, ICON_REVIEW_PROGRESS, ICON_REVIEW_FINDINGS } from "./icons.js";
|
|
13
13
|
import { getSetting } from "./project-settings.js";
|
|
14
14
|
import { setSlot, clearSlot } from "./status-bar.js";
|
|
15
|
+
import { runGit } from "./git-helpers.js";
|
|
15
16
|
|
|
16
17
|
interface ReviewStatusData {
|
|
17
18
|
status: ReviewState["status"];
|
|
@@ -23,11 +24,13 @@ interface ReviewStatusData {
|
|
|
23
24
|
timestamp: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
// Deduplication — skip append if status
|
|
27
|
+
// Deduplication — skip append if status hasn't changed
|
|
27
28
|
let lastAppendedKey = "";
|
|
28
29
|
|
|
29
30
|
/** Dedup key shared by status bar and transcript cards. */
|
|
30
31
|
function stateKey(s: { status: string; cycle: number; findings_count: number; tests_passed: boolean; reviewers_pending: { length: number } }): string {
|
|
32
|
+
// For needs_review, only status+cycle matter — tests_passed flips shouldn't re-trigger the card
|
|
33
|
+
if (s.status === "needs_review") return `${s.status}:${s.cycle}`;
|
|
31
34
|
return `${s.status}:${s.cycle}:${s.findings_count}:${s.tests_passed}:${s.reviewers_pending.length}`;
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -95,6 +98,7 @@ export function registerReviewUI(pi: ExtensionAPI): void {
|
|
|
95
98
|
|
|
96
99
|
let lastCtx: ExtensionContext | null = null;
|
|
97
100
|
let lastBarKey = "";
|
|
101
|
+
let lastGitSnapshot = "";
|
|
98
102
|
|
|
99
103
|
function updateStatusBar(state: ReviewState): void {
|
|
100
104
|
if (!lastCtx?.hasUI) return;
|
|
@@ -142,6 +146,15 @@ export function registerReviewUI(pi: ExtensionAPI): void {
|
|
|
142
146
|
// Capture ctx and show current state on load
|
|
143
147
|
pi.on("session_start", (_event, ctx) => {
|
|
144
148
|
lastCtx = ctx;
|
|
149
|
+
// Initialize git snapshot for dirty detection
|
|
150
|
+
lastGitSnapshot = runGit(["status", "--porcelain"], ctx.cwd).stdout;
|
|
151
|
+
// If tree is already dirty on session start, ensure review state reflects it
|
|
152
|
+
if (lastGitSnapshot && getSetting(ctx.cwd, "review_loop_enforcement")) {
|
|
153
|
+
const state = readReviewState(ctx.cwd);
|
|
154
|
+
if (state.status === "none" || state.status === "clean") {
|
|
155
|
+
try { markNeedsReview(ctx.cwd); } catch (e: any) { console.debug("[review-ui] markNeedsReview failed:", e?.message); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
145
158
|
if (getSetting(ctx.cwd, "review_loop_enforcement")) {
|
|
146
159
|
updateStatusBar(readReviewState(ctx.cwd));
|
|
147
160
|
} else if (ctx.hasUI) {
|
|
@@ -169,22 +182,58 @@ export function registerReviewUI(pi: ExtensionAPI): void {
|
|
|
169
182
|
}, 5000);
|
|
170
183
|
if (reviewPoller.unref) reviewPoller.unref();
|
|
171
184
|
|
|
185
|
+
// Poll git status to detect file changes from CLI/ACPX agents
|
|
186
|
+
const gitDirtyPoller = setInterval(() => {
|
|
187
|
+
if (!lastCtx) return;
|
|
188
|
+
try { void lastCtx.ui.theme; } catch { lastCtx = null; return; }
|
|
189
|
+
try {
|
|
190
|
+
if (!getSetting(lastCtx.cwd, "review_loop_enforcement")) return;
|
|
191
|
+
const result = runGit(["status", "--porcelain"], lastCtx.cwd);
|
|
192
|
+
const snapshot = result.stdout;
|
|
193
|
+
if (snapshot === lastGitSnapshot) return;
|
|
194
|
+
lastGitSnapshot = snapshot;
|
|
195
|
+
if (snapshot) {
|
|
196
|
+
// Dirty tree — markNeedsReview handles all states:
|
|
197
|
+
// none/clean/has_findings → sets needs_review
|
|
198
|
+
// in_progress → sets edited_during_cycle + clears tests_passed
|
|
199
|
+
// needs_review → no-op (deduped internally)
|
|
200
|
+
try { markNeedsReview(lastCtx.cwd); } catch (e: any) { console.debug("[review-ui] markNeedsReview failed:", e?.message); }
|
|
201
|
+
} else if (result.code === 0) {
|
|
202
|
+
// Tree clean AND git succeeded — reset when status is clean (committed-away),
|
|
203
|
+
// needs_review (edits reverted, nothing left to review), or has_findings
|
|
204
|
+
// (committed+pushed — fresh start). Skip in_progress (reviewers still running).
|
|
205
|
+
const state = readReviewState(lastCtx.cwd);
|
|
206
|
+
if (state.status === "clean" || state.status === "needs_review" || state.status === "has_findings") {
|
|
207
|
+
try { resetReviewState(lastCtx.cwd); } catch (e: any) { console.debug("[review-ui] resetReviewState failed:", e?.message); }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (e: any) { console.debug("[review-ui] gitDirtyPoller error:", e?.message); }
|
|
211
|
+
}, 3000);
|
|
212
|
+
if (gitDirtyPoller.unref) gitDirtyPoller.unref();
|
|
213
|
+
|
|
172
214
|
pi.on("session_shutdown", () => {
|
|
173
215
|
clearInterval(reviewPoller);
|
|
216
|
+
clearInterval(gitDirtyPoller);
|
|
174
217
|
});
|
|
175
218
|
|
|
176
219
|
// ── Hook into review state transitions ───────────────────────────────
|
|
177
220
|
|
|
178
221
|
onStateTransition((state: ReviewState) => {
|
|
179
|
-
// Always update status bar
|
|
180
|
-
// Only show if enforcement is enabled
|
|
222
|
+
// Always update status bar
|
|
181
223
|
if (lastCtx && getSetting(lastCtx.cwd, "review_loop_enforcement")) {
|
|
182
224
|
updateStatusBar(state);
|
|
183
225
|
}
|
|
184
226
|
|
|
185
|
-
if (state.status === "none")
|
|
227
|
+
if (state.status === "none") {
|
|
228
|
+
lastAppendedKey = "";
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Card only shows on status change — not on tests_passed/findings/pending changes
|
|
233
|
+
if (state.status === lastAppendedKey) return;
|
|
234
|
+
lastAppendedKey = state.status;
|
|
186
235
|
|
|
187
|
-
|
|
236
|
+
pi.appendEntry<ReviewStatusData>("review-status", {
|
|
188
237
|
status: state.status,
|
|
189
238
|
cycle: state.cycle,
|
|
190
239
|
findings_count: state.findings_count,
|
|
@@ -192,13 +241,6 @@ export function registerReviewUI(pi: ExtensionAPI): void {
|
|
|
192
241
|
reviewers_pending: [...state.reviewers_pending],
|
|
193
242
|
reviewers_total: state.reviewers_total,
|
|
194
243
|
timestamp: Date.now(),
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
// Deduplicate transcript cards — skip if nothing meaningful changed
|
|
198
|
-
const key = stateKey(data);
|
|
199
|
-
if (key === lastAppendedKey) return;
|
|
200
|
-
lastAppendedKey = key;
|
|
201
|
-
|
|
202
|
-
pi.appendEntry<ReviewStatusData>("review-status", data);
|
|
244
|
+
});
|
|
203
245
|
});
|
|
204
246
|
}
|
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure placeholder substitution for rules/*.md prompt text.
|
|
2
|
+
* Pure placeholder substitution for rules/*.md and agent system-prompt text.
|
|
3
3
|
*
|
|
4
4
|
* Kept in its own module (no heavy imports) so it can be unit tested without
|
|
5
5
|
* pulling in the full orchestrator dependency chain (e.g. rules.ts → async-agents.ts
|
|
6
6
|
* → @earendil-works/pi-tui, which is a runtime-only dependency not present in
|
|
7
|
-
* the test install).
|
|
7
|
+
* the test install). Callers pass a resolve function for settings lookups.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
export function substituteRulePlaceholders(text: string, values: { reviewLoopMaxCycles: number }): string {
|
|
11
11
|
return text.replaceAll("{{REVIEW_LOOP_MAX_CYCLES}}", String(values.reviewLoopMaxCycles));
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
const SETTINGS_PATTERN = /\{\{SETTINGS(?::([^}]+))?\}\}/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Replace `{{SETTINGS:key1,key2,...}}` with JSON of resolved setting values.
|
|
18
|
+
* `{{SETTINGS}}` (no colon) resolves every key in `allKeys`.
|
|
19
|
+
*/
|
|
20
|
+
export function substituteSettingsPlaceholders(
|
|
21
|
+
text: string,
|
|
22
|
+
resolve: (key: string) => unknown,
|
|
23
|
+
allKeys: string[],
|
|
24
|
+
): string {
|
|
25
|
+
return text.replace(new RegExp(SETTINGS_PATTERN.source, "g"), (_match, keysGroup: string | undefined) => {
|
|
26
|
+
const keys = keysGroup !== undefined
|
|
27
|
+
? keysGroup.split(",").map((k) => k.trim()).filter((k) => k.length > 0)
|
|
28
|
+
: allKeys;
|
|
29
|
+
const result: Record<string, unknown> = {};
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
if (!allKeys.includes(key)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
result[key] = resolve(key);
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(result);
|
|
37
|
+
});
|
|
38
|
+
}
|