@ilya-lesikov/pi-pi 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +27 -11
- package/extensions/orchestrator/agents/constraints.test.ts +82 -0
- package/extensions/orchestrator/agents/constraints.ts +66 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +39 -1
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +44 -28
- package/extensions/orchestrator/config.test.ts +40 -1
- package/extensions/orchestrator/config.ts +18 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +81 -2
- package/extensions/orchestrator/custom-footer.test.ts +91 -0
- package/extensions/orchestrator/custom-footer.ts +20 -20
- package/extensions/orchestrator/event-handlers.test.ts +412 -2
- package/extensions/orchestrator/event-handlers.ts +556 -227
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +411 -20
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +197 -60
- package/extensions/orchestrator/phases/brainstorm.ts +20 -27
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/machine.test.ts +36 -0
- package/extensions/orchestrator/phases/machine.ts +11 -1
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +47 -22
- package/extensions/orchestrator/phases/review.test.ts +34 -0
- package/extensions/orchestrator/phases/review.ts +44 -6
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.test.ts +207 -1
- package/extensions/orchestrator/pp-menu.ts +514 -402
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/state.test.ts +9 -0
- package/extensions/orchestrator/state.ts +15 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
- package/AGENTS.md +0 -28
|
@@ -122,10 +122,16 @@ describe("validateConfig", () => {
|
|
|
122
122
|
);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it("throws for invalid injectAgentsMd value", () => {
|
|
126
|
+
expect(() => validateConfig({ general: { injectAgentsMd: "yes" } })).toThrow(
|
|
127
|
+
"config.general.injectAgentsMd",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
125
131
|
it("accepts valid partial config", () => {
|
|
126
132
|
expect(() =>
|
|
127
133
|
validateConfig({
|
|
128
|
-
general: { autoCommit: false },
|
|
134
|
+
general: { autoCommit: false, injectAgentsMd: false },
|
|
129
135
|
commands: {
|
|
130
136
|
afterEdit: { fmt: { run: "npm run fmt", globs: ["**/*.ts"] } },
|
|
131
137
|
afterImplement: { test: { run: "npm test" } },
|
|
@@ -192,6 +198,39 @@ describe("loadConfig", () => {
|
|
|
192
198
|
expect(config.performance.commands.afterEdit).toBe(1234);
|
|
193
199
|
expect(config.performance.commands.afterImplement).toBe(300000);
|
|
194
200
|
expect(config.general.autoCommit).toBe(false);
|
|
201
|
+
expect(config.general.injectAgentsMd).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("defaults injectAgentsMd to true and honors an explicit override", () => {
|
|
205
|
+
const cwd = makeTempDir();
|
|
206
|
+
const defaults = loadConfig(cwd, "/nonexistent/global/config.json");
|
|
207
|
+
expect(defaults.general.injectAgentsMd).toBe(true);
|
|
208
|
+
|
|
209
|
+
const cwd2 = makeTempDir();
|
|
210
|
+
const ppDir = join(cwd2, ".pp");
|
|
211
|
+
mkdirSync(ppDir, { recursive: true });
|
|
212
|
+
writeFileSync(join(ppDir, "config.json"), JSON.stringify({ general: { injectAgentsMd: false } }), "utf-8");
|
|
213
|
+
const overridden = loadConfig(cwd2, "/nonexistent/global/config.json");
|
|
214
|
+
expect(overridden.general.injectAgentsMd).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("defaults mainTurnStale to 10m and normalizes an override to ms", () => {
|
|
218
|
+
const cwd = makeTempDir();
|
|
219
|
+
const defaults = loadConfig(cwd, "/nonexistent/global/config.json");
|
|
220
|
+
expect(defaults.performance.internals.mainTurnStale).toBe(600000);
|
|
221
|
+
|
|
222
|
+
const cwd2 = makeTempDir();
|
|
223
|
+
const ppDir = join(cwd2, ".pp");
|
|
224
|
+
mkdirSync(ppDir, { recursive: true });
|
|
225
|
+
writeFileSync(join(ppDir, "config.json"), JSON.stringify({ performance: { internals: { mainTurnStale: "90s" } } }), "utf-8");
|
|
226
|
+
const overridden = loadConfig(cwd2, "/nonexistent/global/config.json");
|
|
227
|
+
expect(overridden.performance.internals.mainTurnStale).toBe(90000);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("rejects an invalid mainTurnStale duration", () => {
|
|
231
|
+
expect(() => validateConfig({ performance: { internals: { mainTurnStale: "soon" } } })).toThrow(
|
|
232
|
+
"config.performance.internals.mainTurnStale",
|
|
233
|
+
);
|
|
195
234
|
});
|
|
196
235
|
|
|
197
236
|
it("creates default config when config.json does not exist", () => {
|
|
@@ -7,7 +7,7 @@ import { isValidLogLevel, getLogger, type LogLevel } from "./log.js";
|
|
|
7
7
|
|
|
8
8
|
export type DurationValue = string | number;
|
|
9
9
|
export type OrchestratorRole = "implement" | "plan" | "debug" | "brainstorm" | "review" | "quick";
|
|
10
|
-
export type SimpleSubagentRole = "explore" | "librarian" | "task";
|
|
10
|
+
export type SimpleSubagentRole = "explore" | "librarian" | "task" | "advisor" | "deep-debugger" | "reviewer";
|
|
11
11
|
export type PresetGroupKey = "planners" | "codeReviewers" | "planReviewers" | "brainstormReviewers";
|
|
12
12
|
|
|
13
13
|
export interface AgentConfig {
|
|
@@ -43,6 +43,7 @@ export interface AfterImplementCommandConfig {
|
|
|
43
43
|
export interface PiPiConfig {
|
|
44
44
|
general: {
|
|
45
45
|
autoCommit: boolean;
|
|
46
|
+
injectAgentsMd: boolean;
|
|
46
47
|
loadExtraRepoConfigs: boolean;
|
|
47
48
|
logLevel: LogLevel;
|
|
48
49
|
tracing: boolean;
|
|
@@ -65,6 +66,7 @@ export interface PiPiConfig {
|
|
|
65
66
|
};
|
|
66
67
|
internals: {
|
|
67
68
|
subagentStale: DurationValue;
|
|
69
|
+
mainTurnStale: DurationValue;
|
|
68
70
|
taskLockStale: DurationValue;
|
|
69
71
|
taskLockRefresh: DurationValue;
|
|
70
72
|
};
|
|
@@ -79,6 +81,7 @@ export interface NormalizedPiPiConfig extends PiPiConfig {
|
|
|
79
81
|
};
|
|
80
82
|
internals: {
|
|
81
83
|
subagentStale: number;
|
|
84
|
+
mainTurnStale: number;
|
|
82
85
|
taskLockStale: number;
|
|
83
86
|
taskLockRefresh: number;
|
|
84
87
|
};
|
|
@@ -92,11 +95,12 @@ export type TimeoutConfig = NormalizedPiPiConfig["performance"]["internals"];
|
|
|
92
95
|
export const PRESET_GROUPS = ["planners", "codeReviewers", "planReviewers", "brainstormReviewers"] as const;
|
|
93
96
|
|
|
94
97
|
const ORCHESTRATOR_ROLES: OrchestratorRole[] = ["implement", "plan", "debug", "brainstorm", "review", "quick"];
|
|
95
|
-
const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "task"];
|
|
98
|
+
const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "task", "advisor", "deep-debugger", "reviewer"];
|
|
96
99
|
|
|
97
100
|
const DEFAULT_CONFIG: PiPiConfig = {
|
|
98
101
|
general: {
|
|
99
102
|
autoCommit: true,
|
|
103
|
+
injectAgentsMd: true,
|
|
100
104
|
loadExtraRepoConfigs: true,
|
|
101
105
|
logLevel: "info",
|
|
102
106
|
tracing: false,
|
|
@@ -115,6 +119,9 @@ const DEFAULT_CONFIG: PiPiConfig = {
|
|
|
115
119
|
explore: { model: "google/gemini-flash-latest", thinking: "low" },
|
|
116
120
|
librarian: { model: "google/gemini-flash-latest", thinking: "medium" },
|
|
117
121
|
task: { model: "anthropic/claude-opus-latest", thinking: "medium" },
|
|
122
|
+
advisor: { model: "openai/gpt-latest", thinking: "high" },
|
|
123
|
+
"deep-debugger": { model: "openai/gpt-latest", thinking: "high" },
|
|
124
|
+
reviewer: { model: "openai/gpt-latest", thinking: "high" },
|
|
118
125
|
},
|
|
119
126
|
presetGroups: {
|
|
120
127
|
planners: {
|
|
@@ -200,6 +207,7 @@ const DEFAULT_CONFIG: PiPiConfig = {
|
|
|
200
207
|
},
|
|
201
208
|
internals: {
|
|
202
209
|
subagentStale: "5m",
|
|
210
|
+
mainTurnStale: "10m",
|
|
203
211
|
taskLockStale: "1m",
|
|
204
212
|
taskLockRefresh: "30s",
|
|
205
213
|
},
|
|
@@ -344,6 +352,7 @@ export function validateConfig(config: Record<string, any>): void {
|
|
|
344
352
|
if (config.general !== undefined) {
|
|
345
353
|
const general = requireObject(config.general, "config.general");
|
|
346
354
|
ensureBool(general.autoCommit, "config.general.autoCommit");
|
|
355
|
+
ensureBool(general.injectAgentsMd, "config.general.injectAgentsMd");
|
|
347
356
|
ensureBool(general.loadExtraRepoConfigs, "config.general.loadExtraRepoConfigs");
|
|
348
357
|
ensureBool(general.tracing, "config.general.tracing");
|
|
349
358
|
if (general.logLevel !== undefined && !isValidLogLevel(general.logLevel)) {
|
|
@@ -414,6 +423,7 @@ export function validateConfig(config: Record<string, any>): void {
|
|
|
414
423
|
if (performance.internals !== undefined) {
|
|
415
424
|
const internals = requireObject(performance.internals, "config.performance.internals");
|
|
416
425
|
ensureDuration(internals.subagentStale, "config.performance.internals.subagentStale");
|
|
426
|
+
ensureDuration(internals.mainTurnStale, "config.performance.internals.mainTurnStale");
|
|
417
427
|
ensureDuration(internals.taskLockStale, "config.performance.internals.taskLockStale");
|
|
418
428
|
ensureDuration(internals.taskLockRefresh, "config.performance.internals.taskLockRefresh");
|
|
419
429
|
}
|
|
@@ -523,6 +533,9 @@ export function validateMergedConfig(config: Record<string, any>): void {
|
|
|
523
533
|
if (parseDuration(typed.performance.internals.subagentStale) === null) {
|
|
524
534
|
throw new Error("config.performance.internals.subagentStale must be a valid duration");
|
|
525
535
|
}
|
|
536
|
+
if (parseDuration(typed.performance.internals.mainTurnStale) === null) {
|
|
537
|
+
throw new Error("config.performance.internals.mainTurnStale must be a valid duration");
|
|
538
|
+
}
|
|
526
539
|
if (parseDuration(typed.performance.internals.taskLockStale) === null) {
|
|
527
540
|
throw new Error("config.performance.internals.taskLockStale must be a valid duration");
|
|
528
541
|
}
|
|
@@ -537,6 +550,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
|
|
|
537
550
|
const afterEdit = parseDuration(next.performance.commands.afterEdit);
|
|
538
551
|
const afterImplement = parseDuration(next.performance.commands.afterImplement);
|
|
539
552
|
const subagentStale = parseDuration(next.performance.internals.subagentStale);
|
|
553
|
+
const mainTurnStale = parseDuration(next.performance.internals.mainTurnStale);
|
|
540
554
|
const taskLockStale = parseDuration(next.performance.internals.taskLockStale);
|
|
541
555
|
const taskLockRefresh = parseDuration(next.performance.internals.taskLockRefresh);
|
|
542
556
|
|
|
@@ -544,6 +558,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
|
|
|
544
558
|
afterEdit === null ||
|
|
545
559
|
afterImplement === null ||
|
|
546
560
|
subagentStale === null ||
|
|
561
|
+
mainTurnStale === null ||
|
|
547
562
|
taskLockStale === null ||
|
|
548
563
|
taskLockRefresh === null
|
|
549
564
|
) {
|
|
@@ -553,6 +568,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
|
|
|
553
568
|
next.performance.commands.afterEdit = afterEdit;
|
|
554
569
|
next.performance.commands.afterImplement = afterImplement;
|
|
555
570
|
next.performance.internals.subagentStale = subagentStale;
|
|
571
|
+
next.performance.internals.mainTurnStale = mainTurnStale;
|
|
556
572
|
next.performance.internals.taskLockStale = taskLockStale;
|
|
557
573
|
next.performance.internals.taskLockRefresh = taskLockRefresh;
|
|
558
574
|
return next;
|
|
@@ -14,6 +14,8 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
14
14
|
import {
|
|
15
15
|
getContextDirs,
|
|
16
16
|
getLatestSynthesizedPlan,
|
|
17
|
+
getArtifactManifest,
|
|
18
|
+
formatManifestBlock,
|
|
17
19
|
getPhaseArtifacts,
|
|
18
20
|
loadAllContextFiles,
|
|
19
21
|
loadContextFiles,
|
|
@@ -377,6 +379,58 @@ describe("getLatestSynthesizedPlan", () => {
|
|
|
377
379
|
});
|
|
378
380
|
});
|
|
379
381
|
|
|
382
|
+
describe("getArtifactManifest", () => {
|
|
383
|
+
it("returns empty when no artifacts or plan exist", () => {
|
|
384
|
+
const taskDir = makeTempDir();
|
|
385
|
+
expect(getArtifactManifest(taskDir)).toEqual([]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("extracts titles from artifact headings with a filename fallback", () => {
|
|
389
|
+
const taskDir = makeTempDir();
|
|
390
|
+
const artifactsDir = join(taskDir, "artifacts");
|
|
391
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
392
|
+
writeFileSync(join(artifactsDir, "design.md"), "# My Design\n\nbody", "utf-8");
|
|
393
|
+
writeFileSync(join(artifactsDir, "notitle.md"), "no heading here", "utf-8");
|
|
394
|
+
|
|
395
|
+
const manifest = getArtifactManifest(taskDir);
|
|
396
|
+
expect(manifest).toEqual([
|
|
397
|
+
{ title: "My Design", path: join(artifactsDir, "design.md") },
|
|
398
|
+
{ title: "artifacts/notitle.md", path: join(artifactsDir, "notitle.md") },
|
|
399
|
+
]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("includes the latest synthesized plan with its REAL path, regardless of phase", () => {
|
|
403
|
+
const taskDir = makeTempDir();
|
|
404
|
+
const plansDir = join(taskDir, "plans");
|
|
405
|
+
mkdirSync(plansDir, { recursive: true });
|
|
406
|
+
writeFileSync(join(plansDir, "001_synthesized.md"), "old", "utf-8");
|
|
407
|
+
writeFileSync(join(plansDir, "010_synthesized.md"), "new", "utf-8");
|
|
408
|
+
|
|
409
|
+
const manifest = getArtifactManifest(taskDir);
|
|
410
|
+
expect(manifest).toEqual([
|
|
411
|
+
{ title: "Synthesized implementation plan", path: join(plansDir, "010_synthesized.md") },
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe("formatManifestBlock", () => {
|
|
417
|
+
it("emits the do-not-re-read line only when the manifest is empty", () => {
|
|
418
|
+
const block = formatManifestBlock([]);
|
|
419
|
+
expect(block).toContain("Do NOT re-read them from disk");
|
|
420
|
+
expect(block).not.toContain("read them from disk with the read tool");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("lists each manifest entry's path and title for on-demand reading", () => {
|
|
424
|
+
const block = formatManifestBlock([
|
|
425
|
+
{ title: "Design", path: "/t/artifacts/design.md" },
|
|
426
|
+
{ title: "Synthesized implementation plan", path: "/t/plans/1_synthesized.md" },
|
|
427
|
+
]);
|
|
428
|
+
expect(block).toContain("do NOT re-read them from disk");
|
|
429
|
+
expect(block).toContain("- /t/artifacts/design.md — Design");
|
|
430
|
+
expect(block).toContain("- /t/plans/1_synthesized.md — Synthesized implementation plan");
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
380
434
|
describe("context regressions", () => {
|
|
381
435
|
it("parses bracket array values without truncation when closing bracket is missing", () => {
|
|
382
436
|
const cwd = makeTempDir();
|
|
@@ -5,7 +5,7 @@ import type { RepoInfo } from "./repo-utils.js";
|
|
|
5
5
|
import type { Phase } from "./state.js";
|
|
6
6
|
import { getLogger } from "./log.js";
|
|
7
7
|
|
|
8
|
-
type AgentType = "main" | "explore" | "librarian" | "planner" | "planReviewer" | "task" | "codeReviewer" | "brainstormReviewer";
|
|
8
|
+
type AgentType = "main" | "explore" | "librarian" | "planner" | "planReviewer" | "task" | "codeReviewer" | "brainstormReviewer" | "advisor" | "deep-debugger" | "reviewer";
|
|
9
9
|
type AgentGroup = "all" | "subagents";
|
|
10
10
|
type InjectMode = "system" | "context";
|
|
11
11
|
type PhaseFilter = "brainstorm" | "debug" | "plan" | "implement" | "review";
|
|
@@ -30,7 +30,7 @@ interface Frontmatter {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const VALID_INJECT_MODES: readonly string[] = ["system", "context"];
|
|
33
|
-
const VALID_AGENTS: readonly string[] = ["main", "explore", "librarian", "planner", "planReviewer", "task", "codeReviewer", "brainstormReviewer"];
|
|
33
|
+
const VALID_AGENTS: readonly string[] = ["main", "explore", "librarian", "planner", "planReviewer", "task", "codeReviewer", "brainstormReviewer", "advisor", "deep-debugger", "reviewer"];
|
|
34
34
|
const VALID_AGENT_GROUPS: readonly string[] = ["all", "subagents"];
|
|
35
35
|
const VALID_PHASES: readonly string[] = ["brainstorm", "debug", "plan", "implement", "review"];
|
|
36
36
|
const VALID_VENDORS: readonly string[] = ["anthropic", "openai", "google", "unknown"];
|
|
@@ -279,6 +279,85 @@ export function getLatestSynthesizedPlan(taskDir: string): string | null {
|
|
|
279
279
|
return readFileSync(join(plansDir, synthFiles[synthFiles.length - 1]), "utf-8");
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// True when the newest `code-reviews/*_final_pass-*.md` exists and carries an
|
|
283
|
+
// `ANCHORS:` block. Shared by publishGuard (Publish menu) and the review-phase
|
|
284
|
+
// completion gates so a review cannot finish without the deliverable Publish
|
|
285
|
+
// consumes. A zero-findings `ANCHORS: (none)` still satisfies this (the line exists).
|
|
286
|
+
export function hasFinalPassAnchors(taskDir: string): boolean {
|
|
287
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
288
|
+
if (!existsSync(reviewsDir)) return false;
|
|
289
|
+
const finalPassFiles = readdirSync(reviewsDir)
|
|
290
|
+
.filter((f) => f.endsWith(".md") && f.includes("_final_pass-"))
|
|
291
|
+
.sort();
|
|
292
|
+
if (finalPassFiles.length === 0) return false;
|
|
293
|
+
const latest = finalPassFiles[finalPassFiles.length - 1];
|
|
294
|
+
const content = readFileSync(join(reviewsDir, latest), "utf8");
|
|
295
|
+
return /^ANCHORS:/m.test(content);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function getLatestSynthesizedPlanPath(taskDir: string): string | null {
|
|
299
|
+
const plansDir = join(taskDir, "plans");
|
|
300
|
+
if (!existsSync(plansDir)) return null;
|
|
301
|
+
|
|
302
|
+
const synthFiles = readdirSync(plansDir)
|
|
303
|
+
.filter((f) => f.includes("synthesized"))
|
|
304
|
+
.sort(sortByTimestampPrefix);
|
|
305
|
+
if (synthFiles.length === 0) return null;
|
|
306
|
+
|
|
307
|
+
return join(plansDir, synthFiles[synthFiles.length - 1]);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function extractTitle(path: string, fallback: string): string {
|
|
311
|
+
try {
|
|
312
|
+
const content = readFileSync(path, "utf-8");
|
|
313
|
+
for (const line of content.split("\n")) {
|
|
314
|
+
const m = line.match(/^#\s+(.+?)\s*$/);
|
|
315
|
+
if (m) return m[1];
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// fall through to filename fallback
|
|
319
|
+
}
|
|
320
|
+
return fallback;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Path-aware manifest of on-demand task documents (artifacts/*.md + the synthesized
|
|
324
|
+
// plan when present) — each entry carries a REAL filesystem path an agent can read.
|
|
325
|
+
// Unlike getPhaseArtifacts, the plan is included whenever it exists (not phase-gated),
|
|
326
|
+
// so a reviewer spawned in the review phase still gets the plan path.
|
|
327
|
+
export function getArtifactManifest(taskDir: string): { title: string; path: string }[] {
|
|
328
|
+
const manifest: { title: string; path: string }[] = [];
|
|
329
|
+
|
|
330
|
+
const artifactsDir = join(taskDir, "artifacts");
|
|
331
|
+
if (existsSync(artifactsDir)) {
|
|
332
|
+
for (const file of readdirSync(artifactsDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
333
|
+
const path = join(artifactsDir, file);
|
|
334
|
+
manifest.push({ title: extractTitle(path, `artifacts/${file}`), path });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const planPath = getLatestSynthesizedPlanPath(taskDir);
|
|
339
|
+
if (planPath) {
|
|
340
|
+
manifest.push({ title: "Synthesized implementation plan", path: planPath });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return manifest;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Renders a manifest into the trailing prompt block shared by phased panels.
|
|
347
|
+
// Replaces the old "Do NOT re-read them from disk" line: USER_REQUEST/RESEARCH
|
|
348
|
+
// (and the inlined plan) stay in context, while additional artifacts are offered
|
|
349
|
+
// for on-demand reading from disk.
|
|
350
|
+
export function formatManifestBlock(manifest: { title: string; path: string }[]): string {
|
|
351
|
+
if (manifest.length === 0) {
|
|
352
|
+
return "The USER REQUEST and RESEARCH above are already in your context. Do NOT re-read them from disk.";
|
|
353
|
+
}
|
|
354
|
+
return [
|
|
355
|
+
"The USER REQUEST and RESEARCH above are already in your context — do NOT re-read them from disk.",
|
|
356
|
+
"Additional analysis artifacts and the plan are listed below; read them from disk with the read tool if relevant:",
|
|
357
|
+
...manifest.map((m) => `- ${m.path} — ${m.title}`),
|
|
358
|
+
].join("\n");
|
|
359
|
+
}
|
|
360
|
+
|
|
282
361
|
export function loadBrainstormReviewOutputs(taskDir: string, pass: number): { name: string; content: string }[] {
|
|
283
362
|
const dir = join(taskDir, "brainstorm-reviews");
|
|
284
363
|
if (!existsSync(dir)) return [];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
|
|
3
|
+
|
|
4
|
+
const theme = { fg: (_color: string, text: string) => text } as any;
|
|
5
|
+
const footerData = { getGitBranch: () => "main" } as any;
|
|
6
|
+
|
|
7
|
+
function render(width = 200): string[] {
|
|
8
|
+
const footer = createCustomFooter({} as any, theme, footerData);
|
|
9
|
+
return footer.render(width);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeCtx(usage?: { tokens: number | null; contextWindow: number; percent: number | null }): any {
|
|
13
|
+
return {
|
|
14
|
+
cwd: "/tmp/project",
|
|
15
|
+
model: { id: "test-model", provider: "test" },
|
|
16
|
+
sessionManager: { getSessionName: () => undefined, getEntries: () => [] },
|
|
17
|
+
getContextUsage: () => usage,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("createCustomFooter", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
setFooterTracker(undefined as any);
|
|
24
|
+
setFooterOrchestrator(undefined as any);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders exactly two lines (no status/LSP line)", () => {
|
|
28
|
+
setFooterContext(makeCtx());
|
|
29
|
+
const lines = render();
|
|
30
|
+
expect(lines).toHaveLength(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("line 1 shows task/phase/mode when a task is active", () => {
|
|
34
|
+
setFooterContext(makeCtx());
|
|
35
|
+
setFooterOrchestrator({
|
|
36
|
+
active: { type: "implement", state: { phase: "plan", mode: "autonomous" } },
|
|
37
|
+
} as any);
|
|
38
|
+
const [line1] = render();
|
|
39
|
+
expect(line1).toContain("task: implement");
|
|
40
|
+
expect(line1).toContain("phase: plan");
|
|
41
|
+
expect(line1).toContain("mode: autonomous");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("line 1 shows autonomous mode in a read-only phase for an autonomous task", () => {
|
|
45
|
+
setFooterContext(makeCtx());
|
|
46
|
+
setFooterOrchestrator({
|
|
47
|
+
active: { type: "implement", state: { phase: "brainstorm", mode: "autonomous" } },
|
|
48
|
+
} as any);
|
|
49
|
+
const [line1] = render();
|
|
50
|
+
expect(line1).toContain("phase: brainstorm");
|
|
51
|
+
expect(line1).toContain("mode: autonomous");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("line 1 shows guided mode for a guided task", () => {
|
|
55
|
+
setFooterContext(makeCtx());
|
|
56
|
+
setFooterOrchestrator({
|
|
57
|
+
active: { type: "implement", state: { phase: "implement", mode: "guided" } },
|
|
58
|
+
} as any);
|
|
59
|
+
const [line1] = render();
|
|
60
|
+
expect(line1).toContain("mode: guided");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("line 1 omits the mode segment for a quick task", () => {
|
|
64
|
+
setFooterContext(makeCtx());
|
|
65
|
+
setFooterOrchestrator({
|
|
66
|
+
active: { type: "quick", state: { phase: "quick", mode: "autonomous" } },
|
|
67
|
+
} as any);
|
|
68
|
+
const [line1] = render();
|
|
69
|
+
expect(line1).toContain("task: quick");
|
|
70
|
+
expect(line1).not.toContain("mode:");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("line 1 omits task metadata when no task is active", () => {
|
|
74
|
+
setFooterContext(makeCtx());
|
|
75
|
+
const [line1] = render();
|
|
76
|
+
expect(line1).not.toContain("task:");
|
|
77
|
+
expect(line1).toContain("main");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("context indicator shows percent/used/max", () => {
|
|
81
|
+
setFooterContext(makeCtx({ tokens: 38000, contextWindow: 1000000, percent: 3.8 }));
|
|
82
|
+
const [, line2] = render();
|
|
83
|
+
expect(line2).toContain("3.8%/38k/1.0M (auto)");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("context indicator degrades gracefully when tokens are null", () => {
|
|
87
|
+
setFooterContext(makeCtx({ tokens: null, contextWindow: 1000000, percent: null }));
|
|
88
|
+
const [, line2] = render();
|
|
89
|
+
expect(line2).toContain("?%/?/1.0M (auto)");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -2,9 +2,12 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { UsageTracker } from "./usage-tracker.js";
|
|
5
|
+
import type { Orchestrator } from "./orchestrator.js";
|
|
6
|
+
import { formatModeIndicator } from "./state.js";
|
|
5
7
|
|
|
6
8
|
let footerCtx: ExtensionContext | undefined;
|
|
7
9
|
let footerTracker: UsageTracker | undefined;
|
|
10
|
+
let footerOrchestrator: Orchestrator | undefined;
|
|
8
11
|
|
|
9
12
|
export function setFooterContext(ctx: ExtensionContext): void {
|
|
10
13
|
footerCtx = ctx;
|
|
@@ -14,6 +17,10 @@ export function setFooterTracker(tracker: UsageTracker): void {
|
|
|
14
17
|
footerTracker = tracker;
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
export function setFooterOrchestrator(orchestrator: Orchestrator): void {
|
|
21
|
+
footerOrchestrator = orchestrator;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
function formatTokens(count: number): string {
|
|
18
25
|
if (count < 1000) return count.toString();
|
|
19
26
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
@@ -22,10 +29,6 @@ function formatTokens(count: number): string {
|
|
|
22
29
|
return `${Math.round(count / 1000000)}M`;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
function sanitizeStatusText(text: string): string {
|
|
26
|
-
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
32
|
function formatPath(cwd: string): string {
|
|
30
33
|
const home = homedir();
|
|
31
34
|
return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
@@ -47,8 +50,10 @@ function toContextUsagePart(ctx: ExtensionContext | undefined, theme: Theme): st
|
|
|
47
50
|
const usage = ctx?.getContextUsage();
|
|
48
51
|
const contextWindow = usage?.contextWindow ?? 0;
|
|
49
52
|
const percentValue = usage?.percent ?? null;
|
|
53
|
+
const tokensValue = usage?.tokens ?? null;
|
|
50
54
|
const percentText = percentValue === null ? "?" : percentValue.toFixed(1);
|
|
51
|
-
const
|
|
55
|
+
const tokensText = tokensValue === null ? "?" : formatTokens(tokensValue);
|
|
56
|
+
const display = `${percentText}%/${tokensText}/${formatTokens(contextWindow)} (auto)`;
|
|
52
57
|
if (percentValue !== null && percentValue > 90) return theme.fg("error", display);
|
|
53
58
|
if (percentValue !== null && percentValue > 70) return theme.fg("warning", display);
|
|
54
59
|
return display;
|
|
@@ -115,23 +120,19 @@ function renderPathLine(width: number, theme: Theme, footerData: ReadonlyFooterD
|
|
|
115
120
|
const ctx = footerCtx;
|
|
116
121
|
const path = formatPath(ctx?.cwd ?? process.cwd());
|
|
117
122
|
const branch = footerData.getGitBranch();
|
|
118
|
-
const sessionName = ctx?.sessionManager.getSessionName();
|
|
119
123
|
|
|
120
124
|
let line = path;
|
|
121
125
|
if (branch) line += ` (${branch})`;
|
|
122
|
-
if (sessionName) line += ` • ${sessionName}`;
|
|
123
|
-
|
|
124
|
-
return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function renderStatusLine(width: number, theme: Theme, footerData: ReadonlyFooterDataProvider): string | undefined {
|
|
128
|
-
const statuses = footerData.getExtensionStatuses();
|
|
129
|
-
if (statuses.size === 0) return undefined;
|
|
130
126
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
.
|
|
134
|
-
.
|
|
127
|
+
const task = footerOrchestrator?.active;
|
|
128
|
+
if (task && task.state.phase !== "done") {
|
|
129
|
+
line += ` • task: ${task.type} • phase: ${task.state.phase}`;
|
|
130
|
+
const mode = formatModeIndicator(task.state, task.type);
|
|
131
|
+
if (mode) line += ` • mode: ${mode}`;
|
|
132
|
+
} else {
|
|
133
|
+
const sessionName = ctx?.sessionManager.getSessionName();
|
|
134
|
+
if (sessionName) line += ` • ${sessionName}`;
|
|
135
|
+
}
|
|
135
136
|
|
|
136
137
|
return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
|
|
137
138
|
}
|
|
@@ -141,8 +142,7 @@ export function createCustomFooter(_tui: TUI, theme: Theme, footerData: Readonly
|
|
|
141
142
|
render(width: number): string[] {
|
|
142
143
|
const line1 = renderPathLine(width, theme, footerData);
|
|
143
144
|
const line2 = renderStatsLine(width, theme);
|
|
144
|
-
|
|
145
|
-
return [line1, line2, line3];
|
|
145
|
+
return [line1, line2];
|
|
146
146
|
},
|
|
147
147
|
invalidate(): void {},
|
|
148
148
|
dispose(): void {},
|