pi-better-harness 0.3.17 → 0.3.18

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.
@@ -27,6 +27,12 @@ When an explicitly invoked skill declares `workflow-role: coordinator` in its me
27
27
 
28
28
  For `rush-issues`, call `sync_workflow_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision` after each checkpoint. This binds the active run to the session and displays its fleet stages and actual units in the widget, `/plan`, and `get_plan`. The view reads the file without writing to it; revision mismatches are rejected. The run binding survives Pi session resume, and ownership release restores the prior generic checklist. Other workflow-owned skills retain their own planning and have no generic plan view.
29
29
 
30
+ ## Plan Display
31
+
32
+ Native and workflow-synced plans share a `plan` heading, completion counts, aligned identifiers and titles, and status colors. Completed rows use `✓`, active rows `●`, pending rows `○`, and blocked rows `!`. Active and blocked rows also carry text labels; failed workflow rows use `×` and `failed` rather than an active indicator.
33
+
34
+ Workflow identity, revision, and fleet progress sit on a secondary line that wraps on narrow terminals. `/plan` uses the same styling and shows workflow stage, raw status, worker, dependencies, and notes beneath each issue. Use ↑/↓ to move the selection and Escape or ← to return. The passive widget never captures editor arrow keys.
35
+
30
36
  ## Install
31
37
 
