@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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- package/scripts/recover-session-checkpoints.mjs +289 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatDuration,
|
|
3
|
+
formatTokenValue,
|
|
4
|
+
statusLabel,
|
|
5
|
+
truncateText,
|
|
6
|
+
} from "./goal-core.ts";
|
|
7
|
+
import {
|
|
8
|
+
latestAuditorResultForGoal, latestAuditForGoal,
|
|
9
|
+
latestEventsForGoal,
|
|
10
|
+
reconstructGoalLedger,
|
|
11
|
+
type GoalLedgerEvent,
|
|
12
|
+
type LedgerStateReadResult,
|
|
13
|
+
} from "./goal-ledger.ts";
|
|
14
|
+
import { type GoalRecord } from "./goal-record.ts";
|
|
15
|
+
import { taskIndex } from "./goal-task-index.ts";
|
|
16
|
+
|
|
17
|
+
export function buildGoalCompactSummary(
|
|
18
|
+
goal: GoalRecord,
|
|
19
|
+
events: GoalLedgerEvent[],
|
|
20
|
+
auditorOverride?: { verdict: "approved" | "disapproved" | "error"; report: string; at: string },
|
|
21
|
+
): string {
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push(`Goal ${goal.id} — ${statusLabel(goal)}`);
|
|
24
|
+
lines.push(` Objective: ${truncateText(goal.objective, 200)}`);
|
|
25
|
+
if (goal.usage.tokensUsed > 0) {
|
|
26
|
+
lines.push(` Cumulative goal usage (not context occupancy): ${formatTokenValue(goal.usage.tokensUsed)}`);
|
|
27
|
+
}
|
|
28
|
+
if (goal.usage.activeSeconds > 0) {
|
|
29
|
+
lines.push(` Time: ${formatDuration(goal.usage.activeSeconds)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const recent = latestEventsForGoal(events, goal.id, 5);
|
|
33
|
+
if (recent.length > 0) {
|
|
34
|
+
lines.push(" Recent events:");
|
|
35
|
+
for (const event of recent) {
|
|
36
|
+
switch (event.type) {
|
|
37
|
+
case "goal_paused":
|
|
38
|
+
lines.push(` - paused: ${event.reason}`);
|
|
39
|
+
break;
|
|
40
|
+
case "goal_resumed":
|
|
41
|
+
lines.push(` - resumed: ${event.reason}`);
|
|
42
|
+
break;
|
|
43
|
+
case "goal_tweaked":
|
|
44
|
+
lines.push(` - tweaked: ${event.changeSummary}`);
|
|
45
|
+
break;
|
|
46
|
+
case "auditor_toggled":
|
|
47
|
+
lines.push(` - auditor toggled ${event.enabled ? "on" : "off"}`);
|
|
48
|
+
break;
|
|
49
|
+
case "completion_requested":
|
|
50
|
+
lines.push(` - completion requested${event.summary ? `: ${truncateText(event.summary, 80)}` : ""}`);
|
|
51
|
+
break;
|
|
52
|
+
case "audit_result":
|
|
53
|
+
lines.push(` - auditor ${event.verdict}${event.verdict === "disapproved" ? `: ${truncateText(event.report, 80)}` : ""}`);
|
|
54
|
+
break;
|
|
55
|
+
case "goal_completed":
|
|
56
|
+
lines.push(" - completed");
|
|
57
|
+
break;
|
|
58
|
+
case "task_list_set":
|
|
59
|
+
lines.push(` - task list set: ${event.taskCount} tasks${event.blockCompletion ? " (blocking)" : ""}`);
|
|
60
|
+
break;
|
|
61
|
+
case "task_complete":
|
|
62
|
+
lines.push(` - task complete: ${event.taskId}${event.evidence ? ` — ${truncateText(event.evidence, 60)}` : ""}`);
|
|
63
|
+
break;
|
|
64
|
+
case "task_skipped":
|
|
65
|
+
lines.push(` - task skipped: ${event.taskId} — ${truncateText(event.reason, 60)}`);
|
|
66
|
+
break;
|
|
67
|
+
case "task_reopened":
|
|
68
|
+
lines.push(` - task reopened: ${event.taskId}`);
|
|
69
|
+
break;
|
|
70
|
+
case "goal_aborted":
|
|
71
|
+
lines.push(` - aborted: ${event.reason}`);
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const auditor = auditorOverride ?? latestAuditorResultForGoal(events, goal.id);
|
|
80
|
+
if (latestAuditForGoal(events, goal.id)?.type === "audit_started") lines.push(" Latest audit: no recorded result.");
|
|
81
|
+
if (auditor && auditor.verdict === "disapproved") {
|
|
82
|
+
lines.push(` Auditor rejection (latest): ${truncateText(auditor.report, 120)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (goal.pauseReason) {
|
|
86
|
+
lines.push(` Pause reason: ${goal.pauseReason}`);
|
|
87
|
+
}
|
|
88
|
+
if (goal.pauseSuggestedAction) {
|
|
89
|
+
lines.push(` Suggested action: ${goal.pauseSuggestedAction}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildCompactionSummary(args: {
|
|
96
|
+
goalsById: Map<string, GoalRecord>;
|
|
97
|
+
focusedGoalId: string | null;
|
|
98
|
+
ledgerEvents?: GoalLedgerEvent[];
|
|
99
|
+
/** Checkpoint-fed view: uses reconstructed state + per-goal recent tails instead of a full event list. */
|
|
100
|
+
ledgerState?: LedgerStateReadResult;
|
|
101
|
+
capOpenGoals?: number;
|
|
102
|
+
capEventsPerGoal?: number;
|
|
103
|
+
}): string {
|
|
104
|
+
const { goalsById, focusedGoalId, ledgerEvents = [], ledgerState, capOpenGoals = 20, capEventsPerGoal = 5 } = args;
|
|
105
|
+
|
|
106
|
+
const lines: string[] = [];
|
|
107
|
+
const openGoals = Array.from(goalsById.values()).filter((g) => g.status !== "complete");
|
|
108
|
+
const reconstructed = ledgerState?.state ?? reconstructGoalLedger(ledgerEvents);
|
|
109
|
+
const recentEventsFor = (goalId: string, cap: number): GoalLedgerEvent[] => {
|
|
110
|
+
if (ledgerState) return (ledgerState.recentEventsByGoal.get(goalId) ?? []).slice(0, cap);
|
|
111
|
+
return latestEventsForGoal(ledgerEvents, goalId, cap);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (focusedGoalId && goalsById.has(focusedGoalId)) {
|
|
115
|
+
const focused = goalsById.get(focusedGoalId)!;
|
|
116
|
+
const auditor = ledgerState
|
|
117
|
+
? reconstructed.goals.get(focusedGoalId)?.latestAuditorResult ?? reconstructed.terminalGoals.get(focusedGoalId)?.latestAuditorResult
|
|
118
|
+
: undefined;
|
|
119
|
+
lines.push(`[FOCUSED GOAL]`);
|
|
120
|
+
lines.push(buildGoalCompactSummary(focused, recentEventsFor(focusedGoalId, capEventsPerGoal), auditor));
|
|
121
|
+
lines.push("");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const otherOpen = openGoals.filter((g) => g.id !== focusedGoalId);
|
|
125
|
+
if (otherOpen.length > 0) {
|
|
126
|
+
lines.push(`[OTHER OPEN GOALS — ${otherOpen.length} total]`);
|
|
127
|
+
for (const goal of otherOpen.slice(0, capOpenGoals)) {
|
|
128
|
+
lines.push(`- ${goal.id} — ${statusLabel(goal)} — ${truncateText(goal.objective, 120)}`);
|
|
129
|
+
}
|
|
130
|
+
if (otherOpen.length > capOpenGoals) {
|
|
131
|
+
lines.push(`... and ${otherOpen.length - capOpenGoals} more`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (reconstructed.terminalGoals.size > 0) {
|
|
137
|
+
lines.push(`[TERMINAL GOALS — ${reconstructed.terminalGoals.size} completed or aborted]`);
|
|
138
|
+
for (const [goalId, state] of reconstructed.terminalGoals) {
|
|
139
|
+
const label = state.latestStatus === "complete" ? "completed" : "aborted";
|
|
140
|
+
lines.push(`- ${goalId} — ${label}${state.completedAt ? ` at ${state.completedAt}` : ""}${state.abortedAt ? ` at ${state.abortedAt}` : ""}`);
|
|
141
|
+
}
|
|
142
|
+
lines.push("");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (openGoals.length === 0 && reconstructed.terminalGoals.size === 0) {
|
|
146
|
+
lines.push("[NO GOALS]");
|
|
147
|
+
lines.push("No open or terminal goals recorded in this session.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push("[INSTRUCTION]");
|
|
151
|
+
lines.push("Continue from the focused goal above, or ask the user to run /goal-focus.");
|
|
152
|
+
lines.push("Do not rely on chat memory for goal state; use the facts above.");
|
|
153
|
+
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* PR E §62: post-compaction DELTA for the focused goal. The active system
|
|
159
|
+
* goal block already contains the objective, full policy, task gate, and
|
|
160
|
+
* verification contract — this delta adds only what compaction may have
|
|
161
|
+
* lost: the execution focus, a bounded recent-events tail, the latest
|
|
162
|
+
* unresolved auditor finding, and the other-open-goals count.
|
|
163
|
+
*/
|
|
164
|
+
export function buildPostCompactionGoalDelta(args: {
|
|
165
|
+
goal: GoalRecord;
|
|
166
|
+
ledgerEvents: GoalLedgerEvent[];
|
|
167
|
+
otherOpenCount: number;
|
|
168
|
+
}): string {
|
|
169
|
+
const { goal, ledgerEvents, otherOpenCount } = args;
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
lines.push(`[POST-COMPACTION RESYNC goalId=${goal.id}]`);
|
|
172
|
+
// Execution focus (with contract) — the one thing memory must not lose.
|
|
173
|
+
if (goal.currentTaskId && goal.taskList) {
|
|
174
|
+
const current = taskIndex(goal.taskList.tasks).byId.get(goal.currentTaskId);
|
|
175
|
+
if (current) {
|
|
176
|
+
lines.push(`Current task: ${truncateText(current.id, 80)} — ${truncateText(current.title, 180)}${current.verificationContract ? ` (contract: ${truncateText(current.verificationContract, 600)})` : ""}`);
|
|
177
|
+
if (current.id.length > 80 || current.title.length > 180 || (current.verificationContract?.length ?? 0) > 600) lines.push('Retrieve omitted task requirements with get_goal(section="tasks") before continuing.');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Bounded recent-event tail.
|
|
181
|
+
const recent = latestEventsForGoal(ledgerEvents, goal.id, 5);
|
|
182
|
+
if (recent.length > 0) {
|
|
183
|
+
lines.push("Recent events:");
|
|
184
|
+
for (const event of recent) {
|
|
185
|
+
lines.push(` ${event.at.slice(11, 19)} ${event.type}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Latest unresolved auditor finding.
|
|
189
|
+
const audit = latestAuditorResultForGoal(ledgerEvents, goal.id);
|
|
190
|
+
if (latestAuditForGoal(ledgerEvents, goal.id)?.type === "audit_started") lines.push("Latest audit: no recorded result.");
|
|
191
|
+
if (audit && audit.verdict === "disapproved") {
|
|
192
|
+
lines.push(`Latest unresolved auditor finding: ${audit.report.slice(0, 300)}`);
|
|
193
|
+
}
|
|
194
|
+
lines.push(`Other open goals: ${otherOpenCount}`);
|
|
195
|
+
lines.push("Continue from authoritative files and goal storage.");
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {randomUUID} from "node:crypto";
|
|
2
|
+
import {type AgentToolResult, type ExtensionContext} from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {GOAL_AUDIT_ENTRY, detailedSummary, goalDetails} from "./goal-format.ts";
|
|
4
|
+
import {buildCompletionReport, buildTaskSummary, taskCompletionBlockWarning, validateGoalCompletion} from "./goal-policy.ts";
|
|
5
|
+
import {loadGoalSettings, loadGoalSettingsFileConfig} from "./goal-settings.ts";
|
|
6
|
+
import {runGoalCompletionAuditor, type GoalAuditorResult} from "./goal-auditor.ts";
|
|
7
|
+
import {nowIso, type GoalRecord} from "./goal-record.ts";
|
|
8
|
+
import {latestEventsForGoal, latestSettledAuditorResultForGoal, goalRuntimeEvents, type GoalLedgerEvent} from "./goal-ledger.ts";
|
|
9
|
+
import {mergeGoalPromptFromDisk} from "./storage/goal-files.ts";
|
|
10
|
+
import {isStaleExtensionContextError} from "./goal-session-safety.ts";
|
|
11
|
+
import {goalStorageContext} from "./storage/goal-root.ts";
|
|
12
|
+
import {showEscapeDialog} from "./widgets/goal-escape-dialog.ts";
|
|
13
|
+
import type {GoalCore, ActiveGoalAudit} from "./goal-state.ts";
|
|
14
|
+
|
|
15
|
+
type CompletionOptions = {
|
|
16
|
+
goal: GoalRecord;
|
|
17
|
+
focus: {goalId: string; revision: number};
|
|
18
|
+
auditorReport?: string;
|
|
19
|
+
skipped?: string;
|
|
20
|
+
userBypass?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** One completion transaction, using current usage and the audited disk revision. */
|
|
24
|
+
function commitCompletion(core: GoalCore, ctx: ExtensionContext, opts: CompletionOptions): AgentToolResult<unknown> {
|
|
25
|
+
if (!core.isFocusedOperationCurrent(opts.focus)) return core.focusedOperationCancelledResult("Goal completion", opts.focus);
|
|
26
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
27
|
+
if (core.state.goal?.revision !== opts.goal.revision) return {
|
|
28
|
+
content: [{type: "text", text: "Goal completion failed: the goal changed during the audit. Recheck the current requirements and retry."}],
|
|
29
|
+
details: goalDetails(core.state.goal), terminate: false,
|
|
30
|
+
};
|
|
31
|
+
core.accountProgress(ctx);
|
|
32
|
+
let result;
|
|
33
|
+
try {
|
|
34
|
+
result = core.goalService.apply(ctx, {
|
|
35
|
+
reconcile: false, focusToken: opts.focus,
|
|
36
|
+
mutate: current => ({...current, status: "complete", stopReason: "agent", updatedAt: nowIso()}),
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {result = {ok: false as const, message: error instanceof Error ? error.message : String(error)};}
|
|
39
|
+
if (!result.ok) return {
|
|
40
|
+
content: [{type: "text", text: `Goal completion failed: ${result.message}. The goal was not completed.`}],
|
|
41
|
+
details: goalDetails(core.state.goal), terminate: false,
|
|
42
|
+
};
|
|
43
|
+
core.runtime.markTurnStopped(result.goal.id);
|
|
44
|
+
core.updateUI(ctx);
|
|
45
|
+
const report = buildCompletionReport({detailedSummary: detailedSummary(core.state.goal), auditorReport: opts.auditorReport, auditSkippedReason: opts.skipped, taskSummary: core.state.goal?.taskList ? buildTaskSummary(core.state.goal.taskList) : null});
|
|
46
|
+
return {content: [{type: "text", text: report + (opts.userBypass ? "\n\nThe goal is complete. Provide a final summary of what was accomplished." : "")}], details: goalDetails(core.state.goal), ...(opts.userBypass ? {} : {terminate: true})};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Capture one audit owner; every outcome and callback belongs to that owner. */
|
|
50
|
+
export async function runGoalCompletionFlow(core: GoalCore, ctx: ExtensionContext, completionSummary?: string): Promise<AgentToolResult<unknown>> {
|
|
51
|
+
const flushError = core.goalService.flushForAudit(ctx);
|
|
52
|
+
if (flushError) return {content: [{type: "text", text: flushError}], details: goalDetails(core.state.goal)};
|
|
53
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
54
|
+
const gate = validateGoalCompletion({goal: core.state.goal, runningGoalId: core.runningGoalId});
|
|
55
|
+
if (!gate.ok) return {content: [{type: "text", text: gate.message}], details: goalDetails(core.state.goal)};
|
|
56
|
+
if (!core.state.goal) throw new Error("Goal disappeared during completion validation.");
|
|
57
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
58
|
+
const warning = !settings.disableTasks && core.state.goal.taskList ? taskCompletionBlockWarning(core.state.goal.taskList) : null;
|
|
59
|
+
if (warning) return {content: [{type: "text", text: warning}], details: goalDetails(core.state.goal)};
|
|
60
|
+
|
|
61
|
+
const target = mergeGoalPromptFromDisk(ctx, core.state.goal);
|
|
62
|
+
const focus = core.focusedOperationToken(target.id);
|
|
63
|
+
const storage = goalStorageContext(ctx);
|
|
64
|
+
const config = loadGoalSettingsFileConfig(ctx.cwd);
|
|
65
|
+
const label = config.provider || config.model || config.thinkingLevel
|
|
66
|
+
? `${config.provider ?? "default"}/${config.model ?? "default"}${config.thinkingLevel ? `:${config.thinkingLevel}` : ""}` : "default";
|
|
67
|
+
const auditId = randomUUID();
|
|
68
|
+
const append = (event: GoalLedgerEvent) => core.goalService.appendEvents(storage, [event], {immediate: true});
|
|
69
|
+
const message = (phase: string, text: string, auditor = label) => core.auditMessages.enqueue(ctx, {customType: GOAL_AUDIT_ENTRY, content: text, display: true, details: {phase, goalId: target.id, auditor}});
|
|
70
|
+
const cancelled = () => core.focusedOperationCancelledResult("Goal completion", focus);
|
|
71
|
+
const persistenceError = (): AgentToolResult<unknown> => ({isError: true, content: [{type: "text", text: "The audit outcome could not be persisted. The goal remains open; inspect the storage diagnostic and retry."}], details: goalDetails(core.state.goal), terminate: false});
|
|
72
|
+
append({type: "completion_requested", goalId: target.id, at: nowIso()});
|
|
73
|
+
core.activeAudit?.cancel("A newer completion attempt replaced this audit.");
|
|
74
|
+
|
|
75
|
+
const skipReason = target.skipAuditor ? "per-goal auditor disabled" : config.disabled ? "auditor disabled in settings" : undefined;
|
|
76
|
+
if (skipReason) {
|
|
77
|
+
if (!append({type: "audit_skipped", auditId, goalId: target.id, reason: "disabled", provider: config.provider, model: config.model, thinkingLevel: config.thinkingLevel, at: nowIso()})) return persistenceError();
|
|
78
|
+
message("skipped", `Goal completed — ${skipReason}.`);
|
|
79
|
+
return commitCompletion(core, ctx, {goal: target, focus, skipped: skipReason});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const run: ActiveGoalAudit = {
|
|
83
|
+
controller: new AbortController(), phase: "running",
|
|
84
|
+
cancel: reason => {
|
|
85
|
+
if (run.phase === "settling" || run.phase === "settled") return;
|
|
86
|
+
run.controller.abort();
|
|
87
|
+
settle({type: "audit_result", auditId, goalId: target.id, verdict: "error", report: reason, at: nowIso()});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const current = () => core.activeAudit === run && core.isFocusedOperationCurrent(focus) && run.phase !== "settled";
|
|
91
|
+
function settle(event: GoalLedgerEvent): boolean {
|
|
92
|
+
if (run.phase === "settling" || run.phase === "settled") return false;
|
|
93
|
+
run.phase = "settling";
|
|
94
|
+
try {return append(event);}
|
|
95
|
+
finally {
|
|
96
|
+
run.phase = "settled";
|
|
97
|
+
if (core.activeAudit === run) {
|
|
98
|
+
core.activeAudit = null;
|
|
99
|
+
core.stopAuditAnimation();
|
|
100
|
+
core.auditProgress = null;
|
|
101
|
+
core.goalWidgetComponentRef.current?.invalidate();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
core.activeAudit = run;
|
|
106
|
+
try {
|
|
107
|
+
if (!append({type: "audit_started", auditId, goalId: target.id, provider: config.provider, model: config.model, thinkingLevel: config.thinkingLevel, at: nowIso()})) {
|
|
108
|
+
run.cancel("Audit could not start because its ledger entry failed.");
|
|
109
|
+
return persistenceError();
|
|
110
|
+
}
|
|
111
|
+
message("started", `Auditor: I am starting the independent completion audit.\nGoal id: ${target.id}\nAuditor model: ${label}`);
|
|
112
|
+
const startedAt = Date.now();
|
|
113
|
+
core.clearAuditResult();
|
|
114
|
+
core.auditProgress = {recentOutput: [], phase: "running", elapsedMs: 0, auditorLabel: label};
|
|
115
|
+
core.stopAuditAnimation();
|
|
116
|
+
core.auditAnimationTimer = setInterval(() => {
|
|
117
|
+
if (!current() || !core.auditProgress) return;
|
|
118
|
+
core.auditProgress.elapsedMs = Date.now() - startedAt;
|
|
119
|
+
core.goalWidgetComponentRef.current?.invalidate();
|
|
120
|
+
}, 80);
|
|
121
|
+
core.auditAnimationTimer.unref?.();
|
|
122
|
+
const ledger = goalRuntimeEvents(storage, target.id);
|
|
123
|
+
const tail = latestEventsForGoal(ledger, target.id, 8);
|
|
124
|
+
const previous = latestSettledAuditorResultForGoal(ledger, target.id);
|
|
125
|
+
let warmContext = tail.length ? `Recent goal events (from the shared ledger):\n${tail.map(e => `- ${e.at} ${e.type}${"taskId" in e ? ` (task ${e.taskId})` : ""}${"evidence" in e && e.evidence ? ` evidence: ${e.evidence}` : ""}`).join("\n")}` : null;
|
|
126
|
+
if (previous?.verdict === "disapproved") warmContext = `${warmContext ?? ""}\nPrevious rejection (verify whether resolved): ${previous.report.slice(0, 600)}`;
|
|
127
|
+
let auditor: GoalAuditorResult;
|
|
128
|
+
try {
|
|
129
|
+
auditor = await (core.dependencies.runCompletionAuditor ?? runGoalCompletionAuditor)({ctx, goal: target, detailedSummary: detailedSummary(target), completionSummary: completionSummary?.trim() || undefined, settings, warmContext, signal: run.controller.signal, onProgress: progress => {
|
|
130
|
+
if (!current() || run.phase !== "running") return;
|
|
131
|
+
core.auditProgress = {...progress, elapsedMs: Date.now() - startedAt};
|
|
132
|
+
core.goalWidgetComponentRef.current?.invalidate();
|
|
133
|
+
}});
|
|
134
|
+
} catch (error) {auditor = {approved: false, disapproved: false, output: "", error: error instanceof Error ? error.message : String(error)};}
|
|
135
|
+
if (!current()) return cancelled();
|
|
136
|
+
core.stopAuditAnimation();
|
|
137
|
+
core.auditProgress = null;
|
|
138
|
+
|
|
139
|
+
if (run.controller.signal.aborted || auditor.error === "Auditor aborted.") {
|
|
140
|
+
run.phase = "awaiting-choice";
|
|
141
|
+
core.updateUI(ctx);
|
|
142
|
+
core.enterGoalModal();
|
|
143
|
+
let choice;
|
|
144
|
+
try {choice = await showEscapeDialog(ctx, target.objective);}
|
|
145
|
+
finally {if (current()) core.exitGoalModal();}
|
|
146
|
+
if (!current()) return cancelled();
|
|
147
|
+
if (choice === "complete_without_audit") {
|
|
148
|
+
if (!settle({type: "audit_skipped", auditId, goalId: target.id, reason: "user_aborted", provider: config.provider, model: config.model, thinkingLevel: config.thinkingLevel, at: nowIso()})) return persistenceError();
|
|
149
|
+
message("skipped", "Goal completed — user bypassed audit via Escape.");
|
|
150
|
+
return commitCompletion(core, ctx, {goal: target, focus, skipped: "auditor bypassed (user pressed Escape during audit)", userBypass: true});
|
|
151
|
+
}
|
|
152
|
+
if (!settle({type: "audit_result", auditId, goalId: target.id, verdict: "error", report: "Audit aborted — the goal remains active and work continues.", at: nowIso()})) return persistenceError();
|
|
153
|
+
core.updateUI(ctx);
|
|
154
|
+
return {content: [{type: "text", text: "Audit aborted — the goal remains active and work continues."}], details: goalDetails(core.state.goal)};
|
|
155
|
+
}
|
|
156
|
+
const verdict = auditor.approved ? "approved" : auditor.error ? "error" : "disapproved";
|
|
157
|
+
if (!settle({type: "audit_result", auditId, goalId: target.id, verdict, report: auditor.output || auditor.error || "Auditor produced no output.", at: nowIso()})) return persistenceError();
|
|
158
|
+
core.setAuditResult(verdict, auditor.output || auditor.error || "Auditor produced no output.");
|
|
159
|
+
if (!auditor.approved) {
|
|
160
|
+
const text = ["Goal audit rejected.", "", "Goal completion rejected by independent auditor.", auditor.model ? `Auditor model: ${auditor.model}${auditor.thinkingLevel ? `:${auditor.thinkingLevel}` : ""}` : undefined, auditor.error ? `Auditor error: ${auditor.error}` : undefined, "", auditor.output || "Auditor produced no approval marker."].filter(line => line !== undefined).join("\n");
|
|
161
|
+
message("rejected", text, auditor.model);
|
|
162
|
+
return {content: [{type: "text", text}], details: goalDetails(core.state.goal)};
|
|
163
|
+
}
|
|
164
|
+
message("approved", ["Auditor: I approve this completion claim.", auditor.model ? `Auditor model: ${auditor.model}${auditor.thinkingLevel ? `:${auditor.thinkingLevel}` : ""}` : undefined, "", auditor.output || "Auditor approved completion."].filter(line => line !== undefined).join("\n"), auditor.model);
|
|
165
|
+
return commitCompletion(core, ctx, {goal: target, focus, auditorReport: auditor.output});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (isStaleExtensionContextError(error)) return cancelled();
|
|
168
|
+
throw error;
|
|
169
|
+
} finally {
|
|
170
|
+
// Covers a dialog/host exception as well as late cancellation; settlement is claimed once.
|
|
171
|
+
if (run.phase !== "settled") run.cancel("Completion audit ended without a terminal result.");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Filter after Pi restores system and tool state, including on the first request. */
|
|
4
|
+
export function registerGoalContext(
|
|
5
|
+
pi: ExtensionAPI,
|
|
6
|
+
transform: (messages: unknown[], ctx: ExtensionContext) => unknown[] | null,
|
|
7
|
+
): void {
|
|
8
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
9
|
+
const messages = transform(event.messages, ctx);
|
|
10
|
+
return messages === null ? undefined : { messages: messages as typeof event.messages };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small record/input parser (Stage 5 of the hardening plan): verification
|
|
3
|
+
* contract extraction and objective prompt-safety live here, outside the
|
|
4
|
+
* (the historical goal-draft module was removed).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const VERIFICATION_CONTRACT_RE = /^Verification contract:\s*(.+)$/im;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a `Verification contract:` section from a goal objective and return
|
|
11
|
+
* the cleaned objective (without the contract section) and the contract text.
|
|
12
|
+
*
|
|
13
|
+
* The contract section is a single line matching:
|
|
14
|
+
* Verification contract: <text>
|
|
15
|
+
*
|
|
16
|
+
* It can appear anywhere in the objective, but by convention it goes after
|
|
17
|
+
* the other sections (like Success criteria, Boundaries, Constraints).
|
|
18
|
+
*
|
|
19
|
+
* If no contract section is found, `verificationContract` is undefined.
|
|
20
|
+
*/
|
|
21
|
+
export function extractVerificationContract(objective: string): { objective: string; verificationContract?: string } {
|
|
22
|
+
const lines = objective.replace(/\r/g, "").split("\n");
|
|
23
|
+
let contract: string | undefined;
|
|
24
|
+
const filtered: string[] = [];
|
|
25
|
+
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
const m = VERIFICATION_CONTRACT_RE.exec(trimmed);
|
|
29
|
+
if (m) {
|
|
30
|
+
contract = m[1]!.trim();
|
|
31
|
+
// Skip this line — don't add it to the cleaned objective
|
|
32
|
+
} else {
|
|
33
|
+
filtered.push(line);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
objective: filtered.join("\n"),
|
|
39
|
+
verificationContract: contract || undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Escape untrusted-objective tags so injected objectives cannot break prompts. */
|
|
44
|
+
export function promptSafeObjective(objective: string): string {
|
|
45
|
+
return objective.replace(/<\/?untrusted_objective>/gi, (tag) => tag.replace(/</g, "<").replace(/>/g, ">"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A Sisyphus objective must express ordered work: numbered items in the body
|
|
50
|
+
* ("1)", "1.", "1:") or explicit "Step N" markers. Without any such marker the
|
|
51
|
+
* objective cannot be executed in patient ordered fashion, so the guided
|
|
52
|
+
* /sisyphus flow (or a corrected /sisyphus-direct argument) is required.
|
|
53
|
+
*/
|
|
54
|
+
export function sisyphusObjectiveSufficient(objective: string): boolean {
|
|
55
|
+
// Ordered work can be written inline ("Refactor: 1) extract. 2) wire.")
|
|
56
|
+
// or as a numbered block; any explicit item marker counts as sufficient.
|
|
57
|
+
return /\b\d{1,2}\s*[).:]|\bstep\s*\d+/i.test(objective.trim());
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Stable action IDs; invocation names are preferences, not lifecycle logic. */
|
|
4
|
+
export const GOAL_COMMANDS = [
|
|
5
|
+
["goal", "Draft a goal", "Start a guided goal with confirmation."],
|
|
6
|
+
["sisyphus", "Draft a Sisyphus goal", "Draft an ordered, persistent goal."],
|
|
7
|
+
["goal-direct", "Start a goal directly", "Create a goal immediately from the supplied objective."],
|
|
8
|
+
["sisyphus-direct", "Start Sisyphus directly", "Create an ordered goal immediately."],
|
|
9
|
+
["goal-tweak", "Revise the goal", "Revise the objective or budget, with confirmation."],
|
|
10
|
+
["goal-pause", "Pause the goal", "Pause the goal and stop the current turn."],
|
|
11
|
+
["goal-resume", "Resume the goal", "Continue and renew the autonomous run allowance."],
|
|
12
|
+
["goal-clear", "Clear the goal", "Archive the goal after confirmation."],
|
|
13
|
+
["goal-list", "List goals", "Show the open goals in this project."],
|
|
14
|
+
["goal-status", "Show goal status", "Show progress; accepts verbose and health."],
|
|
15
|
+
["goal-refresh", "Refresh goal state", "Read goal files and settings again."],
|
|
16
|
+
["goal-recovery", "Recover goal files", "Inspect recovery information; repair requires confirmation."],
|
|
17
|
+
["goal-focus", "Focus a goal", "Choose the goal this session works on."],
|
|
18
|
+
["goal-unfocus", "Unfocus the goal", "Detach this session, leaving the shared goal open."],
|
|
19
|
+
["goal-settings", "Goal settings", "Open settings. /goal-settings always remains available."],
|
|
20
|
+
["goal-cancel", "Cancel a draft", "Cancel the guided draft without creating a goal."],
|
|
21
|
+
] as const;
|
|
22
|
+
export type GoalCommandId = typeof GOAL_COMMANDS[number][0];
|
|
23
|
+
export type GoalCommands = Partial<Record<GoalCommandId, string[]>>;
|
|
24
|
+
export const GOAL_COMMAND_INPUTS: Partial<Record<GoalCommandId, string>> = {
|
|
25
|
+
"goal-direct": "Goal objective", "sisyphus-direct": "Sisyphus objective and ordered steps", "goal-tweak": "Change to the goal",
|
|
26
|
+
};
|
|
27
|
+
export type GoalKeybinding = KeyId | KeyId[];
|
|
28
|
+
export type PauseOrder = "together" | "goal-first" | "turn-first";
|
|
29
|
+
|
|
30
|
+
export const PAUSE_ORDERS: ReadonlyArray<{ id: PauseOrder; label: string; help: string }> = [
|
|
31
|
+
{ id: "together", label: "Goal and turn together", help: "One press pauses the goal and stops the current turn." },
|
|
32
|
+
{ id: "goal-first", label: "Goal first", help: "First: pause the goal; the turn finishes. Next: stop the turn." },
|
|
33
|
+
{ id: "turn-first", label: "Turn first", help: "First: stop the turn and wait. Next: pause the goal. Resume or a message continues." },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export type GoalKeyContext = "global" | "compact" | "expanded" | "audit" | "dialog";
|
|
37
|
+
export const GOAL_DIALOG_KEYS = {
|
|
38
|
+
confirm: "enter", cancel: "escape", up: "up", down: "down", next: ["tab", "right"], previous: ["shift+tab", "left"],
|
|
39
|
+
pageUp: "pageUp", pageDown: "pageDown", scrollUp: "ctrl+up", scrollDown: "ctrl+down", toggleAuditor: "a",
|
|
40
|
+
} satisfies Record<string, GoalKeybinding>;
|
|
41
|
+
export type GoalDialogAction = keyof typeof GOAL_DIALOG_KEYS;
|
|
42
|
+
export interface GoalKeyAction {
|
|
43
|
+
path: string[];
|
|
44
|
+
label: string;
|
|
45
|
+
help: string;
|
|
46
|
+
context: GoalKeyContext;
|
|
47
|
+
defaults: GoalKeybinding;
|
|
48
|
+
}
|
|
49
|
+
const dashboard = (name: string, label: string, defaults: GoalKeybinding, context: GoalKeyContext, help: string): GoalKeyAction =>
|
|
50
|
+
({ path: ["keybindings", "dashboard", name], label, defaults, context, help });
|
|
51
|
+
export const GOAL_KEY_ACTIONS: readonly GoalKeyAction[] = [
|
|
52
|
+
{ path: ["keybindings", "pause"], label: "Pause / interrupt", defaults: "escape", context: "global", help: "Uses the chosen pause order. Dialogs, audit and expanded view take priority." },
|
|
53
|
+
{ path: ["keybindings", "toggleAuditor"], label: "Toggle completion auditor", defaults: "ctrl+shift+a", context: "global", help: "Enable or disable the independent auditor for the focused goal." },
|
|
54
|
+
dashboard("toggleExpand", "Expand / collapse dashboard", "ctrl+shift+t", "global", "Switch between the compact and expanded task list."),
|
|
55
|
+
dashboard("scrollUp", "Compact: scroll up", "ctrl+shift+up", "compact", "Scroll only when the compact task list overflows."),
|
|
56
|
+
dashboard("scrollDown", "Compact: scroll down", "ctrl+shift+down", "compact", "Scroll only when the compact task list overflows."),
|
|
57
|
+
dashboard("scrollPageUp", "Compact: page up", "ctrl+shift+pageUp", "compact", "Scroll the compact list by one page."),
|
|
58
|
+
dashboard("scrollPageDown", "Compact: page down", "ctrl+shift+pageDown", "compact", "Scroll the compact list by one page."),
|
|
59
|
+
dashboard("scrollFirst", "Compact: first task", "ctrl+shift+home", "compact", "Go to the start of the compact task list."),
|
|
60
|
+
dashboard("scrollLast", "Compact: last task", "ctrl+shift+end", "compact", "Go to the end of the compact task list."),
|
|
61
|
+
dashboard("collapse", "Expanded: close dashboard", "escape", "expanded", "Close the expanded view without pausing work."),
|
|
62
|
+
dashboard("up", "Expanded: scroll up", "up", "expanded", "Move up in the expanded task list."),
|
|
63
|
+
dashboard("down", "Expanded: scroll down", "down", "expanded", "Move down in the expanded task list."),
|
|
64
|
+
dashboard("pageUp", "Expanded: page up", "pageUp", "expanded", "Move one page up."),
|
|
65
|
+
dashboard("pageDown", "Expanded: page down", "pageDown", "expanded", "Move one page down."),
|
|
66
|
+
dashboard("first", "Expanded: first task", "home", "expanded", "Go to the start of the expanded task list."),
|
|
67
|
+
dashboard("last", "Expanded: last task", "end", "expanded", "Go to the end of the expanded task list."),
|
|
68
|
+
{ path: ["keybindings", "interruptAudit"], label: "Interrupt the audit", defaults: "escape", context: "audit", help: "Stop the audit and show the existing completion choices." },
|
|
69
|
+
...Object.entries(GOAL_DIALOG_KEYS).map(([name, defaults]): GoalKeyAction => ({
|
|
70
|
+
path: ["keybindings", "dialog", name], label: `Dialog: ${{ confirm: "confirm selection", cancel: "cancel / go back", up: "previous option", down: "next option", next: "next question", previous: "previous question", pageUp: "page up", pageDown: "page down", scrollUp: "scroll up", scrollDown: "scroll down", toggleAuditor: "toggle auditor" }[name]}`,
|
|
71
|
+
defaults, context: "dialog", help: "Applies to goal questionnaires and confirmations. Settings navigation and text editing keep Pi's controls.",
|
|
72
|
+
})),
|
|
73
|
+
...GOAL_COMMANDS.map(([id, label, help]): GoalKeyAction => ({ path: ["keybindings", "commands", id], label, help, context: "global", defaults: [] })),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export function valueAtPath(value: unknown, path: readonly string[]): unknown {
|
|
77
|
+
let cursor = value;
|
|
78
|
+
for (const part of path) {
|
|
79
|
+
if (!cursor || typeof cursor !== "object") return undefined;
|
|
80
|
+
cursor = (cursor as Record<string, unknown>)[part];
|
|
81
|
+
}
|
|
82
|
+
return cursor;
|
|
83
|
+
}
|
|
84
|
+
export function bindingKeys(binding: GoalKeybinding | undefined): KeyId[] {
|
|
85
|
+
return binding === undefined ? [] : Array.isArray(binding) ? binding : [binding];
|
|
86
|
+
}
|
|
87
|
+
export function keyActionBinding(settings: unknown, action: GoalKeyAction): GoalKeybinding {
|
|
88
|
+
return (valueAtPath(settings, action.path) as GoalKeybinding | undefined) ?? action.defaults;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Used only while editing, so validation can consider both scopes before a write. */
|
|
92
|
+
export function resolveGoalControls(global: unknown, project?: unknown): { commands: GoalCommands; keybindings: Record<string, unknown> } {
|
|
93
|
+
const value = { commands: {} as GoalCommands, keybindings: {} as Record<string, unknown> };
|
|
94
|
+
for (const [id] of GOAL_COMMANDS) value.commands[id] = (valueAtPath(project, ["commands", id]) ?? valueAtPath(global, ["commands", id]) ?? [id]) as string[];
|
|
95
|
+
for (const action of GOAL_KEY_ACTIONS) {
|
|
96
|
+
let target: Record<string, unknown> = value;
|
|
97
|
+
for (const part of action.path.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
|
|
98
|
+
target[action.path.at(-1)!] = valueAtPath(project, action.path) ?? valueAtPath(global, action.path) ?? action.defaults;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const specialKeys = new Map("escape enter tab space backspace delete insert clear home end pageUp pageDown up down left right f1 f2 f3 f4 f5 f6 f7 f8 f9 f10 f11 f12".split(" ").map(key => [key.toLowerCase(), key]));
|
|
104
|
+
/** Accept the public Pi key grammar, canonicalizing aliases and modifier order. */
|
|
105
|
+
export function normalizeGoalKey(value: unknown): KeyId | undefined {
|
|
106
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
107
|
+
let text = value.trim().toLowerCase();
|
|
108
|
+
if (text === "esc") text = "escape";
|
|
109
|
+
if (text === "return") text = "enter";
|
|
110
|
+
const modifiers: string[] = [];
|
|
111
|
+
while (/^(ctrl|shift|alt|super)\+/.test(text)) {
|
|
112
|
+
const i = text.indexOf("+");
|
|
113
|
+
const modifier = text.slice(0, i);
|
|
114
|
+
if (modifiers.includes(modifier)) return undefined;
|
|
115
|
+
modifiers.push(modifier); text = text.slice(i + 1);
|
|
116
|
+
}
|
|
117
|
+
text = text === "esc" ? "escape" : text === "return" ? "enter" : text;
|
|
118
|
+
const key = specialKeys.get(text) ?? (/^[a-z0-9`\-=[\]\\;'",./!@#$%^&*()_+|~{}:<>?]$/.test(text) ? text : undefined);
|
|
119
|
+
if (!key) return undefined;
|
|
120
|
+
return [...["ctrl", "alt", "shift", "super"].filter(m => modifiers.includes(m)), key].join("+") as KeyId;
|
|
121
|
+
}
|
|
122
|
+
export function parseGoalKeybinding(value: unknown): GoalKeybinding | undefined {
|
|
123
|
+
if (typeof value === "string") return normalizeGoalKey(value);
|
|
124
|
+
if (!Array.isArray(value) || value.length > 16) return undefined;
|
|
125
|
+
const keys = value.map(normalizeGoalKey);
|
|
126
|
+
return keys.every((key): key is KeyId => key !== undefined) ? [...new Set(keys)] : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function sameGoalKey(a: string, b: string, kitty = false): boolean {
|
|
130
|
+
const physical = (key: string) => kitty ? key : ({ "ctrl+[": "escape", "ctrl+m": "enter", "ctrl+i": "tab", "ctrl+h": "backspace", "ctrl+?": "backspace", "ctrl+@": "ctrl+space" }[key] ?? key);
|
|
131
|
+
return physical(normalizeGoalKey(a) ?? a) === physical(normalizeGoalKey(b) ?? b);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PI_COMMAND_ROOTS = new Set("settings model tree thinking scoped-models export import share bug copy name session changelog hotkeys fork clone trust login logout new compact resume reload quit".split(" "));
|
|
135
|
+
export function normalizeCommandPath(value: unknown): string | undefined {
|
|
136
|
+
if (typeof value !== "string") return undefined;
|
|
137
|
+
const route = value.trim().replace(/^\//, "").replace(/\s+/g, " ");
|
|
138
|
+
return route.length <= 96 && /^[a-z][a-z0-9_-]*(?: [a-z][a-z0-9_-]*)*$/.test(route) ? route : undefined;
|
|
139
|
+
}
|
|
140
|
+
export function parseCommandPaths(value: unknown): string[] | undefined {
|
|
141
|
+
if (!Array.isArray(value) || value.length > 16) return undefined;
|
|
142
|
+
const paths = value.map(normalizeCommandPath);
|
|
143
|
+
return paths.every((route): route is string => route !== undefined) ? [...new Set(paths)] : undefined;
|
|
144
|
+
}
|
|
145
|
+
export function commandPaths(commands: GoalCommands | undefined, id: GoalCommandId): string[] {
|
|
146
|
+
return commands?.[id] ?? [id];
|
|
147
|
+
}
|
|
148
|
+
export function commandHint(id: GoalCommandId, commands?: GoalCommands): string {
|
|
149
|
+
const first = commandPaths(commands, id)[0];
|
|
150
|
+
if (!first && id === "goal-settings") return "/goal-settings";
|
|
151
|
+
return first ? `/${first}` : `/goal-settings controls to enable ${GOAL_COMMANDS.find(([action]) => action === id)![1].toLowerCase()}`;
|
|
152
|
+
}
|
|
153
|
+
const commandReference = new RegExp(`/(${GOAL_COMMANDS.map(([id]) => id).sort((a, b) => b.length - a.length).join("|")})(?![a-z0-9_-])`, "g");
|
|
154
|
+
/** For standing instructions only: objectives and other user text stay verbatim. */
|
|
155
|
+
export function commandInstructions(text: string, commands?: GoalCommands): string {
|
|
156
|
+
return commands ? text.replace(commandReference, (_match, id: GoalCommandId) => commandHint(id, commands)) : text;
|
|
157
|
+
}
|
|
158
|
+
export interface GoalCommandRoute { path: string; id: GoalCommandId }
|
|
159
|
+
export function commandRoutes(commands: GoalCommands | undefined): GoalCommandRoute[] {
|
|
160
|
+
const routes = GOAL_COMMANDS.flatMap(([id]) => commandPaths(commands, id).map(path => ({ path, id })));
|
|
161
|
+
if (!routes.some(route => route.path === "goal-settings")) routes.push({ path: "goal-settings", id: "goal-settings" });
|
|
162
|
+
return routes.sort((a, b) => b.path.length - a.path.length);
|
|
163
|
+
}
|
|
164
|
+
export function commandProblems(commands: GoalCommands | undefined, occupiedRoots: readonly string[] = []): string[] {
|
|
165
|
+
const problems: string[] = [];
|
|
166
|
+
const owners = new Map<string, GoalCommandId>();
|
|
167
|
+
for (const [id] of GOAL_COMMANDS) for (const route of commandPaths(commands, id)) {
|
|
168
|
+
const root = route.split(" ")[0]!;
|
|
169
|
+
if (PI_COMMAND_ROOTS.has(root) || occupiedRoots.includes(root)) problems.push(`/${root} belongs to Pi or another command.`);
|
|
170
|
+
if (root === "goal-settings" && id !== "goal-settings") problems.push("/goal-settings is reserved for settings recovery.");
|
|
171
|
+
const owner = owners.get(route);
|
|
172
|
+
if (owner && owner !== id) problems.push(`/${route} is already assigned to ${owner}.`);
|
|
173
|
+
owners.set(route, id);
|
|
174
|
+
}
|
|
175
|
+
return [...new Set(problems)];
|
|
176
|
+
}
|
|
177
|
+
export function matchCommandRoute(routes: readonly GoalCommandRoute[], root: string, args: string): { id: GoalCommandId; args: string } | undefined {
|
|
178
|
+
const input = root + (args ? ` ${args}` : "");
|
|
179
|
+
for (const route of routes) {
|
|
180
|
+
const matched = new RegExp(`^${route.path.split(" ").join("\\s+")}(?=$|\\s)`).exec(input);
|
|
181
|
+
if (matched) return { id: route.id, args: input.slice(matched[0].length).trimStart() };
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Global keys overlap both dashboard modes; view-specific handlers own Escape first. */
|
|
187
|
+
export function keybindingProblems(settings: unknown, edited?: GoalKeyAction, kitty = false): string[] {
|
|
188
|
+
const problems: string[] = [];
|
|
189
|
+
for (let i = 0; i < GOAL_KEY_ACTIONS.length; i++) {
|
|
190
|
+
const a = GOAL_KEY_ACTIONS[i]!;
|
|
191
|
+
for (const b of GOAL_KEY_ACTIONS.slice(i + 1)) {
|
|
192
|
+
if (edited && a !== edited && b !== edited) continue;
|
|
193
|
+
if ((a.context === "dialog") !== (b.context === "dialog")) continue;
|
|
194
|
+
if (a.context !== b.context && a.context !== "global" && b.context !== "global") continue;
|
|
195
|
+
// These view-specific actions deliberately take priority over pause.
|
|
196
|
+
if ((a.path[1] === "pause" || b.path[1] === "pause") && [a, b].some(x => x.path[1] === "interruptAudit" || x.path[2] === "collapse")) continue;
|
|
197
|
+
const overlap = bindingKeys(keyActionBinding(settings, a)).filter(key => bindingKeys(keyActionBinding(settings, b)).some(bound => sameGoalKey(key, bound, kitty)));
|
|
198
|
+
if (overlap.length) problems.push(`${overlap.join(", ")} is also assigned to ${edited === b ? a.label : b.label}.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return [...new Set(problems)];
|
|
202
|
+
}
|