@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +219 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +321 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,102 @@
1
+ import type {GoalContinuation} from "./goal-scheduler-state.ts";
2
+
3
+ export interface TaskProgressInput {
4
+ task_id: string;
5
+ status: "start" | "complete" | "skipped" | "pending";
6
+ evidence?: string;
7
+ reason?: string;
8
+ }
9
+
10
+ type Result = {ok: true; updates: TaskProgressInput[]; batch: boolean} | {ok: false; message: string};
11
+ const fields = ["task_id", "status", "evidence", "reason"] as const;
12
+ const statuses = new Set(["start", "complete", "skipped", "pending"]);
13
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
14
+
15
+ /** Normalize transport noise without discarding a meaningful task instruction. */
16
+ export function normalizeTaskProgress(input: unknown): Result {
17
+ const fail = (message: string): Result => ({ok: false, message});
18
+ if (!object(input)) return fail("Provide task_id and status, or an updates batch.");
19
+ const normalize = (raw: Record<string, unknown>): Partial<TaskProgressInput> | undefined => {
20
+ const result: Record<string, string> = {};
21
+ for (const field of fields) {
22
+ const value = raw[field];
23
+ if (value === undefined) continue;
24
+ if (typeof value !== "string") return;
25
+ if (value.trim()) result[field] = value.trim();
26
+ }
27
+ if (result.status && !statuses.has(result.status)) return;
28
+ return result as Partial<TaskProgressInput>;
29
+ };
30
+ const complete = (value: Partial<TaskProgressInput>): value is TaskProgressInput => !!value.task_id && !!value.status;
31
+ const root = normalize(input);
32
+ if (!root) return fail("Task fields must be strings; status must be start, complete, skipped, or pending.");
33
+ if (input.updates === undefined || (Array.isArray(input.updates) && input.updates.length === 0)) {
34
+ return complete(root) ? {ok: true, updates: [root], batch: false}
35
+ : fail("Provide task_id and status, or a non-empty updates batch.");
36
+ }
37
+ if (!Array.isArray(input.updates) || input.updates.length > 100) return fail("updates must contain 1–100 valid task updates.");
38
+ const updates: TaskProgressInput[] = [];
39
+ for (const entry of input.updates) {
40
+ if (!object(entry) || Object.keys(entry).some(key => !fields.includes(key as typeof fields[number]))) return fail("updates must contain 1–100 valid task updates.");
41
+ const normalized = normalize(entry);
42
+ if (!normalized || !complete(normalized)) return fail("updates must contain 1–100 valid task updates.");
43
+ updates.push(normalized);
44
+ }
45
+ if (!updates.some(entry => fields.every(field => root[field] === undefined || root[field] === entry[field]))) {
46
+ return fail("Top-level task fields conflict with updates. Put each instruction in updates and omit the top-level task fields.");
47
+ }
48
+ return {ok: true, updates, batch: true};
49
+ }
50
+
51
+ type GoalUpdatePlan =
52
+ | {action: "complete"; completionSummary?: string}
53
+ | {action: "blocked"; reason: string; attemptedActions: string[]}
54
+ | {action: "paused"; reason: string; suggestedAction?: string}
55
+ | {action: "continuation"; continuation: GoalContinuation};
56
+
57
+ /** Classify once before the caller flushes or changes any state. */
58
+ export function planUpdateGoal(input: unknown): {ok: true; plan: GoalUpdatePlan} | {ok: false; message: string} {
59
+ const fail = (message: string) => ({ok: false as const, message});
60
+ const keys = ["status", "continuation", "reason", "attempted_actions", "suggested_action", "completion_summary"];
61
+ if (!object(input) || Object.keys(input).some(key => !keys.includes(key))) return fail("Provide a lifecycle status or continuation using the declared fields.");
62
+ const strings: Record<string, string> = {};
63
+ for (const key of ["status", "reason", "suggested_action", "completion_summary"]) {
64
+ const value = input[key];
65
+ if (value === undefined) continue;
66
+ if (typeof value !== "string") return fail(`${key} must be a string.`);
67
+ if (value.trim()) strings[key] = value;
68
+ }
69
+ const status = strings.status;
70
+ if (status && !["complete", "blocked", "paused"].includes(status)) return fail("status must be complete, blocked, or paused.");
71
+ const rawActions = input.attempted_actions;
72
+ if (rawActions !== undefined && (!Array.isArray(rawActions) || rawActions.length > 8 || rawActions.some(a => typeof a !== "string" || a.length > 240))) return fail("attempted_actions must contain at most eight strings of at most 240 characters.");
73
+ const attemptedActions: string[] = (rawActions ?? []).filter((action: string) => action.trim());
74
+ let continuation: GoalContinuation | undefined;
75
+ if (input.continuation !== undefined) {
76
+ const raw = input.continuation;
77
+ if (!object(raw)) return fail("continuation must be a ready or wait object.");
78
+ if (raw.kind === "ready" && Object.keys(raw).length === 1) {
79
+ if (!status) continuation = {kind: "ready"};
80
+ } else if (raw.kind === "wait" && Object.keys(raw).every(key => ["kind", "reason", "deadline", "wait_id", "polling"].includes(key))) {
81
+ if (typeof raw.reason !== "string" || !raw.reason.trim() || raw.reason.length > 2000 || typeof raw.deadline !== "string" || (raw.wait_id !== undefined && typeof raw.wait_id !== "string")) return fail("continuation.wait requires reason and deadline strings; wait_id must be a string.");
82
+ let polling: {interval_seconds: number; max_checks: number} | undefined;
83
+ if (raw.polling !== undefined) {
84
+ const p = raw.polling;
85
+ if (!object(p) || Object.keys(p).some(key => !["interval_seconds", "max_checks"].includes(key)) || typeof p.interval_seconds !== "number" || !Number.isSafeInteger(p.interval_seconds) || p.interval_seconds < 1 || p.interval_seconds > 2147483 || typeof p.max_checks !== "number" || !Number.isSafeInteger(p.max_checks) || p.max_checks < 1) return fail("continuation.polling requires positive whole interval_seconds and max_checks.");
86
+ polling = {interval_seconds: p.interval_seconds, max_checks: p.max_checks};
87
+ }
88
+ continuation = {kind: "wait", reason: raw.reason, deadline: raw.deadline, ...(raw.wait_id === undefined ? {} : {wait_id: raw.wait_id}), ...(polling ? {polling} : {})};
89
+ } else return fail("continuation must be payload-free ready or a valid wait; unexpected continuation fields are not filler.");
90
+ }
91
+ if (status && continuation) return fail("status conflicts with continuation.wait. Choose one action; a payload-free ready may accompany status as filler.");
92
+ if (!status && !continuation) return fail("Provide one lifecycle status or continuation.");
93
+ const allowed = status === "complete" ? ["completion_summary"] : status === "blocked" ? ["reason", "attempted_actions"] : status === "paused" ? ["reason", "suggested_action"] : [];
94
+ const supplied = [...Object.keys(strings).filter(key => key !== "status"), ...(attemptedActions.length ? ["attempted_actions"] : [])];
95
+ const conflicts = supplied.filter(key => !allowed.includes(key));
96
+ if (conflicts.length) return fail(`${status ? `status=${status}` : "continuation"} conflicts with ${conflicts.join(", ")}. Use only the fields for that action; empty strings and arrays may remain filler.`);
97
+ if ((status === "blocked" || status === "paused") && !strings.reason) return fail(`update_goal({ status: "${status}" }) requires a "reason" describing why work cannot continue.`);
98
+ if (continuation) return {ok: true, plan: {action: "continuation", continuation}};
99
+ if (status === "blocked") return {ok: true, plan: {action: "blocked", reason: strings.reason!, attemptedActions}};
100
+ if (status === "paused") return {ok: true, plan: {action: "paused", reason: strings.reason!, suggestedAction: strings.suggested_action}};
101
+ return {ok: true, plan: {action: "complete", completionSummary: strings.completion_summary}};
102
+ }
@@ -0,0 +1,73 @@
1
+ export const CREATE_GOAL_TOOL_NAME = "create_goal";
2
+ export const GET_GOAL_TOOL_NAME = "get_goal";
3
+ export const UPDATE_GOAL_TOOL_NAME = "update_goal";
4
+ export const SET_GOAL_TASKS_TOOL_NAME = "set_goal_tasks";
5
+ export const UPDATE_GOAL_TASK_TOOL_NAME = "update_goal_task";
6
+ export const QUESTION_TOOL_NAME = "goal_question";
7
+ export const QUESTIONNAIRE_TOOL_NAME = "goal_questionnaire";
8
+ export const PROPOSE_DRAFT_TOOL_NAME = "propose_goal_draft";
9
+
10
+ /** The stable core model surface: three tools, installed without phase-dependent sync. */
11
+ export const CORE_GOAL_TOOL_NAMES = [CREATE_GOAL_TOOL_NAME, GET_GOAL_TOOL_NAME, UPDATE_GOAL_TOOL_NAME] as const;
12
+
13
+ /** The two consolidated task tools advertised when tasks are enabled. */
14
+ export const TASK_TOOL_NAMES = [SET_GOAL_TASKS_TOOL_NAME, UPDATE_GOAL_TASK_TOOL_NAME] as const;
15
+
16
+ /** Fixed task-enabled profile: all five registered goal tools. */
17
+ export const FIVE_GOAL_TOOLS = [...CORE_GOAL_TOOL_NAMES, ...TASK_TOOL_NAMES] as const;
18
+
19
+ /** Fixed task-disabled profile: the three core tools. */
20
+ export const CORE_GOAL_TOOLS = CORE_GOAL_TOOL_NAMES;
21
+
22
+ /** Always advertised; executors require an active user-started draft. */
23
+ export const DRAFTING_GOAL_TOOLS = [
24
+ QUESTION_TOOL_NAME,
25
+ QUESTIONNAIRE_TOOL_NAME,
26
+ PROPOSE_DRAFT_TOOL_NAME,
27
+ ] as const;
28
+
29
+ /** Every goal tool this extension registers (used by installGoalToolProfile). */
30
+ export const ALL_REGISTERED_GOAL_TOOLS = [...FIVE_GOAL_TOOLS, ...DRAFTING_GOAL_TOOLS] as const;
31
+
32
+ /**
33
+ * Legacy work-tool classification plus the common host work tools. Scheduling
34
+ * does not require membership: idle pacing uses the narrower progress set.
35
+ */
36
+ export const GOAL_WORK_TOOL_NAMES = [
37
+ UPDATE_GOAL_TOOL_NAME,
38
+ SET_GOAL_TASKS_TOOL_NAME,
39
+ UPDATE_GOAL_TASK_TOOL_NAME,
40
+ CREATE_GOAL_TOOL_NAME,
41
+ GET_GOAL_TOOL_NAME,
42
+ "write",
43
+ "edit",
44
+ "bash",
45
+ "read",
46
+ "grep",
47
+ "find",
48
+ "ls",
49
+ ] as const;
50
+
51
+ /**
52
+ * The subset of GOAL_WORK_TOOL_NAMES that indicates actual progress (excludes
53
+ * read-only surface tools such as get_goal and create_goal).
54
+ */
55
+ export const GOAL_PROGRESS_TOOL_NAMES = [
56
+ UPDATE_GOAL_TOOL_NAME,
57
+ UPDATE_GOAL_TASK_TOOL_NAME,
58
+ "write",
59
+ "edit",
60
+ "bash",
61
+ "read",
62
+ "grep",
63
+ "find",
64
+ "ls",
65
+ ] as const;
66
+
67
+ /** Tools the model may still call on a stopped turn (state reads only). */
68
+ export const POST_STOP_ALLOWED_TOOLS = ["get_goal"] as const;
69
+
70
+ /** Keep the cached tool/rules prefix independent of goal and draft state. */
71
+ export function applicableGoalTools(tasksEnabled: boolean): string[] {
72
+ return [...(tasksEnabled ? FIVE_GOAL_TOOLS : CORE_GOAL_TOOLS), ...DRAFTING_GOAL_TOOLS];
73
+ }
@@ -0,0 +1,16 @@
1
+ import type { GoalCore } from "./goal-state.ts";
2
+ import { registerCoreTools } from "./goal-core-tools.ts";
3
+ import { registerTaskTools } from "./goal-task-tools.ts";
4
+ import { registerDraftingTools } from "./goal-drafting.ts";
5
+ import { runGoalCompletionFlow } from "./goal-completion.ts";
6
+
7
+ /**
8
+ * Registration composition only (Stage 5 module split): the three core tools,
9
+ * the two task tools, and the shared completion flow live in their own
10
+ * modules; goal.ts calls this installer once at extension load.
11
+ */
12
+ export function registerGoalTools(core: GoalCore): void {
13
+ registerCoreTools(core, { runGoalCompletionFlow });
14
+ registerTaskTools(core);
15
+ registerDraftingTools(core);
16
+ }
@@ -0,0 +1,455 @@
1
+ import { GOAL_KEY_ACTIONS, GOAL_COMMAND_INPUTS, bindingKeys, keyActionBinding, type GoalCommandId } from "./goal-controls.ts";
2
+ import { matchesKey, getKeybindings } from "@earendil-works/pi-tui";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { cloneGoal, createGoal, nowIso, type GoalTask } from "./goal-record.ts";
5
+ import { checkSubtasksComplete, findTaskInTree } from "./goal-policy.ts";
6
+ import { loadGoalSettings } from "./goal-settings.ts";
7
+ import { serializeGoalFile } from "./storage/goal-files.ts";
8
+ import type { AuditorWidgetProgress } from "./widgets/goal-widget.ts";
9
+ import type { GoalCore } from "./goal-state.ts";
10
+
11
+ const DEBUG_GOALS_DIR = ".pi/goals/debug";
12
+ const KEY_ACTION_BY_PATH = new Map(GOAL_KEY_ACTIONS.map(action => [action.path.slice(1).join("."), action]));
13
+ const COMMAND_KEYS = GOAL_KEY_ACTIONS.filter(action => action.path[1] === "commands");
14
+ const EXPANDED_NAVIGATION = [["up", "up"], ["down", "down"], ["pageUp", "pageUp"], ["pageDown", "pageDown"], ["first", "home"], ["last", "end"]] as const;
15
+ const COMPACT_NAVIGATION = [["scrollUp", "up"], ["scrollDown", "down"], ["scrollPageUp", "pageUp"], ["scrollPageDown", "pageDown"], ["scrollFirst", "home"], ["scrollLast", "end"]] as const;
16
+
17
+
18
+ function isKittyRepeatOrRelease(data: string): boolean {
19
+ return data.startsWith("\x1b[") && /^[0-9:;]+:[23][u~ABCDHF]$/.test(data.slice(2));
20
+ }
21
+
22
+ /**
23
+ * Terminal input keybindings (Escape pause/abort-audit, Ctrl+Shift+T dashboard
24
+ * overlay, and the hidden debug-mode bindings) plus the debug goal/task/audit
25
+ * helpers. Re-registered at session start/tree navigation; the handler reads
26
+ * live state through the core at event time.
27
+ */
28
+ let debugGoalCounter = 0;
29
+ let debugMockAuditTimer: ReturnType<typeof setInterval> | null = null;
30
+
31
+ /** Debug helpers are inert unless PI_GOAL_DEBUG is set (P1-13). */
32
+ function isDebugEnabled(): boolean {
33
+ const value = process.env.PI_GOAL_DEBUG;
34
+ return value === "true" || value === "1";
35
+ }
36
+
37
+ /** Render task lines for the debug proposal dialog. */
38
+ function formatModeLabelDebug(sisyphus: boolean): string {
39
+ return sisyphus ? "Sisyphus (prompt/criteria style)" : "Normal goal";
40
+ }
41
+
42
+ function formatPrefixedLinesDebug(content: string): string[] {
43
+ const lines: string[] = [];
44
+ for (const rawLine of content.split("\n")) {
45
+ const trimmed = rawLine.trim();
46
+ if (!trimmed) continue;
47
+ if (trimmed.startsWith("│")) {
48
+ lines.push(rawLine);
49
+ } else {
50
+ lines.push(`│ ${rawLine}`);
51
+ }
52
+ }
53
+ return lines;
54
+ }
55
+
56
+ function formatSectionDebug(title: string, content: string): string[] {
57
+ const body = formatPrefixedLinesDebug(content);
58
+ return ["", `─── ${title} ───`, "", ...body];
59
+ }
60
+
61
+
62
+ /**
63
+ * F3: toggle one task through the goal-service mutation boundary with the
64
+ * same gates as update_goal_task: completing a pending task requires its
65
+ * subtasks complete first and (unless contracts are disabled) evidence when
66
+ * it carries a verification contract — the evidence is collected through the
67
+ * UI input dialog; completing a complete task reopens it to pending (a
68
+ * deliberate TUI-only relaxation of the tool surface's immutability, for
69
+ * explicit human corrections).
70
+ */
71
+ export async function toggleTaskViaService(core: GoalCore, ctx: ExtensionContext, goalId: string, taskId: string): Promise<{ ok: boolean; message?: string }> {
72
+ const goal = core.goalsById.get(goalId);
73
+ if (!goal) return { ok: false, message: `Goal ${goalId} not found in this session.` };
74
+ if (goal.status !== "active") return { ok: false, message: `Task updates apply only to an active goal (current status: ${goal.status}).` };
75
+ if (!goal.taskList) return { ok: false, message: "The goal has no task list." };
76
+ const settings = loadGoalSettings(ctx.cwd);
77
+ const task = findTaskInTree(goal.taskList.tasks, taskId);
78
+ if (!task) return { ok: false, message: `Task "${taskId}" not found.` };
79
+ const now = nowIso();
80
+
81
+ if (task.status === "pending") {
82
+ let evidence: string | undefined;
83
+ if (!settings.disableContracts && task.verificationContract) {
84
+ const input = await ctx.ui.input(`Evidence for task ${taskId}`, "");
85
+ if (input === undefined || !input.trim()) {
86
+ return { ok: false, message: `Task "${taskId}" has a verification contract; provide evidence to complete it.` };
87
+ }
88
+ evidence = input.trim().slice(0, 200);
89
+ }
90
+ const subtaskGate = checkSubtasksComplete(task);
91
+ if (subtaskGate) return { ok: false, message: subtaskGate };
92
+ const result = core.goalService.updateTask(ctx, {
93
+ focusToken: core.focusedOperationToken(goalId),
94
+ taskId,
95
+ validate: (t) => {
96
+ if (t.status === "complete") return { ok: false, message: `Task "${taskId}" is already complete.` };
97
+ return { ok: true };
98
+ },
99
+ update: (t) => ({ ...t, status: "complete" as const, completedAt: now, evidence }),
100
+ ledger: (written) => [{ type: "task_complete", goalId: written.id, taskId, evidence, at: written.updatedAt }],
101
+ });
102
+ if (!result.ok) return { ok: false, message: result.message };
103
+ return { ok: true };
104
+ }
105
+
106
+ if (task.status === "complete") {
107
+ const result = core.goalService.updateTask(ctx, {
108
+ focusToken: core.focusedOperationToken(goalId),
109
+ taskId,
110
+ validate: () => ({ ok: true }),
111
+ update: (t) => ({ ...t, status: "pending" as const, completedAt: undefined, evidence: undefined }),
112
+ ledger: (written) => [{ type: "task_reopened", goalId: written.id, taskId, at: written.updatedAt }],
113
+ });
114
+ if (!result.ok) return { ok: false, message: result.message };
115
+ return { ok: true };
116
+ }
117
+
118
+ return { ok: false, message: `Task "${taskId}" is skipped; change it with /goal-tweak or update_goal_task.` };
119
+ }
120
+
121
+ export function syncTerminalInputPause(core: GoalCore, ctx: ExtensionContext): void {
122
+ if (!ctx.hasUI) return;
123
+ core.terminalInputUnsubscribe?.();
124
+ const goalCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
125
+ core.terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
126
+ // The active dialog owns every key, including key capture and Escape.
127
+ if (core.goalModalDepth > 0) return undefined;
128
+ if (core.goalWidgetComponentRef.current?.isMainEditorFocused?.() === false) return undefined;
129
+ const settings = loadGoalSettings(goalCwd);
130
+ const matches = (path: string): boolean => {
131
+ const action = KEY_ACTION_BY_PATH.get(path)!;
132
+ return bindingKeys(keyActionBinding(settings, action)).some(key => matchesKey(data, key));
133
+ };
134
+ if (isKittyRepeatOrRelease(data)) {
135
+ const applicable = GOAL_KEY_ACTIONS.filter(action => action.context === "global" ||
136
+ (core.auditProgress ? action.context === "audit" : core.isDashboardExpanded() ? action.context === "expanded" : action.context === "compact"));
137
+ return applicable.some(action => bindingKeys(keyActionBinding(settings, action)).some(key => matchesKey(data, key))) ? { consume: true } : undefined;
138
+ }
139
+ if (core.auditProgress && matches("interruptAudit")) { core.abortAudit(ctx); return { consume: true }; }
140
+ if (core.isDashboardExpanded() && matches("dashboard.collapse")) { core.toggleDashboardExpanded(); return { consume: true }; }
141
+ if (matches("pause") && ((core.state.goal?.status === "active" && core.state.goal.autoContinue) || (core.state.goal?.status === "paused" && !ctx.isIdle()))) {
142
+ core.interruption.pause(ctx);
143
+ // Preserve Pi's ordinary Escape handling for the default combined stop.
144
+ return matchesKey(data, "escape") && settings.pauseOrder === "together" ? undefined : { consume: true };
145
+ }
146
+ // Follow Pi's configured interrupt key.
147
+ let hostInterrupt = matchesKey(data, "escape");
148
+ try {
149
+ const manager = getKeybindings();
150
+ const keys = manager.getKeys("app.interrupt");
151
+ if (keys.length) hostInterrupt = keys.some(key => matchesKey(data, key));
152
+ } catch { /* Embeddings without an app keymap retain Escape. */ }
153
+ if (hostInterrupt && !matches("pause") && core.state.goal?.status === "active" && core.state.goal.autoContinue) {
154
+ core.interruption.interruptTurn(ctx); return { consume: true };
155
+ }
156
+ if (matches("dashboard.toggleExpand")) { core.toggleDashboardExpanded(); return { consume: true }; }
157
+ if (matches("toggleAuditor")) {
158
+ core.toggleGoalAuditor(ctx); core.goalWidgetComponentRef.current?.invalidate(); return { consume: true };
159
+ }
160
+ if (core.isDashboardExpanded()) {
161
+ for (const [name, key] of EXPANDED_NAVIGATION) if (matches(`dashboard.${name}`) && core.goalWidgetComponentRef.current?.handleNavigationKey(key)) return { consume: true };
162
+ } else {
163
+ for (const [name, key] of COMPACT_NAVIGATION) if (matches(`dashboard.${name}`) && core.goalWidgetComponentRef.current?.handleCompactScrollKey(key)) return { consume: true };
164
+ }
165
+ for (const action of COMMAND_KEYS) {
166
+ if (!bindingKeys(keyActionBinding(settings, action)).some(key => matchesKey(data, key))) continue;
167
+ const id = action.path[2] as GoalCommandId;
168
+ const revision = core.focusRevision;
169
+ const owner = core.sessionOwner;
170
+ void (async () => {
171
+ core.enterGoalModal();
172
+ try {
173
+ const prompt = GOAL_COMMAND_INPUTS[id];
174
+ const args = prompt ? await ctx.ui.input(prompt) : "";
175
+ if (core.focusRevision !== revision || args === undefined || (prompt && !args.trim())) return;
176
+ await core.invokeGoalCommand?.(id, args, ctx);
177
+ } finally { if (core.sessionOwner === owner) core.exitGoalModal(); }
178
+ })().catch(error => {if (core.sessionOwner === owner) ctx.ui.notify(String(error), "error");});
179
+ return { consume: true };
180
+ }
181
+
182
+ // Debug keybindings are inert unless PI_GOAL_DEBUG is set (P1-13).
183
+ if (!isDebugEnabled()) return undefined;
184
+
185
+ // ── Debug mode keybindings (hidden from normal view) ────────────────
186
+
187
+ // Ctrl+Shift+X — toggle debug mode on/off
188
+ if (matchesKey(data, "ctrl+shift+x")) {
189
+ core.debugMode = !core.debugMode;
190
+ ctx.ui.notify(core.debugMode ? "Debug mode ON" : "Debug mode OFF", "info");
191
+ core.goalWidgetComponentRef.current?.invalidate();
192
+ return { consume: true };
193
+ }
194
+
195
+ // Only process the following debug keybindings when debug mode is active
196
+ if (!core.debugMode) return undefined;
197
+
198
+ // Ctrl+Shift+N — create a test goal
199
+ if (matchesKey(data, "ctrl+shift+n")) {
200
+ createDebugGoal(ctx);
201
+ return { consume: true };
202
+ }
203
+
204
+ // Ctrl+Shift+T — inject sample tasks into current goal
205
+ if (matchesKey(data, "ctrl+shift+t")) {
206
+ injectDebugTasks(ctx);
207
+ return { consume: true };
208
+ }
209
+
210
+ // Ctrl+Shift+R — start mock completion audit
211
+ if (matchesKey(data, "ctrl+shift+r")) {
212
+ startMockAudit(ctx);
213
+ return { consume: true };
214
+ }
215
+
216
+ // Ctrl+Shift+O — open proposal dialog with sample data
217
+ if (matchesKey(data, "ctrl+shift+o")) {
218
+ openDebugProposal(ctx);
219
+ return { consume: true };
220
+ }
221
+
222
+ return undefined;
223
+ });
224
+
225
+ /** Toggle a test goal: create (first press) or remove (second press) */
226
+ function createDebugGoal(ctx: ExtensionContext): void {
227
+ if (!isDebugEnabled()) return;
228
+ const prev = core.state.goal;
229
+ if (prev && prev.id.startsWith("debug-")) {
230
+ // Toggle off — remove debug goal entirely (no archive, full delete)
231
+ const filePath = `${DEBUG_GOALS_DIR}/debug_goal.md`;
232
+ core.goalService.removeDebugFile(ctx, filePath);
233
+ const prevId = prev.id;
234
+ core.state.goal = null;
235
+ if (core.focusedGoalId === prevId) {
236
+ core.goalsById.delete(prevId);
237
+ core.assignFocusedGoalId(null);
238
+ }
239
+ core.clearStoppedRuntimeState();
240
+ core.updateUI(ctx);
241
+ ctx.ui.notify("Debug goal removed", "info");
242
+ return;
243
+ }
244
+
245
+ // Toggle on — create a new debug goal, write to temp dir
246
+ debugGoalCounter++;
247
+ const goal = createGoal({
248
+ objective: "=== Goal ===\nObjective: Debug test goal",
249
+ autoContinue: true,
250
+ sisyphus: false,
251
+ });
252
+ goal.id = `debug-${nowIso().replace(/[:.]/g, "-")}-${debugGoalCounter}`;
253
+ goal.createdAt = nowIso();
254
+ goal.updatedAt = nowIso();
255
+ goal.activePath = `${DEBUG_GOALS_DIR}/debug_goal.md`;
256
+ core.goalService.writeDebugFile(ctx, goal.activePath, serializeGoalFile(goal));
257
+ core.setGoal(goal, ctx, false, "created"); // no persist (we already wrote the file)
258
+ ctx.ui.notify(`Debug goal created: ${goal.id}`, "info");
259
+ }
260
+
261
+ /** Inject 3-4 sample tasks into the current goal */
262
+ function injectDebugTasks(ctx: ExtensionContext): void {
263
+ if (!isDebugEnabled()) return;
264
+ if (!core.state.goal) {
265
+ ctx.ui.notify("No goal to inject tasks into; create one first (Ctrl+Shift+N)", "warning");
266
+ return;
267
+ }
268
+ const now = nowIso();
269
+ const tasks: GoalTask[] = [
270
+ {
271
+ id: "t1",
272
+ title: "Set up project structure",
273
+ status: "complete",
274
+ completedAt: now,
275
+ subtasks: [
276
+ { id: "t1a", title: "Initialize repo", status: "complete", completedAt: now },
277
+ { id: "t1b", title: "Add build config", status: "pending" },
278
+ ],
279
+ },
280
+ {
281
+ id: "t2",
282
+ title: "Implement core feature",
283
+ status: "pending",
284
+ },
285
+ {
286
+ id: "t3",
287
+ title: "Write tests",
288
+ status: "pending",
289
+ },
290
+ ];
291
+ const next = cloneGoal(core.state.goal);
292
+ next.taskList = { tasks, blockCompletion: false, proposedAt: now };
293
+ next.updatedAt = now;
294
+ core.setGoal(next, ctx);
295
+ ctx.ui.notify("Sample tasks injected (3 tasks, 1 completed)", "info");
296
+ }
297
+
298
+ /** Stop mock audit timer if running */
299
+ function stopMockAuditTimer(): void {
300
+ if (debugMockAuditTimer) {
301
+ clearInterval(debugMockAuditTimer);
302
+ debugMockAuditTimer = null;
303
+ }
304
+ }
305
+
306
+ /** Start a mock completion audit that transitions through phases */
307
+ function startMockAudit(ctx: ExtensionContext): void {
308
+ if (!isDebugEnabled()) return;
309
+ stopMockAuditTimer();
310
+ const startedAt = Date.now();
311
+ const phases: { phase: AuditorWidgetProgress["phase"]; atMs: number; label: string; percentage: number }[] = [
312
+ { phase: "tool_executing", atMs: 0, label: "Checking test results...", percentage: 10 },
313
+ { phase: "tool_executing", atMs: 800, label: "Verifying requirements...", percentage: 30 },
314
+ { phase: "thinking", atMs: 1800, label: "Evaluating completion criteria...", percentage: 60 },
315
+ { phase: "producing_report", atMs: 3200, label: "Writing audit report...", percentage: 85 },
316
+ { phase: "done", atMs: 4800, label: "Audit complete", percentage: 100 },
317
+ ];
318
+ core.auditProgress = {
319
+ recentOutput: [],
320
+ phase: "running",
321
+ elapsedMs: 0,
322
+ };
323
+ core.goalWidgetComponentRef.current?.invalidate();
324
+
325
+ debugMockAuditTimer = setInterval(() => {
326
+ const elapsed = Date.now() - startedAt;
327
+ let currentPhase: AuditorWidgetProgress["phase"] = "done";
328
+ let currentLabel = "Audit complete";
329
+ let currentPct = 100;
330
+ for (let i = phases.length - 1; i >= 0; i--) {
331
+ if (elapsed >= phases[i]!.atMs) {
332
+ currentPhase = phases[i]!.phase;
333
+ currentLabel = phases[i]!.label;
334
+ currentPct = phases[i]!.percentage;
335
+ break;
336
+ }
337
+ }
338
+ core.auditProgress = {
339
+ phase: currentPhase,
340
+ label: currentLabel,
341
+ percentage: currentPct,
342
+ elapsedMs: elapsed,
343
+ recentOutput: core.auditProgress?.recentOutput ?? [],
344
+ };
345
+ if (currentPhase === "done") {
346
+ if (core.auditProgress) core.auditProgress.recentOutput = [
347
+ "✓ All requirements verified",
348
+ "✓ Tests pass: 310/310",
349
+ "✓ No truncation cap remaining",
350
+ ];
351
+ stopMockAuditTimer();
352
+ // Auto-clear audit after 3 more seconds
353
+ setTimeout(() => {
354
+ core.auditProgress = null;
355
+ core.goalWidgetComponentRef.current?.invalidate();
356
+ }, 3000);
357
+ }
358
+ core.goalWidgetComponentRef.current?.invalidate();
359
+ }, 100);
360
+ debugMockAuditTimer.unref?.();
361
+ }
362
+
363
+ function renderDebugTaskLines(tasks: GoalTask[], indent = 0): string[] {
364
+ const prefix = " ".repeat(indent);
365
+ const lines: string[] = [];
366
+ for (const t of tasks) {
367
+ const marker = t.status === "complete" ? "[x]" : t.status === "skipped" ? "[~]" : "[ ]";
368
+ const lw = t.lightweightSubtasks ? " (lightweight)" : "";
369
+ lines.push(`${prefix}${marker} ${t.id}: ${t.title}${lw}`);
370
+ if (t.subtasks && t.subtasks.length > 0) {
371
+ lines.push(...renderDebugTaskLines(t.subtasks, indent + 1));
372
+ }
373
+ }
374
+ return lines;
375
+ }
376
+
377
+ /** Show the proposal dialog using real goal state — no hardcoded text */
378
+ function openDebugProposal(ctx: ExtensionContext): void {
379
+ if (!isDebugEnabled()) return;
380
+ // Build a fresh debug goal + tasks in memory for the dialog
381
+ debugGoalCounter++;
382
+ const goal = createGoal({
383
+ objective: `=== Goal ===
384
+ Objective: Add collapsible task sections to the goal widget so large task lists are navigable
385
+
386
+ Success criteria:
387
+ - Tasks are grouped into sections by status (pending, active, complete) with visible section headers
388
+ - Each section header is toggleable — clicking it expands or collapses that section
389
+ - When collapsed, the section shows a header line only with a task count badge
390
+ - When expanded, tasks render with normal indentation and per-line styling
391
+ - Default state: pending section expanded, active and complete sections collapsed
392
+ - Section state is tracked per-render (no persistence needed)
393
+ - All 310 existing tests still pass
394
+
395
+ Boundaries:
396
+ - In scope: GoalWidgetComponent.render() grouping logic, section header toggling, expand/collapse state per render cycle
397
+ - Out of scope: task reordering, drag-and-drop, keyboard navigation for sections, persistence of section state across pi restarts
398
+ - Out of scope: modifying GoalTask or GoalRecord types
399
+
400
+ Constraints:
401
+ - Render width must respect the existing width parameter — no hardcoded widths
402
+ - Section collapse state is a render-only map, not stored in goal record
403
+ - Collapse toggle must be keyboard-accessible via existing widget interaction model
404
+ - Do not change the GoalWidgetComponent public API (constructor options, render signature)
405
+ - Section headers must use theme.fg("accent", ...) consistent with existing render patterns
406
+
407
+ Verification contract:
408
+ - Run npm test and confirm 310/310 pass (0 failures)
409
+ - Read render method and confirm task grouping logic exists
410
+ - Read expand/collapse toggle handler and confirm it inverts section state
411
+ - Confirm collapsed sections only render the header line with task count
412
+ - Confirm expanded sections render tasks with correct indentation and styling`,
413
+ autoContinue: true,
414
+ sisyphus: false,
415
+ });
416
+ goal.id = `debug-${nowIso().replace(/[:.]/g, "-")}-${debugGoalCounter}`;
417
+ goal.createdAt = nowIso();
418
+ goal.updatedAt = nowIso();
419
+
420
+ const now = nowIso();
421
+ const tasks: GoalTask[] = [
422
+ {
423
+ id: "t1",
424
+ title: "Set up project structure",
425
+ status: "complete",
426
+ completedAt: now,
427
+ subtasks: [
428
+ { id: "t1a", title: "Initialize repo", status: "complete", completedAt: now },
429
+ { id: "t1b", title: "Add build config", status: "pending" },
430
+ ],
431
+ },
432
+ {
433
+ id: "t2",
434
+ title: "Implement core feature",
435
+ status: "pending",
436
+ subtasks: [
437
+ { id: "t2a", title: "Status grouping logic", status: "pending" },
438
+ { id: "t2b", title: "Section header component", status: "pending" },
439
+ { id: "t2c", title: "Expand/collapse state", status: "pending" },
440
+ { id: "t2d", title: "Task count badge", status: "pending" },
441
+ ],
442
+ },
443
+ { id: "t3", title: "Update tests", status: "pending" },
444
+ { id: "t4", title: "Manual TUI verification", status: "pending" },
445
+ ];
446
+ goal.taskList = { tasks, blockCompletion: false, proposedAt: now };
447
+ // Development-only diagnostic: the legacy proposal dialog is
448
+ // gone; the injected sample task list is inspected through the goal
449
+ // file and the debug task overlay (Ctrl+Shift+T).
450
+ ctx.ui.notify(
451
+ `Injected sample task list into debug goal ${goal.id}; inspect the goal file or use the debug task overlay.`,
452
+ "info",
453
+ );
454
+ }
455
+ }