@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1
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 +239 -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 +330 -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,866 @@
|
|
|
1
|
+
import { GOAL_COMMANDS, commandHint, commandPaths, commandProblems, commandRoutes, matchCommandRoute, type GoalCommandId } from "./goal-controls.ts";
|
|
2
|
+
import { showGoalControls } from "./widgets/goal-controls-dialog.ts";
|
|
3
|
+
import { goalStoragePath, goalStorageRoot } from "./storage/goal-root.ts";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { extractVerificationContract, sisyphusObjectiveSufficient } from "./goal-contract.ts";
|
|
8
|
+
import { detailedSummary, oneLineSummary } from "./goal-format.ts";
|
|
9
|
+
import {
|
|
10
|
+
goalSettingsPath,
|
|
11
|
+
goalGlobalSettingsPath,
|
|
12
|
+
loadGoalSettings,
|
|
13
|
+
loadSettingsSnapshot,
|
|
14
|
+
mutateSettingsLayer,
|
|
15
|
+
readSettingsLayer,
|
|
16
|
+
envOverrideFor,
|
|
17
|
+
type GoalSettings,
|
|
18
|
+
type SettingsMutation,
|
|
19
|
+
type SettingsScope,
|
|
20
|
+
} from "./goal-settings.ts";
|
|
21
|
+
import {
|
|
22
|
+
buildGoalListText,
|
|
23
|
+
buildUnfocusedOpenGoalsSummary,
|
|
24
|
+
goalSelectorLabel,
|
|
25
|
+
otherOpenGoalCount,
|
|
26
|
+
} from "./goal-pool.ts";
|
|
27
|
+
import { clearGoalCommandMessage, validateResumeGoal } from "./goal-policy.ts";
|
|
28
|
+
import { invalidateGoalLedgerCache, readGoalLedger } from "./goal-ledger.ts";
|
|
29
|
+
import { buildGoalStatusText } from "./goal-status.ts";
|
|
30
|
+
import { effectiveSettingsReport, invalidateGoalSettingsCache, loadGoalSettingsFileConfig } from "./goal-settings.ts";
|
|
31
|
+
import { invalidateGoalPoolCache, mergeGoalPromptFromDisk, readActiveGoalPool } from "./storage/goal-files.ts";
|
|
32
|
+
import { nowIso, type GoalMode, type GoalRecord } from "./goal-record.ts";
|
|
33
|
+
import { clearGoalDrafting, hasActiveDraft, startGoalDrafting } from "./goal-drafting.ts";
|
|
34
|
+
import { formatRecoveryReport, runRecoveryReport, runRecoveryRepair } from "./goal-recovery.ts";
|
|
35
|
+
import { formatCheckpointHealthReport, readSessionCheckpointHealth } from "./goal-session-health.ts";
|
|
36
|
+
|
|
37
|
+
export interface GoalRefreshState {
|
|
38
|
+
poolIds: Iterable<string>;
|
|
39
|
+
ledgerEvents: number;
|
|
40
|
+
ledgerMalformed: number;
|
|
41
|
+
/** Stable fingerprint of the effective settings (JSON of the parsed file). */
|
|
42
|
+
settings: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure diff of a goal-refresh cycle (before invalidation vs after re-read).
|
|
47
|
+
* Unit-testable without the command harness; the command renders the changes.
|
|
48
|
+
*/
|
|
49
|
+
export function diffGoalRefreshState(before: GoalRefreshState, after: GoalRefreshState): string[] {
|
|
50
|
+
const changes: string[] = [];
|
|
51
|
+
const beforeIds = new Set(before.poolIds);
|
|
52
|
+
const afterIds = new Set(after.poolIds);
|
|
53
|
+
const added = [...afterIds].filter((id) => !beforeIds.has(id)).sort();
|
|
54
|
+
const removed = [...beforeIds].filter((id) => !afterIds.has(id)).sort();
|
|
55
|
+
if (added.length > 0) changes.push(`pool: ${added.length} goal(s) added — ${added.join(", ")}`);
|
|
56
|
+
if (removed.length > 0) changes.push(`pool: ${removed.length} goal(s) removed — ${removed.join(", ")}`);
|
|
57
|
+
if (after.ledgerEvents !== before.ledgerEvents) {
|
|
58
|
+
changes.push(`ledger: ${before.ledgerEvents} -> ${after.ledgerEvents} events`);
|
|
59
|
+
}
|
|
60
|
+
if (after.ledgerMalformed !== before.ledgerMalformed) {
|
|
61
|
+
changes.push(`ledger: malformed entries ${before.ledgerMalformed} -> ${after.ledgerMalformed}`);
|
|
62
|
+
}
|
|
63
|
+
if (before.settings !== after.settings) {
|
|
64
|
+
changes.push("settings: effective settings changed (external edit)");
|
|
65
|
+
}
|
|
66
|
+
return changes;
|
|
67
|
+
}
|
|
68
|
+
import {
|
|
69
|
+
AUDITOR_THINKING_LEVELS,
|
|
70
|
+
buildAuditorModelChoices,
|
|
71
|
+
configuredAuditorModelKey,
|
|
72
|
+
filterAuditorModelChoices,
|
|
73
|
+
parseManualAuditorModel,
|
|
74
|
+
thinkingLevelChoices,
|
|
75
|
+
type AuditorChoice,
|
|
76
|
+
} from "./auditor-selector.ts";
|
|
77
|
+
import type { GoalCore } from "./goal-state.ts";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Stable command actions retain their lifecycle checks. Configured invocation
|
|
81
|
+
* paths are registered at load time; aliases and subcommands share the same
|
|
82
|
+
* handler and argument completion. /goal-settings remains the recovery entry.
|
|
83
|
+
*/
|
|
84
|
+
export function registerGoalCommands(core: GoalCore): void {
|
|
85
|
+
const { pi } = core;
|
|
86
|
+
type Definition = { description: string; handler: (args: string, ctx: ExtensionContext) => Promise<void> };
|
|
87
|
+
const actions = new Map<GoalCommandId, Definition>();
|
|
88
|
+
const define = (id: GoalCommandId, definition: Definition): void => { actions.set(id, definition); };
|
|
89
|
+
|
|
90
|
+
async function chooseOpenGoal(ctx: ExtensionContext, title: string): Promise<GoalRecord | null> {
|
|
91
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
92
|
+
if (core.state.goal && core.state.goal.status !== "complete") return core.state.goal;
|
|
93
|
+
const open = core.openGoals();
|
|
94
|
+
if (open.length === 0) return null;
|
|
95
|
+
if (open.length === 1) {
|
|
96
|
+
const only = open[0];
|
|
97
|
+
if (!only) return null;
|
|
98
|
+
core.setFocusedGoalId(only.id, ctx, "selected");
|
|
99
|
+
return core.state.goal;
|
|
100
|
+
}
|
|
101
|
+
if (!ctx.hasUI) {
|
|
102
|
+
ctx.ui.notify(buildUnfocusedOpenGoalsSummary(open.length), "warning");
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const labels = open.map((item) => goalSelectorLabel(item, core.focusedGoalId));
|
|
106
|
+
const byLabel = new Map(labels.map((label, index) => [label, open[index]?.id]));
|
|
107
|
+
core.enterGoalModal();
|
|
108
|
+
try {
|
|
109
|
+
const selected = await ctx.ui.select(title, labels);
|
|
110
|
+
const selectedId = selected ? byLabel.get(selected) : undefined;
|
|
111
|
+
if (!selectedId) {
|
|
112
|
+
ctx.ui.notify("Goal focus unchanged.", "info");
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
core.setFocusedGoalId(selectedId, ctx, "selected");
|
|
116
|
+
return core.state.goal;
|
|
117
|
+
} finally {
|
|
118
|
+
core.exitGoalModal();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function focusGoalCommand(ctx: ExtensionContext): Promise<void> {
|
|
123
|
+
const open = core.openGoals();
|
|
124
|
+
if (open.length === 0) {
|
|
125
|
+
ctx.ui.notify(`No open goals. Use ${commandHint("goal", core.loadedCommands)} to draft one, or ${commandHint("goal-direct", core.loadedCommands)} <objective> to start immediately.`, "warning");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (open.length === 1) {
|
|
129
|
+
const only = open[0];
|
|
130
|
+
if (!only) return;
|
|
131
|
+
core.setFocusedGoalId(only.id, ctx, "selected");
|
|
132
|
+
core.armFocusedContinuation(ctx);
|
|
133
|
+
ctx.ui.notify(`Focused goal: ${oneLineSummary(only)}`, "info");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!ctx.hasUI) {
|
|
137
|
+
ctx.ui.notify(buildGoalListText(core.goalsById, core.focusedGoalId), "info");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const labels = open.map((item) => goalSelectorLabel(item, core.focusedGoalId));
|
|
141
|
+
const byLabel = new Map(labels.map((label, index) => [label, open[index]?.id]));
|
|
142
|
+
core.enterGoalModal();
|
|
143
|
+
try {
|
|
144
|
+
const selected = await ctx.ui.select("Focus open goal", labels);
|
|
145
|
+
const selectedId = selected ? byLabel.get(selected) : undefined;
|
|
146
|
+
if (!selectedId) {
|
|
147
|
+
ctx.ui.notify("Goal focus unchanged.", "info");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
core.setFocusedGoalId(selectedId, ctx, "selected");
|
|
151
|
+
core.armFocusedContinuation(ctx);
|
|
152
|
+
} finally {
|
|
153
|
+
core.exitGoalModal();
|
|
154
|
+
}
|
|
155
|
+
ctx.ui.notify(`Focused goal: ${oneLineSummary(core.state.goal)}`, "info");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function unfocusGoalCommand(ctx: ExtensionContext): void {
|
|
159
|
+
const runtimeGoalId = core.state.goal?.id ?? core.runningGoalId ?? core.runtime.getCheckpointGoalId();
|
|
160
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
161
|
+
const current = core.state.goal;
|
|
162
|
+
const detachedGoalId = current?.id ?? runtimeGoalId;
|
|
163
|
+
let wasBusy = false;
|
|
164
|
+
try {
|
|
165
|
+
wasBusy = !ctx.isIdle();
|
|
166
|
+
} catch {}
|
|
167
|
+
if (detachedGoalId && wasBusy) core.runtime.markTurnStopped(detachedGoalId);
|
|
168
|
+
core.setFocusedGoalId(null, ctx, "unfocused", { recordLedger: false });
|
|
169
|
+
core.runningGoalId = null;
|
|
170
|
+
core.runtime.setCheckpoint(null);
|
|
171
|
+
core.runtime.clearPostCompactReminder();
|
|
172
|
+
core.activeAudit?.cancel("Goal cleared during completion audit.");
|
|
173
|
+
if (detachedGoalId && wasBusy) {
|
|
174
|
+
try {
|
|
175
|
+
ctx.abort?.();
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
if (!current) {
|
|
179
|
+
const openCount = otherOpenGoalCount(core.goalsById, null);
|
|
180
|
+
ctx.ui.notify(openCount > 0 ? buildUnfocusedOpenGoalsSummary(openCount) : detailedSummary(null, core.loadedCommands), "info");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
ctx.ui.notify(`Goal unfocused for this session. It remains open in .pi/goals: ${current.id}`, "info");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function handleDirectGoalSet(rawObjective: string, ctx: ExtensionContext, mode: GoalMode): void {
|
|
187
|
+
const raw = rawObjective.trim();
|
|
188
|
+
if (!raw) {
|
|
189
|
+
const command = `${commandHint(mode === "sisyphus" ? "sisyphus-direct" : "goal-direct", core.loadedCommands)} <objective>`;
|
|
190
|
+
ctx.ui.notify(`No objective provided. Use ${command}.`, "warning");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (mode === "sisyphus" && !sisyphusObjectiveSufficient(raw)) {
|
|
194
|
+
ctx.ui.notify(`A Sisyphus objective needs ordered steps with per-step done criteria. Use ${commandHint("sisyphus", core.loadedCommands)} for guided drafting, or provide numbered steps (1) ..., 2) ...) in the objective.`, "warning");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const settings = loadGoalSettings(ctx.cwd);
|
|
198
|
+
const { objective, verificationContract } = settings.disableContracts ? { objective: raw, verificationContract: undefined } : extractVerificationContract(raw);
|
|
199
|
+
clearGoalDrafting(core, ctx);
|
|
200
|
+
core.clearContinuationState();
|
|
201
|
+
core.clearActiveAccounting();
|
|
202
|
+
core.replaceGoal({ objective, autoContinue: true, sisyphus: mode === "sisyphus" }, ctx, true, verificationContract);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runGoalRecovery(rawArgs: string, ctx: ExtensionContext): Promise<void> {
|
|
206
|
+
const report = runRecoveryReport(ctx);
|
|
207
|
+
if (/^repair$/i.test(rawArgs)) {
|
|
208
|
+
const result = await runRecoveryRepair(ctx, report, async () => {
|
|
209
|
+
const confirmed = await ctx.ui.confirm(`Remove ${report.staleLocks.length} stale lock(s) and refresh the pool snapshot?`, `Files are backed up to .pi/goals/.recovery-backup first.`);
|
|
210
|
+
return confirmed === true;
|
|
211
|
+
});
|
|
212
|
+
if (result.confirmed) {
|
|
213
|
+
ctx.ui.notify(result.applied.length > 0
|
|
214
|
+
? `goal-recovery repair: ${result.applied.length} operation(s) applied.\n${result.applied.map((a) => ` - ${a}`).join("\n")}\nBackup: ${result.backupDir}`
|
|
215
|
+
: "goal-recovery repair: nothing to repair.", "info");
|
|
216
|
+
} else {
|
|
217
|
+
ctx.ui.notify("goal-recovery repair: cancelled — nothing changed.", "info");
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Issue #30: surface persisted-checkpoint health for this session file
|
|
222
|
+
// (read-only). Legacy full checkpoints are repairable offline with the
|
|
223
|
+
// shipped pi-goal-x-recover CLI; this command never writes.
|
|
224
|
+
const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.();
|
|
225
|
+
const checkpointHealth = sessionFile ? readSessionCheckpointHealth(sessionFile) : null;
|
|
226
|
+
const checkpointSection = checkpointHealth && checkpointHealth.total > 0
|
|
227
|
+
? `\n\n${formatCheckpointHealthReport(checkpointHealth, sessionFile)}`
|
|
228
|
+
: "";
|
|
229
|
+
ctx.ui.notify(
|
|
230
|
+
formatRecoveryReport(report, core.loadedCommands) + checkpointSection,
|
|
231
|
+
report.healthy && (checkpointHealth?.legacyFull ?? 0) === 0 ? "info" : "warning",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function runGoalRefresh(ctx: ExtensionContext): Promise<void> {
|
|
236
|
+
// Pre-state snapshots are cache-served (0 fs ops); the re-reads after
|
|
237
|
+
// invalidation go cold. This is the explicit user-owned path for picking
|
|
238
|
+
// up external edits — no watchers, no per-turn polling.
|
|
239
|
+
const beforePool = readActiveGoalPool(ctx);
|
|
240
|
+
const beforeLedger = readGoalLedger(ctx);
|
|
241
|
+
// Settings: the before fingerprint is cache-served; the after one is a
|
|
242
|
+
// cold re-read after invalidation — so an external settings edit shows
|
|
243
|
+
// up as a fingerprint difference.
|
|
244
|
+
const beforeSettings = settingsFingerprint(ctx);
|
|
245
|
+
|
|
246
|
+
invalidateGoalPoolCache();
|
|
247
|
+
invalidateGoalLedgerCache();
|
|
248
|
+
invalidateGoalSettingsCache();
|
|
249
|
+
|
|
250
|
+
const afterPool = readActiveGoalPool(ctx);
|
|
251
|
+
const afterLedger = readGoalLedger(ctx);
|
|
252
|
+
const afterSettings = settingsFingerprint(ctx);
|
|
253
|
+
|
|
254
|
+
const changes = diffGoalRefreshState({
|
|
255
|
+
poolIds: beforePool.keys(),
|
|
256
|
+
ledgerEvents: beforeLedger.events.length,
|
|
257
|
+
ledgerMalformed: beforeLedger.malformed,
|
|
258
|
+
settings: beforeSettings,
|
|
259
|
+
}, {
|
|
260
|
+
poolIds: afterPool.keys(),
|
|
261
|
+
ledgerEvents: afterLedger.events.length,
|
|
262
|
+
ledgerMalformed: afterLedger.malformed,
|
|
263
|
+
settings: afterSettings,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const text = changes.length > 0
|
|
267
|
+
? `goal-refresh: re-read caches from disk — ${changes.length} change(s):\n${changes.map((c) => ` - ${c}`).join("\n")}`
|
|
268
|
+
: "goal-refresh: no changes detected — caches were already current.";
|
|
269
|
+
ctx.ui.notify(text, "info");
|
|
270
|
+
core.scheduler.refreshSettings(ctx);
|
|
271
|
+
core.updateUI(ctx);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Stable fingerprint of the effective settings (cache-served before invalidation). */
|
|
275
|
+
function settingsFingerprint(ctx: ExtensionContext): string {
|
|
276
|
+
return JSON.stringify(loadGoalSettingsFileConfig(ctx.cwd));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function showGoalStatus(rawArgs: string, ctx: ExtensionContext): Promise<void> {
|
|
280
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
281
|
+
if (core.state.goal) core.syncGoalPromptFromDisk(ctx);
|
|
282
|
+
const view = core.goalForDisplay() ?? core.state.goal;
|
|
283
|
+
const otherCount = otherOpenGoalCount(core.goalsById, core.focusedGoalId);
|
|
284
|
+
const verbose = /^\s*verbose\b/i.test(rawArgs);
|
|
285
|
+
const health = /^\s*health\b/i.test(rawArgs);
|
|
286
|
+
const ledger = readGoalLedger(ctx);
|
|
287
|
+
const text = buildGoalStatusText({
|
|
288
|
+
commands: core.loadedCommands,
|
|
289
|
+
keybindings: loadGoalSettings(ctx.cwd).keybindings,
|
|
290
|
+
maxAutonomousRuns: loadGoalSettings(ctx.cwd).maxAutonomousRuns,
|
|
291
|
+
idleContinuationBackoff: loadGoalSettings(ctx.cwd).idleContinuationBackoff,
|
|
292
|
+
goal: view,
|
|
293
|
+
focused: view !== null && core.focusedGoalId === view.id,
|
|
294
|
+
otherOpenGoals: otherCount,
|
|
295
|
+
ledgerEvents: ledger.events,
|
|
296
|
+
ledgerMalformed: ledger.malformed,
|
|
297
|
+
verbose,
|
|
298
|
+
health,
|
|
299
|
+
activeFilePresent: health && view?.activePath
|
|
300
|
+
? existsSync(goalStoragePath(ctx, view.activePath))
|
|
301
|
+
: undefined,
|
|
302
|
+
// Issue #30: checkpoint growth report (health view only, read-only).
|
|
303
|
+
checkpointHealth: health
|
|
304
|
+
? readSessionCheckpointHealth(
|
|
305
|
+
(ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.() ?? "",
|
|
306
|
+
)
|
|
307
|
+
: null,
|
|
308
|
+
checkpointSessionFile: (ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.(),
|
|
309
|
+
// §13.2: effective settings with provenance appear only in verbose mode;
|
|
310
|
+
// the standard mode stays free of settings noise (§13.1).
|
|
311
|
+
settingsReport: verbose ? [...effectiveSettingsReport(ctx.cwd), `effective goal pool: ${goalStorageRoot(ctx)}`] : [],
|
|
312
|
+
});
|
|
313
|
+
ctx.ui.notify(text, "info");
|
|
314
|
+
core.updateUI(ctx);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function handleGoalPause(ctx: ExtensionContext): Promise<void> {
|
|
318
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
319
|
+
if (!core.state.goal) {
|
|
320
|
+
if (otherOpenGoalCount(core.goalsById, null) > 0) {
|
|
321
|
+
const selected = await chooseOpenGoal(ctx, "Pause which open goal?");
|
|
322
|
+
if (!selected) return;
|
|
323
|
+
} else {
|
|
324
|
+
ctx.ui.notify("No goal is set.", "warning");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const currentGoal = core.state.goal;
|
|
329
|
+
if (!currentGoal) return;
|
|
330
|
+
if (currentGoal.status === "complete") {
|
|
331
|
+
ctx.ui.notify("Goal is complete.", "warning");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (currentGoal.status === "paused") {
|
|
335
|
+
ctx.ui.notify(`Goal is already paused. Use ${commandHint("goal-resume", core.loadedCommands)} to continue.`, "info");
|
|
336
|
+
if (!ctx.isIdle()) ctx.abort?.();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
core.interruption.reset();
|
|
340
|
+
ctx.ui.setStatus("goal-interrupt", undefined);
|
|
341
|
+
core.pauseActiveGoal(ctx);
|
|
342
|
+
ctx.abort?.();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function handleGoalResume(ctx: ExtensionContext): Promise<void> {
|
|
346
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
347
|
+
if (!core.state.goal && otherOpenGoalCount(core.goalsById, null) > 0) {
|
|
348
|
+
const selected = await chooseOpenGoal(ctx, "Resume or focus open goal");
|
|
349
|
+
if (!selected) return;
|
|
350
|
+
}
|
|
351
|
+
if (!core.state.goal) { ctx.ui.notify("No goal is focused.", "warning"); return; }
|
|
352
|
+
if (!core.scheduler.resume(ctx)) return;
|
|
353
|
+
ctx.ui.notify("Goal resumed; autonomous allowance renewed.", "info");
|
|
354
|
+
// Append ledger event for resumption
|
|
355
|
+
try {
|
|
356
|
+
core.goalService.appendEvents(ctx, [{
|
|
357
|
+
type: "goal_resumed",
|
|
358
|
+
goalId: core.state.goal.id,
|
|
359
|
+
reason: "user",
|
|
360
|
+
at: nowIso(),
|
|
361
|
+
}]);
|
|
362
|
+
} catch {
|
|
363
|
+
// Ledger append failure should not crash resume
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* One declarative row table for the settings menu (follow-up Stage 1).
|
|
369
|
+
* Rendering and dispatch both derive from SETTING_ROWS so the displayed
|
|
370
|
+
* fields and the selectable fields can never drift apart. Rows are grouped
|
|
371
|
+
* into sections (Goal behavior / Task tracking / Completion auditor). All
|
|
372
|
+
* eight persisted fields are present and operable.
|
|
373
|
+
*/
|
|
374
|
+
type SettingRow = {
|
|
375
|
+
key: keyof GoalSettings | string;
|
|
376
|
+
label: string;
|
|
377
|
+
section: "Goal behavior" | "Task tracking" | "Completion auditor" | "Blocker Oracle" | "Advanced execution";
|
|
378
|
+
kind: "boolean" | "modelSelector" | "thinking" | "positiveInteger";
|
|
379
|
+
/** Settings path for the mutation (defaults to [key]). */
|
|
380
|
+
path?: string[];
|
|
381
|
+
help?: string;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const SETTING_ROWS: readonly SettingRow[] = [
|
|
385
|
+
{ key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
|
|
386
|
+
{ key: "hideUnfocusedBanner", label: "hideUnfocusedBanner", section: "Goal behavior", kind: "boolean" },
|
|
387
|
+
{ key: "hideUnfocusedPrompt", label: "hideUnfocusedPrompt", section: "Goal behavior", kind: "boolean" },
|
|
388
|
+
{ key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
|
|
389
|
+
{ key: "strictExecutionContract", label: "explicit execution contracts (opt-in)", section: "Goal behavior", kind: "boolean" },
|
|
390
|
+
{ key: "maxAutonomousRuns", label: "autonomous run allowance", section: "Goal behavior", kind: "positiveInteger" },
|
|
391
|
+
{ key: "showAutonomousRuns", label: "show autonomous runs line", section: "Goal behavior", kind: "boolean" },
|
|
392
|
+
{ key: "showContextUsage", label: "show context usage line", section: "Goal behavior", kind: "boolean" },
|
|
393
|
+
{ key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
|
|
394
|
+
{ key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
|
|
395
|
+
{ key: "idleContinuationBackoff", label: "slow repeated idle replies (opt-in)", section: "Advanced execution", kind: "boolean",
|
|
396
|
+
help: "Slows repeated replies without recognized work. May delay reasoning or custom tools. For a hard stop, set an autonomous run allowance." },
|
|
397
|
+
{ key: "disableTasks", label: "disableTasks", section: "Task tracking", kind: "boolean" },
|
|
398
|
+
{ key: "subtaskDepth", label: "subtaskDepth", section: "Task tracking", kind: "positiveInteger" },
|
|
399
|
+
{ key: "disabled", label: "auditor disabled", section: "Completion auditor", kind: "boolean" },
|
|
400
|
+
{ key: "provider", label: "provider", section: "Completion auditor", kind: "modelSelector" },
|
|
401
|
+
{ key: "model", label: "model", section: "Completion auditor", kind: "modelSelector" },
|
|
402
|
+
{ key: "thinkingLevel", label: "thinking_level", section: "Completion auditor", kind: "thinking" },
|
|
403
|
+
// Issue #26: opt-in blocker Oracle. Disabled by default; provider/model
|
|
404
|
+
// must BOTH be set explicitly — the executor model is never used silently.
|
|
405
|
+
{ key: "oracleEnabled", label: "oracle enabled", section: "Blocker Oracle", kind: "boolean", path: ["oracle", "enabled"] },
|
|
406
|
+
{ key: "oracleProviderModel", label: "oracle provider/model", section: "Blocker Oracle", kind: "modelSelector", path: ["oracle"] },
|
|
407
|
+
{ key: "oracleThinkingLevel", label: "oracle thinking_level", section: "Blocker Oracle", kind: "thinking", path: ["oracle", "thinkingLevel"] },
|
|
408
|
+
{ key: "oracleProjectResources", label: "oracle project resources", section: "Blocker Oracle", kind: "boolean", path: ["oracle", "projectResources"] },
|
|
409
|
+
{ key: "oracleMaxFailedAttemptsPerBlocker", label: "max failed attempts per blocker", section: "Blocker Oracle", kind: "positiveInteger", path: ["oracle", "maxFailedAttemptsPerBlocker"] },
|
|
410
|
+
];
|
|
411
|
+
|
|
412
|
+
function settingsValue(config: GoalSettings, key: keyof GoalSettings | string): string {
|
|
413
|
+
if (key === "idleContinuationBackoff" || key === "strictExecutionContract" || key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner" || key === "hideUnfocusedPrompt" || key === "showAutonomousRuns" || key === "showContextUsage") {
|
|
414
|
+
return config[key] === true ? "true" : "false";
|
|
415
|
+
}
|
|
416
|
+
if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
|
|
417
|
+
if (key === "maxAutonomousRuns") return config.maxAutonomousRuns === 0 ? "0 (disabled)" : String(config.maxAutonomousRuns ?? "unlimited (default)");
|
|
418
|
+
if (key === "stallTimeoutMinutes") return config.stallTimeoutMinutes !== undefined ? String(config.stallTimeoutMinutes) : "0";
|
|
419
|
+
if (key === "objectiveMaxChars") return config.objectiveMaxChars !== undefined ? String(config.objectiveMaxChars) : "0";
|
|
420
|
+
if (key === "keybindings") return config.keybindings ? `${config.keybindings.dashboard.toggleExpand}, ${config.keybindings.dashboard.scrollUp}, ${config.keybindings.dashboard.scrollDown}` : "(default)";
|
|
421
|
+
const value = (config as Record<string, unknown>)[key];
|
|
422
|
+
return typeof value === "string" ? value : "(default)";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function settingsLines(config: GoalSettings): string[] {
|
|
426
|
+
return SETTING_ROWS.map((row) => `${row.label}: ${settingsValue(config, row.key)}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function handleSettingsMenu(ctx: ExtensionContext): Promise<void> {
|
|
430
|
+
if (!ctx.hasUI) {
|
|
431
|
+
// Headless: read-only report of both layer paths.
|
|
432
|
+
ctx.ui.notify(
|
|
433
|
+
`Settings — project file: ${goalSettingsPath(ctx.cwd)}\nGlobal file: ${goalGlobalSettingsPath()}`,
|
|
434
|
+
"info",
|
|
435
|
+
);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* After any mutation: reinstall the fixed goal tool profile when
|
|
441
|
+
* the effective disableTasks value changed since the last install, and
|
|
442
|
+
* refresh the UI. This is the central settings side-effect hook — no
|
|
443
|
+
* ad hoc per-row refresh code.
|
|
444
|
+
*/
|
|
445
|
+
const applyEffectiveSettings = (): void => {
|
|
446
|
+
const tasksEnabledNow = !loadGoalSettings(ctx.cwd).disableTasks;
|
|
447
|
+
if (tasksEnabledNow !== core.tasksEnabled) {
|
|
448
|
+
core.installGoalToolProfile(tasksEnabledNow);
|
|
449
|
+
}
|
|
450
|
+
// PR #29: settings changes must be visible immediately — the banner
|
|
451
|
+
// hide/restore happens through this coalesced UI refresh.
|
|
452
|
+
core.updateUI(ctx);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const applyMutation = (scope: SettingsScope, mutation: SettingsMutation): boolean => {
|
|
456
|
+
try {
|
|
457
|
+
mutateSettingsLayer({ scope, cwd: ctx.cwd, mutation });
|
|
458
|
+
applyEffectiveSettings();
|
|
459
|
+
if (mutation.path[0] === "idleContinuationBackoff") core.scheduler.refreshSettings(ctx);
|
|
460
|
+
return true;
|
|
461
|
+
} catch (err) {
|
|
462
|
+
ctx.ui.notify(`Settings change failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const scopePath = (scope: SettingsScope): string =>
|
|
468
|
+
scope === "global" ? goalGlobalSettingsPath() : goalSettingsPath(ctx.cwd);
|
|
469
|
+
|
|
470
|
+
core.enterGoalModal();
|
|
471
|
+
try {
|
|
472
|
+
let scope: SettingsScope = "project";
|
|
473
|
+
while (true) {
|
|
474
|
+
const snapshot = loadSettingsSnapshot(ctx.cwd);
|
|
475
|
+
const localLayer = snapshot[scope].layer as Record<string, unknown>;
|
|
476
|
+
const options: string[] = [];
|
|
477
|
+
options.push(`─── Editing: ${scope} (${scopePath(scope)}) ───`);
|
|
478
|
+
options.push(`Scope: ${scope} (select to switch)`);
|
|
479
|
+
options.push("Controls: commands, keys and pause order…");
|
|
480
|
+
let lastSection: string | null = null;
|
|
481
|
+
const pathOf = (row: SettingRow): string[] => row.path ?? [String(row.key)];
|
|
482
|
+
const valueAtPath = (obj: unknown, path: string[]): unknown => {
|
|
483
|
+
let cursor: unknown = obj;
|
|
484
|
+
for (const segment of path) {
|
|
485
|
+
if (!cursor || typeof cursor !== "object") return undefined;
|
|
486
|
+
cursor = (cursor as Record<string, unknown>)[segment];
|
|
487
|
+
}
|
|
488
|
+
return cursor;
|
|
489
|
+
};
|
|
490
|
+
for (const row of SETTING_ROWS) {
|
|
491
|
+
if (row.section !== lastSection) {
|
|
492
|
+
options.push(`─── ${row.section} ───`);
|
|
493
|
+
lastSection = row.section;
|
|
494
|
+
}
|
|
495
|
+
const envVar = typeof row.key === "string" ? envOverrideFor(row.key as keyof GoalSettings) : null;
|
|
496
|
+
if (envVar) {
|
|
497
|
+
options.push(` ${row.label}: ${settingsValue(snapshot.value, row.key)} (environment; read-only)`);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const rowPath = pathOf(row);
|
|
501
|
+
const provenance = snapshot.provenance.get(rowPath.join("."));
|
|
502
|
+
let source: string;
|
|
503
|
+
if (provenance?.source === "global") source = scope === "project" ? "(inherited from global)" : "(global override)";
|
|
504
|
+
else if (provenance?.source === "project") source = scope === "project" ? "(project override)" : "(project; edit via project scope)";
|
|
505
|
+
else source = "(default)";
|
|
506
|
+
const hasLocalOverride = valueAtPath(localLayer, rowPath) !== undefined;
|
|
507
|
+
if (hasLocalOverride && provenance?.source === scope) source = `(${scope} override)`;
|
|
508
|
+
// Nested rows render their leaf value directly.
|
|
509
|
+
const displayValue = row.path
|
|
510
|
+
? String(valueAtPath(snapshot.value, rowPath) ?? "(default)")
|
|
511
|
+
: settingsValue(snapshot.value, row.key);
|
|
512
|
+
options.push(` ${row.label}: ${displayValue} ${source}`);
|
|
513
|
+
}
|
|
514
|
+
options.push("Done");
|
|
515
|
+
const selected = await ctx.ui.select("Goal settings", options);
|
|
516
|
+
if (!selected || selected === "Done") break;
|
|
517
|
+
if (selected.startsWith("Controls:")) { if (await showGoalControls(core, ctx, scope)) return; continue; }
|
|
518
|
+
if (selected.startsWith("───")) continue; // headers are not rows
|
|
519
|
+
const trimmed = selected.trim();
|
|
520
|
+
if (trimmed.startsWith("Scope:")) {
|
|
521
|
+
scope = scope === "project" ? "global" : "project";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const colon = trimmed.indexOf(":");
|
|
525
|
+
if (colon === -1) continue;
|
|
526
|
+
const label = trimmed.slice(0, colon).trim();
|
|
527
|
+
const row = SETTING_ROWS.find((r) => r.label === label);
|
|
528
|
+
if (!row) continue;
|
|
529
|
+
const envVarForRow2 = envOverrideFor(row.key as keyof GoalSettings);
|
|
530
|
+
if (envVarForRow2) {
|
|
531
|
+
ctx.ui.notify(`${row.label} is read-only: overridden by the ${envVarForRow2} env var.`, "warning");
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// Per-row settings path (flat keys default to [key]; nested Blocker
|
|
535
|
+
// Oracle rows carry explicit paths).
|
|
536
|
+
const rowPath = row.path ?? [String(row.key)];
|
|
537
|
+
const pathKeyStr = rowPath.join(".");
|
|
538
|
+
const localValueAtPath = (() => {
|
|
539
|
+
let cursor: unknown = localLayer;
|
|
540
|
+
for (const segment of rowPath) {
|
|
541
|
+
if (!cursor || typeof cursor !== "object") return undefined;
|
|
542
|
+
cursor = (cursor as Record<string, unknown>)[segment];
|
|
543
|
+
}
|
|
544
|
+
return cursor;
|
|
545
|
+
})();
|
|
546
|
+
|
|
547
|
+
const inheritLabel = scope === "project" ? "Use inherited value" : "Use default (delete global override)";
|
|
548
|
+
const hasLocalOverride = localValueAtPath !== undefined;
|
|
549
|
+
|
|
550
|
+
if (row.kind === "boolean") {
|
|
551
|
+
const effective = snapshot.provenance.get(pathKeyStr)?.value === true;
|
|
552
|
+
const actions = [
|
|
553
|
+
`Set ${scope} override to true`,
|
|
554
|
+
`Set ${scope} override to false`,
|
|
555
|
+
];
|
|
556
|
+
if (hasLocalOverride) actions.push(inheritLabel);
|
|
557
|
+
actions.push("Cancel");
|
|
558
|
+
const action = await ctx.ui.select(`${row.label} (${scope})${row.help ? `\n${row.help}` : ""}`, actions);
|
|
559
|
+
if (!action || action === "Cancel") continue;
|
|
560
|
+
if (action === inheritLabel) {
|
|
561
|
+
applyMutation(scope, { op: "unset", path: rowPath });
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const value = action.endsWith("true");
|
|
565
|
+
if (value !== effective || !hasLocalOverride) {
|
|
566
|
+
applyMutation(scope, { op: "set", path: rowPath, value });
|
|
567
|
+
}
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (row.kind === "positiveInteger") {
|
|
572
|
+
const min = row.path ? 1 : ((row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars" || row.key === "maxAutonomousRuns") ? 0 : 1);
|
|
573
|
+
const actions = [`Set ${scope} override...`];
|
|
574
|
+
if (hasLocalOverride) actions.push(inheritLabel);
|
|
575
|
+
actions.push("Cancel");
|
|
576
|
+
const action = await ctx.ui.select(`${row.label} (${scope})${row.help ? `\n${row.help}` : ""}`, actions);
|
|
577
|
+
if (!action || action === "Cancel") continue;
|
|
578
|
+
if (action === inheritLabel) {
|
|
579
|
+
applyMutation(scope, { op: "unset", path: rowPath });
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const input = await ctx.ui.input(`Set ${row.label}`, String(snapshot.provenance.get(pathKeyStr)?.value ?? min));
|
|
583
|
+
if (input === undefined) continue;
|
|
584
|
+
// Full-string decimal validation: rejects 1.5, 1x, negatives,
|
|
585
|
+
// infinity, and unsafe integers alike.
|
|
586
|
+
const value = input.trim();
|
|
587
|
+
if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min) {
|
|
588
|
+
ctx.ui.notify(`${row.label} must be an integer >= ${min} (e.g. ${min}, ${min + 1}, ${min + 2})`, "warning");
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// Oracle attempt cap is bounded at 3 by the settings parser.
|
|
592
|
+
if (row.path?.[0] === "oracle" && Number(value) > 3) {
|
|
593
|
+
ctx.ui.notify(`${row.label} must be an integer between 1 and 3`, "warning");
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
applyMutation(scope, { op: "set", path: rowPath, value: Number(value) });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (row.kind === "thinking") {
|
|
601
|
+
const currentValue = settingsValue(snapshot.value, row.key);
|
|
602
|
+
const levels = thinkingLevelChoices(currentValue === "(default)" ? undefined : currentValue);
|
|
603
|
+
const picked = await ctx.ui.select(`Set ${row.label} (${scope})`, levels);
|
|
604
|
+
if (!picked) continue;
|
|
605
|
+
const choice = picked.trim().replace(/^\u2713\s+/, "");
|
|
606
|
+
if (choice === "(default)") {
|
|
607
|
+
if (hasLocalOverride) applyMutation(scope, { op: "unset", path: rowPath });
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (!(AUDITOR_THINKING_LEVELS as readonly string[]).includes(choice)) {
|
|
611
|
+
ctx.ui.notify(`thinking_level must be one of: ${AUDITOR_THINKING_LEVELS.join(", ")} (or "(default)")`, "warning");
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
applyMutation(scope, { op: "set", path: rowPath, value: choice });
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// modelSelector rows (provider, model): searchable model picker with
|
|
619
|
+
// explicit inherit/default handling. Auditor rows apply provider+model
|
|
620
|
+
// at the flat paths; the Oracle row writes oracle.provider/oracle.model.
|
|
621
|
+
const oraclePair = row.path?.[0] === "oracle";
|
|
622
|
+
const pairPrefix = oraclePair ? ["oracle"] : [];
|
|
623
|
+
const configuredBase = oraclePair
|
|
624
|
+
? [snapshot.value.oracle?.provider, snapshot.value.oracle?.model].filter(Boolean).join("/")
|
|
625
|
+
: configuredAuditorModelKey(snapshot.value as GoalSettings);
|
|
626
|
+
const configured = configuredBase || undefined;
|
|
627
|
+
const session = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
628
|
+
const choices = buildAuditorModelChoices(ctx.modelRegistry.getAvailable(), configured, session);
|
|
629
|
+
const filter = await ctx.ui.input("Filter auditor models (provider/id/name; blank = all)", "");
|
|
630
|
+
if (filter === undefined) continue;
|
|
631
|
+
const filtered = filterAuditorModelChoices(choices, filter);
|
|
632
|
+
const picked = await ctx.ui.select("Select auditor model", filtered.map((choice: AuditorChoice) => choice.label));
|
|
633
|
+
if (!picked) continue;
|
|
634
|
+
const choice = filtered.find((candidate: AuditorChoice) => candidate.label === picked);
|
|
635
|
+
if (!choice) continue;
|
|
636
|
+
if (choice.kind === "default") {
|
|
637
|
+
for (const key of ["provider", "model"] as const) {
|
|
638
|
+
if (localLayer[key] !== undefined) applyMutation(scope, { op: "unset", path: [...pairPrefix, key] });
|
|
639
|
+
}
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
let providerValue: string;
|
|
643
|
+
let modelValue: string;
|
|
644
|
+
if (choice.kind === "manual") {
|
|
645
|
+
const input = await ctx.ui.input("Set auditor provider/model", configured ?? "provider/model");
|
|
646
|
+
if (input === undefined) continue;
|
|
647
|
+
const parsed = parseManualAuditorModel(input);
|
|
648
|
+
if ("error" in parsed) {
|
|
649
|
+
ctx.ui.notify(parsed.error, "warning");
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
providerValue = parsed.provider;
|
|
653
|
+
modelValue = parsed.model;
|
|
654
|
+
} else {
|
|
655
|
+
providerValue = choice.provider;
|
|
656
|
+
modelValue = choice.model;
|
|
657
|
+
}
|
|
658
|
+
if (applyMutation(scope, { op: "set", path: [...pairPrefix, "provider"], value: providerValue })) {
|
|
659
|
+
applyMutation(scope, { op: "set", path: [...pairPrefix, "model"], value: modelValue });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} finally {
|
|
663
|
+
core.exitGoalModal();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async function handleGoalClear(ctx: ExtensionContext): Promise<void> {
|
|
667
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
668
|
+
if (!core.state.goal && otherOpenGoalCount(core.goalsById, null) > 0) {
|
|
669
|
+
const selected = await chooseOpenGoal(ctx, "Clear which open goal?");
|
|
670
|
+
if (!selected) return;
|
|
671
|
+
}
|
|
672
|
+
if (!core.state.goal) {
|
|
673
|
+
ctx.ui.notify(clearGoalCommandMessage({ archived: false }), "warning");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
// Snapshot the selected goal id and focus revision before asking.
|
|
677
|
+
const target = core.state.goal;
|
|
678
|
+
const focusToken = core.focusedOperationToken(target.id);
|
|
679
|
+
// Headless behavior is explicit: guidance without mutation. Clearing
|
|
680
|
+
// requires an interactive confirmation (follow-up Stage 2).
|
|
681
|
+
if (!ctx.hasUI) {
|
|
682
|
+
ctx.ui.notify(`Run ${commandHint("goal-clear", core.loadedCommands)} in an interactive session to confirm clearing: ${oneLineSummary(target)}`, "warning");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const confirmed = await ctx.ui.confirm("Clear goal?", oneLineSummary(target));
|
|
686
|
+
if (!confirmed) {
|
|
687
|
+
ctx.ui.notify("Goal clear cancelled.", "info");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// Reconcile and validate the same focus token after confirmation, then
|
|
691
|
+
// archive. Cancellation above changes no file, focus entry, ledger
|
|
692
|
+
// entry, or runtime state.
|
|
693
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
694
|
+
if (!core.isFocusedOperationCurrent(focusToken) || !core.state.goal || core.state.goal.id !== target.id) {
|
|
695
|
+
ctx.ui.notify("Goal changed while confirming; nothing was cleared.", "warning");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const archived = core.archiveCurrentGoal(ctx, "user");
|
|
699
|
+
const didArchive = !!archived;
|
|
700
|
+
core.setGoal(null, ctx, true, "cleared");
|
|
701
|
+
const msg = clearGoalCommandMessage({ archived: didArchive });
|
|
702
|
+
ctx.ui.notify(msg, didArchive ? "info" : "warning");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function runGoalTweak(replacement: string, ctx: ExtensionContext): Promise<void> {
|
|
706
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
707
|
+
if (!core.state.goal) {
|
|
708
|
+
if (otherOpenGoalCount(core.goalsById, null) > 0) {
|
|
709
|
+
const selected = await chooseOpenGoal(ctx, "Tweak which open goal?");
|
|
710
|
+
if (!selected) return;
|
|
711
|
+
} else {
|
|
712
|
+
ctx.ui.notify(`No goal is set. Use ${commandHint("goal", core.loadedCommands)} to draft one, or ${commandHint("goal-direct", core.loadedCommands)} <objective> to create one immediately.`, "warning");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const currentGoal = core.state.goal;
|
|
717
|
+
if (!currentGoal) return;
|
|
718
|
+
if (currentGoal.status === "complete") {
|
|
719
|
+
ctx.ui.notify(`Goal is complete. Use ${commandHint("goal", core.loadedCommands)} to draft a new one, or ${commandHint("goal-direct", core.loadedCommands)} <objective> to create one immediately.`, "warning");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const trimmed = replacement.trim();
|
|
723
|
+
if (!trimmed) {
|
|
724
|
+
ctx.ui.notify(`Describe the requested change: ${commandHint("goal-tweak", core.loadedCommands)} <change>, for example remove the token budget`, "info");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const max = loadGoalSettings(ctx.cwd).objectiveMaxChars;
|
|
728
|
+
if (trimmed.length > (max ?? 0)) {
|
|
729
|
+
if (max !== undefined && max > 0) {
|
|
730
|
+
ctx.ui.notify(`Requested change exceeds ${max} characters (${trimmed.length}).`, "warning");
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
await startGoalDrafting(core, ctx, "tweak", trimmed, currentGoal);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// /goal and /sisyphus are the guided default. -direct commands are the explicit bypass.
|
|
738
|
+
define("goal", {
|
|
739
|
+
description: "Draft a regular goal with clarification, task planning, and confirmation.",
|
|
740
|
+
handler: async (rawArgs, ctx) => {
|
|
741
|
+
await startGoalDrafting(core, ctx, "goal", rawArgs);
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
define("sisyphus", {
|
|
745
|
+
description: "Draft a Sisyphus goal with clarification, task planning, and confirmation.",
|
|
746
|
+
handler: async (rawArgs, ctx) => {
|
|
747
|
+
await startGoalDrafting(core, ctx, "sisyphus", rawArgs);
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
define("goal-cancel", {
|
|
751
|
+
description: "Cancel the in-progress guided draft without creating or modifying a goal.",
|
|
752
|
+
handler: async (_rawArgs, ctx) => {
|
|
753
|
+
if (!hasActiveDraft(core)) {
|
|
754
|
+
ctx.ui.notify("No active draft to cancel.", "info");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
clearGoalDrafting(core, ctx);
|
|
758
|
+
core.clearContinuationState();
|
|
759
|
+
ctx.ui.notify("Draft cancelled; no goal was created.", "info");
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
define("goal-direct", {
|
|
763
|
+
description: "Create and start a regular goal immediately, without drafting.",
|
|
764
|
+
handler: async (rawArgs, ctx) => { handleDirectGoalSet(rawArgs, ctx, "goal"); },
|
|
765
|
+
});
|
|
766
|
+
define("sisyphus-direct", {
|
|
767
|
+
description: "Create and start a Sisyphus goal immediately, without drafting.",
|
|
768
|
+
handler: async (rawArgs, ctx) => { handleDirectGoalSet(rawArgs, ctx, "sisyphus"); },
|
|
769
|
+
});
|
|
770
|
+
define("goal-list", {
|
|
771
|
+
description: "List all open goals and the current focus.",
|
|
772
|
+
handler: async (_rawArgs, ctx) => {
|
|
773
|
+
core.reconcileFocusedGoalFromDisk(ctx);
|
|
774
|
+
ctx.ui.notify(buildGoalListText(core.goalsById, core.focusedGoalId), "info");
|
|
775
|
+
core.updateUI(ctx);
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
define("goal-status", {
|
|
779
|
+
description: "Show the unified goal dashboard (read-only). Append \"verbose\" for full detail or \"health\" for storage/runtime checks.",
|
|
780
|
+
handler: async (rawArgs, ctx) => {
|
|
781
|
+
await showGoalStatus(rawArgs ?? "", ctx);
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
define("goal-refresh", {
|
|
785
|
+
description: "Re-read goal storage caches (pool, ledger, settings) from disk and report what changed. Picks up external edits to .pi files — no file watchers needed.",
|
|
786
|
+
handler: async (_rawArgs, ctx) => {
|
|
787
|
+
await runGoalRefresh(ctx);
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
define("goal-recovery", {
|
|
791
|
+
description: "Read-only storage/recovery report (malformed goal files, malformed ledger lines, stale locks, orphaned snapshot data). Append \"repair\" to remove stale locks and refresh the pool snapshot after confirmation (with backup).",
|
|
792
|
+
handler: async (rawArgs, ctx) => {
|
|
793
|
+
await runGoalRecovery((rawArgs ?? "").trim(), ctx);
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
define("goal-focus", {
|
|
797
|
+
description: "Choose which open goal this session focuses on.",
|
|
798
|
+
handler: async (_rawArgs, ctx) => {
|
|
799
|
+
await focusGoalCommand(ctx);
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
define("goal-unfocus", {
|
|
803
|
+
description: "Stop focusing the current goal (session only; goal stays open).",
|
|
804
|
+
handler: async (_rawArgs, ctx) => {
|
|
805
|
+
unfocusGoalCommand(ctx);
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
define("goal-settings", {
|
|
809
|
+
description: "Goal settings and controls; accepts controls for commands, keys and pause order.",
|
|
810
|
+
handler: async (rawArgs, ctx) => {
|
|
811
|
+
if (rawArgs.trim() === "controls") await showGoalControls(core, ctx);
|
|
812
|
+
else await handleSettingsMenu(ctx);
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
define("goal-tweak", {
|
|
816
|
+
description: "Revise the current goal or its token budget with confirmation.",
|
|
817
|
+
handler: async (rawArgs, ctx) => {
|
|
818
|
+
await runGoalTweak(rawArgs, ctx);
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
define("goal-clear", {
|
|
822
|
+
description: "Archive the current goal after confirmation (user-owned abandonment).",
|
|
823
|
+
handler: async (_rawArgs, ctx) => {
|
|
824
|
+
await handleGoalClear(ctx);
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
define("goal-pause", {
|
|
828
|
+
description: "Pause the currently running goal and stop its turn.",
|
|
829
|
+
handler: async (_rawArgs, ctx) => {
|
|
830
|
+
await handleGoalPause(ctx);
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
define("goal-resume", {
|
|
834
|
+
description: "Continue now and renew the configured autonomous-run allowance.",
|
|
835
|
+
handler: async (_rawArgs, ctx) => {
|
|
836
|
+
await handleGoalResume(ctx);
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
const configured = loadGoalSettings(process.cwd()).commands;
|
|
840
|
+
const problems = commandProblems(configured);
|
|
841
|
+
core.loadedCommands = problems.length ? undefined : configured;
|
|
842
|
+
core.controlWarnings = problems;
|
|
843
|
+
const routes = commandRoutes(core.loadedCommands);
|
|
844
|
+
core.invokeGoalCommand = async (id, args, ctx) => {
|
|
845
|
+
const action = actions.get(id);
|
|
846
|
+
if (!action) return;
|
|
847
|
+
core.enterGoalModal();
|
|
848
|
+
try { await action.handler(args, ctx); }
|
|
849
|
+
finally { core.exitGoalModal(); }
|
|
850
|
+
};
|
|
851
|
+
const roots = [...actions.keys()].flatMap(id => commandPaths(core.loadedCommands, id).map(route => route.split(" ")[0]!));
|
|
852
|
+
for (const root of new Set([...roots, "goal-settings"])) {
|
|
853
|
+
const rootRoutes = routes.filter(route => route.path.split(" ")[0] === root);
|
|
854
|
+
const direct = rootRoutes.find(route => route.path === root);
|
|
855
|
+
pi.registerCommand(root, {
|
|
856
|
+
description: direct ? actions.get(direct.id)!.description : `Goal actions: ${rootRoutes.map(route => route.path.slice(root.length + 1)).join(", ")}`,
|
|
857
|
+
getArgumentCompletions: prefix => rootRoutes.filter(route => route.path !== root && route.path.slice(root.length + 1).startsWith(prefix)).map(route => ({ value: route.path.slice(root.length + 1), label: route.path.slice(root.length + 1), description: GOAL_COMMANDS.find(([id]) => id === route.id)![1] })),
|
|
858
|
+
handler: async (args, ctx) => {
|
|
859
|
+
const match = matchCommandRoute(routes, root, args);
|
|
860
|
+
if (match) await core.invokeGoalCommand!(match.id, match.args, ctx);
|
|
861
|
+
else ctx.ui.notify(`Choose a goal command: ${rootRoutes.map(route => `/${route.path}`).join(", ")}`, "info");
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
}
|