@ilya-lesikov/pi-pi 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +631 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +7 -3
  28. package/extensions/orchestrator/doctor.test.ts +561 -0
  29. package/extensions/orchestrator/doctor.ts +702 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1220 -343
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +511 -0
  35. package/extensions/orchestrator/flant-infra.ts +390 -49
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +3065 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +302 -0
  41. package/extensions/orchestrator/model-registry.ts +298 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +298 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.test.ts +54 -0
  54. package/extensions/orchestrator/phases/review.ts +89 -48
  55. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  56. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  57. package/extensions/orchestrator/phases/verdict.ts +82 -0
  58. package/extensions/orchestrator/plannotator.test.ts +85 -0
  59. package/extensions/orchestrator/plannotator.ts +24 -6
  60. package/extensions/orchestrator/pp-menu.test.ts +207 -0
  61. package/extensions/orchestrator/pp-menu.ts +2741 -373
  62. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  63. package/extensions/orchestrator/repo-utils.ts +67 -0
  64. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  65. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  66. package/extensions/orchestrator/state.test.ts +76 -6
  67. package/extensions/orchestrator/state.ts +128 -44
  68. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  69. package/extensions/orchestrator/test-helpers.ts +229 -0
  70. package/extensions/orchestrator/tracer.test.ts +132 -0
  71. package/extensions/orchestrator/tracer.ts +127 -0
  72. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  73. package/extensions/orchestrator/transition-controller.ts +259 -0
  74. package/extensions/orchestrator/usage-tracker.test.ts +563 -0
  75. package/extensions/orchestrator/usage-tracker.ts +96 -12
  76. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  77. package/package.json +2 -1
