pi-plans 0.5.7 → 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.
- package/CONTRIBUTING.md +126 -0
- package/README.md +49 -39
- package/agents/ref-analyst.md +7 -4
- package/agents/reviewer.md +12 -3
- package/index.ts +74 -40
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +45 -58
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +63 -47
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +22 -10
- package/skills/debug-and-plan/SKILL.md +4 -4
- package/skills/plan-big/SKILL.md +5 -5
- package/skills/plan-normal/SKILL.md +5 -5
- package/skills/plan-small/SKILL.md +5 -5
- package/skills/plan-with-refs/SKILL.md +8 -8
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +154 -76
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +709 -705
- package/src/global-state.ts +304 -0
- package/src/guard.ts +16 -3
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +14 -72
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +40 -130
- package/src/resume.ts +15 -17
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +5 -4
- package/src/run-picker.ts +98 -0
- package/src/state.ts +380 -77
- package/src/subagent.ts +32 -1
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +78 -57
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-pros-cons.test.ts +147 -0
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +111 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +184 -0
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +37 -22
- package/tests/resume.test.ts +43 -88
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/state.test.ts +151 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +19 -49
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +63 -33
- package/tools/graph-aware-file-tools.ts +6 -4
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
package/src/config-command.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
readActive,
|
|
4
|
+
showStateView,
|
|
5
|
+
setRole,
|
|
6
|
+
type PlansConfig,
|
|
7
|
+
utcNow,
|
|
8
|
+
VALID_ROLE_MODES,
|
|
9
|
+
updateConfig,
|
|
10
|
+
type GlobalRoleConfig,
|
|
11
|
+
} from "./state.ts";
|
|
12
|
+
import { DEFAULT_LEVEL_SENTINEL } from "./thinking-levels.ts";
|
|
13
|
+
import { effortItems, runFirstUseFlow, type RolePanelHost } from "./role-panels.ts";
|
|
3
14
|
import { refreshUiLanguage } from "./exec.ts";
|
|
4
15
|
|
|
5
16
|
interface ModelLike {
|
|
@@ -7,15 +18,18 @@ interface ModelLike {
|
|
|
7
18
|
id?: unknown;
|
|
8
19
|
}
|
|
9
20
|
|
|
10
|
-
interface ConfigCommandContext {
|
|
21
|
+
export interface ConfigCommandContext {
|
|
11
22
|
cwd: string;
|
|
12
23
|
hasUI: boolean;
|
|
24
|
+
mode?: string;
|
|
13
25
|
model?: unknown;
|
|
14
26
|
scopedModels?: Array<{ model?: unknown }>;
|
|
15
27
|
modelRegistry?: {
|
|
16
28
|
getAvailable?: () => unknown[];
|
|
29
|
+
find?: (provider: string, id: string) => unknown;
|
|
17
30
|
};
|
|
18
|
-
ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input"
|
|
31
|
+
ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input"> &
|
|
32
|
+
Partial<Pick<ExtensionContext["ui"], "custom">>;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
type ChoiceResult<T> =
|
|
@@ -34,7 +48,7 @@ function cancelled<T>(reason: "user" | "invalid" = "user"): ChoiceResult<T> {
|
|
|
34
48
|
return { cancelled: true, reason };
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
function modelSelectorOf(value: unknown): string | null {
|
|
51
|
+
export function modelSelectorOf(value: unknown): string | null {
|
|
38
52
|
if (!value || typeof value !== "object") return null;
|
|
39
53
|
const model = value as ModelLike;
|
|
40
54
|
if (typeof model.provider !== "string" || typeof model.id !== "string") return null;
|
|
@@ -42,7 +56,12 @@ function modelSelectorOf(value: unknown): string | null {
|
|
|
42
56
|
return `${model.provider}/${model.id}`;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Merge the session-visible model selectors (ctx.model + ctx.scopedModels +
|
|
61
|
+
* the model registry), deduped, EXCLUDING `currentSelector` — the result is a
|
|
62
|
+
* switch-target list (v0.6.0 delegated-execution model picker reuses this).
|
|
63
|
+
*/
|
|
64
|
+
export function collectModelSelectors(ctx: ConfigCommandContext, currentSelector: string | null): string[] {
|
|
46
65
|
const selectors: string[] = [];
|
|
47
66
|
const push = (selector: string | null): void => {
|
|
48
67
|
if (!selector) return;
|
|
@@ -137,7 +156,7 @@ async function promptLanguage(ctx: ConfigCommandContext, current: string | null)
|
|
|
137
156
|
|
|
138
157
|
async function promptArtifactRoot(ctx: ConfigCommandContext, current: string): Promise<ChoiceResult<string>> {
|
|
139
158
|
const options: Array<MenuOption<string>> = [{ label: `Keep current (${current})`, value: current }];
|
|
140
|
-
for (const root of ["
|
|
159
|
+
for (const root of ["./.git/pi-plans/plans", "./docs/pi-plans"]) {
|
|
141
160
|
if (root === current) continue;
|
|
142
161
|
options.push({ label: root, value: root });
|
|
143
162
|
}
|
|
@@ -183,7 +202,7 @@ async function promptGraphEnabled(ctx: ConfigCommandContext, current: boolean |
|
|
|
183
202
|
return promptMenu(ctx, "Code graph?", options);
|
|
184
203
|
}
|
|
185
204
|
|
|
186
|
-
async function promptRoleMode(ctx: ConfigCommandContext,
|
|
205
|
+
async function promptRoleMode(ctx: ConfigCommandContext, current: string): Promise<ChoiceResult<string>> {
|
|
187
206
|
if (!VALID_ROLE_MODES.has(current)) {
|
|
188
207
|
current = "delegated-subagent";
|
|
189
208
|
}
|
|
@@ -197,56 +216,115 @@ async function promptRoleMode(ctx: ConfigCommandContext, role: "reviewer" | "cri
|
|
|
197
216
|
{ label: "Keep current-session", value: "current-session" },
|
|
198
217
|
{ label: "Switch to delegated-subagent", value: "delegated-subagent" },
|
|
199
218
|
];
|
|
200
|
-
return promptMenu(ctx,
|
|
219
|
+
return promptMenu(ctx, "Reviewer mode?", options);
|
|
201
220
|
}
|
|
202
221
|
|
|
203
|
-
async function
|
|
222
|
+
async function promptRoleModelEntry(
|
|
204
223
|
ctx: ConfigCommandContext,
|
|
205
|
-
role:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
: "Keep current default (inherit)",
|
|
216
|
-
value: currentSelector,
|
|
217
|
-
},
|
|
224
|
+
role: GlobalRoleConfig,
|
|
225
|
+
): Promise<ChoiceResult<"keep" | "change">> {
|
|
226
|
+
const level = role.thinking_level ?? DEFAULT_LEVEL_SENTINEL;
|
|
227
|
+
const keepLabel =
|
|
228
|
+
role.model_selector !== null && role.confirmed_at !== null
|
|
229
|
+
? `Keep current (${role.model_selector} · ${level})`
|
|
230
|
+
: "Keep current (unconfirmed; first refine will ask)";
|
|
231
|
+
const options: Array<MenuOption<"keep" | "change">> = [
|
|
232
|
+
{ label: keepLabel, value: "keep" },
|
|
233
|
+
{ label: "Choose model & thinking level…", value: "change" },
|
|
218
234
|
];
|
|
219
|
-
|
|
220
|
-
|
|
235
|
+
return promptMenu(ctx, "Reviewer model?", options);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Change flow (Q-3=A): TUI pops the native panels via runFirstUseFlow
|
|
239
|
+
* (which persists to the global config on completion); other UI modes use
|
|
240
|
+
* menus (model list → effort list). Esc anywhere keeps the current role —
|
|
241
|
+
* the wizard continues instead of discarding earlier answers (F-011). */
|
|
242
|
+
async function changeReviewerRole(
|
|
243
|
+
ctx: ConfigCommandContext,
|
|
244
|
+
role: GlobalRoleConfig,
|
|
245
|
+
): Promise<{ role: GlobalRoleConfig; changed: boolean; error?: string }> {
|
|
246
|
+
const host = ctx as unknown as RolePanelHost;
|
|
247
|
+
if (ctx.mode === "tui" && typeof ctx.ui.custom === "function") {
|
|
248
|
+
const outcome = await runFirstUseFlow(host, role.thinking_level);
|
|
249
|
+
if (outcome.status === "confirmed") return { role: outcome.role, changed: true };
|
|
250
|
+
if (outcome.status === "cancelled") {
|
|
251
|
+
ctx.ui.notify("Reviewer change cancelled — keeping the current role.", "warning");
|
|
252
|
+
return { role, changed: false };
|
|
253
|
+
}
|
|
254
|
+
return { role, changed: false, error: "native panels unavailable in this session" };
|
|
221
255
|
}
|
|
256
|
+
// Menus: model list (live model first, then scoped/registry), then effort.
|
|
257
|
+
const currentSelector = role.model_selector;
|
|
258
|
+
const selectors: string[] = [];
|
|
259
|
+
const live = modelSelectorOf(ctx.model);
|
|
260
|
+
if (live) selectors.push(live);
|
|
222
261
|
for (const selector of collectModelSelectors(ctx, currentSelector)) {
|
|
223
|
-
if (selector ===
|
|
224
|
-
|
|
262
|
+
if (selector === live) continue;
|
|
263
|
+
selectors.push(selector);
|
|
225
264
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
265
|
+
const modelLabels = [...selectors.map((selector) => `Use ${selector}`), "Other..."];
|
|
266
|
+
const picked = await ctx.ui.select("Reviewer model?", modelLabels);
|
|
267
|
+
if (picked === undefined) {
|
|
268
|
+
ctx.ui.notify("Reviewer change cancelled — keeping the current role.", "warning");
|
|
269
|
+
return { role, changed: false };
|
|
270
|
+
}
|
|
271
|
+
let selector: string | null = null;
|
|
272
|
+
if (picked === "Other...") {
|
|
273
|
+
const raw = await ctx.ui.input("Reviewer model selector:");
|
|
274
|
+
if (raw === undefined) {
|
|
275
|
+
ctx.ui.notify("Reviewer change cancelled — keeping the current role.", "warning");
|
|
276
|
+
return { role, changed: false };
|
|
277
|
+
}
|
|
278
|
+
selector = parseModelSelector(raw);
|
|
279
|
+
if (selector === null) {
|
|
280
|
+
ctx.ui.notify("Model selector must be an exact provider/model string.", "error");
|
|
281
|
+
return { role, changed: false, error: "invalid model selector" };
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
selector = picked.replace(/^Use /, "");
|
|
285
|
+
}
|
|
286
|
+
// Effort menu: default sentinel + the chosen model's supported levels.
|
|
287
|
+
const found =
|
|
288
|
+
typeof ctx.modelRegistry?.find === "function"
|
|
289
|
+
? ((ctx.modelRegistry.find(selector.split("/")[0]!, selector.split("/")[1]!) as never) ?? null)
|
|
290
|
+
: null;
|
|
291
|
+
const items = found ? effortItems(found, role.thinking_level) : [{ value: DEFAULT_LEVEL_SENTINEL, label: DEFAULT_LEVEL_SENTINEL, description: "no --thinking flag" }];
|
|
292
|
+
const levelLabels = items.map((item) => `${item.label} — ${item.description}`);
|
|
293
|
+
const levelPicked = await ctx.ui.select("Reviewer thinking level? (first row = default: no --thinking flag)", levelLabels);
|
|
294
|
+
const levelValue = levelPicked === undefined ? DEFAULT_LEVEL_SENTINEL : items[levelLabels.indexOf(levelPicked)]?.value ?? DEFAULT_LEVEL_SENTINEL;
|
|
295
|
+
try {
|
|
296
|
+
const applied = setRole(ctx.cwd, {
|
|
297
|
+
role: "reviewer",
|
|
298
|
+
modelSelector: selector,
|
|
299
|
+
thinkingLevel: levelValue,
|
|
300
|
+
confirmed: true,
|
|
301
|
+
});
|
|
302
|
+
return { role: applied.global.reviewer, changed: true };
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return { role, changed: false, error: (error as Error).message };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface WizardCurrent {
|
|
309
|
+
config: PlansConfig;
|
|
310
|
+
reviewer: GlobalRoleConfig;
|
|
233
311
|
}
|
|
234
312
|
|
|
235
|
-
function currentConfig(workdir: string):
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
return showConfig(workdir);
|
|
313
|
+
function currentConfig(workdir: string): WizardCurrent {
|
|
314
|
+
const view = showStateView(workdir);
|
|
315
|
+
return { config: view.config, reviewer: view.reviewer };
|
|
239
316
|
}
|
|
240
317
|
|
|
241
|
-
function summarizeConfig(config: PlansConfig): string[] {
|
|
318
|
+
function summarizeConfig(config: PlansConfig, reviewer: GlobalRoleConfig): string[] {
|
|
319
|
+
const level = reviewer.thinking_level ?? DEFAULT_LEVEL_SENTINEL;
|
|
320
|
+
const model = reviewer.model_selector ?? (reviewer.mode === "current-session" ? "(in-session)" : "(unconfirmed)");
|
|
242
321
|
return [
|
|
243
322
|
"pi-plans config updated.",
|
|
244
323
|
`Language: ${config.language.tag ?? "(unset)"}`,
|
|
245
324
|
`Artifact root: ${config.artifact_root}`,
|
|
246
325
|
`Refs root: ${config.refs_root ?? "(unset)"}`,
|
|
247
326
|
`Code graph: ${config.graph_enabled === true ? "enabled" : config.graph_enabled === false ? "disabled" : "unset"}`,
|
|
248
|
-
`Reviewer: ${
|
|
249
|
-
`Criticizer: ${config.criticizer.mode} / ${config.criticizer.model_selector ?? "inherit"}`,
|
|
327
|
+
`Reviewer (global): ${reviewer.mode} / ${model}${reviewer.mode === "current-session" ? "" : ` · ${level}`}`,
|
|
250
328
|
];
|
|
251
329
|
}
|
|
252
330
|
|
|
@@ -259,8 +337,10 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
259
337
|
try {
|
|
260
338
|
const workdir = ctx.cwd;
|
|
261
339
|
const current = currentConfig(workdir);
|
|
340
|
+
let reviewer = current.reviewer;
|
|
341
|
+
const workspace = current.config;
|
|
262
342
|
|
|
263
|
-
const language = await promptLanguage(ctx,
|
|
343
|
+
const language = await promptLanguage(ctx, workspace.language.tag);
|
|
264
344
|
if (language.cancelled) {
|
|
265
345
|
if (language.reason === "user") {
|
|
266
346
|
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
@@ -268,7 +348,7 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
268
348
|
return;
|
|
269
349
|
}
|
|
270
350
|
|
|
271
|
-
const artifactRoot = await promptArtifactRoot(ctx,
|
|
351
|
+
const artifactRoot = await promptArtifactRoot(ctx, workspace.artifact_root);
|
|
272
352
|
if (artifactRoot.cancelled) {
|
|
273
353
|
if (artifactRoot.reason === "user") {
|
|
274
354
|
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
@@ -276,7 +356,7 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
276
356
|
return;
|
|
277
357
|
}
|
|
278
358
|
|
|
279
|
-
const refsRoot = await promptRefsRoot(ctx,
|
|
359
|
+
const refsRoot = await promptRefsRoot(ctx, workspace.refs_root);
|
|
280
360
|
if (refsRoot.cancelled) {
|
|
281
361
|
if (refsRoot.reason === "user") {
|
|
282
362
|
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
@@ -284,7 +364,7 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
284
364
|
return;
|
|
285
365
|
}
|
|
286
366
|
|
|
287
|
-
const graphEnabled = await promptGraphEnabled(ctx,
|
|
367
|
+
const graphEnabled = await promptGraphEnabled(ctx, workspace.graph_enabled);
|
|
288
368
|
if (graphEnabled.cancelled) {
|
|
289
369
|
if (graphEnabled.reason === "user") {
|
|
290
370
|
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
@@ -292,36 +372,40 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
292
372
|
return;
|
|
293
373
|
}
|
|
294
374
|
|
|
295
|
-
const reviewerMode = await promptRoleMode(ctx,
|
|
375
|
+
const reviewerMode = await promptRoleMode(ctx, reviewer.mode);
|
|
296
376
|
if (reviewerMode.cancelled) {
|
|
297
377
|
if (reviewerMode.reason === "user") {
|
|
298
378
|
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
299
379
|
}
|
|
300
380
|
return;
|
|
301
381
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
382
|
+
if (reviewerMode.value !== reviewer.mode) {
|
|
383
|
+
// Mode lives in the global role: persist the switch immediately;
|
|
384
|
+
// the model/thinking level stays untouched.
|
|
385
|
+
try {
|
|
386
|
+
const applied = setRole(workdir, { role: "reviewer", mode: reviewerMode.value });
|
|
387
|
+
reviewer = applied.global.reviewer;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
ctx.ui.notify(`Failed to switch the reviewer mode: ${(error as Error).message}`, "error");
|
|
307
390
|
}
|
|
308
|
-
return;
|
|
309
391
|
}
|
|
310
392
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
393
|
+
// Q-3=A: current-session never uses a spawned model — skip the model
|
|
394
|
+
// step entirely (and never clear an existing selector).
|
|
395
|
+
let reviewerError: string | undefined;
|
|
396
|
+
if (reviewer.mode === "delegated-subagent") {
|
|
397
|
+
const entry = await promptRoleModelEntry(ctx, reviewer);
|
|
398
|
+
if (entry.cancelled) {
|
|
399
|
+
if (entry.reason === "user") {
|
|
400
|
+
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
315
403
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (criticizerModel.cancelled) {
|
|
321
|
-
if (criticizerModel.reason === "user") {
|
|
322
|
-
ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
|
|
404
|
+
if (entry.value === "change") {
|
|
405
|
+
const changed = await changeReviewerRole(ctx, reviewer);
|
|
406
|
+
reviewer = changed.role;
|
|
407
|
+
reviewerError = changed.error;
|
|
323
408
|
}
|
|
324
|
-
return;
|
|
325
409
|
}
|
|
326
410
|
|
|
327
411
|
const updated = updateConfig(workdir, (config) => {
|
|
@@ -335,18 +419,9 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
335
419
|
config.refs_root_updated_at = now;
|
|
336
420
|
config.graph_enabled = graphEnabled.value;
|
|
337
421
|
config.graph_enabled_updated_at = now;
|
|
338
|
-
config.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
model_selector: reviewerModel.value,
|
|
342
|
-
confirmed_at: now,
|
|
343
|
-
};
|
|
344
|
-
config.criticizer = {
|
|
345
|
-
...config.criticizer,
|
|
346
|
-
mode: criticizerMode.value,
|
|
347
|
-
model_selector: criticizerModel.value,
|
|
348
|
-
confirmed_at: now,
|
|
349
|
-
};
|
|
422
|
+
// The reviewer role lives in the GLOBAL config since v0.7.0 — the
|
|
423
|
+
// wizard only strips a legacy workspace key, never writes one.
|
|
424
|
+
delete config.reviewer;
|
|
350
425
|
return config;
|
|
351
426
|
});
|
|
352
427
|
|
|
@@ -357,7 +432,10 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
|
|
|
357
432
|
// Display-only consumer (F-006): deliberately keeps the shared active
|
|
358
433
|
// pointer — the wizard summarizes repo state, not session attribution.
|
|
359
434
|
const active = readActive(workdir);
|
|
360
|
-
const lines = summarizeConfig(updated.config);
|
|
435
|
+
const lines = summarizeConfig(updated.config, reviewer);
|
|
436
|
+
if (reviewerError) {
|
|
437
|
+
lines.push(`Reviewer change failed (${reviewerError}); the reviewer role is unchanged. Workspace settings above were still written.`);
|
|
438
|
+
}
|
|
361
439
|
if (active) {
|
|
362
440
|
lines.push(`Active run left unchanged: ${active.run_id}`);
|
|
363
441
|
}
|
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task dashboard (v0.6.1): the execution-phase UI. A compact aboveEditor
|
|
3
|
+
* widget shows the current task, wave, progress bar, and verification-check
|
|
4
|
+
* state; Ctrl+Shift+T toggles an expanded tree view with per-task markers
|
|
5
|
+
* (✓ complete, ▸ current, ~ skipped, · pending). Pure model + render
|
|
6
|
+
* functions shared by the widget, the status line, and the expanded view so
|
|
7
|
+
* all three never disagree. Width-adaptive: narrow terminals get compact
|
|
8
|
+
* rows, wide terminals get full titles and file lists.
|
|
9
|
+
*
|
|
10
|
+
* Hard invariant: every returned line is at most `width` *display columns*.
|
|
11
|
+
* The widget host measures each line with its own `visibleWidth` and throws an
|
|
12
|
+
* uncaughtException (killing the whole TUI session) when a line is wider than
|
|
13
|
+
* the terminal, so all width math here is column-based via the shared helpers
|
|
14
|
+
* — never `.length`, `padEnd`, or `slice`, which count UTF-16 units and
|
|
15
|
+
* undercount CJK (one CJK char = one unit but two columns).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CheckItem } from "./plan.ts";
|
|
19
|
+
import { truncateToWidth, visibleWidth } from "./refine-ui-helpers.ts";
|
|
20
|
+
import {
|
|
21
|
+
allTasksTerminal,
|
|
22
|
+
currentTask,
|
|
23
|
+
flattenTaskViews,
|
|
24
|
+
maxWave,
|
|
25
|
+
taskIsTerminal,
|
|
26
|
+
taskProgress,
|
|
27
|
+
type TaskView,
|
|
28
|
+
} from "./tasks.ts";
|
|
29
|
+
|
|
30
|
+
export const DASHBOARD_WIDGET_KEY = "pi-plans-dashboard";
|
|
31
|
+
|
|
32
|
+
export interface DashboardModel {
|
|
33
|
+
topic: string;
|
|
34
|
+
tasks: TaskView[];
|
|
35
|
+
checklist: CheckItem[];
|
|
36
|
+
paused: boolean;
|
|
37
|
+
pausedReason?: string;
|
|
38
|
+
/** Audit round counter; null = audit not yet started. */
|
|
39
|
+
auditRounds: number | null;
|
|
40
|
+
auditFailed: string[];
|
|
41
|
+
startedAt: string;
|
|
42
|
+
usage: { inToks: number; outToks: number };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tree marker for one task row. */
|
|
46
|
+
export function taskMarker(task: TaskView, currentId: string | null): string {
|
|
47
|
+
if (taskIsTerminal(task)) return task.status === "skipped" ? "~" : "✓";
|
|
48
|
+
if (task.id === currentId) return "▸";
|
|
49
|
+
return "·";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Truncate to at most `max` display columns, ellipsis when clipped. */
|
|
53
|
+
function clip(text: string, max: number): string {
|
|
54
|
+
return max <= 0 ? "" : truncateToWidth(text, max);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pad with `fill` out to exactly `width` display columns; never widens. */
|
|
58
|
+
function padToWidth(text: string, width: number, fill = " "): string {
|
|
59
|
+
const gap = width - visibleWidth(text);
|
|
60
|
+
return gap > 0 ? text + fill.repeat(gap) : text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One bordered row of exactly `width` display columns: left border, fitted
|
|
65
|
+
* body, filler, right border. Every compact row goes through here so the box
|
|
66
|
+
* can never be `width + 1` columns wide (the old `padEnd().slice(0, width) +
|
|
67
|
+
* border` shape was one column over on every row).
|
|
68
|
+
*/
|
|
69
|
+
function boxRow(left: string, body: string, fill: string, width: number, right = left): string {
|
|
70
|
+
const inner = Math.max(0, width - 2);
|
|
71
|
+
return left + padToWidth(clip(body, inner), inner, fill) + right;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Last-resort clamp so no rendered line can ever exceed the terminal width. */
|
|
75
|
+
function clampLines(lines: string[], width: number): string[] {
|
|
76
|
+
if (width <= 0) return [];
|
|
77
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Minimal structural subset of the pi Theme the widget needs. */
|
|
81
|
+
export interface DashboardTheme {
|
|
82
|
+
fg(color: string, text: string): string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Brand label opening the title row. */
|
|
86
|
+
const BRAND = "π-plans";
|
|
87
|
+
|
|
88
|
+
/** `HH:MM:SS` since the run started. */
|
|
89
|
+
export function formatElapsed(startedAt: string): string {
|
|
90
|
+
const started = Date.parse(startedAt);
|
|
91
|
+
if (Number.isNaN(started)) return "00:00:00";
|
|
92
|
+
const total = Math.max(0, Math.floor((Date.now() - started) / 1000));
|
|
93
|
+
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
|
94
|
+
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
|
95
|
+
const sec = String(total % 60).padStart(2, "0");
|
|
96
|
+
return `${h}:${m}:${sec}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Title row: `┌─ π-plans: <plan> ───┐`, brand in the accent colour, the rest
|
|
101
|
+
* muted. Colours are emitted as *sequential* balanced spans rather than nested:
|
|
102
|
+
* a theme `fg()` closes with the default-foreground reset, so wrapping a muted
|
|
103
|
+
* span around an accent one would leave everything after it unstyled.
|
|
104
|
+
*/
|
|
105
|
+
function titleRow(topic: string, width: number, theme?: DashboardTheme): string {
|
|
106
|
+
const inner = Math.max(0, width - 2);
|
|
107
|
+
const prefix = "─ ";
|
|
108
|
+
const sep = ": ";
|
|
109
|
+
const used = prefix.length + BRAND.length + sep.length;
|
|
110
|
+
const name = clip(topic, Math.max(0, inner - used));
|
|
111
|
+
const fill = "─".repeat(Math.max(0, inner - used - visibleWidth(name)));
|
|
112
|
+
const rest = `${sep}${name}${fill}┐`;
|
|
113
|
+
if (!theme) return `┌${prefix}${BRAND}${rest}`;
|
|
114
|
+
// The left corner rides in the first muted span so the whole border is one colour.
|
|
115
|
+
return theme.fg("muted", `┌${prefix}`) + theme.fg("accent", BRAND) + theme.fg("muted", rest);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Progress row: `Tasks 3/10 · Verification checks 1/6 · 00:04:12 ███░░░░░░░░`.
|
|
120
|
+
* The bar, then the elapsed time, then the counts are dropped in that order
|
|
121
|
+
* when the terminal is too narrow to hold them, so the counts always survive.
|
|
122
|
+
*/
|
|
123
|
+
function progressRow(
|
|
124
|
+
model: DashboardModel,
|
|
125
|
+
done: number,
|
|
126
|
+
total: number,
|
|
127
|
+
vcDone: number,
|
|
128
|
+
width: number,
|
|
129
|
+
narrow: boolean,
|
|
130
|
+
): string {
|
|
131
|
+
const inner = Math.max(0, width - 2);
|
|
132
|
+
const counts = `Tasks ${done}/${total} · Verification checks ${vcDone}/${model.checklist.length}`;
|
|
133
|
+
const elapsed = ` · ${formatElapsed(model.startedAt)}`;
|
|
134
|
+
const bar = ` ${progressBar(done, total, narrow ? 8 : 12)}`;
|
|
135
|
+
let body = ` ${counts}${elapsed}${bar}`;
|
|
136
|
+
if (visibleWidth(body) > inner) body = ` ${counts}${elapsed}`;
|
|
137
|
+
if (visibleWidth(body) > inner) body = ` ${counts}`;
|
|
138
|
+
return boxRow("│", body, " ", width);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Compact widget lines (aboveEditor). Adapts to width: <64 narrow. */
|
|
142
|
+
export function renderDashboardLines(model: DashboardModel, width: number, theme?: DashboardTheme): string[] {
|
|
143
|
+
const p = taskProgress(model.tasks);
|
|
144
|
+
const cur = currentTask(model.tasks);
|
|
145
|
+
const vcDone = model.checklist.filter((item) => item.done).length;
|
|
146
|
+
const narrow = width < 64;
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
const inner = Math.max(0, width - 2);
|
|
149
|
+
lines.push(titleRow(model.topic, width, theme));
|
|
150
|
+
lines.push(progressRow(model, p.done, p.total, vcDone, width, narrow));
|
|
151
|
+
if (model.paused) {
|
|
152
|
+
lines.push(boxRow("│", ` ⏸ ${clip(model.pausedReason ?? "paused", inner - 3)}`, " ", width));
|
|
153
|
+
} else if (cur) {
|
|
154
|
+
const waveLine = narrow ? `▸ ${cur.id} (w${cur.wave})` : `▸ ${cur.id} (wave ${cur.wave}/${maxWave(model.tasks)}): ${cur.title}`;
|
|
155
|
+
lines.push(boxRow("│", ` ${clip(waveLine, inner - 2)}`, " ", width));
|
|
156
|
+
if (!narrow && cur.files.length > 0) {
|
|
157
|
+
lines.push(boxRow("│", ` ${clip(cur.files.join(", "), inner - 4)}`, " ", width));
|
|
158
|
+
}
|
|
159
|
+
} else if (allTasksTerminal(model.tasks)) {
|
|
160
|
+
const auditLine = model.auditFailed.length > 0
|
|
161
|
+
? `audit: ${model.auditFailed.length} check(s) failed — rollback pending`
|
|
162
|
+
: model.auditRounds !== null
|
|
163
|
+
? "audit complete ✓"
|
|
164
|
+
: "all tasks terminal — audit pending";
|
|
165
|
+
lines.push(boxRow("│", ` ${clip(auditLine, inner - 2)}`, " ", width));
|
|
166
|
+
}
|
|
167
|
+
if (!narrow && model.auditFailed.length > 0) {
|
|
168
|
+
lines.push(boxRow("│", ` ✗ ${clip(model.auditFailed.join(", "), inner - 3)}`, " ", width));
|
|
169
|
+
}
|
|
170
|
+
lines.push(boxRow("└", "─ Ctrl+Shift+T tree", "─", width, "┘"));
|
|
171
|
+
// The title row carries its own colours; every other row is muted.
|
|
172
|
+
const painted = theme ? lines.map((line, i) => (i === 0 ? line : theme.fg("muted", line))) : lines;
|
|
173
|
+
return clampLines(painted, width);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function progressBar(done: number, total: number, width = 12): string {
|
|
177
|
+
if (total <= 0) return "";
|
|
178
|
+
const filled = Math.round((done / total) * width);
|
|
179
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function deriveDashboardModel(
|
|
183
|
+
topic: string,
|
|
184
|
+
tasks: TaskView[],
|
|
185
|
+
checklist: CheckItem[],
|
|
186
|
+
extra?: { paused?: boolean; pausedReason?: string; auditRounds?: number | null; auditFailed?: string[]; startedAt?: string; usage?: { inToks: number; outToks: number } },
|
|
187
|
+
): DashboardModel {
|
|
188
|
+
return {
|
|
189
|
+
topic,
|
|
190
|
+
tasks,
|
|
191
|
+
checklist,
|
|
192
|
+
paused: extra?.paused ?? false,
|
|
193
|
+
pausedReason: extra?.pausedReason,
|
|
194
|
+
auditRounds: extra?.auditRounds ?? null,
|
|
195
|
+
auditFailed: extra?.auditFailed ?? [],
|
|
196
|
+
startedAt: extra?.startedAt ?? new Date().toISOString(),
|
|
197
|
+
usage: extra?.usage ?? { inToks: 0, outToks: 0 },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Status-bar summary line (single line, language-neutral symbols). */
|
|
202
|
+
export function formatDashboardSummaryLine(model: DashboardModel): string {
|
|
203
|
+
const p = taskProgress(model.tasks);
|
|
204
|
+
const vcDone = model.checklist.filter((item) => item.done).length;
|
|
205
|
+
const cur = currentTask(model.tasks);
|
|
206
|
+
const wave = cur ? ` · wave ${cur.wave}` : "";
|
|
207
|
+
const audit = model.auditRounds !== null ? ` · audit r${model.auditRounds}` : "";
|
|
208
|
+
const pause = model.paused ? " · ⏸ paused" : "";
|
|
209
|
+
return `plans: ${model.topic} ▸ tasks ${p.done}/${p.total} · VC ${vcDone}/${model.checklist.length}${wave}${audit}${pause}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Expanded tree view lines (Ctrl+Shift+T overlay). Wide layout from 96 cols. */
|
|
213
|
+
export function renderDashboardTreeLines(model: DashboardModel, width: number, theme?: DashboardTheme): string[] {
|
|
214
|
+
const p = taskProgress(model.tasks);
|
|
215
|
+
const vcDone = model.checklist.filter((item) => item.done).length;
|
|
216
|
+
const cur = currentTask(model.tasks);
|
|
217
|
+
const wide = width >= 96;
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
lines.push(`${model.topic} — task tree (${p.done}/${p.total} done · VC ${vcDone}/${model.checklist.length})${model.paused ? " · ⏸ paused" : ""}`);
|
|
220
|
+
const row = (task: TaskView, depth: number): void => {
|
|
221
|
+
const indent = " ".repeat(depth);
|
|
222
|
+
const marker = taskMarker(task, cur?.id ?? null);
|
|
223
|
+
const title = wide ? task.title : clip(task.title, 48);
|
|
224
|
+
let line = `${indent}${marker} ${task.id}: ${title}`;
|
|
225
|
+
if (wide && task.files.length > 0) line += ` [${task.files.join(", ")}]`;
|
|
226
|
+
if (wide && task.deps.length > 0) line += ` (deps: ${task.deps.join(", ")})`;
|
|
227
|
+
if (task.status === "skipped" && task.skipReason) line += ` ~${task.skipReason}`;
|
|
228
|
+
if (task.status === "complete" && task.evidence) line += ` ✓${clip(task.evidence, 40)}`;
|
|
229
|
+
lines.push(line);
|
|
230
|
+
for (const child of task.children) row(child, depth + 1);
|
|
231
|
+
}; for (const task of model.tasks) row(task, 0);
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push("Verification checks:");
|
|
234
|
+
for (const item of model.checklist) {
|
|
235
|
+
const mark = model.auditFailed.includes(item.id) ? "✗" : item.done ? "☑" : "☐";
|
|
236
|
+
lines.push(` ${mark} ${item.id}${wide ? ` ${clip(item.text.split(";")[1] ?? item.text, Math.min(80, width))}` : ""}`);
|
|
237
|
+
}
|
|
238
|
+
if (model.auditRounds !== null) {
|
|
239
|
+
lines.push("");
|
|
240
|
+
lines.push(`Completion audit: round ${model.auditRounds}${model.auditFailed.length > 0 ? ` — failed: ${model.auditFailed.join(", ")}` : " — passed ✓"}`);
|
|
241
|
+
}
|
|
242
|
+
const painted = theme ? lines.map((line) => theme.fg("muted", line)) : lines;
|
|
243
|
+
return clampLines(painted, width);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Flat open-task summary for injections: current wave first. */
|
|
247
|
+
export function openTaskSummary(tasks: TaskView[]): string {
|
|
248
|
+
const cur = currentTask(tasks);
|
|
249
|
+
if (!cur) return "(none — all tasks terminal)";
|
|
250
|
+
const open = flattenTaskViews(tasks).filter((task) => !taskIsTerminal(task));
|
|
251
|
+
const currentWave = cur.wave;
|
|
252
|
+
const inWave = open.filter((task) => task.wave === currentWave).map((task) => task.id);
|
|
253
|
+
const later = open.filter((task) => task.wave > currentWave).map((task) => task.id);
|
|
254
|
+
const parts = [`current wave ${currentWave}: ${inWave.join(", ") || "(none)"}`];
|
|
255
|
+
if (later.length > 0) parts.push(`later waves: ${later.join(", ")}`);
|
|
256
|
+
return parts.join(" · ");
|
|
257
|
+
}
|