pi-long-task 0.3.9 → 0.3.11

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.
@@ -0,0 +1,616 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ cancelGoalLoop,
6
+ type GoalIterationState,
7
+ type GoalLoopState,
8
+ type GoalReviewerDecision,
9
+ type GoalReviewerResultState,
10
+ recordReviewerResult,
11
+ } from "./goal_loop.ts";
12
+ import { GoalStateStore } from "./goal_state.ts";
13
+ import { goalSpecificationToMarkdown, type GoalSpecification } from "./goal_spec.ts";
14
+ import {
15
+ assistantTextFromEvent,
16
+ createIsolatedWorkerSession,
17
+ DEFAULT_WORKER_TOOLS,
18
+ lastAssistantTextFromMessages,
19
+ workerUsageCostFromEvent,
20
+ workerUsageCostFromStats,
21
+ type WorkerSessionFactory,
22
+ } from "./worker_session.ts";
23
+
24
+ export const GOAL_REVIEW_PAYLOAD_FILE = "REVIEW_TASK.md";
25
+ export const GOAL_REVIEW_RAW_FILE = "REVIEW_RESULT_RAW.txt";
26
+
27
+ export interface GoalReviewOptions {
28
+ state: GoalLoopState;
29
+ cwd?: string;
30
+ store?: GoalStateStore;
31
+ reviewerRunner?: GoalReviewerRunner;
32
+ abortSignal?: AbortSignal;
33
+ model?: unknown;
34
+ modelName?: string;
35
+ thinkingLevel?: string;
36
+ now?: () => Date;
37
+ sessionFactory?: WorkerSessionFactory;
38
+ goalSpecification?: GoalSpecification;
39
+ }
40
+
41
+ export interface GoalReviewResult {
42
+ state: GoalLoopState;
43
+ iteration: GoalIterationState;
44
+ payload: string;
45
+ payloadPath: string;
46
+ rawReviewPath: string;
47
+ rawReviewerOutput: string;
48
+ reviewerResult: GoalReviewerResultState;
49
+ sessionResult: GoalReviewerSessionResult;
50
+ }
51
+
52
+ export interface GoalReviewerRunnerOptions {
53
+ prompt: string;
54
+ cwd: string;
55
+ abortSignal?: AbortSignal;
56
+ timeoutMs: number;
57
+ model?: unknown;
58
+ modelName?: string;
59
+ thinkingLevel?: string;
60
+ sessionFactory?: WorkerSessionFactory;
61
+ }
62
+
63
+ export interface GoalReviewerSessionResult {
64
+ assistantText: string;
65
+ reviewerSessionId?: string;
66
+ reviewerSessionFile?: string;
67
+ reviewerCostTotal?: number;
68
+ timedOut?: boolean;
69
+ aborted?: boolean;
70
+ error?: string;
71
+ }
72
+
73
+ export type GoalReviewerRunner = (options: GoalReviewerRunnerOptions) => Promise<GoalReviewerSessionResult>;
74
+
75
+ export class GoalReviewError extends Error {
76
+ readonly state: GoalLoopState | undefined;
77
+ readonly reviewerResult: GoalReviewerResultState | undefined;
78
+
79
+ constructor(
80
+ message: string,
81
+ options: { cause?: unknown; state?: GoalLoopState; reviewerResult?: GoalReviewerResultState } = {},
82
+ ) {
83
+ super(message, { cause: options.cause });
84
+ this.name = "GoalReviewError";
85
+ this.state = options.state;
86
+ this.reviewerResult = options.reviewerResult;
87
+ }
88
+ }
89
+
90
+ export async function runGoalReviewSession(options: GoalReviewOptions): Promise<GoalReviewResult> {
91
+ const now = options.now ?? (() => new Date());
92
+ let state = options.state;
93
+ const previousTraceLength = state.trace.length;
94
+ const store =
95
+ options.store ?? new GoalStateStore({ cwd: options.cwd, goalRunId: state.goalRunId, goalRunDir: state.goalRunDir });
96
+
97
+ const iteration = currentReviewableIteration(state);
98
+ const iterationDir = store.iterationDir(iteration.iteration);
99
+ await mkdir(iterationDir, { recursive: true });
100
+ const payloadPath = path.join(iterationDir, GOAL_REVIEW_PAYLOAD_FILE);
101
+ const rawReviewPath = path.join(iterationDir, GOAL_REVIEW_RAW_FILE);
102
+ const goalSpecification = options.goalSpecification ?? (await store.tryLoadGoalSpecification());
103
+ const payload = buildGoalReviewTaskPayload({
104
+ state,
105
+ iteration,
106
+ goalSpecification,
107
+ goalSpecificationPath: goalSpecification ? store.paths.goalSpecPath : undefined,
108
+ });
109
+ await writeFile(payloadPath, payload, "utf8");
110
+
111
+ if (options.abortSignal?.aborted) {
112
+ state = cancelGoalLoop(state, "Goal review was aborted before starting.", { now: now() });
113
+ await persistStateChange(store, previousTraceLength, state);
114
+ await store.writeIterationSnapshot(currentIteration(state, iteration.iteration));
115
+ throw new GoalReviewError("Goal review was aborted before starting.", { state });
116
+ }
117
+
118
+ let sessionResult: GoalReviewerSessionResult;
119
+ try {
120
+ sessionResult = await (options.reviewerRunner ?? runGoalReviewerSession)({
121
+ prompt: payload,
122
+ cwd: path.resolve(options.cwd ?? process.cwd()),
123
+ abortSignal: options.abortSignal,
124
+ timeoutMs: state.limits.reviewerTimeoutMs,
125
+ model: options.model,
126
+ modelName: options.modelName,
127
+ thinkingLevel: options.thinkingLevel,
128
+ sessionFactory: options.sessionFactory,
129
+ });
130
+ } catch (error) {
131
+ const failure = await recordReviewFailure({
132
+ state,
133
+ iteration,
134
+ store,
135
+ previousTraceLength,
136
+ payloadPath,
137
+ rawReviewPath,
138
+ message: `Reviewer session failed: ${errorMessage(error)}`,
139
+ error,
140
+ now,
141
+ });
142
+ throw new GoalReviewError(failure.reviewerResult.summary, {
143
+ cause: error,
144
+ state: failure.state,
145
+ reviewerResult: failure.reviewerResult,
146
+ });
147
+ }
148
+
149
+ const rawReviewerOutput = sessionResult.assistantText;
150
+ await writeFile(rawReviewPath, rawReviewerOutput, "utf8");
151
+
152
+ let reviewerResult: GoalReviewerResultState;
153
+ try {
154
+ reviewerResult = {
155
+ ...parseGoalReviewerOutput(rawReviewerOutput, { now: now() }),
156
+ reviewerSessionId: sessionResult.reviewerSessionId,
157
+ reviewerSessionFile: sessionResult.reviewerSessionFile,
158
+ payloadPath,
159
+ rawReviewPath,
160
+ reviewerCostTotal: sessionResult.reviewerCostTotal,
161
+ };
162
+ } catch (error) {
163
+ const failure = await recordReviewFailure({
164
+ state,
165
+ iteration,
166
+ store,
167
+ previousTraceLength,
168
+ payloadPath,
169
+ rawReviewPath,
170
+ message: `Reviewer output could not be parsed: ${errorMessage(error)}`,
171
+ error,
172
+ rawReviewerOutput,
173
+ sessionResult,
174
+ now,
175
+ });
176
+ throw new GoalReviewError(failure.reviewerResult.summary, {
177
+ cause: error,
178
+ state: failure.state,
179
+ reviewerResult: failure.reviewerResult,
180
+ });
181
+ }
182
+
183
+ reviewerResult = enforceMinimumIterationsBeforeCompletion(reviewerResult, state, iteration.iteration);
184
+
185
+ state = recordReviewerResult(state, iteration.iteration, reviewerResult, { now: now() });
186
+ await persistStateChange(store, previousTraceLength, state);
187
+ const updatedIteration = currentIteration(state, iteration.iteration);
188
+ await store.writeIterationSnapshot(updatedIteration);
189
+ await store.appendIterationResult(updatedIteration);
190
+
191
+ return {
192
+ state,
193
+ iteration: updatedIteration,
194
+ payload,
195
+ payloadPath,
196
+ rawReviewPath,
197
+ rawReviewerOutput,
198
+ reviewerResult,
199
+ sessionResult,
200
+ };
201
+ }
202
+
203
+ export function buildGoalReviewTaskPayload(options: {
204
+ state: GoalLoopState;
205
+ iteration: GoalIterationState;
206
+ goalSpecification?: GoalSpecification;
207
+ goalSpecificationPath?: string;
208
+ }): string {
209
+ const { state, iteration } = options;
210
+ const workerResult = iteration.workerResult;
211
+ const generatedTodo = iteration.generatedTodo;
212
+ const previousContext = previousReviewContext(state, iteration.iteration);
213
+ const previousContextBlock = previousContext
214
+ ? `\nPrevious iteration review context:\n\n${markdownFence(previousContext, "text")}\n`
215
+ : "";
216
+ const specificationBlock = options.goalSpecification
217
+ ? `\nPersisted goal specification (primary review target):\n\n${markdownFence(
218
+ buildGoalSpecificationReviewContext(options.goalSpecification, options.goalSpecificationPath),
219
+ "markdown",
220
+ )}\n`
221
+ : "";
222
+ const reviewTargetInstruction = options.goalSpecification
223
+ ? "Review whether the latest worker run satisfies the persisted goal specification and definition-of-done. Treat the persisted specification as the primary review target; keep the original high-level goal available only as traceability/context."
224
+ : "Review whether the original high-level goal is complete after the latest worker run.";
225
+ const iterationPolicy = `Goal loop iteration policy:
226
+ - Current iteration: ${iteration.iteration}
227
+ - Minimum iterations before completion may stop the loop: ${state.limits.minIterations}
228
+ - Maximum iterations: ${state.limits.maxIterations}
229
+ - If current iteration is below the minimum, do not return "complete" even if the current implementation looks good. Return "incomplete" with concrete remainingWork for the next improvement pass, such as missing verification, hardening, UX polish, security, performance, docs, edge cases, or maintainability follow-up.`;
230
+ const decisionRules = options.goalSpecification
231
+ ? `- Use "complete" only when the persisted definition-of-done is satisfied, including in-scope requirements, milestones, acceptance criteria, required verification gates, and applicable design/product constraints.
232
+ - Use "incomplete" when any required spec requirement, milestone, acceptance criterion, verification gate, artifact, or constraint still needs work and another TODO-generation iteration should be started.
233
+ - Use "blocked" when a required spec item cannot be evaluated or completed without external input or unavailable resources.
234
+ - Use "failed" when the loop should stop because the run is unrecoverably failed.
235
+ - In summary, rationale, and remainingWork, cite specific spec IDs or named criteria where applicable (for example REQ-*, MS-*, AC-*, VG-*).`
236
+ : `- Use "complete" only when the original high-level goal is satisfied, not merely when the worker finished its TODO.
237
+ - Use "incomplete" when meaningful work remains and another TODO-generation iteration should be started.
238
+ - Use "blocked" when external input or unavailable resources prevent progress.
239
+ - Use "failed" when the loop should stop because the run is unrecoverably failed.`;
240
+
241
+ return `You are a separate Pi SDK reviewer session for a goal-oriented long-task loop.
242
+
243
+ ${reviewTargetInstruction} Do not implement fixes, edit files, or commit. You may inspect files and run focused read-only verification commands when useful.
244
+
245
+ Original high-level goal:
246
+
247
+ ${markdownFence(state.goal, "text")}
248
+ ${specificationBlock}
249
+ Goal run: ${state.goalRunId}
250
+ Iteration: ${iteration.iteration}
251
+ Generated TODO path: ${generatedTodo?.todoPath ?? "unknown"}
252
+
253
+ ${iterationPolicy}
254
+ Worker result:
255
+
256
+ ${markdownFence(JSON.stringify(workerResult ?? null, null, 2), "json")}
257
+ ${previousContextBlock}
258
+ Decision rules:
259
+ ${decisionRules}
260
+
261
+ Reply with only one JSON object, with no Markdown fence or commentary, matching this schema:
262
+ {
263
+ "decision": "complete" | "incomplete" | "blocked" | "failed",
264
+ "complete": boolean,
265
+ "summary": "short reviewer summary",
266
+ "rationale": "why the goal is or is not complete",
267
+ "remainingWork": ["specific remaining item", "..."]
268
+ }`;
269
+ }
270
+
271
+ export function parseGoalReviewerOutput(
272
+ output: string,
273
+ options: { now?: Date } = {},
274
+ ): Omit<GoalReviewerResultState, "reviewerSessionId" | "reviewerSessionFile"> {
275
+ const parsed = parseJsonObjectFromText(output);
276
+ const decision = normalizeDecision(parsed.decision, parsed.complete);
277
+ const complete = decision === "complete";
278
+ const summary = stringField(parsed.summary) || defaultSummary(decision);
279
+ const rationale = stringField(parsed.rationale) || stringField(parsed.reason) || summary;
280
+ const remainingWork = stringArrayField(parsed.remainingWork ?? parsed.remaining ?? parsed.remaining_items);
281
+ return {
282
+ decision,
283
+ complete,
284
+ summary,
285
+ rationale,
286
+ remainingWork: complete ? [] : remainingWork,
287
+ reviewedAt: (options.now ?? new Date()).toISOString(),
288
+ };
289
+ }
290
+
291
+ function enforceMinimumIterationsBeforeCompletion(
292
+ result: GoalReviewerResultState,
293
+ state: GoalLoopState,
294
+ iteration: number,
295
+ ): GoalReviewerResultState {
296
+ if (result.decision !== "complete" && !result.complete) {
297
+ return result;
298
+ }
299
+ if (iteration >= state.limits.minIterations) {
300
+ return result;
301
+ }
302
+
303
+ const remainingIterations = Math.max(1, state.limits.minIterations - iteration);
304
+ const remainingWork = result.remainingWork.length > 0 ? result.remainingWork : minimumIterationRemainingWork(state);
305
+ return {
306
+ ...result,
307
+ decision: "incomplete",
308
+ complete: false,
309
+ summary: `Minimum iteration target not reached (${iteration}/${state.limits.minIterations}); continuing goal loop.`,
310
+ rationale: `${result.rationale}\n\nReviewer completion was deferred because this goal loop requires at least ${state.limits.minIterations} iteration(s) before it may stop. ${remainingIterations} more iteration(s) are required, so the next pass should look for concrete improvements, hardening, verification, and polish rather than stopping early.`,
311
+ remainingWork,
312
+ };
313
+ }
314
+
315
+ function minimumIterationRemainingWork(state: GoalLoopState): string[] {
316
+ return [
317
+ `Continue toward the required minimum of ${state.limits.minIterations} goal-loop iterations before accepting completion.`,
318
+ "Run another pass focused on gaps the previous TODO did not cover: verification depth, edge cases, security, performance, UX polish, documentation, maintainability, and product completeness.",
319
+ "Generate concrete implementation or review tasks from the persisted goal specification and latest evidence instead of stopping after the first apparently complete pass.",
320
+ ];
321
+ }
322
+
323
+ export async function runGoalReviewerSession(options: GoalReviewerRunnerOptions): Promise<GoalReviewerSessionResult> {
324
+ const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
325
+ const events: unknown[] = [];
326
+ let assistantText = "";
327
+ let timedOut = false;
328
+ let aborted = false;
329
+ let error: string | undefined;
330
+ let reviewerCostTotal = 0;
331
+ let session: Awaited<ReturnType<typeof sessionFactory>>["session"] | undefined;
332
+ let unsubscribe: (() => void) | undefined;
333
+ let timeout: ReturnType<typeof setTimeout> | undefined;
334
+
335
+ const abortSession = async (reason: string) => {
336
+ if (!session || aborted) {
337
+ return;
338
+ }
339
+ aborted = true;
340
+ error = error ?? reason;
341
+ await session.abort?.();
342
+ };
343
+ const abortListener = () => {
344
+ void abortSession("reviewer session aborted by outer signal").catch((exc: unknown) => {
345
+ error = error ?? errorMessage(exc);
346
+ });
347
+ };
348
+
349
+ try {
350
+ if (options.abortSignal?.aborted) {
351
+ throw new Error("reviewer session aborted before start");
352
+ }
353
+ const factoryResult = await sessionFactory({
354
+ cwd: options.cwd,
355
+ tools: DEFAULT_WORKER_TOOLS,
356
+ model: options.model,
357
+ modelName: options.modelName,
358
+ thinkingLevel: options.thinkingLevel,
359
+ });
360
+ session = factoryResult.session;
361
+ unsubscribe = session.subscribe((event: unknown) => {
362
+ events.push(event);
363
+ const text = assistantTextFromEvent(event);
364
+ if (text) {
365
+ assistantText = text;
366
+ }
367
+ const cost = workerUsageCostFromEvent(event);
368
+ if (cost !== undefined) {
369
+ reviewerCostTotal = Math.max(reviewerCostTotal, cost);
370
+ }
371
+ });
372
+ options.abortSignal?.addEventListener("abort", abortListener, { once: true });
373
+ if (options.timeoutMs > 0) {
374
+ timeout = setTimeout(() => {
375
+ timedOut = true;
376
+ void abortSession(`reviewer exceeded ${options.timeoutMs}ms timeout`).catch((exc: unknown) => {
377
+ error = error ?? errorMessage(exc);
378
+ });
379
+ }, options.timeoutMs);
380
+ }
381
+ await session.prompt(options.prompt);
382
+ assistantText = latestAssistantText(session, assistantText);
383
+ } catch (exc) {
384
+ error = error ?? errorMessage(exc);
385
+ } finally {
386
+ if (timeout) {
387
+ clearTimeout(timeout);
388
+ }
389
+ options.abortSignal?.removeEventListener("abort", abortListener);
390
+ unsubscribe?.();
391
+ if (session) {
392
+ assistantText = latestAssistantText(session, assistantText);
393
+ const statsCost = session.getSessionStats ? workerUsageCostFromStats(await session.getSessionStats()) : undefined;
394
+ if (statsCost !== undefined) {
395
+ reviewerCostTotal = statsCost;
396
+ }
397
+ session.dispose?.();
398
+ }
399
+ }
400
+
401
+ return {
402
+ assistantText,
403
+ reviewerSessionId: session?.sessionId,
404
+ reviewerSessionFile: session?.sessionFile,
405
+ reviewerCostTotal,
406
+ timedOut,
407
+ aborted: aborted || Boolean(options.abortSignal?.aborted),
408
+ error,
409
+ };
410
+ }
411
+
412
+ function currentReviewableIteration(state: GoalLoopState): GoalIterationState {
413
+ const iteration = currentIteration(state, state.currentIteration);
414
+ if (iteration.status !== "todo_executed" && iteration.status !== "failed") {
415
+ throw new GoalReviewError(
416
+ `Goal iteration ${iteration.iteration} is ${iteration.status}; expected worker result review.`,
417
+ {
418
+ state,
419
+ },
420
+ );
421
+ }
422
+ return iteration;
423
+ }
424
+
425
+ function currentIteration(state: GoalLoopState, iterationNumber: number): GoalIterationState {
426
+ const iteration = state.iterations.find((item) => item.iteration === iterationNumber);
427
+ if (!iteration) {
428
+ throw new GoalReviewError(`Goal iteration ${iterationNumber || "<none>"} does not exist.`, { state });
429
+ }
430
+ return iteration;
431
+ }
432
+
433
+ async function recordReviewFailure(options: {
434
+ state: GoalLoopState;
435
+ iteration: GoalIterationState;
436
+ store: GoalStateStore;
437
+ previousTraceLength: number;
438
+ payloadPath: string;
439
+ rawReviewPath: string;
440
+ message: string;
441
+ error: unknown;
442
+ rawReviewerOutput?: string;
443
+ sessionResult?: GoalReviewerSessionResult;
444
+ now: () => Date;
445
+ }): Promise<{ state: GoalLoopState; reviewerResult: GoalReviewerResultState }> {
446
+ if (options.rawReviewerOutput !== undefined) {
447
+ await writeFile(options.rawReviewPath, options.rawReviewerOutput, "utf8");
448
+ }
449
+ const timestamp = options.now().toISOString();
450
+ const reviewerResult: GoalReviewerResultState = {
451
+ decision: "failed",
452
+ complete: false,
453
+ summary: options.message,
454
+ rationale: options.message,
455
+ remainingWork: [],
456
+ reviewerSessionId: options.sessionResult?.reviewerSessionId,
457
+ reviewerSessionFile: options.sessionResult?.reviewerSessionFile,
458
+ payloadPath: options.payloadPath,
459
+ rawReviewPath: options.rawReviewPath,
460
+ reviewerCostTotal: options.sessionResult?.reviewerCostTotal,
461
+ error: errorMessage(options.error),
462
+ reviewedAt: timestamp,
463
+ };
464
+ const state = recordReviewerResult(options.state, options.iteration.iteration, reviewerResult, {
465
+ now: options.now(),
466
+ });
467
+ await persistStateChange(options.store, options.previousTraceLength, state);
468
+ const updatedIteration = currentIteration(state, options.iteration.iteration);
469
+ await options.store.writeIterationSnapshot(updatedIteration);
470
+ await options.store.appendIterationResult(updatedIteration);
471
+ return { state, reviewerResult };
472
+ }
473
+
474
+ async function persistStateChange(
475
+ store: GoalStateStore,
476
+ previousTraceLength: number,
477
+ state: GoalLoopState,
478
+ ): Promise<void> {
479
+ await store.saveState(state);
480
+ await store.appendNewTraceEvents(previousTraceLength, state);
481
+ }
482
+
483
+ function buildGoalSpecificationReviewContext(spec: GoalSpecification, goalSpecificationPath?: string): string {
484
+ return [
485
+ `Goal spec path: ${goalSpecificationPath ?? "<not provided>"}`,
486
+ "",
487
+ goalSpecificationToMarkdown(spec).trim(),
488
+ "",
489
+ "Reviewer evaluation instructions:",
490
+ "- Treat this persisted specification as the source of truth for final evaluation.",
491
+ "- Check in-scope requirements, milestones, acceptance criteria, required verification gates, required artifacts, and design/product constraints before deciding complete.",
492
+ "- Use the original user goal only as traceability context when interpreting the specification.",
493
+ "- Cite specific spec IDs or named criteria in rationale and remainingWork whenever a criterion is satisfied, missing, blocked, or not applicable.",
494
+ ].join("\n");
495
+ }
496
+
497
+ function previousReviewContext(state: GoalLoopState, currentIterationNumber: number): string {
498
+ return state.iterations
499
+ .filter((iteration) => iteration.iteration < currentIterationNumber)
500
+ .map((iteration) => {
501
+ const lines = [`Iteration ${iteration.iteration}: ${iteration.status}`];
502
+ if (iteration.workerResult) {
503
+ lines.push(`Worker: ${iteration.workerResult.status} — ${iteration.workerResult.summary}`);
504
+ }
505
+ if (iteration.reviewerResult) {
506
+ lines.push(`Reviewer: ${iteration.reviewerResult.decision} — ${iteration.reviewerResult.rationale}`);
507
+ if (iteration.reviewerResult.remainingWork.length > 0) {
508
+ lines.push("Remaining work:", ...iteration.reviewerResult.remainingWork.map((item) => `- ${item}`));
509
+ }
510
+ }
511
+ return lines.join("\n");
512
+ })
513
+ .join("\n\n");
514
+ }
515
+
516
+ function parseJsonObjectFromText(text: string): Record<string, unknown> {
517
+ const trimmed = text.trim();
518
+ if (!trimmed) {
519
+ throw new GoalReviewError("Reviewer output was empty.");
520
+ }
521
+
522
+ for (const candidate of jsonCandidates(trimmed)) {
523
+ try {
524
+ const parsed = JSON.parse(candidate) as unknown;
525
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
526
+ return parsed as Record<string, unknown>;
527
+ }
528
+ } catch {
529
+ // Try the next candidate.
530
+ }
531
+ }
532
+ throw new GoalReviewError("Reviewer output did not contain a JSON object.");
533
+ }
534
+
535
+ function jsonCandidates(text: string): string[] {
536
+ const candidates = [text];
537
+ const fenced = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(text);
538
+ if (fenced?.[1]) {
539
+ candidates.push(fenced[1].trim());
540
+ }
541
+ const start = text.indexOf("{");
542
+ const end = text.lastIndexOf("}");
543
+ if (start >= 0 && end > start) {
544
+ candidates.push(text.slice(start, end + 1));
545
+ }
546
+ return candidates;
547
+ }
548
+
549
+ function normalizeDecision(decisionValue: unknown, completeValue: unknown): GoalReviewerDecision {
550
+ const decision = typeof decisionValue === "string" ? decisionValue.trim().toLowerCase() : "";
551
+ if (decision === "complete" || decision === "incomplete" || decision === "blocked" || decision === "failed") {
552
+ return decision;
553
+ }
554
+ if (typeof completeValue === "boolean") {
555
+ return completeValue ? "complete" : "incomplete";
556
+ }
557
+ throw new GoalReviewError("Reviewer JSON must include decision or complete.");
558
+ }
559
+
560
+ function stringField(value: unknown): string {
561
+ return typeof value === "string" ? value.trim() : "";
562
+ }
563
+
564
+ function stringArrayField(value: unknown): string[] {
565
+ if (Array.isArray(value)) {
566
+ return value.map((item) => String(item).trim()).filter(Boolean);
567
+ }
568
+ if (typeof value === "string" && value.trim()) {
569
+ return [value.trim()];
570
+ }
571
+ return [];
572
+ }
573
+
574
+ function defaultSummary(decision: GoalReviewerDecision): string {
575
+ switch (decision) {
576
+ case "complete":
577
+ return "Reviewer confirmed the goal is complete.";
578
+ case "incomplete":
579
+ return "Reviewer found remaining work.";
580
+ case "blocked":
581
+ return "Reviewer found the goal is blocked.";
582
+ case "failed":
583
+ return "Reviewer found the goal loop failed.";
584
+ }
585
+ }
586
+
587
+ function latestAssistantText(
588
+ session: { getLastAssistantText?: () => string | undefined; messages?: unknown[] },
589
+ fallback: string,
590
+ ): string {
591
+ return session.getLastAssistantText?.() || lastAssistantTextFromMessages(session.messages) || fallback;
592
+ }
593
+
594
+ function markdownFence(value: string, language: string): string {
595
+ const ticks = longestBacktickRun(value) + 1;
596
+ const fence = "`".repeat(Math.max(3, ticks));
597
+ return `${fence}${language}\n${value.trim()}\n${fence}`;
598
+ }
599
+
600
+ function longestBacktickRun(value: string): number {
601
+ let longest = 0;
602
+ let current = 0;
603
+ for (const char of value) {
604
+ if (char === "`") {
605
+ current += 1;
606
+ longest = Math.max(longest, current);
607
+ } else {
608
+ current = 0;
609
+ }
610
+ }
611
+ return longest;
612
+ }
613
+
614
+ function errorMessage(error: unknown): string {
615
+ return error instanceof Error ? error.message : String(error);
616
+ }