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.
@@ -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
- * PI_ASYNC_LLM_PROVIDER, PI_ASYNC_LLM_MODEL, PI_REVIEW_LOOP_MAX_CYCLES)
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
- async_llm_provider?: string;
44
+ internal_operations_provider?: string;
44
45
  /** Model id for detached LLM async children when parent is acpx. */
45
- async_llm_model?: string;
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.async_llm_provider === "string" && raw.async_llm_provider.trim()) {
87
- result.async_llm_provider = raw.async_llm_provider.trim();
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.async_llm_model === "string" && raw.async_llm_model.trim()) {
90
- result.async_llm_model = raw.async_llm_model.trim();
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: "async_llm_provider"): string;
306
- export function getSetting(cwd: string, key: "async_llm_model"): string;
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.PI_COMMIT_TRAILER;
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 false; // default: disabled
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 "comment_signature": {
350
- if (settings.comment_signature !== undefined) return settings.comment_signature;
351
- return false; // default: disabled
352
- }
353
- case "review_loop_enforcement": {
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().acpx_agents;
385
+ const globalAgents = loadGlobalSettings()[agentKey];
374
386
  if (globalAgents !== undefined) {
375
387
  return parseAgentNameList(globalAgents);
376
388
  }
377
- const env = process.env.ACPX_AGENTS;
378
- if (env !== undefined && env !== "") {
379
- return parseAgentNameList(env);
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
- const globalAgents = loadGlobalSettings().cli_agents;
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
- if (settings.pidiff_enable !== undefined) return settings.pidiff_enable;
408
- const disabled = parseDisabledEnv("PI_PIDIFF_ENABLE");
409
- if (disabled !== undefined) return !disabled;
410
- return true;
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
- const envPort = parsePortEnv("PI_PIDASH_PORT");
421
- if (envPort !== undefined) return envPort;
422
- return 19190;
423
- }
424
- case "image_model": {
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
- const env = parseReviewLoopMaxCyclesEnv("PI_REVIEW_LOOP_MAX_CYCLES");
442
- if (env !== undefined) return env;
443
- return 3; // default: 3 cycles
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 "agent_provider": {
446
- return settings.agent_provider || "";
429
+ case "agent_overrides": {
430
+ return settings.agent_overrides || (def.default as Record<string, { provider?: string | null; model?: string | null }>);
447
431
  }
448
- case "agent_model": {
449
- return settings.agent_model || "";
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 "agent_overrides": {
452
- return settings.agent_overrides || {};
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
- * Priority: agent_overrides[name] > agent frontmatter > agent_provider/agent_model setting > parent
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
- if (override) {
20
- // null = explicitly use parent (skip agent frontmatter AND global settings)
21
- // Model and provider resolve independently on purpose: a partial override
22
- // (e.g. only model) still lets provider fall through frontmatter → settings → parent.
23
- const model = override.model === null ? parentModelId : (override.model || agent.model || getSetting(cwd, "agent_model") || parentModelId);
24
- const provider = override.provider === null ? parentProvider : (override.provider || agent.provider || getSetting(cwd, "agent_provider") || parentProvider);
25
- return { model: model || undefined, provider: provider || undefined };
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
- // No override: agent frontmatter > global setting > parent
29
- const model = agent.model || getSetting(cwd, "agent_model") || parentModelId;
30
- const provider = agent.provider || getSetting(cwd, "agent_provider") || parentProvider;
31
- return { model: model || undefined, provider: provider || undefined };
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+cycle hasn't changed
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 (even for "none" — clears it)
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") return;
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
- const data: ReviewStatusData = {
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
+ }