32
38
  ```sh
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,112 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+
4
+ export interface PlanRenderTheme {
5
+ fg(color: string, value: string): string;
6
+ }
7
+
8
+ export type DisplayStatus = "completed" | "in_progress" | "pending" | "blocked" | "failed" | "skipped" | "unknown";
9
+ export interface PlanRow {
10
+ label: string;
11
+ title: string;
12
+ status: DisplayStatus;
13
+ statusLabel?: string;
14
+ details?: string[];
15
+ }
16
+ export interface PlanPresentation {
17
+ rows: PlanRow[];
18
+ metadata?: { text: string; color: string }[][];
19
+ }
20
+
21
+ export const plainPlanTheme: PlanRenderTheme = { fg: (_color, value) => value };
22
+
23
+ export function statusStyle(status: DisplayStatus): { glyph: string; color: string; label: string } {
24
+ switch (status) {
25
+ case "completed": return { glyph: "✓", color: "success", label: "" };
26
+ case "in_progress": return { glyph: "●", color: "accent", label: "active" };
27
+ case "blocked": return { glyph: "!", color: "warning", label: "blocked" };
28
+ case "failed": return { glyph: "×", color: "error", label: "failed" };
29
+ case "skipped": return { glyph: "—", color: "dim", label: "skipped" };
30
+ case "unknown": return { glyph: "?", color: "warning", label: "unknown" };
31
+ case "pending": return { glyph: "○", color: "dim", label: "" };
32
+ }
33
+ }
34
+
35
+ // Keep each piece of external text on one terminal row before measuring it.
36
+ export function planText(value: string): string {
37
+ return value.replace(/[\r\n\t]/g, " ");
38
+ }
39
+
40
+ export function renderPlanPresentation(
41
+ plan: PlanPresentation, width: number, theme: PlanRenderTheme,
42
+ full = false, selectedIndex = -1,
43
+ ): string[] {
44
+ const size = Math.max(1, Math.floor(width));
45
+ const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
46
+ const summary = [`${count("completed")}/${plan.rows.length} complete`];
47
+ for (const [status, label] of [
48
+ ["in_progress", "in progress"], ["blocked", "blocked"], ["failed", "failed"],
49
+ ["skipped", "skipped"], ["unknown", "unknown"],
50
+ ] as const) {
51
+ if (count(status)) summary.push(`${count(status)} ${label}`);
52
+ }
53
+ if (count("pending") && summary.length === 1) summary.push(`${count("pending")} pending`);
54
+ const lines = [theme.fg("warning", "plan") + theme.fg("dim", ` ${summary.join(" · ")}`)];
55
+ for (const metadata of plan.metadata ?? []) {
56
+ const content = metadata.map((part) => theme.fg(part.color, planText(part.text))).join("");
57
+ for (const line of wrapTextWithAnsi(content, Math.max(1, size - 2))) lines.push(` ${line}`);
58
+ }
59
+ lines.push("");
60
+
61
+ const labelWidth = Math.min(12, Math.max(4, ...plan.rows.map((row) => visibleWidth(planText(row.label)))));
62
+ const statusWidth = Math.min(24, Math.max(0, ...plan.rows.map((row) =>
63
+ visibleWidth(planText(row.statusLabel ?? statusStyle(row.status).label)))));
64
+ for (const [index, row] of plan.rows.entries()) {
65
+ const style = statusStyle(row.status);
66
+ const label = truncateToWidth(planText(row.label), labelWidth);
67
+ const prefix = (index === selectedIndex ? theme.fg("accent", "› ") : " ") +
68
+ theme.fg(style.color, style.glyph) + " " + theme.fg("dim", label) +
69
+ " ".repeat(Math.max(0, labelWidth - visibleWidth(label)) + 2);
70
+ const titleColor = row.status === "blocked" ? "warning" : row.status === "failed" ? "error" :
71
+ row.status === "in_progress" ? "text" : "muted";
72
+ const badge = planText(row.statusLabel ?? style.label);
73
+ const available = size - visibleWidth(prefix);
74
+ // At narrow widths use an inline status so it survives title truncation.
75
+ if (badge && available < statusWidth + 14) {
76
+ lines.push(prefix + theme.fg(style.color, `${badge} · `) + theme.fg(titleColor, planText(row.title)));
77
+ } else {
78
+ const titleWidth = Math.max(0, available - (statusWidth ? statusWidth + 2 : 0));
79
+ const title = truncateToWidth(planText(row.title), titleWidth);
80
+ lines.push(prefix + theme.fg(titleColor, title) + (badge
81
+ ? " ".repeat(Math.max(0, titleWidth - visibleWidth(title)) + 2) + theme.fg(style.color, badge)
82
+ : ""));
83
+ }
84
+ if (full) {
85
+ for (const detail of row.details ?? []) {
86
+ const indent = Math.min(visibleWidth(prefix), Math.max(0, size - 4));
87
+ for (const line of wrapTextWithAnsi(planText(detail), Math.max(1, size - indent))) {
88
+ lines.push(" ".repeat(indent) + theme.fg("dim", line));
89
+ }
90
+ }
91
+ }
92
+ }
93
+ if (full) lines.push("", theme.fg("dim", "↑↓ navigate · esc / ← back"));
94
+ return lines.map((line) => truncateToWidth(line, size));
95
+ }
96
+
97
+ export function createPlanPresentationComponent(
98
+ plan: PlanPresentation, theme: PlanRenderTheme, onClose: () => void,
99
+ ): Component {
100
+ let selected = plan.rows.findIndex((row) => row.status === "in_progress");
101
+ if (selected < 0) selected = plan.rows.findIndex((row) => row.status !== "completed");
102
+ if (selected < 0) selected = Math.max(0, plan.rows.length - 1);
103
+ return {
104
+ render: (width) => renderPlanPresentation(plan, width, theme, true, selected),
105
+ handleInput(data) {
106
+ if (matchesKey(data, "up")) selected = Math.max(0, selected - 1);
107
+ else if (matchesKey(data, "down")) selected = Math.min(Math.max(0, plan.rows.length - 1), selected + 1);
108
+ else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
109
+ },
110
+ invalidate() {},
111
+ };
112
+ }
@@ -1,94 +1,28 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import {
3
+ createPlanPresentationComponent, renderPlanPresentation,
4
+ type PlanPresentation, type PlanRenderTheme,
5
+ } from "./plan-presentation.js";
6
+ import type { PlanSnapshot } from "./types.js";
3
7
 
4
- import { planProgress } from "./plan-state.js";
5
- import type { PlanSnapshot, PlanStep } from "./types.js";
8
+ export type { PlanRenderTheme } from "./plan-presentation.js";
6
9
 
7
- export interface PlanRenderTheme {
8
- fg(color: string, value: string): string;
9
- }
10
-
11
- export function renderCompactPlan(
12
- plan: PlanSnapshot,
13
- width: number,
14
- theme: PlanRenderTheme,
15
- ): string[] {
16
- const progress = planProgress(plan);
17
- const stateLabel = progress.blocked > 0
18
- ? `${progress.blocked} blocked`
19
- : progress.state === "complete"
20
- ? "complete"
21
- : progress.state.replace("_", " ");
22
- const lines = [
23
- theme.fg(progress.blocked > 0 ? "warning" : "accent", `plan ${progress.completed}/${progress.total} steps`) +
24
- theme.fg("dim", ` · ${stateLabel}`),
25
- ];
26
-
27
- for (let index = 0; index < plan.steps.length; index += 1) {
28
- const item = plan.steps[index]!;
29
- lines.push(` ${stepGlyph(item, theme)} ${index + 1} ${stepText(item, theme)}`);
30
- }
31
- lines.push("");
32
- return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
33
- }
34
-
35
- export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
36
- const progress = planProgress(plan);
37
- const heading = `Practical Plan · ${progress.completed}/${progress.total} completed`;
38
- const lines = [theme.fg("accent", rule(heading, width)), ""];
39
- for (let index = 0; index < plan.steps.length; index += 1) {
40
- const item = plan.steps[index]!;
41
- const prefix = index === selectedIndex ? theme.fg("accent", "› ") : " ";
42
- lines.push(`${prefix}${stepGlyph(item, theme)} ${String(index + 1).padStart(2, " ")} ${stepText(item, theme)}`);
43
- }
44
- lines.push("", theme.fg("dim", rule("", width)));
45
- return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
46
- }
47
-
48
- export function createFullPlanComponent(
49
- plan: PlanSnapshot,
50
- theme: PlanRenderTheme,
51
- onClose: () => void,
52
- ): Component {
53
- let selected = planProgress(plan).activeIndex ?? firstIncompleteIndex(plan);
10
+ function presentPlan(plan: PlanSnapshot): PlanPresentation {
54
11
  return {
55
- render: (width) => renderFullPlan(plan, width, theme, selected),
56
- handleInput(data) {
57
- if (matchesKey(data, "up")) selected = clampIndex(selected - 1, plan.steps.length);
58
- else if (matchesKey(data, "down")) selected = clampIndex(selected + 1, plan.steps.length);
59
- else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
60
- },
61
- invalidate() {},
12
+ rows: plan.steps.map((item, index) => ({
13
+ label: String(index + 1), title: item.step, status: item.status,
14
+ })),
62
15
  };
63
16
  }
64
17
 
65
- function firstIncompleteIndex(plan: PlanSnapshot): number {
66
- const index = plan.steps.findIndex((item) => item.status !== "completed");
67
- return index >= 0 ? index : Math.max(0, plan.steps.length - 1);
68
- }
69
-
70
- function clampIndex(index: number, total: number): number {
71
- return Math.min(Math.max(0, index), Math.max(0, total - 1));
18
+ export function renderCompactPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme): string[] {
19
+ return renderPlanPresentation(presentPlan(plan), width, theme);
72
20
  }
73
21
 
74
- function stepGlyph(item: PlanStep, theme: PlanRenderTheme): string {
75
- switch (item.status) {
76
- case "completed": return theme.fg("success", "✓");
77
- case "in_progress": return theme.fg("accent", "●");
78
- case "blocked": return theme.fg("warning", "!");
79
- case "pending": return theme.fg("dim", "○");
80
- }
22
+ export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
23
+ return renderPlanPresentation(presentPlan(plan), width, theme, true, selectedIndex);
81
24
  }
82
25
 
83
- function stepText(item: PlanStep, theme: PlanRenderTheme): string {
84
- if (item.status === "completed" || item.status === "pending") return theme.fg("muted", item.step);
85
- if (item.status === "blocked") return theme.fg("warning", `BLOCKED · ${item.step}`);
86
- return item.step;
26
+ export function createFullPlanComponent(plan: PlanSnapshot, theme: PlanRenderTheme, onClose: () => void): Component {
27
+ return createPlanPresentationComponent(presentPlan(plan), theme, onClose);
87
28
  }
88
-
89
- function rule(label: string, width: number): string {
90
- const size = Math.max(1, Math.floor(width));
91
- if (!label) return "━".repeat(size);
92
- const prefix = `━━ ${label} `;
93
- return prefix + "━".repeat(Math.max(0, size - prefix.length));
94
- }
@@ -1,7 +1,10 @@
1
1
  import { readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import type { Component } from "@earendil-works/pi-tui";
4
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ createPlanPresentationComponent, plainPlanTheme, planText, renderPlanPresentation, statusStyle,
6
+ type DisplayStatus, type PlanPresentation,
7
+ } from "./plan-presentation.js";
5
8
 
6
9
  export const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
7
10
 
@@ -98,27 +101,51 @@ export function workflowBinding(entries: Iterable<{ type: string; customType?: s
98
101
  return binding;
99
102
  }
100
103
 
104
+ function workflowStatus(status: string): DisplayStatus {
105
+ switch (status) {
106
+ case "succeeded": case "completed": return "completed";
107
+ case "in-flight": case "in_progress": case "running": case "diagnosing": return "in_progress";
108
+ case "pending": return "pending";
109
+ case "blocked": return "blocked";
110
+ case "failed": return "failed";
111
+ case "skipped": case "not-applicable": case "cancelled": return "skipped";
112
+ default: return "unknown";
113
+ }
114
+ }
115
+
116
+ function presentRushPlan(plan: RushPlan): PlanPresentation {
117
+ const fleet = ["explore", "combine", "canary", "review", "cicd"].map((stage) => {
118
+ const status = stage === "canary" && !plan.warehouseCanaryRequired
119
+ ? "not-applicable" : plan.fleet[stage]?.status ?? "pending";
120
+ const display = workflowStatus(status);
121
+ const style = statusStyle(display);
122
+ return { color: style.color, text: ` ${stage} ${style.glyph}${display === "unknown" ? ` ${planText(status)}` : ""}` };
123
+ });
124
+ const metadata = [[{ color: "dim", text: `rush-issues · rev ${plan.planRevision} ·` }, ...fleet]];
125
+ if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
126
+ return {
127
+ metadata,
128
+ rows: plan.issues.map((unit) => {
129
+ const status = workflowStatus(unit.status);
130
+ const details = [`${unit.stage} · ${unit.status}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}`];
131
+ if (unit.dependsOn.length) details.push(`after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
132
+ if (unit.note) details.push(unit.note);
133
+ return {
134
+ label: `#${unit.id}`, title: unit.title, status, details,
135
+ ...(status === "unknown" || unit.status === "cancelled" ? { statusLabel: unit.status } : {}),
136
+ };
137
+ }),
138
+ };
139
+ }
140
+
101
141
  export function renderRushPlan(
102
142
  plan: RushPlan,
103
143
  width: number,
104
144
  full = false,
105
145
  fg?: (color: string, value: string) => string,
106
146
  ): string[] {
107
- const completed = plan.issues.filter((unit) => unit.status === "succeeded").length;
108
- const summary = `rev ${plan.planRevision} ${completed}/${plan.issues.length} units${plan.spec?.title ? ` ${plan.spec.title}` : ""}`;
109
- const lines = [fg ? `${fg("warning", "rush-issues")} ${fg("dim", summary)}` : `rush-issues ${summary}`];
110
- const fleet = ["explore", "combine", "canary", "review", "cicd"]
111
- .map((stage) => `${stage}: ${stage === "canary" && !plan.warehouseCanaryRequired ? "n/a" : plan.fleet[stage]?.status ?? "pending"}`);
112
- lines.push(`fleet ${fleet.join(" ")}`);
113
- for (const unit of plan.issues) {
114
- const marker = unit.status === "succeeded" ? "✓" : unit.status === "blocked" ? "!" : unit.status === "pending" ? "○" : "●";
115
- lines.push(`${marker} #${unit.id} ${unit.title} [${unit.stage} · ${unit.status}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}]`);
116
- if (full) {
117
- if (unit.dependsOn.length) lines.push(` after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
118
- if (unit.note) lines.push(` ${unit.note}`);
119
- }
120
- }
121
- return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
147
+ const theme = fg ? { fg } : plainPlanTheme;
148
+ return renderPlanPresentation(presentRushPlan(plan), width, theme, full);
122
149
  }
123
150
 
124
151
  export function createRushPlanComponent(
@@ -126,11 +153,6 @@ export function createRushPlanComponent(
126
153
  onClose: () => void,
127
154
  fg?: (color: string, value: string) => string,
128
155
  ): Component {
129
- return {
130
- render: (width) => renderRushPlan(plan, width, true, fg),
131
- handleInput(data) {
132
- if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
133
- },
134
- invalidate() {},
135
- };
156
+ const theme = fg ? { fg } : plainPlanTheme;
157
+ return createPlanPresentationComponent(presentRushPlan(plan), theme, onClose);
136
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  "dependencies": {
53
53
  "pi-better-background-tasks": "0.2.15",
54
54
  "pi-better-goal": "0.4.0",
55
- "pi-better-plan": "0.3.3",
55
+ "pi-better-plan": "0.3.4",
56
56
  "pi-better-sandbox": "0.3.0",
57
57
  "pi-better-ssh": "0.1.1",
58
58
  "pi-better-subagents": "0.1.31",