pi-goal-x 0.6.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 +307 -0
- package/docs/agent-flow-design.md +432 -0
- package/docs/agentic-runtime-prd.md +764 -0
- package/docs/architecture.md +239 -0
- package/docs/goal-ts-refactor-test-strategy.md +82 -0
- package/docs/pi-autoresearch-survey.md +45 -0
- package/extensions/goal-auditor.ts +341 -0
- package/extensions/goal-compaction.ts +124 -0
- package/extensions/goal-core.ts +77 -0
- package/extensions/goal-draft.ts +148 -0
- package/extensions/goal-ledger.ts +319 -0
- package/extensions/goal-policy.ts +152 -0
- package/extensions/goal-pool.ts +94 -0
- package/extensions/goal-questionnaire.ts +533 -0
- package/extensions/goal-record.ts +171 -0
- package/extensions/goal-tool-names.ts +69 -0
- package/extensions/goal.ts +2610 -0
- package/extensions/prompts/goal-prompts.ts +166 -0
- package/extensions/storage/goal-files.ts +267 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +219 -0
- package/package.json +57 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
5
|
+
import {
|
|
6
|
+
createAgentSession,
|
|
7
|
+
createExtensionRuntime,
|
|
8
|
+
SessionManager,
|
|
9
|
+
SettingsManager,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
type ResourceLoader,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { GoalRecord } from "./goal-record.ts";
|
|
14
|
+
|
|
15
|
+
export interface GoalAuditorConfig {
|
|
16
|
+
provider?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
thinkingLevel?: ThinkingLevel;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AuditorProgress {
|
|
23
|
+
/** Current tool being executed by the auditor, if any */
|
|
24
|
+
currentTool?: string;
|
|
25
|
+
/** Arguments passed to the current tool (truncated for display) */
|
|
26
|
+
currentToolArgs?: string;
|
|
27
|
+
/** When the current tool started (ms since epoch) */
|
|
28
|
+
currentToolStartedAt?: number;
|
|
29
|
+
/** Recent text output lines from the auditor's assistant messages */
|
|
30
|
+
recentOutput: string[];
|
|
31
|
+
/** Phase of the audit */
|
|
32
|
+
phase: "running" | "tool_executing" | "producing_report" | "done";
|
|
33
|
+
/** Elapsed ms since audit started */
|
|
34
|
+
elapsedMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type AuditorProgressCallback = (progress: AuditorProgress) => void;
|
|
38
|
+
|
|
39
|
+
export interface GoalAuditorResult {
|
|
40
|
+
approved: boolean;
|
|
41
|
+
disapproved: boolean;
|
|
42
|
+
output: string;
|
|
43
|
+
model?: string;
|
|
44
|
+
thinkingLevel?: ThinkingLevel;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
49
|
+
|
|
50
|
+
export function goalAuditorConfigPath(cwd: string): string {
|
|
51
|
+
return path.join(cwd, ".pi", "goal-auditor.json");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
55
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function asThinkingLevel(value: unknown): ThinkingLevel | undefined {
|
|
59
|
+
const text = asNonEmptyString(value);
|
|
60
|
+
return text && THINKING_LEVELS.has(text) ? text as ThinkingLevel : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseGoalAuditorConfig(raw: unknown): GoalAuditorConfig {
|
|
64
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
65
|
+
const record = raw as Record<string, unknown>;
|
|
66
|
+
const config: GoalAuditorConfig = {};
|
|
67
|
+
const provider = asNonEmptyString(record.provider);
|
|
68
|
+
const model = asNonEmptyString(record.model);
|
|
69
|
+
const thinkingLevel = asThinkingLevel(record.thinkingLevel ?? record.thinking_level);
|
|
70
|
+
if (provider) config.provider = provider;
|
|
71
|
+
if (model) config.model = model;
|
|
72
|
+
if (thinkingLevel) config.thinkingLevel = thinkingLevel;
|
|
73
|
+
if (record.disabled === true || record.disabled === "true") config.disabled = true;
|
|
74
|
+
return config;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function loadGoalAuditorFileConfig(cwd: string): GoalAuditorConfig {
|
|
78
|
+
try {
|
|
79
|
+
const configPath = goalAuditorConfigPath(cwd);
|
|
80
|
+
if (fs.existsSync(configPath)) return parseGoalAuditorConfig(JSON.parse(fs.readFileSync(configPath, "utf8")));
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function loadGoalAuditorConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): GoalAuditorConfig {
|
|
88
|
+
const fileConfig = loadGoalAuditorFileConfig(cwd);
|
|
89
|
+
return {
|
|
90
|
+
...fileConfig,
|
|
91
|
+
provider: asNonEmptyString(env.PI_GOAL_AUDITOR_PROVIDER) ?? fileConfig.provider,
|
|
92
|
+
model: asNonEmptyString(env.PI_GOAL_AUDITOR_MODEL) ?? fileConfig.model,
|
|
93
|
+
thinkingLevel: asThinkingLevel(env.PI_GOAL_AUDITOR_THINKING_LEVEL ?? env.PI_GOAL_AUDITOR_THINKING) ?? fileConfig.thinkingLevel,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function saveGoalAuditorFileConfig(cwd: string, config: GoalAuditorConfig): GoalAuditorConfig {
|
|
98
|
+
const clean: GoalAuditorConfig = {};
|
|
99
|
+
const provider = asNonEmptyString(config.provider);
|
|
100
|
+
const model = asNonEmptyString(config.model);
|
|
101
|
+
const thinkingLevel = asThinkingLevel(config.thinkingLevel);
|
|
102
|
+
if (provider) clean.provider = provider;
|
|
103
|
+
if (model) clean.model = model;
|
|
104
|
+
if (thinkingLevel) clean.thinkingLevel = thinkingLevel;
|
|
105
|
+
if (config.disabled === true) clean.disabled = true;
|
|
106
|
+
const configPath = goalAuditorConfigPath(cwd);
|
|
107
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
108
|
+
const persisted: Record<string, unknown> = {};
|
|
109
|
+
if (clean.provider) persisted.provider = clean.provider;
|
|
110
|
+
if (clean.model) persisted.model = clean.model;
|
|
111
|
+
if (clean.thinkingLevel) persisted.thinking_level = clean.thinkingLevel;
|
|
112
|
+
if (clean.disabled) persisted.disabled = true;
|
|
113
|
+
fs.writeFileSync(configPath, `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
|
|
114
|
+
return clean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function parseAuditorDecision(output: string): { approved: boolean; disapproved: boolean } {
|
|
118
|
+
const approved = /<approved\s*\/>/.test(output);
|
|
119
|
+
const disapproved = /<disapproved\s*\/>/.test(output);
|
|
120
|
+
return { approved: approved && !disapproved, disapproved };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildGoalAuditorPrompt(args: {
|
|
124
|
+
goal: GoalRecord;
|
|
125
|
+
completionSummary?: string | null;
|
|
126
|
+
detailedSummary: string;
|
|
127
|
+
}): string {
|
|
128
|
+
return [
|
|
129
|
+
"You are the independent completion auditor for pi-goal.",
|
|
130
|
+
"The executor claims the goal is complete. Your job is to decide whether the user's objective is actually satisfied.",
|
|
131
|
+
"Be skeptical and semantic. Do not approve from paperwork, intent, file count, word count, build success, or a plausible summary alone.",
|
|
132
|
+
"Use read/grep/find/ls/bash as needed to inspect real artifacts. Do not mutate files or run destructive commands.",
|
|
133
|
+
"If the work is only an alpha scaffold, generated template, shallow draft, proxy milestone, or lacks the user-facing value requested, disapprove.",
|
|
134
|
+
"If any explicit requirement is missing, weakly verified, contradicted, or not inspectable with the available evidence, disapprove.",
|
|
135
|
+
"Return a concise audit report. The final line MUST be exactly one of:",
|
|
136
|
+
"<approved/>",
|
|
137
|
+
"<disapproved/>",
|
|
138
|
+
"",
|
|
139
|
+
"Goal objective:",
|
|
140
|
+
"<objective>",
|
|
141
|
+
args.goal.objective,
|
|
142
|
+
"</objective>",
|
|
143
|
+
"",
|
|
144
|
+
"Executor completion claim:",
|
|
145
|
+
"<completion_summary>",
|
|
146
|
+
args.completionSummary?.trim() || "(none provided)",
|
|
147
|
+
"</completion_summary>",
|
|
148
|
+
"",
|
|
149
|
+
"Current goal metadata:",
|
|
150
|
+
"<goal_details>",
|
|
151
|
+
args.detailedSummary,
|
|
152
|
+
"</goal_details>",
|
|
153
|
+
"",
|
|
154
|
+
"Audit checklist:",
|
|
155
|
+
"1. Extract the real success criteria from the objective, including quality/reader outcomes.",
|
|
156
|
+
"2. Inspect artifacts or command output that can prove or disprove those criteria.",
|
|
157
|
+
"3. Explain missing or weak evidence, especially scaffold-vs-final quality gaps.",
|
|
158
|
+
"4. End with exactly <approved/> only if the objective is truly complete; otherwise end with exactly <disapproved/>.",
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function makeAuditorResourceLoader(): ResourceLoader {
|
|
163
|
+
return {
|
|
164
|
+
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
165
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
166
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
167
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
168
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
169
|
+
getSystemPrompt: () => [
|
|
170
|
+
"You are a read-only completion auditor running in an isolated pi agent session.",
|
|
171
|
+
"Inspect the repository and decide whether the claimed goal completion is genuinely satisfied.",
|
|
172
|
+
"Never modify files. Never approve unless the actual user objective is complete.",
|
|
173
|
+
].join("\n"),
|
|
174
|
+
getAppendSystemPrompt: () => [],
|
|
175
|
+
extendResources: () => {},
|
|
176
|
+
reload: async () => {},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveAuditorModel(ctx: ExtensionContext, config: GoalAuditorConfig): { model: Model<any> | undefined; error?: string } {
|
|
181
|
+
if (!config.model && !config.provider) return { model: ctx.model };
|
|
182
|
+
if (config.provider && config.model) {
|
|
183
|
+
const model = ctx.modelRegistry.find(config.provider, config.model);
|
|
184
|
+
return model ? { model } : { model: undefined, error: `Configured auditor model not found: ${config.provider}/${config.model}` };
|
|
185
|
+
}
|
|
186
|
+
if (config.provider) {
|
|
187
|
+
const matches = ctx.modelRegistry.getAvailable().filter((model) => model.provider === config.provider);
|
|
188
|
+
return matches[0] ? { model: matches[0] } : { model: undefined, error: `No available auditor model for provider: ${config.provider}` };
|
|
189
|
+
}
|
|
190
|
+
if (!config.model) return { model: ctx.model };
|
|
191
|
+
const slash = config.model.indexOf("/");
|
|
192
|
+
if (slash > 0) {
|
|
193
|
+
const provider = config.model.slice(0, slash);
|
|
194
|
+
const modelId = config.model.slice(slash + 1);
|
|
195
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
196
|
+
return model ? { model } : { model: undefined, error: `Configured auditor model not found: ${config.model}` };
|
|
197
|
+
}
|
|
198
|
+
const matches = ctx.modelRegistry.getAvailable().filter((model) => model.id === config.model || model.name === config.model);
|
|
199
|
+
if (matches.length === 1) return { model: matches[0] };
|
|
200
|
+
return { model: undefined, error: `Configured auditor model is ambiguous or unavailable: ${config.model}` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function modelLabel(model: Model<any> | undefined): string | undefined {
|
|
204
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function runGoalCompletionAuditor(args: {
|
|
208
|
+
ctx: ExtensionContext;
|
|
209
|
+
goal: GoalRecord;
|
|
210
|
+
completionSummary?: string | null;
|
|
211
|
+
detailedSummary: string;
|
|
212
|
+
signal?: AbortSignal;
|
|
213
|
+
onProgress?: AuditorProgressCallback;
|
|
214
|
+
/**
|
|
215
|
+
* Optional factory for creating the auditor agent session.
|
|
216
|
+
* Exposed for testing so a mock/controllable session can be injected.
|
|
217
|
+
* Defaults to the real createAgentSession from @earendil-works/pi-coding-agent.
|
|
218
|
+
*/
|
|
219
|
+
createSession?: typeof createAgentSession;
|
|
220
|
+
}): Promise<GoalAuditorResult> {
|
|
221
|
+
const config = loadGoalAuditorConfig(args.ctx.cwd);
|
|
222
|
+
const resolved = resolveAuditorModel(args.ctx, config);
|
|
223
|
+
const model = resolved.model;
|
|
224
|
+
const thinkingLevel = config.thinkingLevel;
|
|
225
|
+
const outputParts: string[] = [];
|
|
226
|
+
if (resolved.error) {
|
|
227
|
+
return { approved: false, disapproved: true, output: "", model: modelLabel(model), thinkingLevel, error: resolved.error };
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const createSession = args.createSession ?? createAgentSession;
|
|
231
|
+
const { session } = await createSession({
|
|
232
|
+
cwd: args.ctx.cwd,
|
|
233
|
+
model,
|
|
234
|
+
thinkingLevel,
|
|
235
|
+
modelRegistry: args.ctx.modelRegistry,
|
|
236
|
+
resourceLoader: makeAuditorResourceLoader(),
|
|
237
|
+
sessionManager: SessionManager.inMemory(args.ctx.cwd),
|
|
238
|
+
settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
|
|
239
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
240
|
+
});
|
|
241
|
+
const startedAt = Date.now();
|
|
242
|
+
const progress: AuditorProgress = {
|
|
243
|
+
recentOutput: [],
|
|
244
|
+
phase: "running",
|
|
245
|
+
elapsedMs: 0,
|
|
246
|
+
};
|
|
247
|
+
function emitProgress(): void {
|
|
248
|
+
progress.elapsedMs = Date.now() - startedAt;
|
|
249
|
+
args.onProgress?.({ ...progress });
|
|
250
|
+
}
|
|
251
|
+
const unsubscribe = session.subscribe((event) => {
|
|
252
|
+
if (event.type === "tool_execution_start") {
|
|
253
|
+
progress.currentTool = event.toolName;
|
|
254
|
+
progress.currentToolArgs = typeof event.args === "object" && event.args !== null
|
|
255
|
+
? JSON.stringify(event.args).slice(0, 120)
|
|
256
|
+
: String(event.args ?? "").slice(0, 120);
|
|
257
|
+
progress.currentToolStartedAt = Date.now();
|
|
258
|
+
progress.phase = "tool_executing";
|
|
259
|
+
emitProgress();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (event.type === "tool_execution_end") {
|
|
263
|
+
progress.currentTool = undefined;
|
|
264
|
+
progress.currentToolArgs = undefined;
|
|
265
|
+
progress.currentToolStartedAt = undefined;
|
|
266
|
+
progress.phase = "running";
|
|
267
|
+
emitProgress();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (event.type === "message_update") {
|
|
271
|
+
progress.phase = "producing_report";
|
|
272
|
+
const message = event.message as any;
|
|
273
|
+
if (message?.role === "assistant") {
|
|
274
|
+
for (const part of message.content ?? []) {
|
|
275
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
276
|
+
// Keep the last 5 non-empty text lines for live display
|
|
277
|
+
const lines = part.text.split("\n").filter((l: string) => l.trim());
|
|
278
|
+
progress.recentOutput = [...lines.slice(-5)];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
emitProgress();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (event.type !== "message_end") return;
|
|
286
|
+
const message = event.message as any;
|
|
287
|
+
if (message.role !== "assistant") return;
|
|
288
|
+
for (const part of message.content ?? []) {
|
|
289
|
+
if (part.type === "text" && typeof part.text === "string") outputParts.push(part.text);
|
|
290
|
+
}
|
|
291
|
+
// Show the accumulated output in progress
|
|
292
|
+
const fullText = outputParts.join("\n\n");
|
|
293
|
+
const lines = fullText.split("\n").filter((l: string) => l.trim());
|
|
294
|
+
progress.recentOutput = lines.slice(-8);
|
|
295
|
+
emitProgress();
|
|
296
|
+
});
|
|
297
|
+
// Wire the external AbortSignal to abort the running session when fired
|
|
298
|
+
// This is the mechanism that makes Esc-to-skip actually stop the auditor.
|
|
299
|
+
const abortSession = () => { session.abort(); };
|
|
300
|
+
args.signal?.addEventListener("abort", abortSession, { once: true });
|
|
301
|
+
|
|
302
|
+
// Emit initial progress
|
|
303
|
+
emitProgress();
|
|
304
|
+
try {
|
|
305
|
+
if (args.signal?.aborted) return { approved: false, disapproved: true, output: "", model: modelLabel(model), thinkingLevel, error: "Auditor aborted." };
|
|
306
|
+
await session.prompt(buildGoalAuditorPrompt(args));
|
|
307
|
+
} finally {
|
|
308
|
+
args.signal?.removeEventListener("abort", abortSession);
|
|
309
|
+
progress.phase = "done";
|
|
310
|
+
emitProgress();
|
|
311
|
+
unsubscribe();
|
|
312
|
+
}
|
|
313
|
+
// session.abort() does NOT throw — the agent loop returns normally with
|
|
314
|
+
// whatever output was captured before the abort. Check the signal after
|
|
315
|
+
// prompt completes and treat any abort as auditor-aborted regardless of
|
|
316
|
+
// whether an exception propagated.
|
|
317
|
+
if (args.signal?.aborted) {
|
|
318
|
+
return {
|
|
319
|
+
approved: false,
|
|
320
|
+
disapproved: true,
|
|
321
|
+
output: outputParts.join("\n\n").trim(),
|
|
322
|
+
model: modelLabel(model),
|
|
323
|
+
thinkingLevel,
|
|
324
|
+
error: "Auditor aborted.",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const output = outputParts.join("\n\n").trim();
|
|
328
|
+
const decision = parseAuditorDecision(output);
|
|
329
|
+
return { ...decision, output, model: modelLabel(model), thinkingLevel };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
const isAborted = args.signal?.aborted || (error instanceof Error && error.name === "AbortError");
|
|
332
|
+
return {
|
|
333
|
+
approved: false,
|
|
334
|
+
disapproved: true,
|
|
335
|
+
output: outputParts.join("\n\n").trim(),
|
|
336
|
+
model: modelLabel(model),
|
|
337
|
+
thinkingLevel,
|
|
338
|
+
error: isAborted ? "Auditor aborted." : (error instanceof Error ? error.message : String(error)),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatDuration,
|
|
3
|
+
formatTokenValue,
|
|
4
|
+
statusLabel,
|
|
5
|
+
truncateText,
|
|
6
|
+
} from "./goal-core.ts";
|
|
7
|
+
import {
|
|
8
|
+
latestAuditorResultForGoal,
|
|
9
|
+
latestEventsForGoal,
|
|
10
|
+
reconstructGoalLedger,
|
|
11
|
+
type GoalLedgerEvent,
|
|
12
|
+
} from "./goal-ledger.ts";
|
|
13
|
+
import { type GoalRecord } from "./goal-record.ts";
|
|
14
|
+
|
|
15
|
+
export function buildGoalCompactSummary(goal: GoalRecord, events: GoalLedgerEvent[]): string {
|
|
16
|
+
const lines: string[] = [];
|
|
17
|
+
lines.push(`Goal ${goal.id} — ${statusLabel(goal)}`);
|
|
18
|
+
lines.push(` Objective: ${truncateText(goal.objective, 200)}`);
|
|
19
|
+
if (goal.usage.tokensUsed > 0) {
|
|
20
|
+
lines.push(` Usage: ${formatTokenValue(goal.usage.tokensUsed)}`);
|
|
21
|
+
}
|
|
22
|
+
if (goal.usage.activeSeconds > 0) {
|
|
23
|
+
lines.push(` Time: ${formatDuration(goal.usage.activeSeconds)}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const recent = latestEventsForGoal(events, goal.id, 5);
|
|
27
|
+
if (recent.length > 0) {
|
|
28
|
+
lines.push(" Recent events:");
|
|
29
|
+
for (const event of recent) {
|
|
30
|
+
switch (event.type) {
|
|
31
|
+
case "goal_paused":
|
|
32
|
+
lines.push(` - paused: ${event.reason}`);
|
|
33
|
+
break;
|
|
34
|
+
case "goal_resumed":
|
|
35
|
+
lines.push(` - resumed: ${event.reason}`);
|
|
36
|
+
break;
|
|
37
|
+
case "goal_tweaked":
|
|
38
|
+
lines.push(` - tweaked: ${event.changeSummary}`);
|
|
39
|
+
break;
|
|
40
|
+
case "completion_requested":
|
|
41
|
+
lines.push(` - completion requested${event.summary ? `: ${truncateText(event.summary, 80)}` : ""}`);
|
|
42
|
+
break;
|
|
43
|
+
case "audit_result":
|
|
44
|
+
lines.push(` - auditor ${event.verdict}${event.verdict === "disapproved" ? `: ${truncateText(event.report, 80)}` : ""}`);
|
|
45
|
+
break;
|
|
46
|
+
case "goal_completed":
|
|
47
|
+
lines.push(" - completed");
|
|
48
|
+
break;
|
|
49
|
+
case "goal_aborted":
|
|
50
|
+
lines.push(` - aborted: ${event.reason}`);
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const auditor = latestAuditorResultForGoal(events, goal.id);
|
|
59
|
+
if (auditor && auditor.verdict === "disapproved") {
|
|
60
|
+
lines.push(` Auditor rejection (latest): ${truncateText(auditor.report, 120)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (goal.pauseReason) {
|
|
64
|
+
lines.push(` Pause reason: ${goal.pauseReason}`);
|
|
65
|
+
}
|
|
66
|
+
if (goal.pauseSuggestedAction) {
|
|
67
|
+
lines.push(` Suggested action: ${goal.pauseSuggestedAction}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildCompactionSummary(args: {
|
|
74
|
+
goalsById: Map<string, GoalRecord>;
|
|
75
|
+
focusedGoalId: string | null;
|
|
76
|
+
ledgerEvents: GoalLedgerEvent[];
|
|
77
|
+
capOpenGoals?: number;
|
|
78
|
+
capEventsPerGoal?: number;
|
|
79
|
+
}): string {
|
|
80
|
+
const { goalsById, focusedGoalId, ledgerEvents, capOpenGoals = 20, capEventsPerGoal = 5 } = args;
|
|
81
|
+
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
const openGoals = Array.from(goalsById.values()).filter((g) => g.status !== "complete");
|
|
84
|
+
const reconstructed = reconstructGoalLedger(ledgerEvents);
|
|
85
|
+
|
|
86
|
+
if (focusedGoalId && goalsById.has(focusedGoalId)) {
|
|
87
|
+
const focused = goalsById.get(focusedGoalId)!;
|
|
88
|
+
lines.push(`[FOCUSED GOAL]`);
|
|
89
|
+
lines.push(buildGoalCompactSummary(focused, latestEventsForGoal(ledgerEvents, focusedGoalId, capEventsPerGoal)));
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const otherOpen = openGoals.filter((g) => g.id !== focusedGoalId);
|
|
94
|
+
if (otherOpen.length > 0) {
|
|
95
|
+
lines.push(`[OTHER OPEN GOALS — ${otherOpen.length} total]`);
|
|
96
|
+
for (const goal of otherOpen.slice(0, capOpenGoals)) {
|
|
97
|
+
lines.push(`- ${goal.id} — ${statusLabel(goal)} — ${truncateText(goal.objective, 120)}`);
|
|
98
|
+
}
|
|
99
|
+
if (otherOpen.length > capOpenGoals) {
|
|
100
|
+
lines.push(`... and ${otherOpen.length - capOpenGoals} more`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (reconstructed.terminalGoals.size > 0) {
|
|
106
|
+
lines.push(`[TERMINAL GOALS — ${reconstructed.terminalGoals.size} completed or aborted]`);
|
|
107
|
+
for (const [goalId, state] of reconstructed.terminalGoals) {
|
|
108
|
+
const label = state.latestStatus === "complete" ? "completed" : "aborted";
|
|
109
|
+
lines.push(`- ${goalId} — ${label}${state.completedAt ? ` at ${state.completedAt}` : ""}${state.abortedAt ? ` at ${state.abortedAt}` : ""}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (openGoals.length === 0 && reconstructed.terminalGoals.size === 0) {
|
|
115
|
+
lines.push("[NO GOALS]");
|
|
116
|
+
lines.push("No open or terminal goals recorded in this session.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push("[INSTRUCTION]");
|
|
120
|
+
lines.push("Continue from the focused goal above, or ask the user to run /goals, /goals-set, or /goal-focus.");
|
|
121
|
+
lines.push("Do not rely on chat memory for goal state; use the facts above.");
|
|
122
|
+
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface GoalUsageLike {
|
|
2
|
+
tokensUsed: number;
|
|
3
|
+
activeSeconds: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface GoalDisplayRecordLike {
|
|
7
|
+
objective: string;
|
|
8
|
+
status: "active" | "paused" | "complete";
|
|
9
|
+
autoContinue: boolean;
|
|
10
|
+
usage: GoalUsageLike;
|
|
11
|
+
sisyphus: boolean;
|
|
12
|
+
stopReason?: "user" | "agent";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { isQuestionLikeToolName } from "./goal-tool-names.ts";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
export function truncateText(value: string, max = 120): string {
|
|
19
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
20
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 3)}...` : oneLine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function displayObjectiveTitle(objective: string): string {
|
|
24
|
+
const lines = objective.replace(/\r/g, "").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
25
|
+
const sectionHeader = /^(success criteria|boundaries|constraints|steps|order rules|don'ts|if blocked|if blocked \/ unclear \/ failing|sisyphus reminder)\s*[::]/i;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (/^=+\s*(?:sisyphus\s+)?goal\s*=+$/i.test(line)) continue;
|
|
28
|
+
const objectiveMatch = line.match(/^(?:objective|目标)\s*[::]\s*(.+)$/i);
|
|
29
|
+
if (objectiveMatch?.[1]) return objectiveMatch[1].trim();
|
|
30
|
+
if (sectionHeader.test(line)) continue;
|
|
31
|
+
return line;
|
|
32
|
+
}
|
|
33
|
+
return truncateText(objective);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatTokenValue(value: number): string {
|
|
37
|
+
const safe = Math.max(0, Math.floor(value));
|
|
38
|
+
const compact =
|
|
39
|
+
safe >= 1_000_000_000
|
|
40
|
+
? `${(safe / 1_000_000_000).toFixed(safe >= 10_000_000_000 ? 0 : 1).replace(/\.0$/, "")}B`
|
|
41
|
+
: safe >= 1_000_000
|
|
42
|
+
? `${(safe / 1_000_000).toFixed(safe >= 10_000_000 ? 0 : 1).replace(/\.0$/, "")}M`
|
|
43
|
+
: safe >= 10_000
|
|
44
|
+
? `${(safe / 1_000).toFixed(0)}K`
|
|
45
|
+
: safe >= 1_000
|
|
46
|
+
? `${(safe / 1_000).toFixed(1).replace(/\.0$/, "")}K`
|
|
47
|
+
: String(safe);
|
|
48
|
+
const exact = safe.toLocaleString("en-US");
|
|
49
|
+
if (compact === exact) return `${exact} tokens`;
|
|
50
|
+
return `${compact} (${exact}) tokens`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatDuration(seconds: number): string {
|
|
54
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
55
|
+
const hours = Math.floor(total / 3600);
|
|
56
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
57
|
+
const secs = total % 60;
|
|
58
|
+
if (hours > 0) return `${hours}h${minutes.toString().padStart(2, "0")}m${secs.toString().padStart(2, "0")}s`;
|
|
59
|
+
if (minutes > 0) return `${minutes}m${secs.toString().padStart(2, "0")}s`;
|
|
60
|
+
return `${secs}s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function statusLabel(goal: Pick<GoalDisplayRecordLike, "sisyphus" | "status" | "autoContinue" | "stopReason">): string {
|
|
64
|
+
const prefix = goal.sisyphus ? "sisyphus " : "";
|
|
65
|
+
if (goal.status === "active" && goal.autoContinue) return `${prefix}running`;
|
|
66
|
+
if (goal.status === "paused" && goal.stopReason === "agent") return `${prefix}paused (agent)`;
|
|
67
|
+
return `${prefix}${goal.status}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function footerStatus(goal: GoalDisplayRecordLike): string {
|
|
71
|
+
const usageBits: string[] = [];
|
|
72
|
+
if (goal.usage.activeSeconds > 0) usageBits.push(formatDuration(goal.usage.activeSeconds));
|
|
73
|
+
if (goal.usage.tokensUsed > 0) usageBits.push(formatTokenValue(goal.usage.tokensUsed).split(" ")[0]);
|
|
74
|
+
const usage = usageBits.length > 0 ? ` [${usageBits.join(" ")}]` : "";
|
|
75
|
+
const prefix = goal.sisyphus ? "goal✊" : "goal";
|
|
76
|
+
return `${prefix}: ${statusLabel(goal)}${usage} - ${truncateText(goal.objective, 60)}`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export type GoalDraftingFocus = "goal" | "sisyphus";
|
|
2
|
+
|
|
3
|
+
export interface GoalConfirmationIntentLike {
|
|
4
|
+
focus: GoalDraftingFocus;
|
|
5
|
+
originalTopic: string;
|
|
6
|
+
startedAt?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DraftProposalInput {
|
|
10
|
+
intent: GoalConfirmationIntentLike | null;
|
|
11
|
+
hasUnfinishedGoal: boolean;
|
|
12
|
+
objective: string;
|
|
13
|
+
sisyphus?: boolean;
|
|
14
|
+
draftId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DraftProposalValidation =
|
|
18
|
+
| { ok: true; objective: string; expectedSisyphus: boolean }
|
|
19
|
+
| { ok: false; message: string; clearDrafting?: boolean };
|
|
20
|
+
|
|
21
|
+
export type ToolGateDecision =
|
|
22
|
+
| { block: false }
|
|
23
|
+
| { block: true; reason: string };
|
|
24
|
+
|
|
25
|
+
export function promptSafeObjective(objective: string): string {
|
|
26
|
+
return objective.replace(/<\/?untrusted_objective>/gi, (tag) => tag.replace(/</g, "<").replace(/>/g, ">"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildDraftConfirmationText(args: {
|
|
30
|
+
focus: GoalDraftingFocus;
|
|
31
|
+
originalTopic: string;
|
|
32
|
+
objective: string;
|
|
33
|
+
autoContinue: boolean;
|
|
34
|
+
}): string {
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
const modeLabel = args.focus === "sisyphus" ? "Sisyphus (prompt/criteria style)" : "Normal goal";
|
|
37
|
+
lines.push("Goal draft ready for confirmation.");
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push("Draft details:");
|
|
40
|
+
lines.push(`Mode: ${modeLabel}`);
|
|
41
|
+
lines.push(`Auto-continue: ${args.autoContinue ? "yes" : "no"}`);
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push("Original topic:");
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(args.originalTopic.trim());
|
|
46
|
+
lines.push("");
|
|
47
|
+
lines.push("Proposed goal:");
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push(args.objective);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function evaluateDraftingToolGate(args: {
|
|
54
|
+
toolName: string;
|
|
55
|
+
draftingFocus?: GoalDraftingFocus | null;
|
|
56
|
+
tweakDraftingGoalId?: string | null;
|
|
57
|
+
activeGoalId?: string | null;
|
|
58
|
+
proposeToolName?: string;
|
|
59
|
+
tweakApplyToolName?: string;
|
|
60
|
+
getGoalToolName?: string;
|
|
61
|
+
}): ToolGateDecision {
|
|
62
|
+
// Goal confirmation is prompt-guided, not runtime-enforced. The agent should
|
|
63
|
+
// avoid substantive work before confirmation, but minimal reconnaissance is allowed.
|
|
64
|
+
void args;
|
|
65
|
+
return { block: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validateGoalDraftProposal(input: DraftProposalInput): DraftProposalValidation {
|
|
69
|
+
if (input.intent === null) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
message: "propose_goal_draft REJECTED: no /goals or /sisyphus intent discussion is in progress. Tell the user to invoke /goals <topic> or /sisyphus <topic> first, or use /goals-set / /sisyphus-set for immediate creation.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const expectedSisyphus = input.intent.focus === "sisyphus";
|
|
77
|
+
const actualSisyphus = input.sisyphus === true;
|
|
78
|
+
if (actualSisyphus !== expectedSisyphus) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
message: `propose_goal_draft REJECTED (focus gate): confirmation focus is "${input.intent.focus}" (user invoked ${input.intent.focus === "sisyphus" ? "/sisyphus" : "/goals"}) but you passed sisyphus=${actualSisyphus}. Set sisyphus=${expectedSisyphus} to match the user's choice, then retry. Do NOT change the user's mode autonomously.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const objective = input.objective.trim();
|
|
86
|
+
if (!objective) {
|
|
87
|
+
return { ok: false, message: "propose_goal_draft REJECTED: objective is empty." };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { ok: true, objective, expectedSisyphus };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function goalDraftingPrompt(topic: string, focus: GoalDraftingFocus): string {
|
|
94
|
+
const safeTopic = promptSafeObjective(topic.trim() || "(no topic provided — ask the user what they want to accomplish)");
|
|
95
|
+
const header = focus === "sisyphus"
|
|
96
|
+
? "[GOAL CONFIRMATION focus=sisyphus]\nThe user invoked Sisyphus intent discussion (/sisyphus). Help turn their request into a confirmed goal contract. Do NOT start substantive work yet."
|
|
97
|
+
: "[GOAL CONFIRMATION focus=goal]\nThe user invoked goal intent discussion (/goals). Help turn their request into a confirmed goal contract. Do NOT start substantive work yet.";
|
|
98
|
+
|
|
99
|
+
const commonProtocol = [
|
|
100
|
+
"Confirmation protocol:",
|
|
101
|
+
"- Treat this as a lightweight conversation with the user, not a separate long-running runtime phase.",
|
|
102
|
+
"- If the topic is vague, ask one focused question with a recommended default. Use goal_question or goal_questionnaire when a structured answer would help, but plain conversation is acceptable.",
|
|
103
|
+
"- Targeted read-only research is allowed when it helps define a better goal contract; do not start implementation before confirmation.",
|
|
104
|
+
"- If the topic is already concrete, you may proceed directly to propose_goal_draft.",
|
|
105
|
+
"- The goal contract should make the objective, success criteria, boundaries, constraints, and blocker rule explicit.",
|
|
106
|
+
"- Keep grilling assumptions until the objective, success criteria, boundaries, constraints, and blocker rule are clear enough to confirm.",
|
|
107
|
+
"- propose_goal_draft opens the user's Confirm / Continue Chatting dialog. Confirm creates and focuses the goal; Continue Chatting means keep clarifying.",
|
|
108
|
+
"- create_goal is not a shortcut. Direct create_goal calls are rejected so the user keeps explicit say in goal creation.",
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const goalFocusItems = [
|
|
112
|
+
"For /goals, propose a normal goal in this shape when ready:",
|
|
113
|
+
"=== Goal ===",
|
|
114
|
+
"Objective: <one-sentence outcome>",
|
|
115
|
+
"Success criteria: <observable evidence the goal is done>",
|
|
116
|
+
"Boundaries: <in scope / out of scope>",
|
|
117
|
+
"Constraints: <hard rules>",
|
|
118
|
+
"If blocked: <default = stop and ask the user>",
|
|
119
|
+
"Call propose_goal_draft with sisyphus=false and autoContinue=true unless the user asked otherwise.",
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const sisyphusFocusItems = [
|
|
123
|
+
"For /sisyphus, remember that Sisyphus is a prompt/criteria style, not a separate step-counter mechanism.",
|
|
124
|
+
"Propose a Sisyphus goal in this shape when ready:",
|
|
125
|
+
"=== Sisyphus Goal ===",
|
|
126
|
+
"Objective: <one-sentence outcome>",
|
|
127
|
+
"Success criteria: <observable evidence the whole ordered goal is done>",
|
|
128
|
+
"Boundaries: <in scope / out of scope>",
|
|
129
|
+
"Constraints: <hard rules, files not to touch, etc.>",
|
|
130
|
+
"Ordered steps: <preserve the user's requested steps and ordering; do not add preflight or reconnaissance steps they did not ask for>",
|
|
131
|
+
"If blocked / unclear / failing: <default = stop and ask the user>",
|
|
132
|
+
"Sisyphus reminder: Work patiently and sequentially. No rushing, no unrequested preflight steps, no improvising around blockers.",
|
|
133
|
+
"Call propose_goal_draft with sisyphus=true and autoContinue=true unless the user asked otherwise.",
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
return [
|
|
137
|
+
header,
|
|
138
|
+
"",
|
|
139
|
+
"Topic the user provided:",
|
|
140
|
+
"<goal_topic>",
|
|
141
|
+
safeTopic,
|
|
142
|
+
"</goal_topic>",
|
|
143
|
+
"",
|
|
144
|
+
...commonProtocol,
|
|
145
|
+
"",
|
|
146
|
+
...(focus === "sisyphus" ? sisyphusFocusItems : goalFocusItems),
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|