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,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal-list-loop-audit — v0.1.0
|
|
3
|
+
* extensions/goal-loop-core.ts
|
|
4
|
+
*
|
|
5
|
+
* Shared types, state machine, JSONL persistence, helpers.
|
|
6
|
+
*
|
|
7
|
+
* Design: see docs/DESIGN.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
// =================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// =================================================================
|
|
16
|
+
|
|
17
|
+
export type Status =
|
|
18
|
+
| "active"
|
|
19
|
+
| "auditing"
|
|
20
|
+
| "complete"
|
|
21
|
+
| "paused"
|
|
22
|
+
| "aborted";
|
|
23
|
+
|
|
24
|
+
export type Policy = "goal" | "list"; // v0.3.0: "loop".
|
|
25
|
+
|
|
26
|
+
export interface Task {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
status: "pending" | "in_progress" | "complete";
|
|
30
|
+
subtasks?: Task[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TaskList {
|
|
34
|
+
version: 1;
|
|
35
|
+
tasks: Task[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// =================================================================
|
|
39
|
+
// Task-list proposal validation (used by the propose_task_list tool)
|
|
40
|
+
//
|
|
41
|
+
// The caps are the fix for pi-goal-x flaw #4: the agent could grow subtasks
|
|
42
|
+
// indefinitely, drifting into self-generated busywork. Hard limits keep a
|
|
43
|
+
// breakdown a breakdown.
|
|
44
|
+
// =================================================================
|
|
45
|
+
|
|
46
|
+
export const MAX_TOP_LEVEL_TASKS = 20;
|
|
47
|
+
export const MAX_SUBTASKS_PER_TASK = 5;
|
|
48
|
+
|
|
49
|
+
export interface TaskProposal {
|
|
50
|
+
title: string;
|
|
51
|
+
subtasks?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Validate a proposed breakdown. Returns an error string or null. */
|
|
55
|
+
export function validateTaskProposal(tasks: TaskProposal[]): string | null {
|
|
56
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return "Empty task list.";
|
|
57
|
+
if (tasks.length > MAX_TOP_LEVEL_TASKS) {
|
|
58
|
+
return `Too many top-level tasks (${tasks.length}); max ${MAX_TOP_LEVEL_TASKS}. Coarser granularity, please.`;
|
|
59
|
+
}
|
|
60
|
+
for (const t of tasks) {
|
|
61
|
+
if (!t.title || !t.title.trim()) return "Every task needs a non-empty title.";
|
|
62
|
+
const n = t.subtasks?.length ?? 0;
|
|
63
|
+
if (n > MAX_SUBTASKS_PER_TASK) {
|
|
64
|
+
return `Task "${t.title}" has ${n} subtasks; max ${MAX_SUBTASKS_PER_TASK}. Merge or split into coarser tasks.`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Assign hierarchical ids ("1", "1.1", …) and pending statuses to a proposal. */
|
|
71
|
+
export function buildTaskList(tasks: TaskProposal[]): TaskList {
|
|
72
|
+
return {
|
|
73
|
+
version: 1,
|
|
74
|
+
tasks: tasks.map((t, i) => ({
|
|
75
|
+
id: String(i + 1),
|
|
76
|
+
title: t.title.trim(),
|
|
77
|
+
status: "pending" as const,
|
|
78
|
+
subtasks: (t.subtasks ?? []).map((s, j) => ({
|
|
79
|
+
id: `${i + 1}.${j + 1}`,
|
|
80
|
+
title: s.trim(),
|
|
81
|
+
status: "pending" as const,
|
|
82
|
+
})),
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AuditVerdict {
|
|
88
|
+
at: string;
|
|
89
|
+
approved: boolean;
|
|
90
|
+
disapproved: boolean;
|
|
91
|
+
model: string;
|
|
92
|
+
thinkingLevel?: string;
|
|
93
|
+
report?: string;
|
|
94
|
+
/** Infrastructure failure detail (abort, auth, no model). Verdicts only — an entry with error and no report is not a real audit. */
|
|
95
|
+
error?: string;
|
|
96
|
+
/** regression_shield outcome when the goal had a verification contract. */
|
|
97
|
+
regressionShieldPassed?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Sum token usage across assistant messages, counting each message once.
|
|
102
|
+
* `agent_end` events may include already-seen history, so callers pass a
|
|
103
|
+
* dedup set keyed by timestamp+tokens (good-enough identity for counting).
|
|
104
|
+
*
|
|
105
|
+
* v0.12.0: counts input+output (real spend) when the usage object carries
|
|
106
|
+
* the split; totalTokens includes cache reads, which inflate 10-50× on long
|
|
107
|
+
* sessions (a day-long goal "used" 216M while real spend was a fraction).
|
|
108
|
+
*/
|
|
109
|
+
export function sumNewAssistantTokens(messages: unknown[], seen: Set<string>): number {
|
|
110
|
+
let total = 0;
|
|
111
|
+
for (const m of messages) {
|
|
112
|
+
const msg = m as {
|
|
113
|
+
role?: string;
|
|
114
|
+
timestamp?: unknown;
|
|
115
|
+
usage?: { input?: unknown; output?: unknown; totalTokens?: unknown };
|
|
116
|
+
};
|
|
117
|
+
if (msg?.role !== "assistant") continue;
|
|
118
|
+
const u = msg.usage;
|
|
119
|
+
const split = (typeof u?.input === "number" ? u.input : 0) + (typeof u?.output === "number" ? u.output : 0);
|
|
120
|
+
const tokens = split > 0 ? split : (typeof u?.totalTokens === "number" ? u.totalTokens : 0);
|
|
121
|
+
if (tokens <= 0) continue;
|
|
122
|
+
const key = `${String(msg.timestamp ?? "?")}:${tokens}`;
|
|
123
|
+
if (seen.has(key)) continue;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
total += tokens;
|
|
126
|
+
}
|
|
127
|
+
return total;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface Goal {
|
|
131
|
+
id: string;
|
|
132
|
+
objective: string;
|
|
133
|
+
status: Status;
|
|
134
|
+
policy: Policy;
|
|
135
|
+
verificationContract?: string;
|
|
136
|
+
autoContinue: boolean;
|
|
137
|
+
taskList?: TaskList;
|
|
138
|
+
auditHistory?: AuditVerdict[];
|
|
139
|
+
stopReason?: string;
|
|
140
|
+
pauseReason?: string;
|
|
141
|
+
pauseSuggestedAction?: string;
|
|
142
|
+
activePath?: string;
|
|
143
|
+
archivedPath?: string;
|
|
144
|
+
usage: {
|
|
145
|
+
tokensUsed: number;
|
|
146
|
+
tokensLimit: number;
|
|
147
|
+
};
|
|
148
|
+
createdAt: string;
|
|
149
|
+
updatedAt: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Route `/goal` args (v0.8.0 top-level consolidation). Subcommands match ONLY
|
|
154
|
+
* on exact word (except tweak/archive which take args) — an objective that
|
|
155
|
+
* starts with "pause" ("/goal pause the pipeline and fix it") must set a
|
|
156
|
+
* goal, not pause one.
|
|
157
|
+
*/
|
|
158
|
+
export type GoalRoute =
|
|
159
|
+
| { kind: "draft" }
|
|
160
|
+
| { kind: "set"; text: string }
|
|
161
|
+
| { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "tweak" | "archive"; rest: string };
|
|
162
|
+
|
|
163
|
+
const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel"]);
|
|
164
|
+
const GOAL_ARG_SUBS = new Set(["tweak", "archive"]);
|
|
165
|
+
|
|
166
|
+
export function routeGoalArgs(raw: string): GoalRoute {
|
|
167
|
+
const trimmed = raw.trim();
|
|
168
|
+
if (!trimmed) return { kind: "draft" };
|
|
169
|
+
const space = trimmed.indexOf(" ");
|
|
170
|
+
const first = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
|
|
171
|
+
const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
172
|
+
if (GOAL_EXACT_SUBS.has(first) && rest === "") {
|
|
173
|
+
return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel", rest: "" };
|
|
174
|
+
}
|
|
175
|
+
if (GOAL_ARG_SUBS.has(first)) {
|
|
176
|
+
return { kind: "sub", name: first as "tweak" | "archive", rest };
|
|
177
|
+
}
|
|
178
|
+
return { kind: "set", text: trimmed };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse a bulk list-import file (v0.8.1): markdown checklists (`- [ ]`,
|
|
183
|
+
* `- [x]`), bullets (`-`, `*`, `•`), numbered items (`1.`, `2)`), and plain
|
|
184
|
+
* lines all become queue items. Headings (`# …`), blank lines, and HTML
|
|
185
|
+
* comments are skipped. A sisyphus-style plan file should import clean.
|
|
186
|
+
*/
|
|
187
|
+
export function parseListImport(content: string): string[] {
|
|
188
|
+
const items: string[] = [];
|
|
189
|
+
for (const line of content.split("\n")) {
|
|
190
|
+
let t = line.trim();
|
|
191
|
+
if (!t) continue;
|
|
192
|
+
if (t.startsWith("#")) continue; // headings
|
|
193
|
+
if (t.startsWith("<!--")) continue; // html comments
|
|
194
|
+
if (/^[-=_*]{3,}$/.test(t)) continue; // hr rules
|
|
195
|
+
t = t.replace(/^-\s*\[[ xX]\]\s*/, ""); // - [ ] / - [x]
|
|
196
|
+
t = t.replace(/^[-*•]\s+/, ""); // bullets
|
|
197
|
+
t = t.replace(/^\d+[.)]\s+/, ""); // 1. / 2)
|
|
198
|
+
t = t.trim();
|
|
199
|
+
if (t) items.push(t);
|
|
200
|
+
}
|
|
201
|
+
return items;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Detect whether a `/list add` argument is a readable file (v0.8.2). File
|
|
206
|
+
* detection, not a separate verb: `/list add plan.md` bulk-imports when the
|
|
207
|
+
* path exists, and is an objective when it doesn't. Returns the absolute
|
|
208
|
+
* path or null. Directories return null.
|
|
209
|
+
*/
|
|
210
|
+
export function resolveImportFile(cwd: string, arg: string): string | null {
|
|
211
|
+
const trimmed = arg.trim();
|
|
212
|
+
if (!trimmed || trimmed.includes("\n")) return null;
|
|
213
|
+
// Cheap short-circuit: objectives rarely look like paths; require a path
|
|
214
|
+
// separator or a file-extension-ish suffix before hitting the filesystem.
|
|
215
|
+
if (!/[\\/]/.test(trimmed) && !/\.[A-Za-z0-9]{1,8}$/.test(trimmed)) return null;
|
|
216
|
+
try {
|
|
217
|
+
const abs = path.resolve(cwd, trimmed);
|
|
218
|
+
const stat = fs.statSync(abs);
|
|
219
|
+
return stat.isFile() ? abs : null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Layered settings merge (v0.7.0): later layers win, but only for keys they
|
|
227
|
+
* actually define — an `undefined` value in a layer means "not set here",
|
|
228
|
+
* never "set to undefined". Used for defaults → global → project resolution.
|
|
229
|
+
*/
|
|
230
|
+
export function mergeSettings<T extends Record<string, unknown>>(base: T, ...layers: Array<Partial<T> | null | undefined>): T {
|
|
231
|
+
const out: Record<string, unknown> = { ...base };
|
|
232
|
+
for (const layer of layers) {
|
|
233
|
+
if (!layer) continue;
|
|
234
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
235
|
+
if (v !== undefined) out[k] = v;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return out as T;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ListItem {
|
|
242
|
+
id: string;
|
|
243
|
+
objective: string;
|
|
244
|
+
verificationContract?: string;
|
|
245
|
+
addedAt: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Should /goal args go through contract drafting instead of direct activation?
|
|
250
|
+
* Rule (v0.11.0): any objective WITHOUT an explicit "Done when:" clause is
|
|
251
|
+
* vague enough to grill first — the pi-goal-x lesson (arg + Enter is worse
|
|
252
|
+
* than a 5-minute draft). An explicit contract clause activates instantly.
|
|
253
|
+
*/
|
|
254
|
+
export function goalArgsNeedDrafting(args: string): boolean {
|
|
255
|
+
const t = args.trim();
|
|
256
|
+
if (!t) return false; // no-args is already the drafting path
|
|
257
|
+
return !/\bdone\s+when\s*:/i.test(t);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build the seeded drafting message (v0.14.0). v0.13.0 had the PLUGIN ask
|
|
262
|
+
* three canned questions — a questionnaire, not a grilling: it accepted
|
|
263
|
+
* non-answers ("not sure", "none") and produced weak contracts. The LLM
|
|
264
|
+
* does the interviewing (its strength); the plugin only enforces the floor
|
|
265
|
+
* via draftProposalBlock: propose is blocked until the user has replied.
|
|
266
|
+
*/
|
|
267
|
+
export function buildSeedGrillMessage(tmpl: string, seed: string, tool: string): string {
|
|
268
|
+
return `${tmpl}\n\nThe user's initial objective (verbatim): ${seed}\n\nGRILL THEM ABOUT THIS SEED BEFORE PROPOSING. ${tool} is BLOCKED until the user has replied to at least one of your questions — proposing without interviewing returns an error.\n\nHow to grill:\n- Ask ONE sharp, seed-specific question at a time — about THIS objective, not generic filler. If an ask_user_question tool is available in this session, prefer it (structured options render better); plain conversation is fine for free-form answers.\n- Every question ships with a recommended default the user can accept with "yes".\n- Probe what matters: what "done" concretely looks like (checkable evidence — files, commands, behaviors), scope boundaries (what is explicitly OUT), constraints (what must not change), and priorities when the seed bundles several wishes.\n- A non-answer ("not sure", "none", "whatever") is a trigger to offer 2-3 concrete options to pick from — never silently proceed on a non-answer.\n- Do targeted read-only research first when it makes your questions sharper (repo layout, existing docs).\n- Do NOT activate the raw seed. Do NOT implement anything. When the contract is concrete, call ${tool}.`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The drafting floor (v0.14.0): the propose tools call this before opening
|
|
273
|
+
* the user's Confirm dialog. 0 user replies since drafting started → the
|
|
274
|
+
* agent is attempting a contract dump; block it with instructions. The
|
|
275
|
+
* mechanism guarantees an interview HAPPENED; question quality is the
|
|
276
|
+
* model's job (shaped by buildSeedGrillMessage).
|
|
277
|
+
*/
|
|
278
|
+
export function draftProposalBlock(userReplies: number): string | null {
|
|
279
|
+
if (userReplies > 0) return null;
|
|
280
|
+
return "INTERVIEW FIRST — you have not received a single user reply since drafting started. Ask the user ONE sharp question about their objective (seed-specific, with a recommended default; challenge non-answers by offering concrete options), wait for the answer, and only then call the propose tool again. The Confirm dialog stays closed until the user has actually been heard.";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Take item at 1-based index n out of the list (v0.10.0 pick-any-item
|
|
285
|
+
* activation). n=1 is the head (FIFO default). Returns [taken, rest] or
|
|
286
|
+
* null when n is out of range.
|
|
287
|
+
*/
|
|
288
|
+
export function takeAt<T>(items: T[], n: number): [T, T[]] | null {
|
|
289
|
+
if (!Number.isInteger(n) || n < 1 || n > items.length) return null;
|
|
290
|
+
const taken = items[n - 1]!;
|
|
291
|
+
return [taken, items.filter((_, i) => i !== n - 1)];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface State {
|
|
295
|
+
goal: Goal | null;
|
|
296
|
+
/** Loop 2: queue of pending goal items. Activated one at a time. */
|
|
297
|
+
list?: ListItem[];
|
|
298
|
+
/** Loop 3: metric-driven forever loop. */
|
|
299
|
+
loop?: import("./goal-loop-forever.js").LoopState;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Default per-goal token budget (v0.9.7): a runaway threshold, not a
|
|
303
|
+
* "big goal" threshold — real research/feature goals legitimately burn 2-4M.
|
|
304
|
+
* Loop 3 doesn't rely on this cap (it has max-iterations + plateau brakes). */
|
|
305
|
+
export const DEFAULT_TOKEN_LIMIT = 0; // 0 = opt-in guard, off by default (v0.12.0)
|
|
306
|
+
|
|
307
|
+
export const DEFAULT_STATE: State = {
|
|
308
|
+
goal: null,
|
|
309
|
+
list: [],
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// =================================================================
|
|
313
|
+
// Path helpers
|
|
314
|
+
// =================================================================
|
|
315
|
+
|
|
316
|
+
export function piGlaDir(cwd: string): string {
|
|
317
|
+
return path.join(cwd, ".pi-gla");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function goalMdPath(cwd: string, id: string): string {
|
|
321
|
+
return path.join(piGlaDir(cwd), "goals", `${id}.md`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function archiveDir(cwd: string): string {
|
|
325
|
+
return path.join(piGlaDir(cwd), "archive");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function archivedGoalPath(cwd: string, id: string): string {
|
|
329
|
+
return path.join(archiveDir(cwd), `${id}.md`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function ledgerPath(cwd: string): string {
|
|
333
|
+
return path.join(piGlaDir(cwd), "active.jsonl");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =================================================================
|
|
337
|
+
// Persistence
|
|
338
|
+
// =================================================================
|
|
339
|
+
|
|
340
|
+
export function ensureDirs(cwd: string): void {
|
|
341
|
+
fs.mkdirSync(path.join(piGlaDir(cwd), "goals"), { recursive: true });
|
|
342
|
+
fs.mkdirSync(archiveDir(cwd), { recursive: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function readState(cwd: string): State {
|
|
346
|
+
const file = ledgerPath(cwd);
|
|
347
|
+
if (!fs.existsSync(file)) return { ...DEFAULT_STATE };
|
|
348
|
+
const lines = fs.readFileSync(file, "utf-8").split("\n").filter(Boolean);
|
|
349
|
+
if (lines.length === 0) return { ...DEFAULT_STATE };
|
|
350
|
+
let parsed: Partial<State> = {};
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
try {
|
|
353
|
+
const evt = JSON.parse(line);
|
|
354
|
+
if (evt.type === "state") parsed = { ...parsed, ...evt.value };
|
|
355
|
+
} catch {
|
|
356
|
+
// skip malformed lines
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
goal: parsed.goal ?? null,
|
|
361
|
+
list: Array.isArray(parsed.list) ? parsed.list : [],
|
|
362
|
+
loop: parsed.loop && typeof parsed.loop === "object" ? parsed.loop as State["loop"] : undefined,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function appendLedger(cwd: string, type: string, value: unknown): void {
|
|
367
|
+
ensureDirs(cwd);
|
|
368
|
+
const line = JSON.stringify({ type, value, at: new Date().toISOString() });
|
|
369
|
+
fs.appendFileSync(ledgerPath(cwd), line + "\n");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function writeGoalMd(cwd: string, goal: Goal): string {
|
|
373
|
+
ensureDirs(cwd);
|
|
374
|
+
const file = goalMdPath(cwd, goal.id);
|
|
375
|
+
const md = renderGoalMarkdown(goal);
|
|
376
|
+
fs.writeFileSync(file, md);
|
|
377
|
+
return file;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function readGoalMd(cwd: string, id: string): string | null {
|
|
381
|
+
const file = goalMdPath(cwd, id);
|
|
382
|
+
if (!fs.existsSync(file)) return null;
|
|
383
|
+
return fs.readFileSync(file, "utf-8");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// =================================================================
|
|
387
|
+
// Renderer — replace pi-goal-x's hand-concat detailedSummary
|
|
388
|
+
// =================================================================
|
|
389
|
+
|
|
390
|
+
export function renderGoalMarkdown(goal: Goal): string {
|
|
391
|
+
const lines: string[] = [];
|
|
392
|
+
lines.push(`# Goal`);
|
|
393
|
+
lines.push("");
|
|
394
|
+
lines.push(`**Status**: ${statusLabel(goal.status)}`);
|
|
395
|
+
lines.push(`**Policy**: ${goal.policy}`);
|
|
396
|
+
lines.push(`**Auto-continue**: ${goal.autoContinue ? "on" : "off"}`);
|
|
397
|
+
if (goal.activePath) lines.push(`**File**: \`${path.relative(path.dirname(goal.activePath), goal.activePath) || goal.activePath}\``);
|
|
398
|
+
if (goal.archivedPath) lines.push(`**Archive**: \`${path.relative(path.dirname(goal.archivedPath), goal.archivedPath) || goal.archivedPath}\``);
|
|
399
|
+
if (goal.stopReason) lines.push(`**Stop reason**: ${goal.stopReason}`);
|
|
400
|
+
if (goal.pauseReason) lines.push(`**Pause reason**: ${goal.pauseReason}`);
|
|
401
|
+
if (goal.pauseSuggestedAction) lines.push(`**Agent suggests**: ${goal.pauseSuggestedAction}`);
|
|
402
|
+
lines.push("");
|
|
403
|
+
lines.push("## Objective");
|
|
404
|
+
lines.push("");
|
|
405
|
+
lines.push("> " + goal.objective);
|
|
406
|
+
lines.push("");
|
|
407
|
+
if (goal.verificationContract) {
|
|
408
|
+
lines.push("## Verification contract");
|
|
409
|
+
lines.push("");
|
|
410
|
+
lines.push(goal.verificationContract);
|
|
411
|
+
lines.push("");
|
|
412
|
+
}
|
|
413
|
+
if (goal.taskList && goal.taskList.tasks.length > 0) {
|
|
414
|
+
lines.push("## Tasks");
|
|
415
|
+
lines.push("");
|
|
416
|
+
renderTaskTreeMarkdown(goal.taskList.tasks, lines, 0);
|
|
417
|
+
lines.push("");
|
|
418
|
+
}
|
|
419
|
+
if (goal.auditHistory && goal.auditHistory.length > 0) {
|
|
420
|
+
lines.push("## Audit history");
|
|
421
|
+
lines.push("");
|
|
422
|
+
for (const v of goal.auditHistory) {
|
|
423
|
+
lines.push(`- ${v.at} — ${v.approved ? "approved" : "disapproved"} — \`${v.model}\``);
|
|
424
|
+
}
|
|
425
|
+
lines.push("");
|
|
426
|
+
}
|
|
427
|
+
return lines.join("\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function renderTaskTreeMarkdown(tasks: Task[], out: string[], depth: number): void {
|
|
431
|
+
for (const t of tasks) {
|
|
432
|
+
const indent = " ".repeat(depth);
|
|
433
|
+
const bullet = t.status === "complete" ? "- [x]" : t.status === "in_progress" ? "- [~]" : "- [ ]";
|
|
434
|
+
out.push(`${indent}${bullet} ${t.title} \`${t.id}\``);
|
|
435
|
+
if (t.subtasks && t.subtasks.length > 0) {
|
|
436
|
+
renderTaskTreeMarkdown(t.subtasks, out, depth + 1);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// =================================================================
|
|
442
|
+
// Status helpers
|
|
443
|
+
// =================================================================
|
|
444
|
+
|
|
445
|
+
export function statusLabel(status: Status | null | undefined): string {
|
|
446
|
+
switch (status) {
|
|
447
|
+
case "active": return "active";
|
|
448
|
+
case "auditing": return "auditing";
|
|
449
|
+
case "complete": return "complete";
|
|
450
|
+
case "paused": return "paused";
|
|
451
|
+
case "aborted": return "aborted";
|
|
452
|
+
default: return "no goal";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// =================================================================
|
|
457
|
+
// ID generation
|
|
458
|
+
// =================================================================
|
|
459
|
+
|
|
460
|
+
export function nowIso(): string {
|
|
461
|
+
return new Date().toISOString();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function newGoalId(): string {
|
|
465
|
+
const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
|
466
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
467
|
+
return `${ts}-${rand}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// =================================================================
|
|
471
|
+
// Task helpers
|
|
472
|
+
// =================================================================
|
|
473
|
+
|
|
474
|
+
export function findNextPendingTask(tasks: Task[]): { id: string; title: string } | undefined {
|
|
475
|
+
const queue = [...tasks];
|
|
476
|
+
while (queue.length > 0) {
|
|
477
|
+
const t = queue.shift()!;
|
|
478
|
+
if (t.status === "pending") return { id: t.id, title: t.title };
|
|
479
|
+
// Push subtasks regardless of parent status; we want BFS to find
|
|
480
|
+
// the first pending task anywhere in the tree. A parent's status
|
|
481
|
+
// does not preclude one of its subtasks being pending.
|
|
482
|
+
if (t.subtasks && t.subtasks.length > 0) queue.push(...t.subtasks);
|
|
483
|
+
}
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function buildTaskSummary(tasks: Task[]): string {
|
|
488
|
+
let total = 0;
|
|
489
|
+
let complete = 0;
|
|
490
|
+
const queue = [...tasks];
|
|
491
|
+
while (queue.length > 0) {
|
|
492
|
+
const t = queue.shift()!;
|
|
493
|
+
total++;
|
|
494
|
+
if (t.status === "complete") complete++;
|
|
495
|
+
if (t.subtasks) queue.push(...t.subtasks);
|
|
496
|
+
}
|
|
497
|
+
return `${complete}/${total} done`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// =================================================================
|
|
501
|
+
// Lightweight structural clone (we don't need deepcopy for our shape)
|
|
502
|
+
// =================================================================
|
|
503
|
+
|
|
504
|
+
export function cloneGoal(goal: Goal): Goal {
|
|
505
|
+
return JSON.parse(JSON.stringify(goal));
|
|
506
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal-list-loop-audit — v0.9.0
|
|
3
|
+
* extensions/goal-loop-display.ts
|
|
4
|
+
*
|
|
5
|
+
* Pure display builders for the live TUI (status line + above-editor widget).
|
|
6
|
+
* No pi imports — unit tests exercise these directly. The orchestrator calls
|
|
7
|
+
* ctx.ui.setStatus/setWidget with whatever these return.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Goal, State } from "./goal-loop-core.js";
|
|
11
|
+
import type { LoopState } from "./goal-loop-forever.js";
|
|
12
|
+
|
|
13
|
+
// ---- formatters ----
|
|
14
|
+
|
|
15
|
+
export function fmtElapsed(ms: number): string {
|
|
16
|
+
if (ms < 0) ms = 0;
|
|
17
|
+
const s = Math.floor(ms / 1000);
|
|
18
|
+
if (s < 60) return `${s}s`;
|
|
19
|
+
const m = Math.floor(s / 60);
|
|
20
|
+
if (m < 60) return `${m}m`;
|
|
21
|
+
const h = Math.floor(m / 60);
|
|
22
|
+
return `${h}h${m % 60}m`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fmtTokens(n: number): string {
|
|
26
|
+
if (n < 1000) return `${n}`;
|
|
27
|
+
if (n < 100_000) return `${(n / 1000).toFixed(1)}k`;
|
|
28
|
+
return `${Math.round(n / 1000)}k`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function truncate(s: string, max: number): string {
|
|
32
|
+
return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sinceIso(iso: string): number {
|
|
36
|
+
const t = Date.parse(iso);
|
|
37
|
+
return Number.isFinite(t) ? Date.now() - t : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- status line (one-liner, always-on) ----
|
|
41
|
+
|
|
42
|
+
export interface AuditDisplayProgress {
|
|
43
|
+
currentTool?: string;
|
|
44
|
+
label?: string;
|
|
45
|
+
elapsedMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One-line status for ctx.ui.setStatus("pi-gla", …).
|
|
50
|
+
* Returns undefined when nothing is being supervised (clears the segment).
|
|
51
|
+
*/
|
|
52
|
+
export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now()): string | undefined {
|
|
53
|
+
if (state.loop?.active) {
|
|
54
|
+
const l = state.loop;
|
|
55
|
+
const arrow = l.direction === "min" ? "↓" : "↑";
|
|
56
|
+
return `gla: loop ${arrow} iter ${l.iteration}/${l.maxIterations} · best ${l.bestValue ?? "n/a"} · stall ${l.stallCount}/${l.plateauWindow}`;
|
|
57
|
+
}
|
|
58
|
+
const g = state.goal;
|
|
59
|
+
if (!g) return undefined;
|
|
60
|
+
if (g.status === "auditing") {
|
|
61
|
+
const tool = audit?.currentTool ? ` · ${audit.currentTool}` : "";
|
|
62
|
+
return `gla: auditing…${tool}`;
|
|
63
|
+
}
|
|
64
|
+
if (g.status === "paused") {
|
|
65
|
+
return `gla: paused ⏸ ${truncate(g.pauseReason ?? "", 40)}`;
|
|
66
|
+
}
|
|
67
|
+
if (g.status === "active") {
|
|
68
|
+
const queue = state.list?.length ? ` · list ${state.list.length}` : "";
|
|
69
|
+
const tasks = g.taskList ? ` ${countDone(g)}/${countTotal(g)} tasks ·` : "";
|
|
70
|
+
return `gla: goal ●${tasks} ${fmtElapsed(now - Date.parse(g.createdAt))}${queue}`;
|
|
71
|
+
}
|
|
72
|
+
return undefined; // complete/aborted → clear
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function countDone(g: Goal): number {
|
|
76
|
+
let n = 0;
|
|
77
|
+
const walk = (ts: Array<{ status: string; subtasks?: any[] }>) => {
|
|
78
|
+
for (const t of ts) {
|
|
79
|
+
if (t.status === "complete") n++;
|
|
80
|
+
if (t.subtasks) walk(t.subtasks);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(g.taskList?.tasks ?? []);
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function countTotal(g: Goal): number {
|
|
88
|
+
let n = 0;
|
|
89
|
+
const walk = (ts: Array<{ subtasks?: any[] }>) => {
|
|
90
|
+
for (const t of ts) {
|
|
91
|
+
n++;
|
|
92
|
+
if (t.subtasks) walk(t.subtasks);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(g.taskList?.tasks ?? []);
|
|
96
|
+
return n;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- above-editor widget (multi-line panel) ----
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Widget lines for ctx.ui.setWidget("pi-gla", lines).
|
|
103
|
+
* Returns undefined when nothing is worth showing.
|
|
104
|
+
*/
|
|
105
|
+
export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now()): string[] | undefined {
|
|
106
|
+
if (state.loop?.active) return loopLines(state.loop, now);
|
|
107
|
+
const g = state.goal;
|
|
108
|
+
if (!g) return undefined;
|
|
109
|
+
if (g.status === "complete" || g.status === "aborted") return undefined;
|
|
110
|
+
return goalLines(g, state, audit, now);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | undefined, now: number): string[] {
|
|
114
|
+
const icon = g.status === "paused" ? "⏸" : g.status === "auditing" ? "⟡" : "◆";
|
|
115
|
+
const head = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), 64)}`;
|
|
116
|
+
const lines = [head, `├─ ${statusLabel(g.status)} · ${fmtElapsed(now - Date.parse(g.createdAt))} · ${fmtTokens(g.usage?.tokensUsed ?? 0)}/${fmtTokens(g.usage?.tokensLimit ?? 10_000_000)} tok`];
|
|
117
|
+
if (g.status === "auditing") {
|
|
118
|
+
lines.push(`├─ auditor: ${audit?.label ?? "running"}${audit?.currentTool ? ` · ${truncate(audit.currentTool, 30)}` : ""}`);
|
|
119
|
+
if (audit?.elapsedMs) lines.push(`└─ ${fmtElapsed(audit.elapsedMs)} in isolated session`);
|
|
120
|
+
else lines.push(`└─ isolated session, read-only tools`);
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
if (g.status === "paused" && g.pauseReason) {
|
|
124
|
+
lines.push(`├─ ${truncate(g.pauseReason, 60)}`);
|
|
125
|
+
if (g.pauseSuggestedAction) lines.push(`└─ ${truncate(g.pauseSuggestedAction, 60)}`);
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
const next = nextPending(g);
|
|
129
|
+
if (next) lines.push(`├─ next: ${truncate(next, 56)}`);
|
|
130
|
+
const queue = state.list?.length ?? 0;
|
|
131
|
+
lines.push(`└─ ${queue > 0 ? `list ${queue} · ` : ""}/goal status · /gla`);
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loopLines(l: LoopState, now: number): string[] {
|
|
136
|
+
const arrow = l.direction === "min" ? "↓" : "↑";
|
|
137
|
+
const lines = [
|
|
138
|
+
`◆ ${truncate(l.target, 64)}`,
|
|
139
|
+
`├─ loop ${arrow} iter ${l.iteration}/${l.maxIterations} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
|
|
140
|
+
`├─ best ${l.bestValue ?? "n/a"} · last ${l.lastValue ?? "n/a"} · stall ${l.stallCount}/${l.plateauWindow}`,
|
|
141
|
+
`└─ ${truncate(l.measureCmd, 56)}`,
|
|
142
|
+
];
|
|
143
|
+
if (l.branchName) lines.push(` ⎇ ${truncate(l.branchName, 50)}`);
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function statusLabel(s: string): string {
|
|
148
|
+
return s === "active" ? "active" : s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function nextPending(g: Goal): string | undefined {
|
|
152
|
+
const tasks = g.taskList?.tasks ?? [];
|
|
153
|
+
const queue = [...tasks];
|
|
154
|
+
while (queue.length > 0) {
|
|
155
|
+
const t = queue.shift()!;
|
|
156
|
+
if (t.status === "pending") return t.title;
|
|
157
|
+
if (t.subtasks) queue.push(...t.subtasks);
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|