@@ -0,0 +1,702 @@
1
+ import { execFileSync } from "child_process";
2
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ readRawConfig,
7
+ GLOBAL_CONFIG_PATH,
8
+ mergeConfigLayers,
9
+ resolvePreset,
10
+ type NormalizedPiPiConfig,
11
+ type PiPiConfig,
12
+ PRESET_GROUPS,
13
+ } from "./config.js";
14
+ import { resolveModel, getAllAliases } from "./model-registry.js";
15
+ import { loadFlantSettings, readClaudeOAuthToken, readGatewayApiKey, refreshClaudeOAuthToken } from "./flant-infra.js";
16
+ import type { Orchestrator } from "./orchestrator.js";
17
+
18
+ type Severity = "pass" | "warning" | "failure";
19
+
20
+ interface CheckLine {
21
+ severity: Severity;
22
+ text: string;
23
+ }
24
+
25
+ interface AvailableModel {
26
+ provider: string;
27
+ id: string;
28
+ spec: string;
29
+ }
30
+
31
+ function toErrorMessage(error: unknown): string {
32
+ if (error instanceof Error) return error.message;
33
+ return String(error);
34
+ }
35
+
36
+ function isObject(value: unknown): value is Record<string, unknown> {
37
+ return !!value && typeof value === "object" && !Array.isArray(value);
38
+ }
39
+
40
+ function resolveAgentDir(): string {
41
+ const envDir = process.env.PI_CODING_AGENT_DIR;
42
+ if (envDir) {
43
+ if (envDir === "~") return homedir();
44
+ if (envDir.startsWith("~/")) return homedir() + envDir.slice(1);
45
+ return envDir;
46
+ }
47
+ return join(homedir(), ".pi", "agent");
48
+ }
49
+
50
+ function flantCacheDir(): string {
51
+ return join(resolveAgentDir(), "extensions", "pp", "cache");
52
+ }
53
+
54
+ function which(bin: string): string | null {
55
+ try {
56
+ const out = execFileSync("which", [bin], { encoding: "utf-8", stdio: "pipe" }).trim();
57
+ return out || null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function commandBinary(command: string): string | null {
64
+ const tokens = tokenizeCommand(command);
65
+ if (tokens.length === 0) return null;
66
+ let index = 0;
67
+ while (index < tokens.length && isEnvAssignment(tokens[index] ?? "")) {
68
+ index += 1;
69
+ }
70
+ return tokens[index] ?? null;
71
+ }
72
+
73
+ function tokenizeCommand(command: string): string[] {
74
+ const trimmed = command.trim();
75
+ if (!trimmed) return [];
76
+ const tokens: string[] = [];
77
+ let current = "";
78
+ let quote: "'" | '"' | "`" | null = null;
79
+ for (let i = 0; i < trimmed.length; i += 1) {
80
+ const ch = trimmed[i]!;
81
+ if (quote) {
82
+ if (ch === quote) {
83
+ quote = null;
84
+ continue;
85
+ }
86
+ if (ch === "\\" && quote === '"' && i + 1 < trimmed.length) {
87
+ current += trimmed[i + 1]!;
88
+ i += 1;
89
+ continue;
90
+ }
91
+ current += ch;
92
+ continue;
93
+ }
94
+ if (ch === "'" || ch === '"' || ch === "`") {
95
+ quote = ch;
96
+ continue;
97
+ }
98
+ if (/\s/.test(ch)) {
99
+ if (current.length > 0) {
100
+ tokens.push(current);
101
+ current = "";
102
+ }
103
+ continue;
104
+ }
105
+ if (ch === "\\" && i + 1 < trimmed.length) {
106
+ current += trimmed[i + 1]!;
107
+ i += 1;
108
+ continue;
109
+ }
110
+ current += ch;
111
+ }
112
+ if (current.length > 0) tokens.push(current);
113
+ return tokens;
114
+ }
115
+
116
+ function isEnvAssignment(token: string): boolean {
117
+ return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
118
+ }
119
+
120
+ function isPathLike(binary: string): boolean {
121
+ return binary.startsWith("/") || binary.startsWith("./") || binary.startsWith("../");
122
+ }
123
+
124
+ function timedFetch(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
127
+ return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
128
+ }
129
+
130
+ function listAvailableModels(ctx: any): AvailableModel[] {
131
+ const available = ctx?.modelRegistry?.getAvailable?.();
132
+ if (!Array.isArray(available)) return [];
133
+ const seen = new Set<string>();
134
+ const out: AvailableModel[] = [];
135
+ for (const model of available) {
136
+ const provider = typeof model?.provider === "string" ? model.provider.trim() : "";
137
+ const id = typeof model?.id === "string" ? model.id.trim() : "";
138
+ if (!provider || !id) continue;
139
+ const spec = `${provider}/${id}`;
140
+ const key = spec.toLowerCase();
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push({ provider, id, spec });
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function collectEmptyObjects(value: unknown, basePath: string, out: string[]): void {
149
+ if (!isObject(value)) return;
150
+ const keys = Object.keys(value);
151
+ if (keys.length === 0) {
152
+ out.push(basePath);
153
+ return;
154
+ }
155
+ for (const key of keys) {
156
+ const nestedPath = `${basePath}.${key}`;
157
+ collectEmptyObjects((value as Record<string, unknown>)[key], nestedPath, out);
158
+ }
159
+ }
160
+
161
+ function statusSymbol(severity: Severity): string {
162
+ if (severity === "pass") return "✓";
163
+ if (severity === "warning") return "⚠";
164
+ return "✗";
165
+ }
166
+
167
+ export async function runDoctor(orchestrator: Orchestrator, ctx: any): Promise<void> {
168
+ const reportLines: string[] = ["Doctor Results"];
169
+ let passCount = 0;
170
+ let warningCount = 0;
171
+ let failureCount = 0;
172
+
173
+ const addCategory = (name: string): void => {
174
+ reportLines.push("", name);
175
+ };
176
+
177
+ const addLine = (line: CheckLine): void => {
178
+ if (line.severity === "pass") passCount += 1;
179
+ else if (line.severity === "warning") warningCount += 1;
180
+ else failureCount += 1;
181
+ reportLines.push(` ${statusSymbol(line.severity)} ${line.text}`);
182
+ };
183
+
184
+ const safeCheck = async (run: () => void | Promise<void>, onError: string): Promise<void> => {
185
+ try {
186
+ await run();
187
+ } catch (error) {
188
+ addLine({ severity: "failure", text: `${onError}: ${toErrorMessage(error)}` });
189
+ }
190
+ };
191
+
192
+ const projectConfigPath = join(orchestrator.cwd, ".pp", "config.json");
193
+ let availableModels: AvailableModel[] = [];
194
+ let availableSet = new Set<string>();
195
+ let gitBin: string | null = null;
196
+ const config: NormalizedPiPiConfig = orchestrator.config;
197
+
198
+ addCategory("Config");
199
+
200
+ await safeCheck(() => {
201
+ const paths = [
202
+ { label: "global", path: GLOBAL_CONFIG_PATH },
203
+ { label: "project", path: projectConfigPath },
204
+ ];
205
+ const errors: string[] = [];
206
+ for (const entry of paths) {
207
+ try {
208
+ readRawConfig(entry.path);
209
+ } catch (error) {
210
+ errors.push(`${entry.label}: ${toErrorMessage(error)}`);
211
+ }
212
+ }
213
+ if (errors.length > 0) {
214
+ addLine({ severity: "failure", text: `Config files parseable: ${errors.join("; ")}` });
215
+ return;
216
+ }
217
+ addLine({ severity: "pass", text: "Config files parseable" });
218
+ }, "Config files parseable check failed");
219
+
220
+ await safeCheck(() => {
221
+ const globalConfig = existsSync(GLOBAL_CONFIG_PATH) ? readRawConfig(GLOBAL_CONFIG_PATH) : null;
222
+ const projectConfig = existsSync(projectConfigPath) ? readRawConfig(projectConfigPath) : null;
223
+ mergeConfigLayers(globalConfig, projectConfig);
224
+ addLine({ severity: "pass", text: "Config layer merge OK" });
225
+ }, "Config layer merge failed");
226
+
227
+ await safeCheck(() => {
228
+ const knownTopLevelKeys = new Set(["general", "agents", "commands", "performance"]);
229
+ const unknown: string[] = [];
230
+ const files = [
231
+ { label: "global", path: GLOBAL_CONFIG_PATH },
232
+ { label: "project", path: projectConfigPath },
233
+ ];
234
+ for (const file of files) {
235
+ if (!existsSync(file.path)) continue;
236
+ const raw = readRawConfig(file.path);
237
+ if (!isObject(raw)) continue;
238
+ for (const key of Object.keys(raw)) {
239
+ if (!knownTopLevelKeys.has(key)) unknown.push(`${file.label}.${key}`);
240
+ }
241
+ }
242
+ if (unknown.length === 0) {
243
+ addLine({ severity: "pass", text: "No legacy/unknown top-level keys" });
244
+ return;
245
+ }
246
+ addLine({ severity: "warning", text: `Unknown top-level keys: ${unknown.join(", ")}` });
247
+ }, "Legacy/unknown key check failed");
248
+
249
+ await safeCheck(() => {
250
+ const emptyPaths: string[] = [];
251
+ const files = [
252
+ { label: "global", path: GLOBAL_CONFIG_PATH },
253
+ { label: "project", path: projectConfigPath },
254
+ ];
255
+ for (const file of files) {
256
+ if (!existsSync(file.path)) continue;
257
+ const raw = readRawConfig(file.path);
258
+ if (!isObject(raw)) continue;
259
+ collectEmptyObjects(raw, file.label, emptyPaths);
260
+ }
261
+ if (emptyPaths.length === 0) {
262
+ addLine({ severity: "pass", text: "No empty overrides" });
263
+ return;
264
+ }
265
+ addLine({ severity: "warning", text: `Empty override objects: ${emptyPaths.join(", ")}` });
266
+ }, "Empty override check failed");
267
+
268
+ addCategory("Models");
269
+
270
+ await safeCheck(() => {
271
+ availableModels = listAvailableModels(ctx);
272
+ availableSet = new Set(availableModels.map((m) => m.spec));
273
+ addLine({ severity: "pass", text: `${availableSet.size} models available` });
274
+ }, "Available models listing failed");
275
+
276
+ await safeCheck(() => {
277
+ const missing: string[] = [];
278
+ for (const [role, roleConfig] of Object.entries(config.agents.orchestrators)) {
279
+ const resolved = resolveModel(roleConfig.model);
280
+ if (!availableSet.has(resolved)) {
281
+ missing.push(`${role} → ${roleConfig.model} resolved to ${resolved}`);
282
+ }
283
+ }
284
+ if (missing.length === 0) {
285
+ addLine({ severity: "pass", text: "All orchestrator models available" });
286
+ return;
287
+ }
288
+ for (const item of missing) {
289
+ addLine({ severity: "failure", text: `Orchestrator model missing: ${item}` });
290
+ }
291
+ }, "Orchestrator model availability check failed");
292
+
293
+ await safeCheck(() => {
294
+ const missing: string[] = [];
295
+ for (const [role, roleConfig] of Object.entries(config.agents.subagents.simple)) {
296
+ const resolved = resolveModel(roleConfig.model);
297
+ if (!availableSet.has(resolved)) {
298
+ missing.push(`${role} → ${roleConfig.model} resolved to ${resolved}`);
299
+ }
300
+ }
301
+ if (missing.length === 0) {
302
+ addLine({ severity: "pass", text: "All subagent models available" });
303
+ return;
304
+ }
305
+ for (const item of missing) {
306
+ addLine({ severity: "failure", text: `Subagent model missing: ${item}` });
307
+ }
308
+ }, "Subagent model availability check failed");
309
+
310
+ await safeCheck(() => {
311
+ const missing: string[] = [];
312
+ for (const group of PRESET_GROUPS) {
313
+ const groupConfig = config.agents.subagents.presetGroups[group];
314
+ for (const [presetName, preset] of Object.entries(groupConfig.presets)) {
315
+ if (preset.enabled === false) continue;
316
+ for (const [agentName, agent] of Object.entries(preset.agents)) {
317
+ if (agent.enabled === false) continue;
318
+ const resolved = resolveModel(agent.model);
319
+ if (!availableSet.has(resolved)) {
320
+ missing.push(`Preset "${presetName}" group "${group}" agent "${agentName}" → ${resolved} not available`);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ if (missing.length === 0) {
326
+ addLine({ severity: "pass", text: "All enabled preset agent models available" });
327
+ return;
328
+ }
329
+ for (const item of missing) {
330
+ addLine({ severity: "warning", text: item });
331
+ }
332
+ }, "Preset model availability check failed");
333
+
334
+ await safeCheck(() => {
335
+ const aliasCount = Object.keys(getAllAliases()).length;
336
+ addLine({ severity: "pass", text: `Model alias registry loaded (${aliasCount} aliases)` });
337
+ }, "Model alias registry check failed");
338
+
339
+ addCategory("Presets");
340
+
341
+ await safeCheck(() => {
342
+ for (const group of PRESET_GROUPS) {
343
+ const groupConfig = config.agents.subagents.presetGroups[group];
344
+ const defaultPresetName = groupConfig.default;
345
+ const defaultPreset = groupConfig.presets[defaultPresetName];
346
+ if (!defaultPreset) {
347
+ addLine({ severity: "failure", text: `${group}: default preset "${defaultPresetName}" is missing` });
348
+ continue;
349
+ }
350
+ if (defaultPreset.enabled === false) {
351
+ addLine({ severity: "failure", text: `${group}: default preset "${defaultPresetName}" is disabled` });
352
+ continue;
353
+ }
354
+ const totalAgents = Object.keys(defaultPreset.agents).length;
355
+ const enabledAgents = Object.values(defaultPreset.agents).filter((agent) => agent.enabled !== false).length;
356
+ if (enabledAgents === 0) {
357
+ addLine({ severity: "failure", text: `${group}: default preset "${defaultPresetName}" has no enabled agents` });
358
+ continue;
359
+ }
360
+ const resolved = resolvePreset(config as PiPiConfig, group);
361
+ addLine({
362
+ severity: "pass",
363
+ text: `${group}: default="${defaultPresetName}", total agents=${totalAgents}, enabled agents=${enabledAgents}, resolved agents=${Object.keys(resolved).length}`,
364
+ });
365
+ }
366
+ }, "Preset consistency checks failed");
367
+
368
+ addCategory("Tools");
369
+
370
+ await safeCheck(() => {
371
+ gitBin = which("git");
372
+ if (gitBin) addLine({ severity: "pass", text: `git: ${gitBin}` });
373
+ else addLine({ severity: "warning", text: "git: not found" });
374
+ }, "git binary check failed");
375
+
376
+ await safeCheck(() => {
377
+ const ghBin = which("gh");
378
+ if (ghBin) addLine({ severity: "pass", text: `gh: ${ghBin}` });
379
+ else addLine({ severity: "warning", text: "gh: not found" });
380
+ }, "gh binary check failed");
381
+
382
+ await safeCheck(() => {
383
+ const cbmBin = which("codebase-memory-mcp");
384
+ if (cbmBin) addLine({ severity: "pass", text: `codebase-memory-mcp: ${cbmBin}` });
385
+ else addLine({ severity: "warning", text: "codebase-memory-mcp: not found" });
386
+
387
+ if (!cbmBin) {
388
+ addLine({ severity: "warning", text: "CBM daemon: skipped (binary not found)" });
389
+ return;
390
+ }
391
+
392
+ const daemon = (globalThis as any)[Symbol.for("pi-pi:cbm-daemon")] as { proc?: unknown } | null | undefined;
393
+ if (daemon && daemon.proc !== null && daemon.proc !== undefined) {
394
+ addLine({ severity: "pass", text: "CBM daemon: initialized" });
395
+ return;
396
+ }
397
+ addLine({ severity: "warning", text: "CBM daemon: not initialized" });
398
+ }, "CBM checks failed");
399
+
400
+ await safeCheck(() => {
401
+ const sgBin = which("sg");
402
+ if (sgBin) addLine({ severity: "pass", text: `sg (ast-grep): ${sgBin}` });
403
+ else addLine({ severity: "warning", text: "sg (ast-grep): not found" });
404
+ }, "ast-grep check failed");
405
+
406
+ addCategory("Commands");
407
+
408
+ await safeCheck(() => {
409
+ const afterEditEntries = Object.entries(config.commands.afterEdit);
410
+ if (afterEditEntries.length === 0) {
411
+ addLine({ severity: "pass", text: "No afterEdit commands configured" });
412
+ }
413
+ for (const [name, command] of afterEditEntries) {
414
+ if (command.enabled === false) {
415
+ addLine({ severity: "pass", text: `afterEdit.${name}: skipped (disabled)` });
416
+ continue;
417
+ }
418
+ const bin = commandBinary(command.run);
419
+ if (!bin) {
420
+ addLine({ severity: "failure", text: `afterEdit.${name}: cannot determine binary from "${command.run}"` });
421
+ } else {
422
+ if (isPathLike(bin)) {
423
+ const fullPath = bin.startsWith("/") ? bin : join(orchestrator.cwd, bin);
424
+ if (existsSync(fullPath)) addLine({ severity: "pass", text: `afterEdit.${name}: executable path exists at ${fullPath}` });
425
+ else addLine({ severity: "failure", text: `afterEdit.${name}: executable path not found at ${fullPath}` });
426
+ } else {
427
+ const binaryPath = which(bin);
428
+ if (binaryPath) addLine({ severity: "pass", text: `afterEdit.${name}: ${bin} found at ${binaryPath}` });
429
+ else addLine({ severity: "failure", text: `afterEdit.${name}: ${bin} not found in PATH` });
430
+ }
431
+ }
432
+
433
+ if (command.globs !== undefined) {
434
+ const invalidGlob = command.globs.find((glob) => typeof glob !== "string" || glob.trim().length === 0);
435
+ if (invalidGlob !== undefined) {
436
+ addLine({ severity: "warning", text: `afterEdit.${name}: invalid glob pattern` });
437
+ } else {
438
+ addLine({ severity: "pass", text: `afterEdit.${name}: glob patterns valid (${command.globs.length})` });
439
+ }
440
+ }
441
+ }
442
+
443
+ const afterImplementEntries = Object.entries(config.commands.afterImplement);
444
+ if (afterImplementEntries.length === 0) {
445
+ addLine({ severity: "pass", text: "No afterImplement commands configured" });
446
+ }
447
+ for (const [name, command] of afterImplementEntries) {
448
+ if (command.enabled === false) {
449
+ addLine({ severity: "pass", text: `afterImplement.${name}: skipped (disabled)` });
450
+ continue;
451
+ }
452
+ const bin = commandBinary(command.run);
453
+ if (!bin) {
454
+ addLine({ severity: "failure", text: `afterImplement.${name}: cannot determine binary from "${command.run}"` });
455
+ continue;
456
+ }
457
+ if (isPathLike(bin)) {
458
+ const fullPath = bin.startsWith("/") ? bin : join(orchestrator.cwd, bin);
459
+ if (existsSync(fullPath)) addLine({ severity: "pass", text: `afterImplement.${name}: executable path exists at ${fullPath}` });
460
+ else addLine({ severity: "failure", text: `afterImplement.${name}: executable path not found at ${fullPath}` });
461
+ continue;
462
+ }
463
+ const binaryPath = which(bin);
464
+ if (binaryPath) addLine({ severity: "pass", text: `afterImplement.${name}: ${bin} found at ${binaryPath}` });
465
+ else addLine({ severity: "failure", text: `afterImplement.${name}: ${bin} not found in PATH` });
466
+ }
467
+ }, "Command checks failed");
468
+
469
+ addCategory("Flant");
470
+
471
+ await safeCheck(async () => {
472
+ const settings = loadFlantSettings();
473
+ const shouldCheck = Boolean(process.env.FLANT_API_KEY) || settings.enabled || settings.subscription || !!settings.cachedFlantModels || !!settings.cachedOpenRouterData;
474
+ if (!shouldCheck) {
475
+ addLine({ severity: "pass", text: "Skipped: FLANT_API_KEY not set and no Flant configuration detected" });
476
+ return;
477
+ }
478
+
479
+ const apiKey = process.env.FLANT_API_KEY;
480
+ if (apiKey) addLine({ severity: "pass", text: "FLANT_API_KEY is present" });
481
+ else addLine({ severity: "failure", text: "FLANT_API_KEY is missing" });
482
+
483
+ const cacheDir = flantCacheDir();
484
+ const probePath = join(cacheDir, `doctor-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
485
+ try {
486
+ mkdirSync(cacheDir, { recursive: true });
487
+ writeFileSync(probePath, "ok", "utf-8");
488
+ unlinkSync(probePath);
489
+ addLine({ severity: "pass", text: `Flant cache directory writable: ${cacheDir}` });
490
+ } catch (error) {
491
+ addLine({ severity: "failure", text: `Flant cache directory is not writable: ${toErrorMessage(error)}` });
492
+ }
493
+
494
+ if (apiKey) {
495
+ const started = Date.now();
496
+ try {
497
+ const response = await timedFetch("https://llm-api.flant.ru/v1/models", {
498
+ method: "GET",
499
+ headers: { Authorization: `Bearer ${apiKey}` },
500
+ }, 10000);
501
+ if (!response.ok) {
502
+ addLine({ severity: "failure", text: `Flant API probe failed with HTTP ${response.status}` });
503
+ } else {
504
+ const payload = await response.json() as { data?: Array<{ id?: unknown }> };
505
+ const models = (payload.data ?? []).filter((item) => typeof item?.id === "string");
506
+ addLine({ severity: "pass", text: `Flant API reachable (${Date.now() - started}ms, ${models.length} models)` });
507
+ }
508
+ } catch (error) {
509
+ addLine({ severity: "failure", text: `Flant API probe failed: ${toErrorMessage(error)}` });
510
+ }
511
+ } else {
512
+ addLine({ severity: "failure", text: "Flant API probe failed: FLANT_API_KEY is required" });
513
+ }
514
+
515
+ const openRouterStarted = Date.now();
516
+ try {
517
+ const response = await timedFetch("https://openrouter.ai/api/v1/models", { method: "GET" }, 10000);
518
+ const latency = Date.now() - openRouterStarted;
519
+ if (!response.ok) {
520
+ addLine({ severity: "failure", text: `OpenRouter probe failed with HTTP ${response.status} (${latency}ms)` });
521
+ } else {
522
+ const payload = await response.json() as { data?: unknown[] };
523
+ const modelCount = Array.isArray(payload.data) ? payload.data.length : 0;
524
+ addLine({ severity: "pass", text: `OpenRouter reachable (${latency}ms, ${modelCount} models)` });
525
+ }
526
+ } catch (error) {
527
+ addLine({ severity: "failure", text: `OpenRouter probe failed: ${toErrorMessage(error)}` });
528
+ }
529
+
530
+ if (settings.subscription) {
531
+ const oauthToken = (await refreshClaudeOAuthToken()) ?? readClaudeOAuthToken();
532
+ const gatewayKey = readGatewayApiKey();
533
+ if (!oauthToken) {
534
+ addLine({ severity: "warning", text: "Personal subscription enabled, but no valid Claude OAuth token found (log in to your subscription in pi)" });
535
+ } else if (!gatewayKey) {
536
+ addLine({ severity: "warning", text: "Personal subscription enabled, but no gateway key (LLM_API_KEY / FLANT_API_KEY)" });
537
+ } else {
538
+ const started = Date.now();
539
+ try {
540
+ const response = await timedFetch("https://llm-api.flant.ru/v1/messages", {
541
+ method: "POST",
542
+ headers: {
543
+ "content-type": "application/json",
544
+ "anthropic-version": "2023-06-01",
545
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
546
+ "user-agent": "claude-cli/1.0.0",
547
+ "x-app": "cli",
548
+ Authorization: `Bearer ${oauthToken}`,
549
+ "x-litellm-api-key": `Bearer ${gatewayKey}`,
550
+ },
551
+ body: JSON.stringify({
552
+ model: "sub/claude-haiku-4-5",
553
+ max_tokens: 4,
554
+ messages: [{ role: "user", content: "ping" }],
555
+ }),
556
+ }, 15000);
557
+ if (!response.ok) {
558
+ addLine({ severity: "failure", text: `Personal subscription probe failed with HTTP ${response.status}` });
559
+ } else {
560
+ addLine({ severity: "pass", text: `Personal subscription reachable (sub/claude-*, ${Date.now() - started}ms)` });
561
+ }
562
+ } catch (error) {
563
+ addLine({ severity: "failure", text: `Personal subscription probe failed: ${toErrorMessage(error)}` });
564
+ }
565
+ }
566
+ }
567
+ }, "Flant checks failed");
568
+
569
+ addCategory("LSP");
570
+
571
+ await safeCheck(() => {
572
+ const api = (globalThis as any)[Symbol.for("pi-lsp:api")] as Record<string, unknown> | undefined;
573
+ if (!api) {
574
+ addLine({ severity: "warning", text: "LSP API: not available" });
575
+ return;
576
+ }
577
+ addLine({ severity: "pass", text: "LSP API: available" });
578
+ }, "LSP checks failed");
579
+
580
+ addCategory("Connectivity");
581
+
582
+ await safeCheck(async () => {
583
+ const started = Date.now();
584
+ const response = await timedFetch("https://mcp.exa.ai/mcp", {
585
+ method: "POST",
586
+ headers: { "Content-Type": "application/json" },
587
+ body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 }),
588
+ }, 10000);
589
+ const latency = Date.now() - started;
590
+ if (!response.ok) {
591
+ addLine({ severity: "failure", text: `Exa MCP probe failed with HTTP ${response.status} (${latency}ms)` });
592
+ return;
593
+ }
594
+ addLine({ severity: "pass", text: `Exa MCP reachable (${latency}ms)` });
595
+ }, "Connectivity checks failed");
596
+
597
+ addCategory("Repos");
598
+
599
+ await safeCheck(() => {
600
+ if (!orchestrator.active) {
601
+ const repoPath = orchestrator.cwd;
602
+ if (!existsSync(repoPath)) {
603
+ addLine({ severity: "failure", text: `${repoPath}: path does not exist` });
604
+ return;
605
+ }
606
+ addLine({ severity: "pass", text: `${repoPath}: path exists` });
607
+
608
+ let isGitRepo = existsSync(join(repoPath, ".git"));
609
+ if (!isGitRepo && gitBin) {
610
+ try {
611
+ execFileSync("git", ["rev-parse", "--git-dir"], { cwd: repoPath, encoding: "utf-8", stdio: "pipe" });
612
+ isGitRepo = true;
613
+ } catch {
614
+ isGitRepo = false;
615
+ }
616
+ }
617
+ if (!isGitRepo) {
618
+ addLine({ severity: "failure", text: `${repoPath}: not a git repository` });
619
+ return;
620
+ }
621
+ addLine({ severity: "pass", text: `${repoPath}: git repository detected` });
622
+
623
+ if (!gitBin) {
624
+ addLine({ severity: "failure", text: `${repoPath}: cannot read git status because git binary is not available` });
625
+ return;
626
+ }
627
+
628
+ try {
629
+ const statusOutput = execFileSync("git", ["status", "--porcelain", "--branch"], {
630
+ cwd: repoPath,
631
+ encoding: "utf-8",
632
+ stdio: "pipe",
633
+ });
634
+ const lines = statusOutput.split("\n").filter((line) => line.trim().length > 0);
635
+ const branchLineRaw = lines[0]?.startsWith("## ") ? lines[0].slice(3).trim() : "detached";
636
+ const changeLines = lines[0]?.startsWith("## ") ? lines.slice(1) : lines;
637
+ if (changeLines.length === 0) {
638
+ addLine({ severity: "pass", text: `${repoPath}: git status clean (${branchLineRaw})` });
639
+ } else {
640
+ addLine({ severity: "warning", text: `${repoPath}: git status ${changeLines.length} change(s) (${branchLineRaw})` });
641
+ }
642
+ } catch (error) {
643
+ addLine({ severity: "failure", text: `${repoPath}: git status failed: ${toErrorMessage(error)}` });
644
+ }
645
+ return;
646
+ }
647
+
648
+ const repos = orchestrator.active.state.repos ?? [];
649
+ if (repos.length === 0) {
650
+ addLine({ severity: "warning", text: "No repositories registered in active task" });
651
+ return;
652
+ }
653
+
654
+ for (const repo of repos) {
655
+ const exists = existsSync(repo.path);
656
+ if (!exists) {
657
+ addLine({ severity: "failure", text: `${repo.path}: path does not exist` });
658
+ continue;
659
+ }
660
+ addLine({ severity: "pass", text: `${repo.path}: path exists` });
661
+
662
+ let isGitRepo = existsSync(join(repo.path, ".git"));
663
+ if (!isGitRepo) {
664
+ try {
665
+ execFileSync("git", ["rev-parse", "--git-dir"], { cwd: repo.path, encoding: "utf-8", stdio: "pipe" });
666
+ isGitRepo = true;
667
+ } catch {
668
+ isGitRepo = false;
669
+ }
670
+ }
671
+ if (isGitRepo) addLine({ severity: "pass", text: `${repo.path}: git repository detected` });
672
+ else {
673
+ addLine({ severity: "failure", text: `${repo.path}: not a git repository` });
674
+ continue;
675
+ }
676
+
677
+ if (!repo.baseBranch) {
678
+ addLine({ severity: "warning", text: `${repo.path}: base branch is not configured` });
679
+ continue;
680
+ }
681
+
682
+ let baseBranchValid = false;
683
+ try {
684
+ execFileSync("git", ["show-ref", "--verify", `refs/remotes/${repo.baseBranch}`], { cwd: repo.path, encoding: "utf-8", stdio: "pipe" });
685
+ baseBranchValid = true;
686
+ } catch {
687
+ try {
688
+ execFileSync("git", ["show-ref", "--verify", `refs/heads/${repo.baseBranch}`], { cwd: repo.path, encoding: "utf-8", stdio: "pipe" });
689
+ baseBranchValid = true;
690
+ } catch {
691
+ baseBranchValid = false;
692
+ }
693
+ }
694
+
695
+ if (baseBranchValid) addLine({ severity: "pass", text: `${repo.path}: base branch "${repo.baseBranch}" is valid` });
696
+ else addLine({ severity: "failure", text: `${repo.path}: base branch "${repo.baseBranch}" is invalid` });
697
+ }
698
+ }, "Repo checks failed");
699
+
700
+ reportLines.push("", `Summary: ${passCount} passed, ${warningCount} warnings, ${failureCount} failures`);
701
+ ctx.ui.notify(reportLines.join("\n"), "info");
702
+ }