pi-goal-x 0.15.0 → 0.16.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/README.md
CHANGED
|
@@ -28,7 +28,7 @@ All core features of [@capyup/pi-goal](https://github.com/capyup/pi-goal) are pr
|
|
|
28
28
|
### Task list & sub-task system
|
|
29
29
|
|
|
30
30
|
- **Structured task breakdown** — the agent can propose a task list via `propose_task_list` (standalone) or `propose_goal_draft` with `tasks` (unified). Both show a Confirm / Continue Chatting dialog. Once confirmed, tasks are displayed in prompts, the widget, serialized to disk, and included in auditor review.
|
|
31
|
-
- **Recursive subtasks** — tasks can have nested sub-tasks via `subtasks?: GoalTask[]` (full recursive type). Subtask depth is controlled globally by `subtaskDepth` in `.pi/goal-settings.json` (default: 1 level). Too-deep subtrees are rejected at proposal.
|
|
31
|
+
- **Recursive subtasks** — tasks can have nested sub-tasks via `subtasks?: GoalTask[]` (full recursive type). Subtask depth is controlled globally by `subtaskDepth` in `.pi/pi-goal-x-settings.json` (default: 1 level). Too-deep subtrees are rejected at proposal.
|
|
32
32
|
- **Lightweight subtasks** — each task has an optional `lightweightSubtasks?: boolean` flag. When true, the parent can complete regardless of subtask status. When false/absent (full subtasks), all subtasks must be individually complete before the parent can close.
|
|
33
33
|
- **Per-task completion** — `complete_task` marks individual tasks done with optional evidence/verificationSummary, and `skip_task` marks tasks as skipped with a required reason. Neither stops the turn, so the agent can continue uninterrupted.
|
|
34
34
|
- **Hierarchical display** — task lists with subtasks render with indentation in prompts (`taskListBlock`, `goalPrompt`, `continuationPrompt`) and in the TUI widget (recursive count, BFS next-pending).
|
package/extensions/goal.ts
CHANGED
|
@@ -105,6 +105,7 @@ import {
|
|
|
105
105
|
} from "./prompts/goal-prompts.ts";
|
|
106
106
|
import { buildGoalRunningNotification } from "./widgets/goal-notifications.ts";
|
|
107
107
|
import { GoalWidgetComponent, type AuditorWidgetProgress } from "./widgets/goal-widget.ts";
|
|
108
|
+
import { showEscapeDialog, type EscapeDialogResult } from "./widgets/goal-escape-dialog.ts";
|
|
108
109
|
|
|
109
110
|
import {
|
|
110
111
|
abortGoalCommandMessage,
|
|
@@ -407,6 +408,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
407
408
|
let auditProgress: AuditorWidgetProgress | null = null;
|
|
408
409
|
let auditAnimationTimer: ReturnType<typeof setInterval> | null = null;
|
|
409
410
|
let auditAbortController: AbortController | null = null;
|
|
411
|
+
let showingEscapeDialog = false;
|
|
410
412
|
|
|
411
413
|
|
|
412
414
|
// Per-turn flags reset in turn_start (#4, C9 fix).
|
|
@@ -959,6 +961,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
959
961
|
// Must return { consume: true } so the TUI doesn't also process the key
|
|
960
962
|
// and abort the running tool execution, which would cascade into pausing
|
|
961
963
|
// the entire goal (agent_end sees ctx.signal?.aborted and calls pauseActiveGoal).
|
|
964
|
+
if (showingEscapeDialog) return undefined;
|
|
962
965
|
if (matchesKey(data, "escape") && auditProgress) {
|
|
963
966
|
abortAudit(ctx);
|
|
964
967
|
return { consume: true };
|
|
@@ -2192,41 +2195,88 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
2192
2195
|
// Clear auditor progress display
|
|
2193
2196
|
stopAuditAnimation();
|
|
2194
2197
|
|
|
2195
|
-
// If the audit was aborted by the user (Esc),
|
|
2196
|
-
//
|
|
2198
|
+
// If the audit was aborted by the user (Esc), show a TUI dialog letting
|
|
2199
|
+
// the user choose: mark complete without audit, or continue working.
|
|
2197
2200
|
if (auditor.error === "Auditor aborted.") {
|
|
2198
|
-
pi.sendMessage<GoalAuditEventDetails>({
|
|
2199
|
-
customType: GOAL_AUDIT_ENTRY,
|
|
2200
|
-
content: [
|
|
2201
|
-
`Goal audit skipped — auditor bypassed (user pressed Escape during audit).`,
|
|
2202
|
-
`Goal remains ${statusLabel(auditTarget)}.`,
|
|
2203
|
-
"",
|
|
2204
|
-
"Use goal_question to ask the user whether to:",
|
|
2205
|
-
" - Mark the goal complete anyway (call complete_goal again)",
|
|
2206
|
-
" - Give feedback on the work so far",
|
|
2207
|
-
" - Continue working toward the goal",
|
|
2208
|
-
].join("\n"),
|
|
2209
|
-
display: true,
|
|
2210
|
-
details: { phase: "skipped", goalId: auditTarget.id, auditor: auditorLabel },
|
|
2211
|
-
});
|
|
2212
2201
|
auditProgress = null;
|
|
2213
2202
|
goalWidgetComponent?.invalidate();
|
|
2214
|
-
syncGoalTools();
|
|
2215
2203
|
updateUI(ctx);
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2204
|
+
|
|
2205
|
+
showingEscapeDialog = true;
|
|
2206
|
+
const userChoice: EscapeDialogResult = await showEscapeDialog(ctx, auditTarget.objective);
|
|
2207
|
+
showingEscapeDialog = false;
|
|
2208
|
+
|
|
2209
|
+
if (userChoice === "complete_without_audit") {
|
|
2210
|
+
// ── Mark complete without audit ────────────────────────────
|
|
2211
|
+
pi.sendMessage<GoalAuditEventDetails>({
|
|
2212
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
2213
|
+
content: `Goal completed — user bypassed audit via Escape.`,
|
|
2214
|
+
display: true,
|
|
2215
|
+
details: { phase: "skipped", goalId: auditTarget.id, auditor: auditorLabel },
|
|
2216
|
+
});
|
|
2217
|
+
try {
|
|
2218
|
+
appendGoalEvent(ctx, {
|
|
2219
|
+
type: "audit_skipped",
|
|
2220
|
+
goalId: auditTarget.id,
|
|
2221
|
+
reason: "user_aborted",
|
|
2222
|
+
provider: settings.provider,
|
|
2223
|
+
model: settings.model,
|
|
2224
|
+
thinkingLevel: settings.thinkingLevel,
|
|
2225
|
+
at: nowIso(),
|
|
2226
|
+
});
|
|
2227
|
+
} catch {
|
|
2228
|
+
// Ledger append failure should not block completion
|
|
2229
|
+
}
|
|
2230
|
+
// Set goal complete in memory (defer archival to turn_end)
|
|
2231
|
+
accountProgress(ctx);
|
|
2232
|
+
state.goal = {
|
|
2233
|
+
...auditTarget,
|
|
2234
|
+
status: "complete",
|
|
2235
|
+
stopReason: "agent",
|
|
2236
|
+
updatedAt: nowIso(),
|
|
2237
|
+
};
|
|
2238
|
+
state.goal = writeActiveGoalFile(ctx, state.goal);
|
|
2239
|
+
pi.appendEntry(STATE_ENTRY, goalDetails(state.goal));
|
|
2240
|
+
turnStoppedFor = state.goal?.id ?? null;
|
|
2241
|
+
resetGetGoalNudgeState(state.goal?.id);
|
|
2242
|
+
syncGoalTools();
|
|
2243
|
+
updateUI(ctx);
|
|
2244
|
+
return {
|
|
2245
|
+
content: [{
|
|
2246
|
+
type: "text",
|
|
2247
|
+
text: [
|
|
2248
|
+
"User chose to mark the goal complete (bypassed audit via Escape).",
|
|
2249
|
+
"",
|
|
2250
|
+
"The goal is complete. Provide a final summary of what was accomplished.",
|
|
2251
|
+
].join("\n"),
|
|
2252
|
+
}],
|
|
2253
|
+
details: goalDetails(state.goal),
|
|
2254
|
+
};
|
|
2255
|
+
} else {
|
|
2256
|
+
// ── Continue working ────────────────────────────────────
|
|
2257
|
+
pi.sendMessage<GoalAuditEventDetails>({
|
|
2258
|
+
customType: GOAL_AUDIT_ENTRY,
|
|
2259
|
+
content: [
|
|
2260
|
+
`Goal audit skipped — user chose to continue working.`,
|
|
2261
|
+
`Goal remains ${statusLabel(auditTarget)}.`,
|
|
2226
2262
|
].join("\n"),
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2263
|
+
display: true,
|
|
2264
|
+
details: { phase: "skipped", goalId: auditTarget.id, auditor: auditorLabel },
|
|
2265
|
+
});
|
|
2266
|
+
syncGoalTools();
|
|
2267
|
+
updateUI(ctx);
|
|
2268
|
+
return {
|
|
2269
|
+
content: [{
|
|
2270
|
+
type: "text",
|
|
2271
|
+
text: [
|
|
2272
|
+
"User chose to continue working (bypassed audit via Escape).",
|
|
2273
|
+
"",
|
|
2274
|
+
"Resume working toward the goal.",
|
|
2275
|
+
].join("\n"),
|
|
2276
|
+
}],
|
|
2277
|
+
details: goalDetails(state.goal),
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2230
2280
|
}
|
|
2231
2281
|
|
|
2232
2282
|
// Show final audit output briefly before clearing
|
|
@@ -2515,7 +2565,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
2515
2565
|
title: Type.String({ description: "Human-readable task title" }),
|
|
2516
2566
|
verificationContract: Type.Optional(Type.String({ description: "Optional verification contract for this task — what evidence is required before marking it complete." })),
|
|
2517
2567
|
lightweightSubtasks: Type.Optional(Type.Boolean({ description: "If true, subtasks are lightweight (no completion enforcement). Default false (full subtasks)." })),
|
|
2518
|
-
subtasks: Type.Optional(Type.Any({ description: "Optional recursive array of sub-tasks (same shape as parent). Nested up to subtaskDepth (default 1, from
|
|
2568
|
+
subtasks: Type.Optional(Type.Any({ description: "Optional recursive array of sub-tasks (same shape as parent). Nested up to subtaskDepth (default 1, from settings)." })),
|
|
2519
2569
|
}), { description: "Array of task objects with id, title, optional subtasks" }),
|
|
2520
2570
|
blockCompletion: Type.Optional(Type.Boolean({ description: "If true, warns when pending tasks remain during complete_goal. Default false." })),
|
|
2521
2571
|
changeSummary: Type.Optional(Type.String({ description: "Optional summary of the task list proposal" })),
|
|
@@ -2532,7 +2582,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
2532
2582
|
// Reject if task lists are disabled via settings
|
|
2533
2583
|
if (loadGoalSettings(ctx.cwd).disableTasks) {
|
|
2534
2584
|
return {
|
|
2535
|
-
content: [{ type: "text", text: "propose_task_list is disabled by
|
|
2585
|
+
content: [{ type: "text", text: "propose_task_list is disabled by settings (disableTasks: true)." }],
|
|
2536
2586
|
details: goalDetails(state.goal),
|
|
2537
2587
|
};
|
|
2538
2588
|
}
|
|
@@ -2669,7 +2719,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
2669
2719
|
reconcileFocusedGoalFromDisk(ctx);
|
|
2670
2720
|
if (loadGoalSettings(ctx.cwd).disableTasks) {
|
|
2671
2721
|
return {
|
|
2672
|
-
content: [{ type: "text", text: "complete_task is disabled by
|
|
2722
|
+
content: [{ type: "text", text: "complete_task is disabled by settings (disableTasks: true)." }],
|
|
2673
2723
|
details: goalDetails(state.goal),
|
|
2674
2724
|
};
|
|
2675
2725
|
}
|
|
@@ -2774,7 +2824,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
|
|
|
2774
2824
|
reconcileFocusedGoalFromDisk(ctx);
|
|
2775
2825
|
if (loadGoalSettings(ctx.cwd).disableTasks) {
|
|
2776
2826
|
return {
|
|
2777
|
-
content: [{ type: "text", text: "skip_task is disabled by
|
|
2827
|
+
content: [{ type: "text", text: "skip_task is disabled by settings (disableTasks: true)." }],
|
|
2778
2828
|
details: goalDetails(state.goal),
|
|
2779
2829
|
};
|
|
2780
2830
|
}
|
|
@@ -140,7 +140,7 @@ The completion auditor is independent and semantic, not a paperwork checklist. I
|
|
|
140
140
|
|
|
141
141
|
Before marking any sub-item as complete (including ✅ checkmarks in your output), verify thoroughly against the goal's success criteria and any verification contract. Only mark items as done when you have concrete evidence — not intent or partial progress.
|
|
142
142
|
|
|
143
|
-
If the user presses Escape
|
|
143
|
+
If the user presses Escape during a completion audit, a TUI dialog appears with "Mark complete without audit" or "Continue working". You will receive a structured message with the user's choice.
|
|
144
144
|
|
|
145
145
|
If you hit a real blocker that you cannot resolve with one more reasonable next step (missing credentials, contradictory spec, file/permission you cannot access, dangerous operation pending user approval, or an unclear Sisyphus-style ordered plan), the CORRECT action is to call pause_goal({reason, suggestedAction?}) with a structured, non-empty reason. pause_goal IS the channel for handing control back to the user — do not substitute a conversational "blocked, please help" summary in your final message and skip the tool call. Without pause_goal, the goal stays "active" and the UI cannot show the blocker. After pause_goal returns, you may add one short user-facing summary, but the tool call comes first.
|
|
146
146
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of the Escape dialog during audit.
|
|
7
|
+
*/
|
|
8
|
+
export type EscapeDialogResult = "complete_without_audit" | "continue_working";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Show a TUI confirmation dialog when the user presses Escape during a completion audit.
|
|
12
|
+
*
|
|
13
|
+
* Presents two choices:
|
|
14
|
+
* - Mark complete without audit (skips auditor, marks goal complete immediately)
|
|
15
|
+
* - Continue working (returns to agent, goal stays active)
|
|
16
|
+
*
|
|
17
|
+
* Returns the user's choice. Escape or Enter on a focused option submits the selection.
|
|
18
|
+
*/
|
|
19
|
+
export async function showEscapeDialog(
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
goalObjective: string,
|
|
22
|
+
): Promise<EscapeDialogResult> {
|
|
23
|
+
if (!ctx.hasUI) {
|
|
24
|
+
// Fallback for headless/RPC mode — return "continue" as the safe default
|
|
25
|
+
return "continue_working";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return await ctx.ui.custom<EscapeDialogResult>(
|
|
29
|
+
(tui: TUI, theme: Theme, _keybindings: unknown, done: (result: EscapeDialogResult) => void): Component => {
|
|
30
|
+
const wasHardwareCursorShown = tui.getShowHardwareCursor();
|
|
31
|
+
tui.setShowHardwareCursor(false);
|
|
32
|
+
|
|
33
|
+
let selectedIndex = 1; // Default: "Continue working" (index 1)
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
|
|
36
|
+
const OPTIONS: Array<{ label: string; value: EscapeDialogResult; description: string }> = [
|
|
37
|
+
{
|
|
38
|
+
label: "Mark complete without audit",
|
|
39
|
+
value: "complete_without_audit",
|
|
40
|
+
description: "Bypass the auditor and mark the goal complete now.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
label: "Continue working",
|
|
44
|
+
value: "continue_working",
|
|
45
|
+
description: "Resume work on the goal. The audit will not run this turn.",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Build a bordered line: fits exactly `innerWidth` visible chars between ││ */
|
|
50
|
+
function line(leftContent: string): string {
|
|
51
|
+
const vis = visibleWidth(leftContent);
|
|
52
|
+
const fill = innerWidth - vis;
|
|
53
|
+
return accent("│") + leftContent + (fill > 0 ? " ".repeat(fill) : "") + accent("│");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
57
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
58
|
+
const warning = (s: string) => theme.fg("warning", s);
|
|
59
|
+
|
|
60
|
+
// ── Component ────────────────────────────────────────────────────
|
|
61
|
+
const component: Component & { dispose?(): void } = {
|
|
62
|
+
dispose() {
|
|
63
|
+
tui.setShowHardwareCursor(wasHardwareCursorShown);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
invalidate(): void {
|
|
67
|
+
// No cached state to invalidate
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
render(width: number): string[] {
|
|
71
|
+
const termWidth = Math.min(width, 80);
|
|
72
|
+
const innerWidth = Math.min(termWidth, 64) - 2; // inner content width between ││
|
|
73
|
+
const horizLine = "─".repeat(innerWidth);
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
const p = " "; // left padding inside the border
|
|
76
|
+
|
|
77
|
+
// ── Header ────────────────────────────────────────────────
|
|
78
|
+
lines.push(accent(`┌${horizLine}┐`));
|
|
79
|
+
lines.push(line(p + theme.bold("Audit interrupted by Escape") + dim(" (continue = default)")));
|
|
80
|
+
const truncatedObjective = truncateToWidth(goalObjective, innerWidth - 14, "…");
|
|
81
|
+
lines.push(line(p + dim("Goal: ") + dim(truncatedObjective)));
|
|
82
|
+
lines.push(accent(`├${horizLine}┤`));
|
|
83
|
+
|
|
84
|
+
// ── Options ─────────────────────────────────────────────
|
|
85
|
+
OPTIONS.forEach((opt, i) => {
|
|
86
|
+
const isSelected = i === selectedIndex && !cancelled;
|
|
87
|
+
const marker = isSelected ? "▸ " : " ";
|
|
88
|
+
const label = isSelected ? warning(opt.label) : opt.label;
|
|
89
|
+
const truncLabel = truncateToWidth(label, innerWidth - 6, "…");
|
|
90
|
+
lines.push(line(p + marker + truncLabel));
|
|
91
|
+
if (isSelected && opt.description) {
|
|
92
|
+
const desc = dim(opt.description);
|
|
93
|
+
const truncDesc = truncateToWidth(desc, innerWidth - 10, "…");
|
|
94
|
+
lines.push(line(p + " ".repeat(4) + truncDesc));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── Footer ───────────────────────────────────────────────
|
|
99
|
+
lines.push(accent(`├${horizLine}┤`));
|
|
100
|
+
const footerText = dim("Enter to select · ↑↓ to navigate · Esc = continue working");
|
|
101
|
+
const truncFooter = truncateToWidth(footerText, innerWidth - 2, "…");
|
|
102
|
+
lines.push(line(p + truncFooter));
|
|
103
|
+
lines.push(accent(`└${horizLine}┘`));
|
|
104
|
+
|
|
105
|
+
return lines;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
handleInput(data: string): void {
|
|
109
|
+
if (matchesKey(data, "up")) {
|
|
110
|
+
selectedIndex = (selectedIndex - 1 + OPTIONS.length) % OPTIONS.length;
|
|
111
|
+
tui.requestRender();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (matchesKey(data, "down")) {
|
|
115
|
+
selectedIndex = (selectedIndex + 1) % OPTIONS.length;
|
|
116
|
+
tui.requestRender();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (matchesKey(data, "enter")) {
|
|
120
|
+
cancelled = false;
|
|
121
|
+
done(OPTIONS[selectedIndex].value);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (matchesKey(data, "escape")) {
|
|
125
|
+
cancelled = true;
|
|
126
|
+
done("continue_working");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return component;
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
overlay: true,
|
|
136
|
+
overlayOptions: {
|
|
137
|
+
anchor: "center",
|
|
138
|
+
width: "70%",
|
|
139
|
+
minWidth: 50,
|
|
140
|
+
maxHeight: "50%",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-x",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Goal mode extension for pi: persistent long-running objectives, /goal-set drafting, Sisyphus prompt style, autoContinue, and an above-editor status overlay. Fork of @capyup/pi-goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "pi-goal-x contributors",
|