pi-goal-list-loop-audit 0.15.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 +252 -0
- package/docs/DESIGN.md +214 -0
- package/extensions/goal-loop-auditor.ts +391 -0
- package/extensions/goal-loop-backoff.ts +99 -0
- package/extensions/goal-loop-core.ts +506 -0
- package/extensions/goal-loop-display.ts +160 -0
- package/extensions/goal-loop-forever.ts +230 -0
- package/extensions/goal-loop-shield.ts +59 -0
- package/extensions/loops/goal.ts +2323 -0
- package/package.json +66 -0
- package/prompts/goal-loop-continuation.md +83 -0
- package/prompts/goal-loop-draft.md +39 -0
- package/prompts/goal-loop-forever-draft.md +50 -0
- package/prompts/goal-loop-forever.md +46 -0
- package/schemas/goal.schema.json +98 -0
- package/scripts/smoke.sh +210 -0
|
@@ -0,0 +1,2323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal-list-loop-audit — v0.1.0
|
|
3
|
+
* extensions/loops/goal.ts
|
|
4
|
+
*
|
|
5
|
+
* The goal loop. The agent continues working, and on complete_goal,
|
|
6
|
+
* an isolated auditor verifies the work.
|
|
7
|
+
*
|
|
8
|
+
* Design: see docs/DESIGN.md.
|
|
9
|
+
*
|
|
10
|
+
* Command surface (v0.8.0 — four top-level commands):
|
|
11
|
+
* /goal "<objective>" | /goal (draft) | /goal status|pause|resume|cancel|tweak <text>|archive
|
|
12
|
+
* /list add|show|next|remove|clear
|
|
13
|
+
* /loop (draft) | /loop start|status|stop
|
|
14
|
+
* /gla (settings UI) | /gla key=value | /gla project key=value
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
|
|
21
|
+
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type Goal,
|
|
26
|
+
type State,
|
|
27
|
+
type Status,
|
|
28
|
+
appendLedger,
|
|
29
|
+
archiveDir,
|
|
30
|
+
archivedGoalPath,
|
|
31
|
+
buildTaskList,
|
|
32
|
+
buildTaskSummary,
|
|
33
|
+
DEFAULT_TOKEN_LIMIT,
|
|
34
|
+
mergeSettings,
|
|
35
|
+
parseListImport,
|
|
36
|
+
resolveImportFile,
|
|
37
|
+
routeGoalArgs,
|
|
38
|
+
sumNewAssistantTokens,
|
|
39
|
+
takeAt,
|
|
40
|
+
goalArgsNeedDrafting,
|
|
41
|
+
buildSeedGrillMessage,
|
|
42
|
+
draftProposalBlock,
|
|
43
|
+
type TaskProposal,
|
|
44
|
+
validateTaskProposal,
|
|
45
|
+
cloneGoal,
|
|
46
|
+
ensureDirs,
|
|
47
|
+
findNextPendingTask,
|
|
48
|
+
goalMdPath,
|
|
49
|
+
newGoalId,
|
|
50
|
+
nowIso,
|
|
51
|
+
piGlaDir,
|
|
52
|
+
readState,
|
|
53
|
+
renderGoalMarkdown,
|
|
54
|
+
statusLabel,
|
|
55
|
+
writeGoalMd,
|
|
56
|
+
} from "../goal-loop-core.js";
|
|
57
|
+
import { runGoalCompletionAuditor } from "../goal-loop-auditor.js";
|
|
58
|
+
import { buildStatusText, buildWidgetLines, type AuditDisplayProgress } from "../goal-loop-display.js";
|
|
59
|
+
import {
|
|
60
|
+
applyMeasurement,
|
|
61
|
+
applyRefinement,
|
|
62
|
+
loopBranchName,
|
|
63
|
+
parseLoopStartArgs,
|
|
64
|
+
parseMetric,
|
|
65
|
+
type LoopState,
|
|
66
|
+
} from "../goal-loop-forever.js";
|
|
67
|
+
import {
|
|
68
|
+
accountTurnForNudges,
|
|
69
|
+
BACKOFF_HARD_CAP_MS,
|
|
70
|
+
BACKOFF_IDLE_RETRY_MS,
|
|
71
|
+
backoffMs,
|
|
72
|
+
HEARTBEAT_INTERVAL_MS,
|
|
73
|
+
HEARTBEAT_MAX_NUDGES,
|
|
74
|
+
HEARTBEAT_STALL_MS,
|
|
75
|
+
humanMs,
|
|
76
|
+
shouldHeartbeatRefire,
|
|
77
|
+
shouldPauseAfterBackoff,
|
|
78
|
+
} from "../goal-loop-backoff.js";
|
|
79
|
+
|
|
80
|
+
// =================================================================
|
|
81
|
+
// Constants
|
|
82
|
+
// =================================================================
|
|
83
|
+
|
|
84
|
+
const GOAL_EVENT_ENTRY = "goal-event";
|
|
85
|
+
const STATE_ENTRY = "goal-state";
|
|
86
|
+
|
|
87
|
+
// =================================================================
|
|
88
|
+
// Module-level state (one per session)
|
|
89
|
+
// =================================================================
|
|
90
|
+
|
|
91
|
+
// The ExtensionAPI captured in the factory. sendMessage lives on the API,
|
|
92
|
+
// not on ExtensionContext, so continuation sends need it at module scope.
|
|
93
|
+
let extensionApi: ExtensionAPI | null = null;
|
|
94
|
+
|
|
95
|
+
// The most recent ExtensionContext seen from any event or command handler.
|
|
96
|
+
// pi replaces sessions (newSession/fork/reload) and stale ctx throws on use,
|
|
97
|
+
// so timers must never capture a ctx — they read lastCtx at fire time.
|
|
98
|
+
let lastCtx: ExtensionContext | null = null;
|
|
99
|
+
|
|
100
|
+
function rememberCtx(ctx: ExtensionContext): void {
|
|
101
|
+
lastCtx = ctx;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let state: State = { goal: null };
|
|
105
|
+
|
|
106
|
+
// Drafting mode: a no-arg loop command starts a clarification turn; the agent
|
|
107
|
+
// must call propose_goal_draft / propose_loop_draft, which opens the user's
|
|
108
|
+
// Confirm dialog. The target decides where the confirmed contract lands.
|
|
109
|
+
let draftingTarget: "goal" | "list" | "loop" | null = null;
|
|
110
|
+
// v0.14.0 drafting floor: user replies counted while drafting; the injected
|
|
111
|
+
// seed prompt itself arrives as a user message — skip exactly that one.
|
|
112
|
+
let draftingUserReplies = 0;
|
|
113
|
+
let draftingSeedInFlight = false;
|
|
114
|
+
|
|
115
|
+
// Dedup set for token accounting (agent_end may replay seen messages).
|
|
116
|
+
const countedTokenMessages = new Set<string>();
|
|
117
|
+
const countedLoopTokenMessages = new Set<string>();
|
|
118
|
+
|
|
119
|
+
// Heartbeat self-watchdog state: liveness is the loop's own job.
|
|
120
|
+
let lastActivityAt = Date.now();
|
|
121
|
+
let heartbeatNudges = 0;
|
|
122
|
+
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
123
|
+
|
|
124
|
+
function noteActivity(): void {
|
|
125
|
+
lastActivityAt = Date.now();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isSupervising(): boolean {
|
|
129
|
+
return isLoopActive() || (!!state.goal && state.goal.status === "active" && state.goal.autoContinue);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// =================================================================
|
|
133
|
+
// Live TUI (v0.9.0): persistent status segment + above-editor widget.
|
|
134
|
+
// "Can't tell if it's on" is a bug, not a nice-to-have.
|
|
135
|
+
// =================================================================
|
|
136
|
+
|
|
137
|
+
let latestAuditProgress: AuditDisplayProgress | null = null;
|
|
138
|
+
let uiTicker: NodeJS.Timeout | null = null;
|
|
139
|
+
|
|
140
|
+
function refreshUI(ctx: ExtensionContext): void {
|
|
141
|
+
if (!ctx.hasUI) return;
|
|
142
|
+
try {
|
|
143
|
+
ctx.ui.setStatus("pi-gla", buildStatusText(state, latestAuditProgress));
|
|
144
|
+
ctx.ui.setWidget("pi-gla", buildWidgetLines(state, latestAuditProgress));
|
|
145
|
+
} catch {
|
|
146
|
+
// stale ctx — next event refreshes
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function startUITicker(): void {
|
|
151
|
+
if (uiTicker) return;
|
|
152
|
+
uiTicker = setInterval(() => {
|
|
153
|
+
const ctx = freshCtx();
|
|
154
|
+
if (ctx && isSupervising()) refreshUI(ctx);
|
|
155
|
+
}, 5_000);
|
|
156
|
+
uiTicker.unref?.();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function heartbeatTick(): void {
|
|
160
|
+
const ctx = freshCtx();
|
|
161
|
+
if (!ctx) return;
|
|
162
|
+
let sessionIdle = false;
|
|
163
|
+
try {
|
|
164
|
+
sessionIdle = ctx.isIdle() && !ctx.hasPendingMessages();
|
|
165
|
+
} catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const fire = shouldHeartbeatRefire({
|
|
169
|
+
supervising: isSupervising(),
|
|
170
|
+
sessionIdle,
|
|
171
|
+
timerPending: continuationTimer !== null || loopTimer !== null,
|
|
172
|
+
msSinceActivity: Date.now() - lastActivityAt,
|
|
173
|
+
stallMs: HEARTBEAT_STALL_MS,
|
|
174
|
+
});
|
|
175
|
+
if (!fire) return;
|
|
176
|
+
noteActivity();
|
|
177
|
+
appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges });
|
|
178
|
+
ctx.ui.notify("Heartbeat: supervisor active but session stalled — re-firing continuation.", "info");
|
|
179
|
+
if (isLoopActive()) {
|
|
180
|
+
scheduleLoopTick(ctx);
|
|
181
|
+
} else {
|
|
182
|
+
scheduleContinuation(ctx, true);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function startHeartbeat(): void {
|
|
187
|
+
if (heartbeatTimer) return;
|
|
188
|
+
heartbeatTimer = setInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
|
|
189
|
+
heartbeatTimer.unref?.();
|
|
190
|
+
}
|
|
191
|
+
let continuationTimer: NodeJS.Timeout | null = null;
|
|
192
|
+
let continuationScheduledFor: string | null = null;
|
|
193
|
+
let iterationCounter = 0;
|
|
194
|
+
let toolCallsThisTurn = 0;
|
|
195
|
+
let consecutiveStuckIterations = 0;
|
|
196
|
+
let consecutiveErrorIterations = 0;
|
|
197
|
+
let consecutiveNoToolIterations = 0;
|
|
198
|
+
|
|
199
|
+
// =================================================================
|
|
200
|
+
// Helpers
|
|
201
|
+
// =================================================================
|
|
202
|
+
|
|
203
|
+
function clearContinuationTimer(): void {
|
|
204
|
+
if (continuationTimer) {
|
|
205
|
+
clearTimeout(continuationTimer);
|
|
206
|
+
continuationTimer = null;
|
|
207
|
+
}
|
|
208
|
+
continuationScheduledFor = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isActionableGoal(): boolean {
|
|
212
|
+
return !!state.goal && state.goal.status === "active" && state.goal.autoContinue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function freshCtx(): ExtensionContext | null {
|
|
216
|
+
// A captured ctx throws "stale" after session replacement. Probe cheaply;
|
|
217
|
+
// on stale, drop it and wait for the next event to hand us a fresh one.
|
|
218
|
+
if (!lastCtx) return null;
|
|
219
|
+
try {
|
|
220
|
+
lastCtx.isIdle();
|
|
221
|
+
return lastCtx;
|
|
222
|
+
} catch {
|
|
223
|
+
lastCtx = null;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function scheduleContinuation(ctx: ExtensionContext, force = false): void {
|
|
229
|
+
if (!isActionableGoal()) return;
|
|
230
|
+
rememberCtx(ctx);
|
|
231
|
+
const goalId = state.goal!.id;
|
|
232
|
+
if (!force && continuationScheduledFor === goalId) return;
|
|
233
|
+
clearContinuationTimer();
|
|
234
|
+
let delay = 0;
|
|
235
|
+
try {
|
|
236
|
+
delay = ctx.isIdle() && !ctx.hasPendingMessages() ? 0 : BACKOFF_IDLE_RETRY_MS;
|
|
237
|
+
} catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
continuationScheduledFor = goalId;
|
|
241
|
+
continuationTimer = setTimeout(() => sendContinuation(goalId), delay);
|
|
242
|
+
continuationTimer.unref?.();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sendContinuation(goalId: string): void {
|
|
246
|
+
continuationTimer = null;
|
|
247
|
+
continuationScheduledFor = null;
|
|
248
|
+
if (!isActionableGoal()) return;
|
|
249
|
+
const ctx = freshCtx();
|
|
250
|
+
if (!ctx) {
|
|
251
|
+
// No live ctx — retry shortly; the next session event will refresh it.
|
|
252
|
+
continuationScheduledFor = goalId;
|
|
253
|
+
continuationTimer = setTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
|
|
254
|
+
continuationTimer.unref?.();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
258
|
+
continuationScheduledFor = goalId;
|
|
259
|
+
continuationTimer = setTimeout(() => sendContinuation(goalId), BACKOFF_IDLE_RETRY_MS);
|
|
260
|
+
continuationTimer.unref?.();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (!extensionApi) return;
|
|
264
|
+
try {
|
|
265
|
+
extensionApi.sendMessage({
|
|
266
|
+
customType: GOAL_EVENT_ENTRY,
|
|
267
|
+
content: continuationPrompt(state.goal!),
|
|
268
|
+
display: false,
|
|
269
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
270
|
+
} catch {
|
|
271
|
+
// API went stale mid-flight; next agent_end/session_start will reschedule.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function continuationPrompt(goal: Goal): string {
|
|
276
|
+
// Read the .md file as the template, then substitute {{tokens}}.
|
|
277
|
+
// For v0.1.0 we inline-substitute so we don't need fs at runtime.
|
|
278
|
+
const next = findNextPendingTask(goal.taskList?.tasks ?? []);
|
|
279
|
+
const nextBlock = next
|
|
280
|
+
? `**Next pending task**: \`${next.id}\` — ${next.title}`
|
|
281
|
+
: "**Next pending task**: (none — only call complete_goal when the objective is satisfied)";
|
|
282
|
+
const taskSummary = goal.taskList?.tasks.length
|
|
283
|
+
? buildTaskSummary(goal.taskList.tasks)
|
|
284
|
+
: "(no task list)";
|
|
285
|
+
const tmplPath = path.resolve(__dirname, "..", "..", "prompts", "goal-loop-continuation.md");
|
|
286
|
+
let tmpl: string;
|
|
287
|
+
try {
|
|
288
|
+
tmpl = fs.readFileSync(tmplPath, "utf-8");
|
|
289
|
+
} catch {
|
|
290
|
+
tmpl = "[template-not-found]";
|
|
291
|
+
}
|
|
292
|
+
return tmpl
|
|
293
|
+
.replace(/\$\{GOAL_ID\}/g, goal.id)
|
|
294
|
+
.replace(/\$\{OBJECTIVE\}/g, goal.objective)
|
|
295
|
+
.replace(/\$\{VERIFICATION_CONTRACT\}/g, goal.verificationContract || "(none — auditor will decide based on objective)")
|
|
296
|
+
.replace(/\$\{TASK_LIST\}/g, taskSummary)
|
|
297
|
+
.replace(/\$\{NEXT_PENDING_TASK_BLOCK\}/g, nextBlock);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// =================================================================
|
|
301
|
+
// Goal lifecycle
|
|
302
|
+
// =================================================================
|
|
303
|
+
|
|
304
|
+
function createGoal(objective: string, ctx: ExtensionContext, policy: "goal" | "list" = "goal"): Goal {
|
|
305
|
+
ensureDirs(ctx.cwd);
|
|
306
|
+
// Extract verification contract if present in objective.
|
|
307
|
+
const { objective: cleanObj, verificationContract } = extractVerificationContract(objective);
|
|
308
|
+
const id = newGoalId();
|
|
309
|
+
const goal: Goal = {
|
|
310
|
+
id,
|
|
311
|
+
objective: cleanObj,
|
|
312
|
+
status: "active",
|
|
313
|
+
policy,
|
|
314
|
+
autoContinue: true,
|
|
315
|
+
verificationContract: verificationContract || "",
|
|
316
|
+
usage: { tokensUsed: 0, tokensLimit: loadSettings(ctx.cwd).tokenLimit ?? DEFAULT_TOKEN_LIMIT },
|
|
317
|
+
createdAt: nowIso(),
|
|
318
|
+
updatedAt: nowIso(),
|
|
319
|
+
};
|
|
320
|
+
return goal;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function extractVerificationContract(raw: string): { objective: string; verificationContract: string } {
|
|
324
|
+
// Line-based first: a marker at line start begins the contract block.
|
|
325
|
+
const lines = raw.split("\n");
|
|
326
|
+
let mode: "obj" | "verify" = "obj";
|
|
327
|
+
const objParts: string[] = [];
|
|
328
|
+
const verifyParts: string[] = [];
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
const lower = line.toLowerCase();
|
|
331
|
+
if (lower.match(/^\s*(?:done when|verify|verified when|verification|done):/)) {
|
|
332
|
+
mode = "verify";
|
|
333
|
+
}
|
|
334
|
+
if (mode === "obj") objParts.push(line);
|
|
335
|
+
else verifyParts.push(line);
|
|
336
|
+
}
|
|
337
|
+
let objective = objParts.join("\n").trim();
|
|
338
|
+
let verificationContract = verifyParts.join("\n").trim();
|
|
339
|
+
|
|
340
|
+
// Inline fallback: users write one-liners like
|
|
341
|
+
// "Create x.txt. Done when: grep -q ok x.txt"
|
|
342
|
+
// where the marker is mid-line. Split at the first inline marker.
|
|
343
|
+
if (!verificationContract) {
|
|
344
|
+
const m = raw.match(/^(.*?)(?:\.|;)??\s+(done when|verified when|verify|verification)\s*:\s*(.+)$/is);
|
|
345
|
+
if (m) {
|
|
346
|
+
objective = (m[1] ?? "").trim().replace(/[.;]\s*$/, "");
|
|
347
|
+
verificationContract = (m[3] ?? "").trim();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { objective, verificationContract };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function persistState(ctx: ExtensionContext): void {
|
|
354
|
+
appendLedger(ctx.cwd, "state", { goal: state.goal, list: state.list ?? [], loop: state.loop ?? null });
|
|
355
|
+
refreshUI(ctx); // every state transition flows through here → the TUI is always current
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function setGoal(goal: Goal, ctx: ExtensionContext): void {
|
|
359
|
+
state = { goal, list: state.list ?? [] }; // preserve the queue!
|
|
360
|
+
const file = writeGoalMd(ctx.cwd, goal);
|
|
361
|
+
state.goal!.activePath = path.relative(ctx.cwd, file) || file;
|
|
362
|
+
persistState(ctx);
|
|
363
|
+
appendLedger(ctx.cwd, "goal_created", { goalId: goal.id, objective: goal.objective, policy: goal.policy });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function updateGoal(patch: Partial<Goal>, ctx: ExtensionContext): void {
|
|
367
|
+
if (!state.goal) return;
|
|
368
|
+
state.goal = { ...state.goal, ...patch, updatedAt: nowIso() };
|
|
369
|
+
const file = writeGoalMd(ctx.cwd, state.goal);
|
|
370
|
+
state.goal.activePath = path.relative(ctx.cwd, file) || file;
|
|
371
|
+
persistState(ctx);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?: string): void {
|
|
375
|
+
if (!state.goal) return;
|
|
376
|
+
const goal = state.goal;
|
|
377
|
+
ensureDirs(ctx.cwd);
|
|
378
|
+
const target = archivedGoalPath(ctx.cwd, goal.id);
|
|
379
|
+
const md = renderGoalMarkdown({ ...goal, status, stopReason });
|
|
380
|
+
fs.writeFileSync(target, md);
|
|
381
|
+
// Remove active md file
|
|
382
|
+
try { fs.unlinkSync(goalMdPath(ctx.cwd, goal.id)); } catch {}
|
|
383
|
+
state = { goal: { ...goal, status, archivedPath: path.relative(ctx.cwd, target) || target, stopReason }, list: state.list ?? [] };
|
|
384
|
+
appendLedger(ctx.cwd, "goal_archived", { goalId: goal.id, status, stopReason });
|
|
385
|
+
persistState(ctx);
|
|
386
|
+
// Loop 2: a list-sourced goal COMPLETED → auto-activate the next item.
|
|
387
|
+
// Aborts are user actions (/list next, /goal cancel, list_activate) which
|
|
388
|
+
// pick their own next step — auto-advancing on abort double-activates
|
|
389
|
+
// (v0.2.0 bug: bare /list next silently consumed TWO items, found by the
|
|
390
|
+
// pick-any-item verification in v0.10.0).
|
|
391
|
+
if (goal.policy === "list" && status === "complete") {
|
|
392
|
+
activateNextListItem(ctx);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// =================================================================
|
|
397
|
+
// Loop 2: /list list
|
|
398
|
+
// =================================================================
|
|
399
|
+
|
|
400
|
+
function listQueue(): NonNullable<State["list"]> {
|
|
401
|
+
return state.list ?? [];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
|
|
405
|
+
const queue = listQueue();
|
|
406
|
+
const taken = takeAt(queue, n);
|
|
407
|
+
if (!taken) return false;
|
|
408
|
+
const [next, rest] = taken;
|
|
409
|
+
state = { ...state, list: rest };
|
|
410
|
+
const goal = createGoal(next.objective, ctx, "list");
|
|
411
|
+
if (next.verificationContract) goal.verificationContract = next.verificationContract;
|
|
412
|
+
setGoal(goal, ctx);
|
|
413
|
+
iterationCounter = 0;
|
|
414
|
+
consecutiveStuckIterations = 0;
|
|
415
|
+
consecutiveErrorIterations = 0;
|
|
416
|
+
ctx.ui.notify(`List item #${n} activated (${rest.length} remaining): ${goal.objective.slice(0, 80)}`, "info");
|
|
417
|
+
scheduleContinuation(ctx, true);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// =================================================================
|
|
422
|
+
// Drafting: /goal with no args → clarify → Confirm dialog → activate
|
|
423
|
+
// =================================================================
|
|
424
|
+
|
|
425
|
+
async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "loop", seed?: string): Promise<void> {
|
|
426
|
+
draftingTarget = target;
|
|
427
|
+
const prompts: Record<string, [string, string, string]> = {
|
|
428
|
+
goal: ["goal-loop-draft.md", "Goal drafting", "propose_goal_draft"],
|
|
429
|
+
list: ["goal-loop-draft.md", "Goal drafting (for the queue)", "propose_goal_draft"],
|
|
430
|
+
loop: ["goal-loop-forever-draft.md", "Loop drafting", "propose_loop_draft"],
|
|
431
|
+
};
|
|
432
|
+
const [file, label, tool] = prompts[target]!;
|
|
433
|
+
ctx.ui.notify(
|
|
434
|
+
seed
|
|
435
|
+
? `${label}: the objective has no "Done when:" clause — the agent will grill you about it first (nothing activates until you confirm).`
|
|
436
|
+
: `${label} started. The agent will grill until the contract is concrete, then ${tool} opens a Confirm dialog. No work begins before confirmation.`,
|
|
437
|
+
"info",
|
|
438
|
+
);
|
|
439
|
+
const tmplPath = path.resolve(__dirname, "..", "..", "prompts", file);
|
|
440
|
+
let tmpl: string;
|
|
441
|
+
try {
|
|
442
|
+
tmpl = fs.readFileSync(tmplPath, "utf-8");
|
|
443
|
+
if (target === "list") {
|
|
444
|
+
tmpl = tmpl.replace(
|
|
445
|
+
"[GOAL DRAFTING]",
|
|
446
|
+
"[GOAL DRAFTING — the confirmed goal goes into the /list LIST, it does not activate immediately. If the user wants MANY things queued (a plan, a checklist, 'these 50 tasks'), propose them ALL AT ONCE with the items[] parameter — one Confirm for the whole batch, never 50 separate proposals.]",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
tmpl = `[DRAFTING] Clarify the user's ${target}, then call ${tool}.`;
|
|
451
|
+
}
|
|
452
|
+
// v0.14.0: the LLM grills (its strength — v0.13.0's canned questionnaire
|
|
453
|
+
// accepted non-answers), the plugin enforces the floor: propose_goal_draft
|
|
454
|
+
// is blocked until the user has replied at least once (see message_start).
|
|
455
|
+
if (seed) {
|
|
456
|
+
tmpl = buildSeedGrillMessage(tmpl, seed, tool);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
extensionApi?.sendUserMessage(tmpl, { deliverAs: ctx.isIdle() ? "followUp" : "steer" });
|
|
460
|
+
draftingUserReplies = 0;
|
|
461
|
+
draftingSeedInFlight = true; // our injected prompt also arrives as a user message — don't count it
|
|
462
|
+
} catch {
|
|
463
|
+
draftingTarget = null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// =================================================================
|
|
468
|
+
// /goal router (v0.8.0): subcommands route to their handlers; everything
|
|
469
|
+
// else is an objective (draft if empty, set+start otherwise).
|
|
470
|
+
// =================================================================
|
|
471
|
+
|
|
472
|
+
async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
|
|
473
|
+
const route = routeGoalArgs(args);
|
|
474
|
+
if (route.kind === "sub") {
|
|
475
|
+
if (route.name === "status") return cmdStatus(ctx);
|
|
476
|
+
if (route.name === "pause") return cmdPause(ctx);
|
|
477
|
+
if (route.name === "resume") return cmdResume(ctx);
|
|
478
|
+
if (route.name === "cancel") return cmdCancel(ctx);
|
|
479
|
+
if (route.name === "tweak") return cmdTweak(route.rest, ctx);
|
|
480
|
+
if (route.name === "archive") return cmdGoals(ctx);
|
|
481
|
+
}
|
|
482
|
+
return cmdSet(route.kind === "set" ? route.text : "", ctx);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// =================================================================
|
|
486
|
+
// /goal: bypass drafting, start now (the only entry in v0.1.0)
|
|
487
|
+
// =================================================================
|
|
488
|
+
|
|
489
|
+
async function cmdSet(args: string, ctx: ExtensionContext): Promise<void> {
|
|
490
|
+
let raw = args.trim();
|
|
491
|
+
// Users naturally quote the objective ("/goal \"do X\""); strip one layer of
|
|
492
|
+
// surrounding matching quotes so they don't leak into the goal text.
|
|
493
|
+
if (raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")))) {
|
|
494
|
+
raw = raw.slice(1, -1).trim();
|
|
495
|
+
}
|
|
496
|
+
if (!raw) {
|
|
497
|
+
await startDrafting(ctx, "goal");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (isLoopActive()) {
|
|
501
|
+
ctx.ui.notify("A /loop is active — /loop stop it before setting a goal.", "warning");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// v0.11.0: a contract-less objective gets drafted, not activated raw —
|
|
505
|
+
// the pi-goal-x lesson: arg + Enter is worse than a 5-minute draft.
|
|
506
|
+
// Include an explicit "Done when: …" clause to activate instantly.
|
|
507
|
+
if (goalArgsNeedDrafting(raw)) {
|
|
508
|
+
await startDrafting(ctx, "goal", raw);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
draftingTarget = null; // explicit objective cancels any drafting session
|
|
512
|
+
const goal = createGoal(raw, ctx);
|
|
513
|
+
setGoal(goal, ctx);
|
|
514
|
+
// Reset counters
|
|
515
|
+
iterationCounter = 0;
|
|
516
|
+
consecutiveStuckIterations = 0;
|
|
517
|
+
consecutiveErrorIterations = 0;
|
|
518
|
+
consecutiveNoToolIterations = 0;
|
|
519
|
+
ctx.ui.notify(`Goal ${goal.id} created — starting now. Auditor will verify on completion.`, "info");
|
|
520
|
+
scheduleContinuation(ctx, true);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function cmdStatus(ctx: ExtensionContext): Promise<void> {
|
|
524
|
+
if (!state.goal) {
|
|
525
|
+
ctx.ui.notify("No active goal. Use /goal <objective>.", "info");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const g = state.goal;
|
|
529
|
+
const lines = [
|
|
530
|
+
`[${g.id}] ${statusLabel(g.status)}`,
|
|
531
|
+
`Objective: ${g.objective}`,
|
|
532
|
+
`Auto-continue: ${g.autoContinue ? "on" : "off"}`,
|
|
533
|
+
`Iteration: ${iterationCounter}`,
|
|
534
|
+
`Tokens: ${(g.usage?.tokensUsed ?? 0).toLocaleString()}${(g.usage?.tokensLimit ?? 0) > 0 ? ` / ${(g.usage!.tokensLimit).toLocaleString()}` : " (no cap — /gla tokenlimit=<n> to set)"}`,
|
|
535
|
+
];
|
|
536
|
+
if (g.auditHistory && g.auditHistory.length > 0) {
|
|
537
|
+
lines.push(`Audits: ${g.auditHistory.length} (${g.auditHistory.filter((v) => v.approved).length} approved)`);
|
|
538
|
+
}
|
|
539
|
+
if (g.pauseReason) lines.push(`Paused: ${g.pauseReason}`);
|
|
540
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function cmdPause(ctx: ExtensionContext): Promise<void> {
|
|
544
|
+
if (!state.goal) return;
|
|
545
|
+
updateGoal({ status: "paused" }, ctx);
|
|
546
|
+
ctx.ui.notify(`Goal ${state.goal.id} paused. /goal resume to continue.`, "info");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function cmdResume(ctx: ExtensionContext): Promise<void> {
|
|
550
|
+
if (!state.goal || state.goal.status !== "paused") return;
|
|
551
|
+
// v0.12.0: refresh the token cap from CURRENT settings on resume — goals
|
|
552
|
+
// snapshot the cap at creation, so a goal paused under an old default
|
|
553
|
+
// (e.g. 10M) would re-pause instantly even after the default changed.
|
|
554
|
+
const freshLimit = loadSettings(ctx.cwd).tokenLimit ?? DEFAULT_TOKEN_LIMIT;
|
|
555
|
+
const usage = state.goal.usage
|
|
556
|
+
? { tokensUsed: state.goal.usage.tokensUsed, tokensLimit: freshLimit }
|
|
557
|
+
: undefined;
|
|
558
|
+
updateGoal({ status: "active", pauseReason: undefined, pauseSuggestedAction: undefined, ...(usage ? { usage } : {}) }, ctx);
|
|
559
|
+
scheduleContinuation(ctx, true);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function cmdCancel(ctx: ExtensionContext): Promise<void> {
|
|
563
|
+
if (!state.goal) return;
|
|
564
|
+
archiveCurrentGoal(ctx, "aborted", "user cancelled");
|
|
565
|
+
ctx.abort();
|
|
566
|
+
ctx.ui.notify("Goal aborted.", "info");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function cmdGoals(ctx: ExtensionContext): Promise<void> {
|
|
570
|
+
const dir = archiveDir(ctx.cwd);
|
|
571
|
+
if (!fs.existsSync(dir)) {
|
|
572
|
+
ctx.ui.notify("No archived goals yet.", "info");
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort().reverse();
|
|
576
|
+
if (files.length === 0) {
|
|
577
|
+
ctx.ui.notify("No archived goals yet.", "info");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const lines = files.slice(0, 20).map((f) => {
|
|
581
|
+
let status = "?";
|
|
582
|
+
let stop = "";
|
|
583
|
+
let obj = "";
|
|
584
|
+
try {
|
|
585
|
+
const content = fs.readFileSync(path.join(dir, f), "utf-8");
|
|
586
|
+
status = content.match(/\*\*Status\*\*:\s*(\w+)/)?.[1] ?? "?";
|
|
587
|
+
stop = content.match(/\*\*Stop reason\*\*:\s*(.+)/)?.[1]?.trim() ?? "";
|
|
588
|
+
obj = content.match(/## Objective\s+>\s*(.+)/)?.[1]?.trim() ?? "";
|
|
589
|
+
} catch { /* unreadable file — show name only */ }
|
|
590
|
+
return `${f.replace(/\.md$/, "")} [${status}] ${obj.slice(0, 60)}${stop ? ` — ${stop.slice(0, 40)}` : ""}`;
|
|
591
|
+
});
|
|
592
|
+
ctx.ui.notify(
|
|
593
|
+
`Archived goals (${files.length}${files.length > 20 ? ", showing 20" : ""}):\n` + lines.join("\n"),
|
|
594
|
+
"info",
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function cmdTweak(args: string, ctx: ExtensionContext): Promise<void> {
|
|
599
|
+
if (!state.goal || state.goal.status !== "active") {
|
|
600
|
+
ctx.ui.notify("No active goal to tweak. /goal <objective> to start one.", "info");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
let raw = args.trim();
|
|
604
|
+
if (raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")))) {
|
|
605
|
+
raw = raw.slice(1, -1).trim();
|
|
606
|
+
}
|
|
607
|
+
if (!raw) {
|
|
608
|
+
ctx.ui.notify("Usage: /goal tweak <replacement objective, optional 'Done when: ...' clause>", "info");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const current = state.goal;
|
|
612
|
+
const proposed = extractVerificationContract(raw);
|
|
613
|
+
const newObjective = proposed.objective;
|
|
614
|
+
const newContract = proposed.verificationContract;
|
|
615
|
+
let confirmed = false;
|
|
616
|
+
try {
|
|
617
|
+
confirmed = await ctx.ui.confirm(
|
|
618
|
+
"Tweak goal?",
|
|
619
|
+
`CURRENT:\n${current.objective.slice(0, 400)}\n\nNEW:\n${newObjective.slice(0, 400)}` +
|
|
620
|
+
(newContract ? `\n\nNew contract:\n${newContract.slice(0, 200)}` : "\n\n(New text carries no contract; old contract is dropped.)"),
|
|
621
|
+
);
|
|
622
|
+
} catch {
|
|
623
|
+
confirmed = false;
|
|
624
|
+
}
|
|
625
|
+
if (!confirmed) {
|
|
626
|
+
ctx.ui.notify("Tweak cancelled; goal unchanged.", "info");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
updateGoal({ objective: newObjective, verificationContract: newContract }, ctx);
|
|
630
|
+
appendLedger(ctx.cwd, "goal_tweaked", { goalId: current.id, objective: newObjective });
|
|
631
|
+
ctx.ui.notify("Goal tweaked. The loop continues against the new objective.", "info");
|
|
632
|
+
scheduleContinuation(ctx, true);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// =================================================================
|
|
636
|
+
// /list commands (loop 2)
|
|
637
|
+
// =================================================================
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The ONE enqueue path (v0.8.4): bulk import, items[] drafting, and the
|
|
641
|
+
* agent's list_add tool all funnel here. Texts → ListItems (with per-item
|
|
642
|
+
* contract extraction) → appended to the queue → persisted → first item
|
|
643
|
+
* activated when nothing is running. Returns the count enqueued.
|
|
644
|
+
*/
|
|
645
|
+
function enqueueItems(ctx: ExtensionContext, texts: string[], source: string): number {
|
|
646
|
+
const items = texts.map((text) => {
|
|
647
|
+
const extracted = extractVerificationContract(text);
|
|
648
|
+
return { id: newGoalId(), objective: extracted.objective, verificationContract: extracted.verificationContract || undefined, addedAt: nowIso() };
|
|
649
|
+
});
|
|
650
|
+
state = { ...state, list: [...listQueue(), ...items] };
|
|
651
|
+
persistState(ctx);
|
|
652
|
+
appendLedger(ctx.cwd, "list_imported", { source, count: items.length });
|
|
653
|
+
if (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") {
|
|
654
|
+
activateNextListItem(ctx);
|
|
655
|
+
}
|
|
656
|
+
return items.length;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Bulk-enqueue parsed items: one Confirm for the whole batch, never drafts. */
|
|
660
|
+
async function bulkAddItems(ctx: ExtensionContext, parsed: string[], sourceName: string): Promise<void> {
|
|
661
|
+
if (parsed.length === 0) {
|
|
662
|
+
ctx.ui.notify("No items found (headings/blank lines don't count).", "warning");
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const preview = parsed.slice(0, 5).map((t, i) => ` ${i + 1}. ${t.slice(0, 70)}`).join("\n");
|
|
666
|
+
let confirmed = true;
|
|
667
|
+
if (ctx.hasUI) {
|
|
668
|
+
try {
|
|
669
|
+
confirmed = await ctx.ui.confirm(
|
|
670
|
+
"Import into queue?",
|
|
671
|
+
`${parsed.length} items from ${sourceName}:\n${preview}${parsed.length > 5 ? `\n … and ${parsed.length - 5} more` : ""}`,
|
|
672
|
+
);
|
|
673
|
+
} catch {
|
|
674
|
+
confirmed = false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (!confirmed) {
|
|
678
|
+
ctx.ui.notify("Import cancelled.", "info");
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const n = enqueueItems(ctx, parsed, sourceName);
|
|
682
|
+
if (state.goal && state.goal.status === "active") {
|
|
683
|
+
ctx.ui.notify(`Imported ${n} items (${listQueue().length} queued).`, "info");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Bulk-enqueue from a file: read, parse, delegate to bulkAddItems. */
|
|
688
|
+
async function bulkAddFromFile(ctx: ExtensionContext, abs: string): Promise<void> {
|
|
689
|
+
let content: string;
|
|
690
|
+
try {
|
|
691
|
+
content = fs.readFileSync(abs, "utf-8");
|
|
692
|
+
} catch {
|
|
693
|
+
ctx.ui.notify(`Cannot read: ${abs}`, "warning");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
await bulkAddItems(ctx, parseListImport(content), path.basename(abs));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function cmdList(args: string, ctx: ExtensionContext): Promise<void> {
|
|
700
|
+
const parts = args.trim().split(/\s+/);
|
|
701
|
+
const sub = (parts[0] ?? "").toLowerCase();
|
|
702
|
+
const rest = args.trim().slice(sub.length).trim();
|
|
703
|
+
|
|
704
|
+
if (!sub || sub === "show") {
|
|
705
|
+
const queue = listQueue();
|
|
706
|
+
const lines: string[] = [];
|
|
707
|
+
if (state.goal) {
|
|
708
|
+
lines.push(`Active: [${state.goal.policy}] ${state.goal.objective.slice(0, 80)} (${statusLabel(state.goal.status)})`);
|
|
709
|
+
} else {
|
|
710
|
+
lines.push("Active: (none)");
|
|
711
|
+
}
|
|
712
|
+
if (queue.length === 0) {
|
|
713
|
+
lines.push("List: empty. /list add <objective> | /list add <file>");
|
|
714
|
+
} else {
|
|
715
|
+
lines.push(`Queue (${queue.length}):`);
|
|
716
|
+
const PAGE = 15;
|
|
717
|
+
queue.slice(0, PAGE).forEach((item, i) => lines.push(` ${i + 1}. ${item.objective.slice(0, 90)}`));
|
|
718
|
+
if (queue.length > PAGE) {
|
|
719
|
+
lines.push(` … and ${queue.length - PAGE} more. /list remove <n> to prune, /list clear to empty.`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
if (sub === "import") {
|
|
728
|
+
// Backwards-compat alias (0.8.1 shipped it; 0.8.2 folded it into add).
|
|
729
|
+
const abs = resolveImportFile(ctx.cwd, rest.replace(/^["']|["']$/g, ""));
|
|
730
|
+
if (!abs) {
|
|
731
|
+
ctx.ui.notify("Usage: /list add <path> — file detection is automatic now; 'import' is just an alias", "info");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await bulkAddFromFile(ctx, abs);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (sub === "add") {
|
|
739
|
+
let raw = rest;
|
|
740
|
+
if (raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")))) {
|
|
741
|
+
raw = raw.slice(1, -1).trim();
|
|
742
|
+
}
|
|
743
|
+
if (!raw) {
|
|
744
|
+
// /list add with no args → draft a confirmed contract INTO THE QUEUE.
|
|
745
|
+
await startDrafting(ctx, "list");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Flexible by detection, not by verb: an existing file path bulk-imports,
|
|
749
|
+
// multi-line pasted text parses as a batch, anything else is one objective.
|
|
750
|
+
const importFile = resolveImportFile(ctx.cwd, raw);
|
|
751
|
+
if (importFile) {
|
|
752
|
+
await bulkAddFromFile(ctx, importFile);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (raw.includes("\n")) {
|
|
756
|
+
const pasted = parseListImport(raw);
|
|
757
|
+
if (pasted.length > 1) {
|
|
758
|
+
await bulkAddItems(ctx, pasted, "pasted text");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const { objective, verificationContract } = extractVerificationContract(raw);
|
|
763
|
+
const item = { id: newGoalId(), objective, verificationContract: verificationContract || undefined, addedAt: nowIso() };
|
|
764
|
+
state = { ...state, list: [...listQueue(), item] };
|
|
765
|
+
persistState(ctx);
|
|
766
|
+
appendLedger(ctx.cwd, "list_added", { id: item.id, objective: item.objective });
|
|
767
|
+
// Nothing active → activate immediately.
|
|
768
|
+
if (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") {
|
|
769
|
+
activateNextListItem(ctx);
|
|
770
|
+
} else {
|
|
771
|
+
ctx.ui.notify(`Queued (${listQueue().length} waiting): ${objective.slice(0, 80)}`, "info");
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (sub === "clear") {
|
|
777
|
+
state = { ...state, list: [] };
|
|
778
|
+
persistState(ctx);
|
|
779
|
+
appendLedger(ctx.cwd, "list_cleared", {});
|
|
780
|
+
ctx.ui.notify("List cleared. Active goal (if any) is untouched — /goal cancel for that.", "info");
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (sub === "next") {
|
|
785
|
+
// Skip the current active goal (abort it) and activate a queued item.
|
|
786
|
+
// Bare = the head (FIFO default); /list next <n> = item n (shopping-list
|
|
787
|
+
// semantics: order is the default, not the law).
|
|
788
|
+
const n = rest ? Number.parseInt(rest, 10) : 1;
|
|
789
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
790
|
+
ctx.ui.notify(`Usage: /list next [1-${listQueue().length || 1}]`, "info");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (state.goal && state.goal.status === "active") {
|
|
794
|
+
archiveCurrentGoal(ctx, "aborted", `skipped via /list next ${n > 1 ? n : ""}`.trim());
|
|
795
|
+
}
|
|
796
|
+
if (!activateNextListItem(ctx, n)) {
|
|
797
|
+
ctx.ui.notify(listQueue().length === 0 ? "List is empty — nothing to activate." : `No item #${n} (list has ${listQueue().length}).`, "info");
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (sub === "remove" || sub === "rm") {
|
|
803
|
+
const n = Number.parseInt(rest, 10);
|
|
804
|
+
const queue = listQueue();
|
|
805
|
+
if (!Number.isFinite(n) || n < 1 || n > queue.length) {
|
|
806
|
+
ctx.ui.notify(`Usage: /list remove <1-${queue.length}>`, "info");
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const removed = queue[n - 1]!;
|
|
810
|
+
state = { ...state, list: queue.filter((_, i) => i !== n - 1) };
|
|
811
|
+
persistState(ctx);
|
|
812
|
+
appendLedger(ctx.cwd, "list_removed", { id: removed.id, objective: removed.objective });
|
|
813
|
+
ctx.ui.notify(`Removed: ${removed.objective.slice(0, 80)}`, "info");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
ctx.ui.notify("Usage: /list [show] | /list add <objective or file> | /list next | /list remove <n> | /list clear", "info");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Config-gated push notification: if settings.notifyCmd is set, shell out
|
|
822
|
+
* with the message as $1. Fire-and-forget — a broken notify command never
|
|
823
|
+
* blocks the loop. /gla notify='<cmd>' to configure.
|
|
824
|
+
*/
|
|
825
|
+
function notifyExternal(ctx: ExtensionContext, message: string): void {
|
|
826
|
+
try {
|
|
827
|
+
const settings = loadSettings(ctx.cwd);
|
|
828
|
+
const cmd = settings.notifyCmd;
|
|
829
|
+
if (!cmd || !extensionApi) return;
|
|
830
|
+
void extensionApi.exec("bash", ["-c", cmd, "pi-goal-list-loop-audit", message], { cwd: ctx.cwd }).catch(() => {});
|
|
831
|
+
} catch {
|
|
832
|
+
// non-fatal by design
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// =================================================================
|
|
837
|
+
// Loop 3: /loop — metric-driven forever loop
|
|
838
|
+
//
|
|
839
|
+
// The anti-doorknob law: the loop only believes a number. The orchestrator
|
|
840
|
+
// runs the user's measure command (via pi.exec) after every agent turn;
|
|
841
|
+
// the agent never self-reports progress. Termination: plateau, iteration
|
|
842
|
+
// cap, or /loop stop. There is NO auditor in loop 3 — the metric is the
|
|
843
|
+
// verdict.
|
|
844
|
+
// =================================================================
|
|
845
|
+
|
|
846
|
+
let loopTimer: NodeJS.Timeout | null = null;
|
|
847
|
+
|
|
848
|
+
function clearLoopTimer(): void {
|
|
849
|
+
if (loopTimer) {
|
|
850
|
+
clearTimeout(loopTimer);
|
|
851
|
+
loopTimer = null;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function isLoopActive(): boolean {
|
|
856
|
+
return !!state.loop?.active;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Run the user's measure command. Orchestrator-side, never agent-side. */
|
|
860
|
+
async function runMeasure(ctx: ExtensionContext, cmd: string): Promise<number | null> {
|
|
861
|
+
if (!extensionApi) return null;
|
|
862
|
+
try {
|
|
863
|
+
const result = await extensionApi.exec("bash", ["-c", cmd], { cwd: ctx.cwd });
|
|
864
|
+
const stdout = (result as any)?.stdout ?? "";
|
|
865
|
+
return parseMetric(String(stdout));
|
|
866
|
+
} catch {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** git wrapper for branch=1 mode. Returns {ok, stdout}; never throws. */
|
|
872
|
+
async function runGit(ctx: ExtensionContext, args: string[]): Promise<{ ok: boolean; stdout: string }> {
|
|
873
|
+
if (!extensionApi) return { ok: false, stdout: "" };
|
|
874
|
+
try {
|
|
875
|
+
const result = await extensionApi.exec("git", args, { cwd: ctx.cwd });
|
|
876
|
+
const r = result as any;
|
|
877
|
+
const code = typeof r?.code === "number" ? r.code : (r?.exitCode ?? 1);
|
|
878
|
+
return { ok: code === 0, stdout: String(r?.stdout ?? "").trim() };
|
|
879
|
+
} catch {
|
|
880
|
+
return { ok: false, stdout: "" };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: string, boundsNote: string): string {
|
|
885
|
+
const tmplPath = path.resolve(__dirname, "..", "..", "prompts", "goal-loop-forever.md");
|
|
886
|
+
let tmpl: string;
|
|
887
|
+
try {
|
|
888
|
+
tmpl = fs.readFileSync(tmplPath, "utf-8");
|
|
889
|
+
} catch {
|
|
890
|
+
tmpl = `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric.`;
|
|
891
|
+
}
|
|
892
|
+
return tmpl
|
|
893
|
+
.replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
|
|
894
|
+
.replace(/\$\{TARGET\}/g, loop.target)
|
|
895
|
+
.replace(/\$\{MEASURE_CMD\}/g, loop.measureCmd)
|
|
896
|
+
.replace(/\$\{DIRECTION\}/g, loop.direction)
|
|
897
|
+
.replace(/\$\{DIRECTION_WORD\}/g, loop.direction === "min" ? "lower is better" : "higher is better")
|
|
898
|
+
.replace(/\$\{LAST_VALUE\}/g, loop.lastValue === null ? "(none yet)" : String(loop.lastValue))
|
|
899
|
+
.replace(/\$\{BEST_VALUE\}/g, loop.bestValue === null ? "(none yet)" : String(loop.bestValue))
|
|
900
|
+
.replace(/\$\{STALL_COUNT\}/g, String(loop.stallCount))
|
|
901
|
+
.replace(/\$\{PLATEAU_WINDOW\}/g, String(loop.plateauWindow))
|
|
902
|
+
.replace(/\$\{REGRESSION_NOTE\}/g, regressionNote)
|
|
903
|
+
.replace(/\$\{STRATEGY_NOTE\}/g, strategyNote)
|
|
904
|
+
.replace(/\$\{BOUNDS_NOTE\}/g, boundsNote);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function scheduleLoopTick(ctx: ExtensionContext): void {
|
|
908
|
+
if (!isLoopActive()) return;
|
|
909
|
+
rememberCtx(ctx);
|
|
910
|
+
clearLoopTimer();
|
|
911
|
+
let delay = 0;
|
|
912
|
+
try {
|
|
913
|
+
delay = ctx.isIdle() && !ctx.hasPendingMessages() ? 0 : BACKOFF_IDLE_RETRY_MS;
|
|
914
|
+
} catch {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
loopTimer = setTimeout(() => sendLoopTurn(), delay);
|
|
918
|
+
loopTimer.unref?.();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function sendLoopTurn(): void {
|
|
922
|
+
loopTimer = null;
|
|
923
|
+
if (!isLoopActive() || !extensionApi) return;
|
|
924
|
+
const ctx = freshCtx();
|
|
925
|
+
if (!ctx || !ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
926
|
+
loopTimer = setTimeout(() => sendLoopTurn(), BACKOFF_IDLE_RETRY_MS);
|
|
927
|
+
loopTimer.unref?.();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const loop = state.loop!;
|
|
931
|
+
const regressedLast = loop.history.length > 0 && !loop.history[loop.history.length - 1]!.improved && loop.lastValue !== null;
|
|
932
|
+
const regressionNote = regressedLast
|
|
933
|
+
? "**Your last change REGRESSED the metric. Undo it first, then try a different small change.**"
|
|
934
|
+
: "";
|
|
935
|
+
// Strategy rotation (from pi-loop-mode's one good idea): one stall before
|
|
936
|
+
// the plateau window closes, stop polishing and change approach entirely.
|
|
937
|
+
const strategyNote = loop.stallCount >= loop.plateauWindow - 1 && loop.stallCount > 0
|
|
938
|
+
? "**You are one stall from a plateau stop. Small tweaks are not working — try a FUNDAMENTALLY different approach: different file, different technique, or revert and rethink the angle of attack.**"
|
|
939
|
+
: "";
|
|
940
|
+
// v0.15.0: arbitrary bounds (never "completion") — surface what's armed.
|
|
941
|
+
const bounds: string[] = [];
|
|
942
|
+
if (loop.timeLimitHours !== undefined) bounds.push(`${loop.timeLimitHours}h`);
|
|
943
|
+
if (loop.tokenBudget !== undefined) bounds.push(`${loop.tokenBudget.toLocaleString()} tokens (used ${(loop.tokensUsed ?? 0).toLocaleString()})`);
|
|
944
|
+
const boundsNote = bounds.length ? `\n- Arbitrary bounds: the loop also stops after ${bounds.join(" or ")}` : "";
|
|
945
|
+
try {
|
|
946
|
+
extensionApi.sendMessage({
|
|
947
|
+
customType: GOAL_EVENT_ENTRY,
|
|
948
|
+
content: loopPrompt(loop, regressionNote, strategyNote, boundsNote),
|
|
949
|
+
display: false,
|
|
950
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
951
|
+
} catch {
|
|
952
|
+
// stale API — next agent_end reschedules
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** agent_end hook for loop 3: measure → judge → continue or stop. */
|
|
957
|
+
async function runLoopTick(ctx: ExtensionContext, event?: any): Promise<void> {
|
|
958
|
+
const loop = state.loop!;
|
|
959
|
+
// v0.15.0: token budget is an arbitrary bound; accumulate orchestrator-side.
|
|
960
|
+
if (event?.messages) {
|
|
961
|
+
loop.tokensUsed = (loop.tokensUsed ?? 0) + sumNewAssistantTokens(event.messages as unknown[], countedLoopTokenMessages);
|
|
962
|
+
}
|
|
963
|
+
const value = await runMeasure(ctx, loop.measureCmd);
|
|
964
|
+
// Hypothesis line (pi-autoresearch's good idea): the agent's stated intent
|
|
965
|
+
// for the turn goes into the ledger, making loop history auditable.
|
|
966
|
+
let hypothesis: string | undefined;
|
|
967
|
+
if (event) {
|
|
968
|
+
const last = [...(event.messages as any[])].reverse().find((m) => m.role === "assistant");
|
|
969
|
+
const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
|
|
970
|
+
hypothesis = text.match(/^HYPOTHESIS:\s*(.+)$/m)?.[1]?.trim().slice(0, 200);
|
|
971
|
+
}
|
|
972
|
+
const outcome = applyMeasurement(loop, value, nowIso());
|
|
973
|
+
persistState(ctx);
|
|
974
|
+
appendLedger(ctx.cwd, "loop_measured", {
|
|
975
|
+
iteration: loop.iteration,
|
|
976
|
+
value,
|
|
977
|
+
best: loop.bestValue,
|
|
978
|
+
stall: loop.stallCount,
|
|
979
|
+
hypothesis,
|
|
980
|
+
});
|
|
981
|
+
// branch=1 mode: commit improvements, hard-reset regressions — always and
|
|
982
|
+
// only on the scratch branch.
|
|
983
|
+
if (loop.branchName && outcome.kind === "continue") {
|
|
984
|
+
if (outcome.improved) {
|
|
985
|
+
await runGit(ctx, ["add", "-A"]);
|
|
986
|
+
const committed = await runGit(ctx, ["commit", "-m", `pi-gla-loop: iteration ${loop.iteration} (${loop.direction}=${loop.bestValue})`]);
|
|
987
|
+
appendLedger(ctx.cwd, "loop_git", { action: "commit", iteration: loop.iteration, ok: committed.ok });
|
|
988
|
+
} else {
|
|
989
|
+
const reset = await runGit(ctx, ["reset", "--hard", "HEAD"]);
|
|
990
|
+
appendLedger(ctx.cwd, "loop_git", { action: "reset", iteration: loop.iteration, ok: reset.ok });
|
|
991
|
+
}
|
|
992
|
+
persistState(ctx);
|
|
993
|
+
}
|
|
994
|
+
if (outcome.kind === "stop") {
|
|
995
|
+
await finishLoopGit(ctx, loop);
|
|
996
|
+
ctx.ui.notify(`Loop stopped: ${outcome.reason}. ${loop.history.length} iterations recorded.`, "info");
|
|
997
|
+
appendLedger(ctx.cwd, "loop_stopped", { reason: outcome.reason, iterations: loop.iteration, best: loop.bestValue });
|
|
998
|
+
notifyExternal(ctx, `Loop stopped: ${outcome.reason}`);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
scheduleLoopTick(ctx);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** On loop stop (any reason): return to the original branch, tell the user
|
|
1005
|
+
* where the work lives and how to merge it. Scratch branch is never deleted. */
|
|
1006
|
+
async function finishLoopGit(ctx: ExtensionContext, loop: LoopState): Promise<void> {
|
|
1007
|
+
if (!loop.branchName) return;
|
|
1008
|
+
// Uncommitted remnants (final stalled iterations were reset already, but be safe).
|
|
1009
|
+
await runGit(ctx, ["reset", "--hard", "HEAD"]);
|
|
1010
|
+
if (loop.originalBranch) {
|
|
1011
|
+
await runGit(ctx, ["checkout", loop.originalBranch]);
|
|
1012
|
+
}
|
|
1013
|
+
ctx.ui.notify(
|
|
1014
|
+
`Loop work is on branch ${loop.branchName} (${loop.iteration} iterations, best ${loop.bestValue ?? "n/a"}).\nMerge with: git merge ${loop.branchName} — or delete with: git branch -D ${loop.branchName}`,
|
|
1015
|
+
"info",
|
|
1016
|
+
);
|
|
1017
|
+
appendLedger(ctx.cwd, "loop_git", { action: "finish", branch: loop.branchName, returnedTo: loop.originalBranch });
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
interface LoopConfig {
|
|
1021
|
+
target: string;
|
|
1022
|
+
measureCmd: string;
|
|
1023
|
+
direction: "min" | "max";
|
|
1024
|
+
plateauWindow: number;
|
|
1025
|
+
maxIterations: number;
|
|
1026
|
+
branch: boolean;
|
|
1027
|
+
force?: boolean;
|
|
1028
|
+
timeLimitHours?: number;
|
|
1029
|
+
tokenBudget?: number;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Shared loop-start path: /loop start AND propose_loop_draft (after Confirm). */
|
|
1033
|
+
async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Promise<boolean> {
|
|
1034
|
+
// branch=1 mode: scratch branch ONLY. Refuse on non-git or dirty tree —
|
|
1035
|
+
// we never mix uncommitted user work into the loop's branch.
|
|
1036
|
+
let branchName: string | undefined;
|
|
1037
|
+
let originalBranch: string | undefined;
|
|
1038
|
+
if (cfg.branch) {
|
|
1039
|
+
const isRepo = await runGit(ctx, ["rev-parse", "--is-inside-work-tree"]);
|
|
1040
|
+
if (!isRepo.ok) {
|
|
1041
|
+
ctx.ui.notify("branch=1 requires a git repository.", "warning");
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
const dirty = await runGit(ctx, ["status", "--porcelain"]);
|
|
1045
|
+
if (!dirty.ok || dirty.stdout.length > 0) {
|
|
1046
|
+
ctx.ui.notify("branch=1 requires a clean working tree — commit or stash your changes first.", "warning");
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
const current = await runGit(ctx, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1050
|
+
originalBranch = current.ok ? current.stdout : undefined;
|
|
1051
|
+
branchName = loopBranchName(nowIso(), cfg.target);
|
|
1052
|
+
const created = await runGit(ctx, ["checkout", "-b", branchName]);
|
|
1053
|
+
if (!created.ok) {
|
|
1054
|
+
ctx.ui.notify(`Failed to create scratch branch ${branchName}.`, "warning");
|
|
1055
|
+
return false;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// Baseline measurement before the first agent turn. A measure that
|
|
1059
|
+
// produces no number is a footgun: without a baseline the loop burns stall
|
|
1060
|
+
// iterations before plateau stops it. Refuse fast (force=1 overrides for
|
|
1061
|
+
// measures that only work after the agent builds something first).
|
|
1062
|
+
const baseline = await runMeasure(ctx, cfg.measureCmd);
|
|
1063
|
+
if (baseline === null && !(cfg as { force?: boolean }).force) {
|
|
1064
|
+
ctx.ui.notify(
|
|
1065
|
+
`/loop start refused: the measure produced no number.\nCommand: ${cfg.measureCmd}\nFix it so it prints exactly one number, or re-run with force=1 if it only works after the agent builds something first.\n(Non-numeric goal — research, docs, features? Use /goal: the independent auditor verifies semantically. /loop only believes a number.)`,
|
|
1066
|
+
"warning",
|
|
1067
|
+
);
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
state = {
|
|
1071
|
+
...state,
|
|
1072
|
+
loop: {
|
|
1073
|
+
target: cfg.target,
|
|
1074
|
+
measureCmd: cfg.measureCmd,
|
|
1075
|
+
direction: cfg.direction,
|
|
1076
|
+
iteration: 0,
|
|
1077
|
+
maxIterations: cfg.maxIterations,
|
|
1078
|
+
plateauWindow: cfg.plateauWindow,
|
|
1079
|
+
stallCount: 0,
|
|
1080
|
+
bestValue: baseline,
|
|
1081
|
+
lastValue: baseline,
|
|
1082
|
+
active: true,
|
|
1083
|
+
history: [],
|
|
1084
|
+
startedAt: nowIso(),
|
|
1085
|
+
timeLimitHours: cfg.timeLimitHours,
|
|
1086
|
+
tokenBudget: cfg.tokenBudget,
|
|
1087
|
+
tokensUsed: 0,
|
|
1088
|
+
branchName,
|
|
1089
|
+
originalBranch,
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
persistState(ctx);
|
|
1093
|
+
appendLedger(ctx.cwd, "loop_started", { target: cfg.target, measureCmd: cfg.measureCmd, direction: cfg.direction, baseline, branch: branchName, timeLimitHours: cfg.timeLimitHours, tokenBudget: cfg.tokenBudget });
|
|
1094
|
+
ctx.ui.notify(
|
|
1095
|
+
`Loop started: ${cfg.target.slice(0, 60)}\nBaseline: ${baseline ?? "(forced without a number — first turn must produce one)"} · direction ${cfg.direction} · window ${cfg.plateauWindow} · max ${cfg.maxIterations}` +
|
|
1096
|
+
(branchName ? `\nbranch mode: committing improvements to ${branchName}` : ""),
|
|
1097
|
+
"info",
|
|
1098
|
+
);
|
|
1099
|
+
scheduleLoopTick(ctx);
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
|
|
1104
|
+
const parts = args.trim().split(/\s+/);
|
|
1105
|
+
const sub = (parts[0] ?? "").toLowerCase();
|
|
1106
|
+
const rest = args.trim().slice(sub.length).trim();
|
|
1107
|
+
|
|
1108
|
+
if (!sub) {
|
|
1109
|
+
// /loop with no args → draft the loop config (metric design is the whole
|
|
1110
|
+
// game for a long-running loop; never start one blind).
|
|
1111
|
+
if (isLoopActive()) {
|
|
1112
|
+
ctx.ui.notify("A loop is already active — /loop status to inspect, /loop stop to end it.", "info");
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
await startDrafting(ctx, "loop");
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (sub === "status") {
|
|
1120
|
+
const loop = state.loop;
|
|
1121
|
+
if (!loop) {
|
|
1122
|
+
ctx.ui.notify("No loop. /loop to draft one, or /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [window=5] [max=50] [time=<hours>] [tokens=<budget>]", "info");
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const lines = [
|
|
1126
|
+
`Loop: ${loop.active ? "active" : "stopped"} — ${loop.target.slice(0, 80)}`,
|
|
1127
|
+
`Metric: ${loop.measureCmd} (${loop.direction})`,
|
|
1128
|
+
`Iteration ${loop.iteration}/${loop.maxIterations} · best ${loop.bestValue ?? "n/a"} · last ${loop.lastValue ?? "n/a"} · stall ${loop.stallCount}/${loop.plateauWindow}`,
|
|
1129
|
+
];
|
|
1130
|
+
const bounds: string[] = [];
|
|
1131
|
+
if (loop.timeLimitHours !== undefined) bounds.push(`time ≤ ${loop.timeLimitHours}h`);
|
|
1132
|
+
if (loop.tokenBudget !== undefined) bounds.push(`tokens ${(loop.tokensUsed ?? 0).toLocaleString()}/${loop.tokenBudget.toLocaleString()}`);
|
|
1133
|
+
if (bounds.length) lines.push(`Bounds: ${bounds.join(" · ")}`);
|
|
1134
|
+
if (loop.refinements?.length) lines.push(`Spec refined ${loop.refinements.length}× (latest: iteration ${loop.refinements[loop.refinements.length - 1]!.iteration})`);
|
|
1135
|
+
if (loop.stopReason) lines.push(`Stopped: ${loop.stopReason}`);
|
|
1136
|
+
const tail = loop.history.slice(-5);
|
|
1137
|
+
if (tail.length > 0) {
|
|
1138
|
+
lines.push("Recent: " + tail.map((h) => `${h.value ?? "ERR"}${h.improved ? "↑" : ""}`).join(" "));
|
|
1139
|
+
}
|
|
1140
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (sub === "start") {
|
|
1145
|
+
if (state.goal && state.goal.status === "active") {
|
|
1146
|
+
ctx.ui.notify("A goal is active — /goal cancel or /goal pause it before starting a loop.", "warning");
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
if (isLoopActive()) {
|
|
1150
|
+
ctx.ui.notify("A loop is already active. /loop stop first.", "warning");
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
let cfg;
|
|
1154
|
+
try {
|
|
1155
|
+
cfg = parseLoopStartArgs(rest);
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
ctx.ui.notify(
|
|
1158
|
+
`/loop start: ${err instanceof Error ? err.message : String(err)}\n(Non-numeric goal — research, docs, features? Use /goal: the auditor verifies semantically. /loop only believes a number. Or /loop with no args to draft.)`,
|
|
1159
|
+
"warning",
|
|
1160
|
+
);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
await startLoopFromConfig(ctx, cfg);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (sub === "stop") {
|
|
1168
|
+
if (!state.loop) {
|
|
1169
|
+
ctx.ui.notify("No loop to stop.", "info");
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
clearLoopTimer();
|
|
1173
|
+
state.loop = { ...state.loop, active: false, stopReason: state.loop.stopReason ?? "stopped by user (/loop stop)" };
|
|
1174
|
+
persistState(ctx);
|
|
1175
|
+
await finishLoopGit(ctx, state.loop);
|
|
1176
|
+
appendLedger(ctx.cwd, "loop_stopped", { reason: "user", iterations: state.loop.iteration, best: state.loop.bestValue });
|
|
1177
|
+
ctx.ui.notify(
|
|
1178
|
+
`Loop stopped after ${state.loop.iteration} iterations. Best: ${state.loop.bestValue ?? "n/a"}.`,
|
|
1179
|
+
"info",
|
|
1180
|
+
);
|
|
1181
|
+
notifyExternal(ctx, `Loop stopped by user after ${state.loop.iteration} iterations (best: ${state.loop.bestValue ?? "n/a"})`);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
ctx.ui.notify("Usage: /loop [status] | /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [done=<value>] [window=5] [max=50] | /loop stop", "info");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// =================================================================
|
|
1189
|
+
// Tools exposed to the agent
|
|
1190
|
+
// =================================================================
|
|
1191
|
+
|
|
1192
|
+
function registerAgentTools(pi: any, ctx: ExtensionContext): void {
|
|
1193
|
+
pi.registerTool(defineTool({
|
|
1194
|
+
name: "complete_goal",
|
|
1195
|
+
label: "Complete goal",
|
|
1196
|
+
description: "Mark the active goal as complete. Spawns an isolated auditor to verify. Use only when the objective is genuinely satisfied.",
|
|
1197
|
+
parameters: Type.Object({
|
|
1198
|
+
completionSummary: Type.Optional(Type.String({ description: "1-paragraph completion claim" })),
|
|
1199
|
+
verificationSummary: Type.Optional(Type.String({ description: "Per-item evidence for the verification contract" })),
|
|
1200
|
+
}),
|
|
1201
|
+
async execute(_id, params, signal) {
|
|
1202
|
+
if (!state.goal || state.goal.status !== "active") {
|
|
1203
|
+
return { content: [{ type: "text", text: "No active goal." }], details: {} };
|
|
1204
|
+
}
|
|
1205
|
+
const p = params as { completionSummary?: string; verificationSummary?: string };
|
|
1206
|
+
updateGoal({ status: "auditing" }, ctx);
|
|
1207
|
+
const settings = loadSettings(ctx.cwd);
|
|
1208
|
+
const { model: auditorModel, error: modelError, via } = resolveAuditorModel(ctx, settings.auditorModel);
|
|
1209
|
+
if (modelError) {
|
|
1210
|
+
ctx.ui.notify(`Auditor model issue: ${modelError}`, "warning");
|
|
1211
|
+
}
|
|
1212
|
+
ctx.ui.notify(`Auditor running (isolated session, model: ${via ?? "setting"})…`, "info");
|
|
1213
|
+
// Esc during the audit aborts this tool's signal → threaded into the
|
|
1214
|
+
// auditor session, which aborts cleanly and returns "Auditor aborted."
|
|
1215
|
+
latestAuditProgress = { label: "starting" };
|
|
1216
|
+
const result = await runGoalCompletionAuditor({
|
|
1217
|
+
ctx,
|
|
1218
|
+
goal: state.goal,
|
|
1219
|
+
completionSummary: p.completionSummary,
|
|
1220
|
+
verificationSummary: p.verificationSummary,
|
|
1221
|
+
model: auditorModel,
|
|
1222
|
+
thinkingLevel: settings.auditorThinkingLevel ?? getSessionThinkingLevel(),
|
|
1223
|
+
signal: signal ?? undefined,
|
|
1224
|
+
onProgress: (progress) => {
|
|
1225
|
+
latestAuditProgress = {
|
|
1226
|
+
currentTool: progress.currentTool,
|
|
1227
|
+
label: progress.label,
|
|
1228
|
+
elapsedMs: progress.elapsedMs,
|
|
1229
|
+
};
|
|
1230
|
+
refreshUI(ctx);
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
latestAuditProgress = null;
|
|
1234
|
+
// Audit history: record REAL verdicts only — a non-empty report is the
|
|
1235
|
+
// evidence the auditor actually inspected something. Empty-report runs
|
|
1236
|
+
// (abort, auth failure, no model) are surfaced via pauseReason, not
|
|
1237
|
+
// logged as disapprovals.
|
|
1238
|
+
const auditorRan = result.output.trim().length > 0;
|
|
1239
|
+
const history = state.goal.auditHistory ?? [];
|
|
1240
|
+
if (auditorRan) {
|
|
1241
|
+
history.push({
|
|
1242
|
+
at: nowIso(),
|
|
1243
|
+
approved: result.approved,
|
|
1244
|
+
disapproved: result.disapproved,
|
|
1245
|
+
model: result.model,
|
|
1246
|
+
thinkingLevel: result.thinkingLevel,
|
|
1247
|
+
report: result.output,
|
|
1248
|
+
error: result.error,
|
|
1249
|
+
regressionShieldPassed: result.regressionShieldPassed,
|
|
1250
|
+
});
|
|
1251
|
+
// Cap history — 39 infra errors taught us unbounded growth is real.
|
|
1252
|
+
if (history.length > 20) history.splice(0, history.length - 20);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Escape hatch: the user aborted the audit (Esc). Offer the explicit
|
|
1256
|
+
// choice — complete WITHOUT audit, or keep working. (pi-goal-x parity.)
|
|
1257
|
+
if (result.error === "Auditor aborted.") {
|
|
1258
|
+
updateGoal({ status: "active", auditHistory: history, pauseReason: "audit aborted by user (Esc)" }, ctx);
|
|
1259
|
+
let completeAnyway = false;
|
|
1260
|
+
try {
|
|
1261
|
+
completeAnyway = await ctx.ui.confirm(
|
|
1262
|
+
"Audit aborted",
|
|
1263
|
+
"You aborted the auditor (Escape).\n\nYes = mark the goal COMPLETE WITHOUT AUDIT (you take responsibility for verification).\nNo = continue working; the auditor will verify on the next complete_goal.",
|
|
1264
|
+
);
|
|
1265
|
+
} catch {
|
|
1266
|
+
completeAnyway = false;
|
|
1267
|
+
}
|
|
1268
|
+
if (completeAnyway) {
|
|
1269
|
+
updateGoal({ auditHistory: history }, ctx);
|
|
1270
|
+
archiveCurrentGoal(ctx, "complete", "completed without audit (user choice after Esc)");
|
|
1271
|
+
return { content: [{ type: "text", text: "Goal marked complete without audit (user choice)." }], details: {} };
|
|
1272
|
+
}
|
|
1273
|
+
scheduleContinuation(ctx, true);
|
|
1274
|
+
return {
|
|
1275
|
+
content: [{ type: "text", text: "Audit aborted; continuing. Call complete_goal again when ready — the auditor will re-run." }],
|
|
1276
|
+
details: {},
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (result.approved) {
|
|
1281
|
+
updateGoal({ auditHistory: history }, ctx);
|
|
1282
|
+
const objective = state.goal.objective;
|
|
1283
|
+
archiveCurrentGoal(ctx, "complete", `auditor ${result.model} approved`);
|
|
1284
|
+
notifyExternal(ctx, `Goal complete (auditor approved): ${objective.slice(0, 120)}`);
|
|
1285
|
+
return { content: [{ type: "text", text: `Goal approved by auditor ${result.model}.` }], details: {} };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// THREE-WAY SPLIT (v0.9.9): infrastructure failure is NOT a verdict.
|
|
1289
|
+
// The wild-caught case: 6 silent "disapprovals" that were really a dead
|
|
1290
|
+
// auditor model. The agent must be able to tell the difference.
|
|
1291
|
+
if (result.error && !result.disapproved) {
|
|
1292
|
+
updateGoal({
|
|
1293
|
+
status: "active",
|
|
1294
|
+
auditHistory: history,
|
|
1295
|
+
pauseReason: `auditor infrastructure: ${result.error}`,
|
|
1296
|
+
pauseSuggestedAction: "Fix the auditor model (/gla model=provider/id) and call complete_goal again — your work was NOT judged",
|
|
1297
|
+
}, ctx);
|
|
1298
|
+
scheduleContinuation(ctx, true);
|
|
1299
|
+
return {
|
|
1300
|
+
content: [{
|
|
1301
|
+
type: "text",
|
|
1302
|
+
text: `The auditor could not run (infrastructure, NOT a verdict): ${result.error}\nYour completion claim was not evaluated. Fix the auditor model with /gla model=provider/id and call complete_goal again — do not change your deliverable for this.`,
|
|
1303
|
+
}],
|
|
1304
|
+
details: {},
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const noContractHint = state.goal.verificationContract?.trim()
|
|
1309
|
+
? ""
|
|
1310
|
+
: "\n\nNote: this goal has no verification contract, so the auditor inferred done-criteria from the objective text. For sharper verdicts, /goal tweak the objective to add a 'Done when: ...' clause.";
|
|
1311
|
+
updateGoal({
|
|
1312
|
+
status: "active",
|
|
1313
|
+
auditHistory: history,
|
|
1314
|
+
pauseReason: "auditor disapproved",
|
|
1315
|
+
pauseSuggestedAction: "Inspect auditor feedback and fix the actual gap before calling complete_goal again",
|
|
1316
|
+
}, ctx);
|
|
1317
|
+
scheduleContinuation(ctx, true);
|
|
1318
|
+
return {
|
|
1319
|
+
content: [{
|
|
1320
|
+
type: "text",
|
|
1321
|
+
text: `Auditor disapproved. Report (first 800 chars):\n${result.output.slice(0, 800)}${noContractHint}`,
|
|
1322
|
+
}],
|
|
1323
|
+
details: {},
|
|
1324
|
+
};
|
|
1325
|
+
},
|
|
1326
|
+
}));
|
|
1327
|
+
|
|
1328
|
+
pi.registerTool(defineTool({
|
|
1329
|
+
name: "pause_goal",
|
|
1330
|
+
label: "Pause goal",
|
|
1331
|
+
description: "Pause the active goal with a reason and suggested action. Use when blocked on user input or unable to make progress.",
|
|
1332
|
+
parameters: Type.Object({
|
|
1333
|
+
reason: Type.String({ description: "Why the work is paused" }),
|
|
1334
|
+
suggestedAction: Type.Optional(Type.String({ description: "What the user should do next" })),
|
|
1335
|
+
}),
|
|
1336
|
+
async execute(_id, params) {
|
|
1337
|
+
const p = params as { reason: string; suggestedAction?: string };
|
|
1338
|
+
if (!state.goal) return { content: [{ type: "text", text: "No active goal." }], details: {} };
|
|
1339
|
+
updateGoal({
|
|
1340
|
+
status: "paused",
|
|
1341
|
+
pauseReason: p.reason,
|
|
1342
|
+
pauseSuggestedAction: p.suggestedAction,
|
|
1343
|
+
}, ctx);
|
|
1344
|
+
ctx.ui.notify(`Goal paused: ${p.reason}`, "info");
|
|
1345
|
+
notifyExternal(ctx, `Goal paused: ${p.reason.slice(0, 120)}`);
|
|
1346
|
+
return { content: [{ type: "text", text: "Goal paused. /goal resume to continue." }], details: {} };
|
|
1347
|
+
},
|
|
1348
|
+
}));
|
|
1349
|
+
|
|
1350
|
+
pi.registerTool(defineTool({
|
|
1351
|
+
name: "complete_task",
|
|
1352
|
+
label: "Complete task",
|
|
1353
|
+
description: "Mark a task in the active goal's task list as complete (does not stop the turn).",
|
|
1354
|
+
parameters: Type.Object({
|
|
1355
|
+
id: Type.String({ description: "Task id to complete" }),
|
|
1356
|
+
}),
|
|
1357
|
+
async execute(_id, params) {
|
|
1358
|
+
const p = params as { id: string };
|
|
1359
|
+
if (!state.goal || !state.goal.taskList) {
|
|
1360
|
+
return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
|
|
1361
|
+
}
|
|
1362
|
+
const tl = state.goal.taskList;
|
|
1363
|
+
const queue: any[] = [...tl.tasks];
|
|
1364
|
+
while (queue.length > 0) {
|
|
1365
|
+
const t = queue.shift();
|
|
1366
|
+
if (t.id === p.id && t.status !== "complete") {
|
|
1367
|
+
t.status = "complete";
|
|
1368
|
+
updateGoal({ taskList: tl }, ctx);
|
|
1369
|
+
return { content: [{ type: "text", text: `Task ${p.id} marked complete.` }], details: {} };
|
|
1370
|
+
}
|
|
1371
|
+
if (t.subtasks) queue.push(...t.subtasks);
|
|
1372
|
+
}
|
|
1373
|
+
return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
|
|
1374
|
+
},
|
|
1375
|
+
}));
|
|
1376
|
+
|
|
1377
|
+
pi.registerTool(defineTool({
|
|
1378
|
+
name: "update_task_status",
|
|
1379
|
+
label: "Update task status",
|
|
1380
|
+
description: "Update a task's status (pending/in_progress/complete).",
|
|
1381
|
+
parameters: Type.Object({
|
|
1382
|
+
id: Type.String(),
|
|
1383
|
+
status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("complete")]),
|
|
1384
|
+
}),
|
|
1385
|
+
async execute(_id, params) {
|
|
1386
|
+
const p = params as { id: string; status: "pending" | "in_progress" | "complete" };
|
|
1387
|
+
if (!state.goal || !state.goal.taskList) {
|
|
1388
|
+
return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
|
|
1389
|
+
}
|
|
1390
|
+
const tl = state.goal.taskList;
|
|
1391
|
+
const queue: any[] = [...tl.tasks];
|
|
1392
|
+
while (queue.length > 0) {
|
|
1393
|
+
const t = queue.shift();
|
|
1394
|
+
if (t.id === p.id) {
|
|
1395
|
+
t.status = p.status;
|
|
1396
|
+
updateGoal({ taskList: tl }, ctx);
|
|
1397
|
+
return { content: [{ type: "text", text: `Task ${p.id} → ${p.status}` }], details: {} };
|
|
1398
|
+
}
|
|
1399
|
+
if (t.subtasks) queue.push(...t.subtasks);
|
|
1400
|
+
}
|
|
1401
|
+
return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
|
|
1402
|
+
},
|
|
1403
|
+
}));
|
|
1404
|
+
|
|
1405
|
+
pi.registerTool(defineTool({
|
|
1406
|
+
name: "propose_goal_draft",
|
|
1407
|
+
label: "Propose goal draft",
|
|
1408
|
+
description: "During goal drafting (/goal with no args), propose the clarified goal contract. Opens the user's Confirm dialog — nothing activates until they confirm. BLOCKED until the user has replied to at least one of your interview questions.",
|
|
1409
|
+
parameters: Type.Object({
|
|
1410
|
+
objective: Type.String({ description: "The clarified, concrete objective (single item) or a summary when items[] is used" }),
|
|
1411
|
+
verificationContract: Type.Optional(Type.String({ description: "Checkable done-criteria (commands, file states, test outcomes)" })),
|
|
1412
|
+
items: Type.Optional(Type.Array(Type.String(), { description: "LIST drafting only: many objectives at once (e.g. 'queue these 50 things'). Each becomes a queue item; per-item 'Done when:' clauses are honored." })),
|
|
1413
|
+
}),
|
|
1414
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
1415
|
+
const p = params as { objective: string; verificationContract?: string; items?: string[] };
|
|
1416
|
+
if (draftingTarget !== "goal" && draftingTarget !== "list") {
|
|
1417
|
+
return {
|
|
1418
|
+
content: [{ type: "text", text: "Not in goal drafting mode. The user starts drafting with /goal or /list add (no args), or activates directly with /goal <objective>." }],
|
|
1419
|
+
details: {},
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
// v0.14.0: the interview floor — no Confirm until the user replied.
|
|
1423
|
+
const block = draftProposalBlock(draftingUserReplies);
|
|
1424
|
+
if (block) {
|
|
1425
|
+
return { content: [{ type: "text", text: block }], details: {} };
|
|
1426
|
+
}
|
|
1427
|
+
// Multi-item drafts are LIST-only: a goal is single by definition.
|
|
1428
|
+
if (p.items && p.items.length > 0 && draftingTarget !== "list") {
|
|
1429
|
+
return {
|
|
1430
|
+
content: [{ type: "text", text: "items[] is only valid in /list drafting — a goal is a single objective. Propose one objective, or ask the user to switch to /list." }],
|
|
1431
|
+
details: {},
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
1435
|
+
// Multi-item list draft: one Confirm for the whole batch.
|
|
1436
|
+
if (p.items && p.items.length > 0) {
|
|
1437
|
+
const preview = p.items.slice(0, 6).map((t, i) => ` ${i + 1}. ${t.slice(0, 60)}`).join("\n");
|
|
1438
|
+
let batchConfirmed = false;
|
|
1439
|
+
try {
|
|
1440
|
+
batchConfirmed = await liveCtx.ui.confirm(
|
|
1441
|
+
"Confirm queue batch",
|
|
1442
|
+
`${p.items.length} items:\n${preview}${p.items.length > 6 ? `\n … and ${p.items.length - 6} more` : ""}`,
|
|
1443
|
+
);
|
|
1444
|
+
} catch {
|
|
1445
|
+
batchConfirmed = false;
|
|
1446
|
+
}
|
|
1447
|
+
if (!batchConfirmed) {
|
|
1448
|
+
return {
|
|
1449
|
+
content: [{ type: "text", text: "Batch rejected by the user. Ask what to change, refine the item list, and propose again." }],
|
|
1450
|
+
details: {},
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
draftingTarget = null;
|
|
1454
|
+
const wasIdle = !state.goal || state.goal.status === "complete" || state.goal.status === "aborted";
|
|
1455
|
+
const n = enqueueItems(liveCtx, p.items, "drafted batch");
|
|
1456
|
+
if (wasIdle) {
|
|
1457
|
+
return { content: [{ type: "text", text: `${n} items confirmed; first activated (queue was empty). Begin work now.` }], details: {} };
|
|
1458
|
+
}
|
|
1459
|
+
return { content: [{ type: "text", text: `${n} items confirmed and queued (${listQueue().length} waiting).` }], details: {} };
|
|
1460
|
+
}
|
|
1461
|
+
const contractBlock = p.verificationContract?.trim()
|
|
1462
|
+
? `\n\nDone when:\n${p.verificationContract.trim()}`
|
|
1463
|
+
: "\n\n(No verification contract — the auditor will infer done-criteria from the objective. Consider adding one.)";
|
|
1464
|
+
let confirmed = false;
|
|
1465
|
+
try {
|
|
1466
|
+
confirmed = await liveCtx.ui.confirm("Confirm goal", `${p.objective.trim()}${contractBlock}`);
|
|
1467
|
+
} catch {
|
|
1468
|
+
confirmed = false;
|
|
1469
|
+
}
|
|
1470
|
+
if (!confirmed) {
|
|
1471
|
+
return {
|
|
1472
|
+
content: [{ type: "text", text: "Draft rejected by the user. Ask what to change, refine, and propose again. Do not repeat the identical draft." }],
|
|
1473
|
+
details: {},
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
const confirmedTarget = draftingTarget;
|
|
1477
|
+
draftingTarget = null;
|
|
1478
|
+
const full = p.objective.trim() + (p.verificationContract?.trim() ? `\nDone when:\n${p.verificationContract.trim()}` : "");
|
|
1479
|
+
// List drafting: the confirmed contract goes into the QUEUE, not active.
|
|
1480
|
+
if (confirmedTarget === "list") {
|
|
1481
|
+
const extracted = extractVerificationContract(full);
|
|
1482
|
+
const item = { id: newGoalId(), objective: extracted.objective, verificationContract: extracted.verificationContract || undefined, addedAt: nowIso() };
|
|
1483
|
+
state = { ...state, list: [...listQueue(), item] };
|
|
1484
|
+
persistState(liveCtx);
|
|
1485
|
+
appendLedger(liveCtx.cwd, "list_added", { id: item.id, objective: item.objective, drafted: true });
|
|
1486
|
+
if (!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") {
|
|
1487
|
+
activateNextListItem(liveCtx);
|
|
1488
|
+
return { content: [{ type: "text", text: "Confirmed and activated (queue was empty). Begin work now." }], details: {} };
|
|
1489
|
+
}
|
|
1490
|
+
return { content: [{ type: "text", text: `Confirmed and queued (${listQueue().length} waiting). It activates when the current goal completes.` }], details: {} };
|
|
1491
|
+
}
|
|
1492
|
+
const goal = createGoal(full, liveCtx);
|
|
1493
|
+
setGoal(goal, liveCtx);
|
|
1494
|
+
iterationCounter = 0;
|
|
1495
|
+
consecutiveStuckIterations = 0;
|
|
1496
|
+
consecutiveErrorIterations = 0;
|
|
1497
|
+
scheduleContinuation(liveCtx, true);
|
|
1498
|
+
return {
|
|
1499
|
+
content: [{ type: "text", text: `Goal confirmed and activated (id ${goal.id}). Begin work now; call complete_goal only when the objective is genuinely satisfied.` }],
|
|
1500
|
+
details: {},
|
|
1501
|
+
};
|
|
1502
|
+
},
|
|
1503
|
+
}));
|
|
1504
|
+
|
|
1505
|
+
pi.registerTool(defineTool({
|
|
1506
|
+
name: "propose_loop_draft",
|
|
1507
|
+
label: "Propose loop draft",
|
|
1508
|
+
description: "During loop drafting (/loop with no args), propose the loop configuration. The orchestrator test-runs the measure command ONCE and shows the user real output + parsed number in a Confirm dialog. A measure producing no number is auto-rejected.",
|
|
1509
|
+
parameters: Type.Object({
|
|
1510
|
+
target: Type.String({ description: "What to improve, concretely" }),
|
|
1511
|
+
measureCmd: Type.String({ description: "Shell command that prints ONE number representing progress" }),
|
|
1512
|
+
direction: Type.Union([Type.Literal("min"), Type.Literal("max")], { description: "min = lower is better, max = higher is better" }),
|
|
1513
|
+
window: Type.Optional(Type.Number({ description: "Plateau stop after N non-improving iterations (default 5)" })),
|
|
1514
|
+
max: Type.Optional(Type.Number({ description: "Iteration cap (default 50)" })),
|
|
1515
|
+
time: Type.Optional(Type.Number({ description: "Arbitrary bound: stop after this many hours" })),
|
|
1516
|
+
tokens: Type.Optional(Type.Number({ description: "Arbitrary bound: stop after this many tokens (input+output)" })),
|
|
1517
|
+
branch: Type.Optional(Type.Boolean({ description: "branch=true: scratch-branch mode (clean git tree required)" })),
|
|
1518
|
+
}),
|
|
1519
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
1520
|
+
const p = params as { target: string; measureCmd: string; direction: "min" | "max"; window?: number; max?: number; time?: number; tokens?: number; branch?: boolean };
|
|
1521
|
+
if (draftingTarget !== "loop") {
|
|
1522
|
+
return {
|
|
1523
|
+
content: [{ type: "text", text: "Not in loop drafting mode. The user starts loop drafting with /loop (no args), or starts directly with /loop start." }],
|
|
1524
|
+
details: {},
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
// v0.14.0: the interview floor — no Confirm until the user replied.
|
|
1528
|
+
const loopBlock = draftProposalBlock(draftingUserReplies);
|
|
1529
|
+
if (loopBlock) {
|
|
1530
|
+
return { content: [{ type: "text", text: loopBlock }], details: {} };
|
|
1531
|
+
}
|
|
1532
|
+
if (!p.target?.trim() || !p.measureCmd?.trim()) {
|
|
1533
|
+
return { content: [{ type: "text", text: "target and measureCmd are both required." }], details: {} };
|
|
1534
|
+
}
|
|
1535
|
+
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
1536
|
+
// THE TEST-RUN: orchestrator runs the proposed measure once. The user
|
|
1537
|
+
// sees the real number before a single iteration burns tokens.
|
|
1538
|
+
let rawOutput = "";
|
|
1539
|
+
let parsed: number | null = null;
|
|
1540
|
+
if (extensionApi) {
|
|
1541
|
+
try {
|
|
1542
|
+
const result = await extensionApi.exec("bash", ["-c", p.measureCmd], { cwd: liveCtx.cwd });
|
|
1543
|
+
rawOutput = String((result as any)?.stdout ?? "").trim();
|
|
1544
|
+
parsed = parseMetric(rawOutput);
|
|
1545
|
+
} catch (err) {
|
|
1546
|
+
rawOutput = `(measure command failed: ${err instanceof Error ? err.message : String(err)})`;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (parsed === null) {
|
|
1550
|
+
return {
|
|
1551
|
+
content: [{
|
|
1552
|
+
type: "text",
|
|
1553
|
+
text: `Measure test-run produced NO number — proposal auto-rejected.\nCommand: ${p.measureCmd}\nOutput: ${rawOutput.slice(0, 300) || "(empty)"}\nFix the command so it prints exactly one number, sanity-check it against the repo, and propose again.`,
|
|
1554
|
+
}],
|
|
1555
|
+
details: {},
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
const window = p.window && p.window > 0 ? Math.floor(p.window) : 5;
|
|
1559
|
+
const max = p.max && p.max > 0 ? Math.floor(p.max) : 50;
|
|
1560
|
+
let confirmed = false;
|
|
1561
|
+
try {
|
|
1562
|
+
confirmed = await liveCtx.ui.confirm(
|
|
1563
|
+
"Confirm loop",
|
|
1564
|
+
`Target: ${p.target.trim()}\n\nMeasure: ${p.measureCmd}\nTest-run output: ${rawOutput.slice(0, 200)}\nParsed number: ${parsed} (${p.direction === "min" ? "lower is better" : "higher is better"})\n\nPlateau stop: ${window} non-improving iterations · Cap: ${max} iterations${typeof p.time === "number" && p.time > 0 ? ` · Time bound: ${p.time}h` : ""}${typeof p.tokens === "number" && p.tokens > 0 ? ` · Token bound: ${p.tokens.toLocaleString()}` : ""}${p.branch ? "\nbranch mode: scratch branch (clean tree required)" : ""}\n\nThe loop never completes — it runs until one of these bounds, plateau, or /loop stop. Start it?`,
|
|
1565
|
+
);
|
|
1566
|
+
} catch {
|
|
1567
|
+
confirmed = false;
|
|
1568
|
+
}
|
|
1569
|
+
if (!confirmed) {
|
|
1570
|
+
return {
|
|
1571
|
+
content: [{ type: "text", text: "Loop draft rejected by the user. Ask what to change — target, metric, direction, or window/max — and propose again." }],
|
|
1572
|
+
details: {},
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
draftingTarget = null;
|
|
1576
|
+
const started = await startLoopFromConfig(liveCtx, {
|
|
1577
|
+
target: p.target.trim(),
|
|
1578
|
+
measureCmd: p.measureCmd,
|
|
1579
|
+
direction: p.direction,
|
|
1580
|
+
plateauWindow: window,
|
|
1581
|
+
maxIterations: max,
|
|
1582
|
+
timeLimitHours: typeof p.time === "number" && Number.isFinite(p.time) && p.time > 0 ? p.time : undefined,
|
|
1583
|
+
tokenBudget: typeof p.tokens === "number" && Number.isFinite(p.tokens) && p.tokens > 0 ? Math.floor(p.tokens) : undefined,
|
|
1584
|
+
branch: p.branch === true,
|
|
1585
|
+
});
|
|
1586
|
+
if (!started) {
|
|
1587
|
+
return { content: [{ type: "text", text: "Loop could not start (see the warning above — likely a git/dirty-tree issue with branch mode)." }], details: {} };
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
content: [{ type: "text", text: `Loop confirmed and started. Baseline ${parsed}. Make ONE small change per turn to move the metric ${p.direction === "min" ? "down" : "up"}.` }],
|
|
1591
|
+
details: {},
|
|
1592
|
+
};
|
|
1593
|
+
},
|
|
1594
|
+
}));
|
|
1595
|
+
|
|
1596
|
+
pi.registerTool(defineTool({
|
|
1597
|
+
name: "propose_loop_refine",
|
|
1598
|
+
label: "Propose loop spec refinement",
|
|
1599
|
+
description: "While a loop is ACTIVE, propose refining the loop's spec — sharpen the target and/or change the measure command — when the current spec no longer captures 'better'. The user confirms; on a measure change the orchestrator test-runs the new command and re-baselines. Never edit the measure command or its inputs directly — that is gaming the metric.",
|
|
1600
|
+
parameters: Type.Object({
|
|
1601
|
+
target: Type.Optional(Type.String({ description: "The sharpened target text (omit to keep the current target)" })),
|
|
1602
|
+
measureCmd: Type.Optional(Type.String({ description: "The new measure command printing ONE number (omit to keep the current metric)" })),
|
|
1603
|
+
rationale: Type.String({ description: "Why the current spec no longer captures 'better' — shown to the user in the Confirm dialog" }),
|
|
1604
|
+
}),
|
|
1605
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
1606
|
+
const p = params as { target?: string; measureCmd?: string; rationale: string };
|
|
1607
|
+
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
1608
|
+
const loop = state.loop;
|
|
1609
|
+
if (!loop?.active) {
|
|
1610
|
+
return { content: [{ type: "text", text: "No active loop to refine. propose_loop_refine is only valid while a loop is running." }], details: {} };
|
|
1611
|
+
}
|
|
1612
|
+
const newTarget = p.target?.trim() || loop.target;
|
|
1613
|
+
const newMeasure = p.measureCmd?.trim() || loop.measureCmd;
|
|
1614
|
+
if (newTarget === loop.target && newMeasure === loop.measureCmd) {
|
|
1615
|
+
return { content: [{ type: "text", text: "Refinement proposed no changes — provide a new target, a new measureCmd, or both." }], details: {} };
|
|
1616
|
+
}
|
|
1617
|
+
// Measure change → orchestrator test-runs the new command first.
|
|
1618
|
+
let newBaseline: number | null = null;
|
|
1619
|
+
let testOutput = "";
|
|
1620
|
+
if (newMeasure !== loop.measureCmd) {
|
|
1621
|
+
if (!extensionApi) return { content: [{ type: "text", text: "No extension API available." }], details: {} };
|
|
1622
|
+
try {
|
|
1623
|
+
const result = await extensionApi.exec("bash", ["-c", newMeasure], { cwd: liveCtx.cwd });
|
|
1624
|
+
testOutput = String((result as any)?.stdout ?? "");
|
|
1625
|
+
} catch (e) {
|
|
1626
|
+
return { content: [{ type: "text", text: `New measure command failed to run: ${String(e).slice(0, 200)}` }], details: {} };
|
|
1627
|
+
}
|
|
1628
|
+
newBaseline = parseMetric(testOutput);
|
|
1629
|
+
if (newBaseline === null) {
|
|
1630
|
+
return {
|
|
1631
|
+
content: [{ type: "text", text: `New measure produced NO number — refinement auto-rejected.\nCommand: ${newMeasure}\nOutput: ${testOutput.slice(0, 300) || "(empty)"}\nFix it and propose again.` }],
|
|
1632
|
+
details: {},
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
let confirmed = false;
|
|
1637
|
+
try {
|
|
1638
|
+
confirmed = await liveCtx.ui.confirm(
|
|
1639
|
+
"Confirm loop spec refinement",
|
|
1640
|
+
`Rationale: ${p.rationale}\n\nTarget:\n old: ${loop.target.slice(0, 120)}\n new: ${newTarget.slice(0, 120)}\n\nMeasure:\n old: ${loop.measureCmd}\n new: ${newMeasure}${newMeasure !== loop.measureCmd ? `\n test-run: ${testOutput.slice(0, 120)} → ${newBaseline}` : ""}\n\nThe loop keeps running against the refined spec (iteration ${loop.iteration} so far). Apply?`,
|
|
1641
|
+
);
|
|
1642
|
+
} catch {
|
|
1643
|
+
confirmed = false;
|
|
1644
|
+
}
|
|
1645
|
+
if (!confirmed) {
|
|
1646
|
+
return { content: [{ type: "text", text: "Refinement rejected by the user. The loop continues against the current spec — keep improving the metric as defined." }], details: {} };
|
|
1647
|
+
}
|
|
1648
|
+
applyRefinement(loop, {
|
|
1649
|
+
at: nowIso(),
|
|
1650
|
+
iteration: loop.iteration,
|
|
1651
|
+
oldTarget: loop.target,
|
|
1652
|
+
newTarget,
|
|
1653
|
+
oldMeasureCmd: loop.measureCmd,
|
|
1654
|
+
newMeasureCmd: newMeasure,
|
|
1655
|
+
}, newBaseline);
|
|
1656
|
+
persistState(liveCtx);
|
|
1657
|
+
appendLedger(liveCtx.cwd, "loop_refined", { iteration: loop.iteration, newTarget, newMeasureCmd: newMeasure, newBaseline });
|
|
1658
|
+
liveCtx.ui.notify(`Loop spec refined at iteration ${loop.iteration}.${newBaseline !== null ? ` New baseline: ${newBaseline}.` : ""}`, "info");
|
|
1659
|
+
return { content: [{ type: "text", text: "Refinement confirmed and applied. Continue improving against the NEW spec — one small change per turn." }], details: {} };
|
|
1660
|
+
},
|
|
1661
|
+
}));
|
|
1662
|
+
|
|
1663
|
+
pi.registerTool(defineTool({
|
|
1664
|
+
name: "list_add",
|
|
1665
|
+
label: "Add to queue",
|
|
1666
|
+
description: "Add one or many objectives to the /list list (loop 2). Use when the user asks to queue work — 'add these to my list', 'queue these 10 things', 'put this on the backlog'. Each item becomes an audited goal; per-item 'Done when:' clauses are honored. The first queued item activates automatically when nothing is running.",
|
|
1667
|
+
parameters: Type.Object({
|
|
1668
|
+
items: Type.Array(Type.String(), { description: "Objectives to enqueue (1-100). Each may include its own 'Done when:' clause." }),
|
|
1669
|
+
}),
|
|
1670
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
1671
|
+
const p = params as { items: string[] };
|
|
1672
|
+
if (!Array.isArray(p.items) || p.items.length === 0) {
|
|
1673
|
+
return { content: [{ type: "text", text: "No items given." }], details: {} };
|
|
1674
|
+
}
|
|
1675
|
+
if (p.items.length > 100) {
|
|
1676
|
+
return { content: [{ type: "text", text: `Too many items at once (${p.items.length}); max 100 per call — batch larger plans across calls.` }], details: {} };
|
|
1677
|
+
}
|
|
1678
|
+
const clean = p.items.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
1679
|
+
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
1680
|
+
const wasIdle = !state.goal || state.goal.status === "complete" || state.goal.status === "aborted";
|
|
1681
|
+
const n = enqueueItems(liveCtx, clean, "agent list_add");
|
|
1682
|
+
return {
|
|
1683
|
+
content: [{
|
|
1684
|
+
type: "text",
|
|
1685
|
+
text: wasIdle
|
|
1686
|
+
? `${n} item(s) queued; the first is now active. Work it normally and call complete_goal when done — the next item activates automatically.`
|
|
1687
|
+
: `${n} item(s) queued (${listQueue().length} waiting behind the active goal).`,
|
|
1688
|
+
}],
|
|
1689
|
+
details: {},
|
|
1690
|
+
};
|
|
1691
|
+
},
|
|
1692
|
+
}));
|
|
1693
|
+
|
|
1694
|
+
pi.registerTool(defineTool({
|
|
1695
|
+
name: "list_activate",
|
|
1696
|
+
label: "Activate list item",
|
|
1697
|
+
description: "Activate a specific item from the /list queue by position (1-based). Order is the default, not the law: use this when a different item should be worked next (e.g. you want to research item 5 while item 1 waits). Aborts the currently active goal if one is running.",
|
|
1698
|
+
parameters: Type.Object({
|
|
1699
|
+
n: Type.Number({ description: "1-based position in the queue (1 = head)" }),
|
|
1700
|
+
}),
|
|
1701
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
1702
|
+
const p = params as { n: number };
|
|
1703
|
+
const n = Math.floor(p.n);
|
|
1704
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1705
|
+
return { content: [{ type: "text", text: "n must be a positive integer (1-based position)." }], details: {} };
|
|
1706
|
+
}
|
|
1707
|
+
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
1708
|
+
if (state.goal && state.goal.status === "active") {
|
|
1709
|
+
archiveCurrentGoal(liveCtx, "aborted", "skipped via list_activate");
|
|
1710
|
+
}
|
|
1711
|
+
if (!activateNextListItem(liveCtx, n)) {
|
|
1712
|
+
return { content: [{ type: "text", text: listQueue().length === 0 ? "List is empty." : `No item #${n} (list has ${listQueue().length} items).` }], details: {} };
|
|
1713
|
+
}
|
|
1714
|
+
return { content: [{ type: "text", text: `Item #${n} activated. Work it normally; call complete_goal when done.` }], details: {} };
|
|
1715
|
+
},
|
|
1716
|
+
}));
|
|
1717
|
+
|
|
1718
|
+
pi.registerTool(defineTool({
|
|
1719
|
+
name: "list_status",
|
|
1720
|
+
label: "Queue status",
|
|
1721
|
+
description: "Show the active goal and the /list list (loop 2) as text: what's running, what's waiting.",
|
|
1722
|
+
parameters: Type.Object({}),
|
|
1723
|
+
async execute() {
|
|
1724
|
+
const lines: string[] = [];
|
|
1725
|
+
if (state.goal) {
|
|
1726
|
+
lines.push(`Active [${state.goal.policy}] (${statusLabel(state.goal.status)}): ${state.goal.objective}`);
|
|
1727
|
+
} else {
|
|
1728
|
+
lines.push("Active: (none)");
|
|
1729
|
+
}
|
|
1730
|
+
const queue = listQueue();
|
|
1731
|
+
if (queue.length === 0) {
|
|
1732
|
+
lines.push("List: empty.");
|
|
1733
|
+
} else {
|
|
1734
|
+
lines.push(`Queue (${queue.length}):`);
|
|
1735
|
+
queue.slice(0, 20).forEach((item, i) => lines.push(`${i + 1}. ${item.objective}`));
|
|
1736
|
+
if (queue.length > 20) lines.push(`… and ${queue.length - 20} more`);
|
|
1737
|
+
}
|
|
1738
|
+
if (state.loop) {
|
|
1739
|
+
lines.push(`Loop: ${state.loop.active ? "active" : "stopped"} — ${state.loop.target} (best ${state.loop.bestValue ?? "n/a"}, iteration ${state.loop.iteration})`);
|
|
1740
|
+
}
|
|
1741
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
|
|
1742
|
+
},
|
|
1743
|
+
}));
|
|
1744
|
+
|
|
1745
|
+
pi.registerTool(defineTool({
|
|
1746
|
+
name: "propose_task_list",
|
|
1747
|
+
label: "Propose task list",
|
|
1748
|
+
description: "Propose a task breakdown for the active goal. Opens the user's Confirm dialog. Limits: 20 top-level tasks, 5 subtasks per task.",
|
|
1749
|
+
parameters: Type.Object({
|
|
1750
|
+
tasks: Type.Array(Type.Object({
|
|
1751
|
+
title: Type.String(),
|
|
1752
|
+
subtasks: Type.Optional(Type.Array(Type.String())),
|
|
1753
|
+
})),
|
|
1754
|
+
}),
|
|
1755
|
+
async execute(_id, params, _signal, _onUpdate, execCtx) {
|
|
1756
|
+
if (!state.goal || state.goal.status !== "active") {
|
|
1757
|
+
return { content: [{ type: "text", text: "No active goal to break down." }], details: {} };
|
|
1758
|
+
}
|
|
1759
|
+
if (state.goal.taskList && state.goal.taskList.tasks.length > 0) {
|
|
1760
|
+
return { content: [{ type: "text", text: "A task list already exists. Use update_task_status / complete_task to work it." }], details: {} };
|
|
1761
|
+
}
|
|
1762
|
+
const p = params as { tasks: TaskProposal[] };
|
|
1763
|
+
const invalid = validateTaskProposal(p.tasks);
|
|
1764
|
+
if (invalid) {
|
|
1765
|
+
return { content: [{ type: "text", text: invalid }], details: {} };
|
|
1766
|
+
}
|
|
1767
|
+
const liveCtx = (execCtx as ExtensionContext | undefined) ?? ctx;
|
|
1768
|
+
const preview = p.tasks.map((t, i) => {
|
|
1769
|
+
const subs = (t.subtasks ?? []).map((s, j) => ` ${i + 1}.${j + 1} ${s}`).join("\n");
|
|
1770
|
+
return `${i + 1}. ${t.title}` + (subs ? `\n${subs}` : "");
|
|
1771
|
+
}).join("\n");
|
|
1772
|
+
let confirmed = false;
|
|
1773
|
+
try {
|
|
1774
|
+
confirmed = await liveCtx.ui.confirm("Confirm task list", preview);
|
|
1775
|
+
} catch {
|
|
1776
|
+
confirmed = false;
|
|
1777
|
+
}
|
|
1778
|
+
if (!confirmed) {
|
|
1779
|
+
return { content: [{ type: "text", text: "Task list rejected by the user. Adjust and propose again." }], details: {} };
|
|
1780
|
+
}
|
|
1781
|
+
const taskList = buildTaskList(p.tasks);
|
|
1782
|
+
updateGoal({ taskList }, liveCtx);
|
|
1783
|
+
const subCount = taskList.tasks.reduce((n, t) => n + (t.subtasks?.length ?? 0), 0);
|
|
1784
|
+
return {
|
|
1785
|
+
content: [{ type: "text", text: `Task list set: ${taskList.tasks.length} tasks, ${subCount} subtasks. Track progress with complete_task / update_task_status.` }],
|
|
1786
|
+
details: {},
|
|
1787
|
+
};
|
|
1788
|
+
},
|
|
1789
|
+
}));
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// =================================================================
|
|
1793
|
+
// Settings (auditor model, thinking level)
|
|
1794
|
+
// =================================================================
|
|
1795
|
+
|
|
1796
|
+
interface Settings {
|
|
1797
|
+
/** "provider/model-id" or bare "model-id". Unset → session model. */
|
|
1798
|
+
auditorModel?: string;
|
|
1799
|
+
auditorThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
1800
|
+
/** Shell command run on goal complete / goal pause / loop stop; message passed as $1. */
|
|
1801
|
+
notifyCmd?: string;
|
|
1802
|
+
/** Per-goal token budget; crossing it pauses the goal. Default 1,000,000. */
|
|
1803
|
+
tokenLimit?: number;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const DEFAULT_SETTINGS: Settings = {
|
|
1807
|
+
// Unset = follow the pi session thinking level (user selects thinking in
|
|
1808
|
+
// pi, auditor follows), floor "high" — the auditor is the verification
|
|
1809
|
+
// gate, depth is worth more there than speed. /gla thinking= overrides.
|
|
1810
|
+
auditorThinkingLevel: undefined,
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
// Two-tier config (v0.7.0): GLOBAL is the normal home — you set things once
|
|
1814
|
+
// and rarely open this again. PROJECT is the rare local override.
|
|
1815
|
+
// Resolution: project > global > defaults (per key).
|
|
1816
|
+
function globalSettingsPath(): string {
|
|
1817
|
+
return path.join(os.homedir(), ".pi", "agent", "pi-goal-list-loop-audit.settings.json");
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function projectSettingsPath(cwd: string): string {
|
|
1821
|
+
return path.join(piGlaDir(cwd), "settings.json");
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function readSettingsFile(file: string): Partial<Settings> {
|
|
1825
|
+
try {
|
|
1826
|
+
if (!fs.existsSync(file)) return {};
|
|
1827
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
1828
|
+
return typeof parsed === "object" && parsed !== null ? parsed as Partial<Settings> : {};
|
|
1829
|
+
} catch {
|
|
1830
|
+
return {};
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function loadSettings(cwd: string): Settings {
|
|
1835
|
+
return mergeSettings(
|
|
1836
|
+
DEFAULT_SETTINGS as unknown as Record<string, unknown>,
|
|
1837
|
+
readSettingsFile(globalSettingsPath()) as Record<string, unknown>,
|
|
1838
|
+
readSettingsFile(projectSettingsPath(cwd)) as Record<string, unknown>,
|
|
1839
|
+
) as unknown as Settings;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** Where each effective setting comes from (for the /gla display). */
|
|
1843
|
+
function settingsProvenance(cwd: string): Record<keyof Settings, { value: unknown; source: "project" | "global" | "default" }> {
|
|
1844
|
+
const proj = readSettingsFile(projectSettingsPath(cwd));
|
|
1845
|
+
const glob = readSettingsFile(globalSettingsPath());
|
|
1846
|
+
const effective = loadSettings(cwd);
|
|
1847
|
+
const out: Record<string, { value: unknown; source: "project" | "global" | "default" }> = {};
|
|
1848
|
+
const keys: Array<keyof Settings> = ["auditorModel", "auditorThinkingLevel", "notifyCmd", "tokenLimit"];
|
|
1849
|
+
for (const k of keys) {
|
|
1850
|
+
if ((proj as Record<string, unknown>)[k] !== undefined) out[k] = { value: (proj as any)[k], source: "project" };
|
|
1851
|
+
else if ((glob as Record<string, unknown>)[k] !== undefined) out[k] = { value: (glob as any)[k], source: "global" };
|
|
1852
|
+
else out[k] = { value: (effective as any)[k], source: "default" };
|
|
1853
|
+
}
|
|
1854
|
+
return out as Record<keyof Settings, { value: unknown; source: "project" | "global" | "default" }>;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function saveSettings(scope: "global" | "project", cwd: string, patch: Partial<Settings>): void {
|
|
1858
|
+
const file = scope === "global" ? globalSettingsPath() : projectSettingsPath(cwd);
|
|
1859
|
+
const current = readSettingsFile(file);
|
|
1860
|
+
const next: Record<string, unknown> = { ...current };
|
|
1861
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1862
|
+
if (v === undefined) delete next[k]; // key=unset removes the key
|
|
1863
|
+
else next[k] = v;
|
|
1864
|
+
}
|
|
1865
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1866
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Session thinking level with a "high" floor (v0.8.5): the auditor follows
|
|
1871
|
+
* the thinking level the user selected in pi; if none is set, audits run at
|
|
1872
|
+
* "high" — the auditor is the verification gate, depth beats speed there.
|
|
1873
|
+
*/
|
|
1874
|
+
function getSessionThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
|
|
1875
|
+
try {
|
|
1876
|
+
const level = extensionApi?.getThinkingLevel?.();
|
|
1877
|
+
if (level && ["off", "minimal", "low", "medium", "high", "xhigh"].includes(level)) {
|
|
1878
|
+
return level as "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
1879
|
+
}
|
|
1880
|
+
} catch {
|
|
1881
|
+
// fall through to the floor
|
|
1882
|
+
}
|
|
1883
|
+
return "high";
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Resolve the auditor model (v0.6.2). The principle: **the user selects the
|
|
1888
|
+
* model in pi; the auditor uses it.** The plugin never picks a model itself.
|
|
1889
|
+
*
|
|
1890
|
+
* Chain:
|
|
1891
|
+
* 1. Explicit `/gla model=provider/id` override (rare).
|
|
1892
|
+
* 2. The pi session model (ctx.model) — whatever the user selected.
|
|
1893
|
+
*
|
|
1894
|
+
* If the session model's provider is extension-registered, the auditor's
|
|
1895
|
+
* extension-less session cannot auth it; that failure is surfaced with a
|
|
1896
|
+
* clear explanation (switch pi's model to a built-in provider, or set the
|
|
1897
|
+
* override) — we do NOT silently substitute a different model.
|
|
1898
|
+
*/
|
|
1899
|
+
function resolveAuditorModel(ctx: ExtensionContext, ref?: string): { model: any; error?: string; via?: string } {
|
|
1900
|
+
if (ref && ref.trim()) {
|
|
1901
|
+
const trimmed = ref.trim();
|
|
1902
|
+
const slash = trimmed.indexOf("/");
|
|
1903
|
+
if (slash > 0) {
|
|
1904
|
+
const provider = trimmed.slice(0, slash);
|
|
1905
|
+
const id = trimmed.slice(slash + 1);
|
|
1906
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
1907
|
+
return model ? { model, via: "setting" } : { model: undefined, error: `model not found: ${trimmed}` };
|
|
1908
|
+
}
|
|
1909
|
+
const matches = ctx.modelRegistry.getAvailable().filter((m: any) => m.id === trimmed || m.name === trimmed);
|
|
1910
|
+
return matches[0] ? { model: matches[0], via: "setting" } : { model: undefined, error: `no available model matching: ${trimmed}` };
|
|
1911
|
+
}
|
|
1912
|
+
const sessionModel = ctx.model as any;
|
|
1913
|
+
if (sessionModel) return { model: sessionModel, via: "session" };
|
|
1914
|
+
return { model: undefined, error: "no session model and no auditorModel configured — set one with /gla model=provider/id" };
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// (v0.9.12) The auto-fallback apparatus was REMOVED: no tier ranking, no
|
|
1918
|
+
// candidate chains, no dead-model caches. The plugin never picks a model —
|
|
1919
|
+
// you select it in pi (session model) or in /gla (explicit override). When
|
|
1920
|
+
// neither works, the failure surfaces plainly (see the three-way split in
|
|
1921
|
+
// the complete_goal handler) with the exact fix; nothing is substituted
|
|
1922
|
+
// silently.
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* The /gla interactive settings UI (v0.8.0): a menu loop over pi's dialog
|
|
1926
|
+
* primitives. Pick a setting → edit it → saved to GLOBAL → back to the menu.
|
|
1927
|
+
* Done/Esc exits. Rarely opened by design; scriptable /gla key=value remains
|
|
1928
|
+
* for tmux/headless.
|
|
1929
|
+
*/
|
|
1930
|
+
async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
1931
|
+
for (;;) {
|
|
1932
|
+
const prov = settingsProvenance(ctx.cwd);
|
|
1933
|
+
const show = (k: keyof Settings, fallback: string) => {
|
|
1934
|
+
const p = prov[k];
|
|
1935
|
+
const v = p.value === undefined ? fallback : String(p.value);
|
|
1936
|
+
return `${v} [${p.source}]`;
|
|
1937
|
+
};
|
|
1938
|
+
let choice: string | undefined;
|
|
1939
|
+
try {
|
|
1940
|
+
choice = await ctx.ui.select(
|
|
1941
|
+
`pi-goal-list-loop-audit settings — global: ${globalSettingsPath()}`,
|
|
1942
|
+
[
|
|
1943
|
+
`Auditor model override — ${show("auditorModel", "(pi session model)")}`,
|
|
1944
|
+
`Auditor thinking — ${show("auditorThinkingLevel", "(session, floor high)")}`,
|
|
1945
|
+
`Notify command — ${show("notifyCmd", "(off)")}`,
|
|
1946
|
+
`Token limit per goal — ${show("tokenLimit", "(off)")}`,
|
|
1947
|
+
"Done",
|
|
1948
|
+
],
|
|
1949
|
+
);
|
|
1950
|
+
} catch {
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (!choice || choice === "Done") return;
|
|
1954
|
+
try {
|
|
1955
|
+
if (choice.startsWith("Auditor model")) {
|
|
1956
|
+
const v = await ctx.ui.input("Auditor model override", "provider/model-id — empty keeps the pi session model");
|
|
1957
|
+
if (v !== undefined) saveSettings("global", ctx.cwd, { auditorModel: v.trim() || undefined });
|
|
1958
|
+
} else if (choice.startsWith("Auditor thinking")) {
|
|
1959
|
+
const v = await ctx.ui.select("Auditor thinking level", ["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
1960
|
+
if (v) saveSettings("global", ctx.cwd, { auditorThinkingLevel: v as Settings["auditorThinkingLevel"] });
|
|
1961
|
+
} else if (choice.startsWith("Notify command")) {
|
|
1962
|
+
const v = await ctx.ui.input("Notify command — the event message is passed as $1", "e.g. a desktop-notification or push command; empty = off");
|
|
1963
|
+
if (v !== undefined) saveSettings("global", ctx.cwd, { notifyCmd: v.trim() || undefined });
|
|
1964
|
+
} else if (choice.startsWith("Token limit")) {
|
|
1965
|
+
const v = await ctx.ui.input("Per-goal token budget", "non-negative integer; 0 or empty = off (no cap)");
|
|
1966
|
+
if (v !== undefined) {
|
|
1967
|
+
const n = Number.parseInt(v.trim(), 10);
|
|
1968
|
+
if (Number.isFinite(n) && n >= 0) saveSettings("global", ctx.cwd, { tokenLimit: n });
|
|
1969
|
+
else if (!v.trim()) saveSettings("global", ctx.cwd, { tokenLimit: undefined });
|
|
1970
|
+
else ctx.ui.notify(`Not a non-negative integer: ${v}`, "warning");
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
} catch {
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
1980
|
+
// The plugin's ONE config surface — global by default, rarely opened.
|
|
1981
|
+
// /gla show effective values + where each comes from
|
|
1982
|
+
// /gla model=provider/id write to GLOBAL config
|
|
1983
|
+
// /gla thinking=high write to GLOBAL config
|
|
1984
|
+
// /gla notify='cmd $1' write to GLOBAL config
|
|
1985
|
+
// /gla tokenlimit=2000000 write to GLOBAL config
|
|
1986
|
+
// /gla project model=... write to PROJECT override (rare)
|
|
1987
|
+
// /gla model=unset remove key (from global; project model=unset for project)
|
|
1988
|
+
const trimmed = args.trim();
|
|
1989
|
+
if (!trimmed) {
|
|
1990
|
+
if (ctx.hasUI) {
|
|
1991
|
+
await openSettingsUI(ctx);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
// Headless fallback: text display with provenance.
|
|
1995
|
+
const prov = settingsProvenance(ctx.cwd);
|
|
1996
|
+
const fmt = (k: keyof Settings, label: string) => {
|
|
1997
|
+
const p = prov[k];
|
|
1998
|
+
const v = p.value === undefined ? "(unset)" : String(p.value);
|
|
1999
|
+
return `${label}: ${v} [${p.source}]`;
|
|
2000
|
+
};
|
|
2001
|
+
ctx.ui.notify(
|
|
2002
|
+
[
|
|
2003
|
+
fmt("auditorModel", "auditorModel"),
|
|
2004
|
+
fmt("auditorThinkingLevel", "thinking"),
|
|
2005
|
+
fmt("notifyCmd", "notify"),
|
|
2006
|
+
fmt("tokenLimit", "tokenLimit"),
|
|
2007
|
+
`\nglobal: ${globalSettingsPath()}`,
|
|
2008
|
+
`project: ${projectSettingsPath(ctx.cwd)}`,
|
|
2009
|
+
`Set with: /gla key=value (global) · /gla project key=value (project override)`,
|
|
2010
|
+
].join("\n"),
|
|
2011
|
+
"info",
|
|
2012
|
+
);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
// Optional scope prefix: "project" writes the project override; default is global.
|
|
2016
|
+
let scope: "global" | "project" = "global";
|
|
2017
|
+
let rest = trimmed;
|
|
2018
|
+
if (/^project\s+/i.test(rest)) {
|
|
2019
|
+
scope = "project";
|
|
2020
|
+
rest = rest.replace(/^project\s+/i, "");
|
|
2021
|
+
}
|
|
2022
|
+
const patch: Partial<Settings> = {};
|
|
2023
|
+
let changed = false;
|
|
2024
|
+
// Quote-aware key=value parsing: notify='echo $1 >> /tmp/log' must survive
|
|
2025
|
+
// with its spaces intact (naive whitespace splitting mangled it to "'echo").
|
|
2026
|
+
const kvRe = /(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
2027
|
+
let m: RegExpExecArray | null;
|
|
2028
|
+
while ((m = kvRe.exec(rest)) !== null) {
|
|
2029
|
+
const key = m[1]!.toLowerCase();
|
|
2030
|
+
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
2031
|
+
if (key === "model" || key === "auditormodel") {
|
|
2032
|
+
patch.auditorModel = value === "unset" ? undefined : value;
|
|
2033
|
+
changed = true;
|
|
2034
|
+
} else if (key === "notify" || key === "notifycmd") {
|
|
2035
|
+
patch.notifyCmd = value === "unset" ? undefined : value;
|
|
2036
|
+
changed = true;
|
|
2037
|
+
} else if (key === "tokenlimit") {
|
|
2038
|
+
const n = Number.parseInt(value, 10);
|
|
2039
|
+
if (Number.isFinite(n) && n > 0) {
|
|
2040
|
+
patch.tokenLimit = n;
|
|
2041
|
+
changed = true;
|
|
2042
|
+
} else {
|
|
2043
|
+
ctx.ui.notify(`tokenlimit must be a positive integer, got: ${value}`, "warning");
|
|
2044
|
+
}
|
|
2045
|
+
} else if (key === "thinking" || key === "auditorthinkinglevel") {
|
|
2046
|
+
if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(value)) {
|
|
2047
|
+
patch.auditorThinkingLevel = value as Settings["auditorThinkingLevel"];
|
|
2048
|
+
changed = true;
|
|
2049
|
+
} else {
|
|
2050
|
+
ctx.ui.notify(`Unknown thinking level: ${value}`, "warning");
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
if (!changed) {
|
|
2055
|
+
ctx.ui.notify("Nothing changed. Use key=value (model, thinking, notify, tokenlimit), optionally prefixed with 'project'.", "info");
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
saveSettings(scope, ctx.cwd, patch);
|
|
2059
|
+
const effective = loadSettings(ctx.cwd);
|
|
2060
|
+
ctx.ui.notify(
|
|
2061
|
+
`Saved to ${scope} config. Effective now: model=${effective.auditorModel ?? "(session model)"} thinking=${effective.auditorThinkingLevel ?? "(session)"} notify=${effective.notifyCmd ?? "(off)"} tokenLimit=${effective.tokenLimit ?? 0}${(effective.tokenLimit ?? 0) > 0 ? "" : " (off)"}\n` +
|
|
2062
|
+
`Note: the auditor runs without extensions — it must be a built-in provider, not an extension-registered one.`,
|
|
2063
|
+
"info",
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// =================================================================
|
|
2068
|
+
// Command-collision detector (PLAN.md D1)
|
|
2069
|
+
//
|
|
2070
|
+
// pi's runner.js resolveRegisteredCommands() never throws on duplicate
|
|
2071
|
+
// command names: the first registrant keeps the bare name, later ones
|
|
2072
|
+
// become "goal:2", "list:3", etc. So a collision degrades UX silently.
|
|
2073
|
+
// We detect duplicates at session start and warn loudly once.
|
|
2074
|
+
// =================================================================
|
|
2075
|
+
|
|
2076
|
+
const OUR_COMMANDS = ["goal", "gla", "list", "queue", "loop"];
|
|
2077
|
+
let collisionWarned = false;
|
|
2078
|
+
|
|
2079
|
+
// Providers verified to exist in a bare (extension-less) session. The auditor
|
|
2080
|
+
// spawns exactly such a session, so extension-registered providers (kilocode,
|
|
2081
|
+
// zenmux on this rig) fail inside it. Unknown providers get a soft one-time
|
|
2082
|
+
// notice — not an error, since the built-in set grows over time.
|
|
2083
|
+
const KNOWN_BUILTIN_PROVIDERS = new Set([
|
|
2084
|
+
"anthropic", "google", "google-vertex", "google-gemini-cli", "openai", "openai-codex",
|
|
2085
|
+
"openrouter", "opencode", "azure-openai-responses", "groq", "cerebras", "xai", "zai",
|
|
2086
|
+
"minimax", "minimax-cn", "moonshotai", "kimi-coding", "github-copilot", "mistral", "huggingface",
|
|
2087
|
+
]);
|
|
2088
|
+
let providerWarned = false;
|
|
2089
|
+
|
|
2090
|
+
function warnIfAuditorProviderRisky(ctx: ExtensionContext): void {
|
|
2091
|
+
if (providerWarned) return;
|
|
2092
|
+
providerWarned = true;
|
|
2093
|
+
try {
|
|
2094
|
+
const settings = loadSettings(ctx.cwd);
|
|
2095
|
+
if (settings.auditorModel) return; // explicit auditor model — user's call
|
|
2096
|
+
const provider = (ctx.model as any)?.provider as string | undefined;
|
|
2097
|
+
if (!provider || KNOWN_BUILTIN_PROVIDERS.has(provider)) return;
|
|
2098
|
+
ctx.ui.notify(
|
|
2099
|
+
`pi-goal-list-loop-audit: session provider "${provider}" is extension-registered — the auditor (extension-less session) will fail auth with it. If audits error, set a built-in override once: /gla model=provider/id`,
|
|
2100
|
+
"info",
|
|
2101
|
+
);
|
|
2102
|
+
} catch {
|
|
2103
|
+
// non-fatal by design
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function warnOnCommandCollision(ctx: ExtensionContext): void {
|
|
2108
|
+
if (collisionWarned) return;
|
|
2109
|
+
collisionWarned = true;
|
|
2110
|
+
try {
|
|
2111
|
+
if (!extensionApi) return;
|
|
2112
|
+
const counts = new Map<string, number>();
|
|
2113
|
+
for (const cmd of extensionApi.getCommands() as any[]) {
|
|
2114
|
+
const name = String(cmd.invocationName ?? cmd.name ?? "").split(":")[0] ?? "";
|
|
2115
|
+
if (OUR_COMMANDS.includes(name)) {
|
|
2116
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
const dupes = [...counts.entries()].filter(([, n]) => n > 1).map(([n]) => `/${n}`);
|
|
2120
|
+
if (dupes.length > 0) {
|
|
2121
|
+
const first = dupes[0] ?? "goal";
|
|
2122
|
+
ctx.ui.notify(
|
|
2123
|
+
`pi-goal-list-loop-audit: command collision on ${dupes.join(", ")}. Another extension registered the same name; ours may be reachable as /${first.slice(1)}:2. Consider disabling the other plugin.`,
|
|
2124
|
+
"warning",
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
} catch {
|
|
2128
|
+
// getCommands unavailable or shape changed — stay silent, collision is non-fatal.
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// =================================================================
|
|
2133
|
+
// Public extension entry
|
|
2134
|
+
// =================================================================
|
|
2135
|
+
|
|
2136
|
+
export default function (pi: ExtensionAPI): void {
|
|
2137
|
+
extensionApi = pi;
|
|
2138
|
+
startHeartbeat();
|
|
2139
|
+
startUITicker();
|
|
2140
|
+
// Four top-level commands, that's all (v0.8.0 consolidation):
|
|
2141
|
+
// /goal — set/draft + status|pause|resume|cancel|tweak|archive subcommands
|
|
2142
|
+
// /list — the list (add|show|next|remove|clear)
|
|
2143
|
+
// /loop — the metric loop (draft|start|status|stop)
|
|
2144
|
+
// /gla — the settings UI (+ scriptable key=value)
|
|
2145
|
+
pi.registerCommand("goal", {
|
|
2146
|
+
description: "Set/draft a goal, or /goal status|pause|resume|cancel|tweak <text>|archive. Objectives without a 'Done when:' clause are grilled into a contract first (nothing activates until you confirm); include the clause to start instantly.",
|
|
2147
|
+
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdGoal(args, ctx); },
|
|
2148
|
+
});
|
|
2149
|
+
const settingsHandler = (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdSettings(args, ctx); };
|
|
2150
|
+
pi.registerCommand("glla", {
|
|
2151
|
+
description: "Open the settings UI for goals, loops, lists, and the auditor. Scriptable form: /glla key=value · /glla project key=value",
|
|
2152
|
+
handler: settingsHandler,
|
|
2153
|
+
});
|
|
2154
|
+
// /gla kept as an alias (renamed package, v0.15.0) — muscle memory is real.
|
|
2155
|
+
pi.registerCommand("gla", {
|
|
2156
|
+
description: "Alias for /glla (package renamed to pi-goal-list-loop-audit in v0.15.0).",
|
|
2157
|
+
handler: settingsHandler,
|
|
2158
|
+
});
|
|
2159
|
+
pi.registerCommand("list", {
|
|
2160
|
+
description: "Loop 2: the list of audited goals — order is the default, not the law. /list add <obj or file or paste> | /list show | /list next [n] | /list remove <n> | /list clear",
|
|
2161
|
+
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
|
|
2162
|
+
});
|
|
2163
|
+
// Alias for one release (renamed 0.10.0 back to /list — order isn't
|
|
2164
|
+
// mandatory with subagents; it's a shopping list, not a FIFO).
|
|
2165
|
+
pi.registerCommand("queue", {
|
|
2166
|
+
description: "Alias for /list (renamed in v0.10.0). Same subcommands.",
|
|
2167
|
+
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
|
|
2168
|
+
});
|
|
2169
|
+
pi.registerCommand("loop", {
|
|
2170
|
+
description: "Loop 3: metric-driven forever loop. /loop start \"<target>\" measure=\"<cmd>\" direction=min|max [done=<value>] [window=5] [max=50] | /loop status | /loop stop",
|
|
2171
|
+
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdLoop(args, ctx); },
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
// Tool registration is lazy: done on the first session event, when a
|
|
2175
|
+
// context exists. Tools show even without an active goal (and return
|
|
2176
|
+
// "no active goal" if called).
|
|
2177
|
+
let registeredCtx: ExtensionContext | null = null;
|
|
2178
|
+
|
|
2179
|
+
pi.on("message_start", async (event: any, _ctx: ExtensionContext) => {
|
|
2180
|
+
// v0.14.0 drafting floor: count real user replies while drafting. Our
|
|
2181
|
+
// own injected draft prompt arrives as a user message — skip that one.
|
|
2182
|
+
if (draftingTarget === null) return;
|
|
2183
|
+
if (event?.message?.role !== "user") return;
|
|
2184
|
+
if (draftingSeedInFlight) {
|
|
2185
|
+
draftingSeedInFlight = false;
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
draftingUserReplies++;
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
|
|
2192
|
+
rememberCtx(ctx);
|
|
2193
|
+
state = readState(ctx.cwd);
|
|
2194
|
+
if (!registeredCtx) {
|
|
2195
|
+
registerAgentTools(pi, ctx);
|
|
2196
|
+
registeredCtx = ctx;
|
|
2197
|
+
}
|
|
2198
|
+
warnOnCommandCollision(ctx);
|
|
2199
|
+
warnIfAuditorProviderRisky(ctx);
|
|
2200
|
+
// Resumption notice: make persisted supervisor state visible on startup.
|
|
2201
|
+
if (isLoopActive()) {
|
|
2202
|
+
const l = state.loop!;
|
|
2203
|
+
ctx.ui.notify(
|
|
2204
|
+
`Resuming loop (iteration ${l.iteration}/${l.maxIterations}, best ${l.bestValue ?? "n/a"}, stall ${l.stallCount}/${l.plateauWindow}): ${l.target.slice(0, 60)}`,
|
|
2205
|
+
"info",
|
|
2206
|
+
);
|
|
2207
|
+
} else if (state.goal && state.goal.status === "active") {
|
|
2208
|
+
ctx.ui.notify(
|
|
2209
|
+
`Resuming goal [${state.goal.id}]: ${state.goal.objective.slice(0, 70)}${listQueue().length > 0 ? ` (+${listQueue().length} queued)` : ""}`,
|
|
2210
|
+
"info",
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
if (isLoopActive()) {
|
|
2214
|
+
// Session restarted mid-loop: resume measuring from persisted state.
|
|
2215
|
+
scheduleLoopTick(ctx);
|
|
2216
|
+
} else if (state.goal && state.goal.status === "active" && state.goal.autoContinue) {
|
|
2217
|
+
scheduleContinuation(ctx, true);
|
|
2218
|
+
} else if ((!state.goal || state.goal.status === "complete" || state.goal.status === "aborted") && listQueue().length > 0) {
|
|
2219
|
+
// Session restarted with a non-empty queue but no active goal.
|
|
2220
|
+
activateNextListItem(ctx);
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
|
|
2225
|
+
rememberCtx(ctx);
|
|
2226
|
+
noteActivity();
|
|
2227
|
+
if (!registeredCtx) {
|
|
2228
|
+
registerAgentTools(pi, ctx);
|
|
2229
|
+
registeredCtx = ctx;
|
|
2230
|
+
}
|
|
2231
|
+
// Nudge accounting: a supervising turn with zero tool calls is a nudge
|
|
2232
|
+
// (no real progress); 3 consecutive → pause. Tool-use turns reset it.
|
|
2233
|
+
if (isSupervising()) {
|
|
2234
|
+
heartbeatNudges = accountTurnForNudges(toolCallsThisTurn, heartbeatNudges);
|
|
2235
|
+
if (heartbeatNudges >= HEARTBEAT_MAX_NUDGES) {
|
|
2236
|
+
heartbeatNudges = 0;
|
|
2237
|
+
if (isLoopActive()) {
|
|
2238
|
+
clearLoopTimer();
|
|
2239
|
+
state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive turns with no tool calls` };
|
|
2240
|
+
persistState(ctx);
|
|
2241
|
+
ctx.ui.notify(`Loop stopped: stalled (${HEARTBEAT_MAX_NUDGES} turns, no tools). /loop start to begin a new one.`, "warning");
|
|
2242
|
+
notifyExternal(ctx, "Loop stopped: stalled (no tool calls).");
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (state.goal) {
|
|
2246
|
+
updateGoal({
|
|
2247
|
+
status: "paused",
|
|
2248
|
+
pauseReason: `stalled: ${HEARTBEAT_MAX_NUDGES} consecutive turns with no tool calls`,
|
|
2249
|
+
pauseSuggestedAction: "Inspect the goal — /goal resume to retry, /goal tweak to narrow it, /goal cancel to abort.",
|
|
2250
|
+
}, ctx);
|
|
2251
|
+
ctx.ui.notify(`Goal paused: stalled (${HEARTBEAT_MAX_NUDGES} turns, no tools).`, "warning");
|
|
2252
|
+
notifyExternal(ctx, "Goal paused: stalled (no tool calls).");
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
toolCallsThisTurn = 0;
|
|
2258
|
+
// Loop 3 runs on the same heartbeat: measure after every agent turn.
|
|
2259
|
+
if (isLoopActive()) {
|
|
2260
|
+
clearLoopTimer();
|
|
2261
|
+
await runLoopTick(ctx, event);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
if (!state.goal) return;
|
|
2265
|
+
if (state.goal.status !== "active") return;
|
|
2266
|
+
clearContinuationTimer();
|
|
2267
|
+
|
|
2268
|
+
const last = [...(event.messages as any[])].reverse().find((m) => m.role === "assistant");
|
|
2269
|
+
const text = last && Array.isArray(last.content) ? last.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
|
|
2270
|
+
const stopReason = last?.stopReason;
|
|
2271
|
+
iterationCounter++;
|
|
2272
|
+
|
|
2273
|
+
// Token accounting + cost guard: accumulate this turn's assistant tokens
|
|
2274
|
+
// (deduped — agent_end may replay seen messages). Crossing the goal's
|
|
2275
|
+
// token limit pauses it; /gla tokenlimit=<n> to raise.
|
|
2276
|
+
const newTokens = sumNewAssistantTokens(event.messages as unknown[], countedTokenMessages);
|
|
2277
|
+
if (newTokens > 0) {
|
|
2278
|
+
const used = (state.goal.usage?.tokensUsed ?? 0) + newTokens;
|
|
2279
|
+
const limit = state.goal.usage?.tokensLimit ?? DEFAULT_TOKEN_LIMIT;
|
|
2280
|
+
// v0.12.0: the guard is opt-in — limit 0/unset means never pause.
|
|
2281
|
+
if (limit > 0 && used > limit) {
|
|
2282
|
+
updateGoal({
|
|
2283
|
+
usage: { tokensUsed: used, tokensLimit: limit },
|
|
2284
|
+
status: "paused",
|
|
2285
|
+
pauseReason: `token limit exceeded (${used.toLocaleString()} > ${limit.toLocaleString()})`,
|
|
2286
|
+
pauseSuggestedAction: "/gla tokenlimit=<n> to raise the cap (or 0 to disable), then /goal resume",
|
|
2287
|
+
}, ctx);
|
|
2288
|
+
ctx.ui.notify(`Goal paused: token limit exceeded (${used.toLocaleString()} > ${limit.toLocaleString()}). /gla tokenlimit=<n> to raise, 0 to disable.`, "warning");
|
|
2289
|
+
notifyExternal(ctx, `Goal paused: token limit exceeded (${used} > ${limit}).`);
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
updateGoal({ usage: { tokensUsed: used, tokensLimit: limit } }, ctx);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
2296
|
+
consecutiveErrorIterations++;
|
|
2297
|
+
if (consecutiveErrorIterations >= 5) {
|
|
2298
|
+
updateGoal({
|
|
2299
|
+
status: "paused",
|
|
2300
|
+
pauseReason: `5 consecutive errors: ${stopReason}`,
|
|
2301
|
+
pauseSuggestedAction: "Use /goal resume to retry, or /goal cancel to abort.",
|
|
2302
|
+
}, ctx);
|
|
2303
|
+
ctx.ui.notify("Goal paused: 5 consecutive errors.", "warning");
|
|
2304
|
+
notifyExternal(ctx, "Goal paused: 5 consecutive errors.");
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
} else {
|
|
2308
|
+
consecutiveErrorIterations = 0;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// Hard 5-min cap: if no successful continue for >5 min, pause.
|
|
2312
|
+
// For v0.1.0 we only detect this on agent_end; a tighter watchdog lands in v0.2.0.
|
|
2313
|
+
// We schedule the next continuation; if it produces nothing useful in 5 min,
|
|
2314
|
+
// the next agent_end iteration of THIS branch will trigger the cap.
|
|
2315
|
+
|
|
2316
|
+
scheduleContinuation(ctx, false);
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
pi.on("tool_call", () => {
|
|
2320
|
+
toolCallsThisTurn++;
|
|
2321
|
+
noteActivity();
|
|
2322
|
+
});
|
|
2323
|
+
}
|