@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +239 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +330 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,239 @@
1
+ import { createHash } from "node:crypto";
2
+ import { asRecord } from "./goal-record.ts";
3
+
4
+ /**
5
+ * Prefix-stable retention for request-only goal-state tails.
6
+ *
7
+ * The context hook appends live goal state to every provider request without
8
+ * persisting it. When the next request arrives, the completed assistant turn
9
+ * occupies the position where the previous tail sat, so request N is no
10
+ * longer a prefix of request N+1 and implicit prompt caches (OpenAI Responses
11
+ * / Chat Completions, which carry no explicit marker) freeze at the first
12
+ * injection point.
13
+ *
14
+ * This module makes each request a true prefix of the next: previously sent
15
+ * tails are re-inserted verbatim at their recorded anchor (the session length
16
+ * when they were emitted), the whole preceding history is verified unchanged
17
+ * by digest, and only genuinely new tail content is appended. Identical
18
+ * content across repeated requests appends nothing, so tool loops and idle
19
+ * re-requests cost zero growth. A stable-state change, any history rewrite
20
+ * (compaction, resume, session-tree moves, filter changes, middle-message
21
+ * edits), an unsafe insertion point, or overflow all perform one documented
22
+ * full reset instead of silently shifting the prefix or corrupting message
23
+ * structure.
24
+ *
25
+ * Task snapshots and usage counters are separate tail kinds. Each is appended
26
+ * only when it differs from its previous emission, so task progress extends
27
+ * the prefix instead of resetting it; only the policy block forces a reset.
28
+ */
29
+
30
+ export const LIVE_CONTEXT_TYPE = "pi-goal-live-context";
31
+
32
+ export type LiveTailKind = "goal-state" | "goal-tasks" | "goal-counters";
33
+
34
+ /** A request-only tail message. Never persisted; the hook replays it per request. */
35
+ export interface LiveMessage {
36
+ role: "custom";
37
+ customType: typeof LIVE_CONTEXT_TYPE;
38
+ content: string;
39
+ display: false;
40
+ timestamp: 0;
41
+ }
42
+
43
+ /**
44
+ * Upper bound on retained tails per session. Retained tails are small (the
45
+ * per-turn volatile counters), so the steady-state wire overhead stays near
46
+ * a few kilobytes; exceeding the bound performs a single full reset, i.e. at
47
+ * most one prefix break per MAX_RETAINED_LIVE_TAILS state changes.
48
+ */
49
+ export const MAX_RETAINED_LIVE_TAILS = 32;
50
+
51
+ /** Upper bound on concurrently tracked sessions (parent plus isolated runs). */
52
+ export const MAX_TRACKED_SESSIONS = 16;
53
+ /** Extra historical observations, excluding the single current policy block. */
54
+ export const MAX_RETAINED_COUNTER_BYTES = 4096;
55
+ /**
56
+ * Retained task snapshots. Each task transition appends one snapshot, so a
57
+ * long task run stays prefix-stable until this bound forces a single reset.
58
+ */
59
+ export const MAX_RETAINED_TASK_BYTES = 16_384;
60
+
61
+ export interface FreshLiveTails {
62
+ state: string;
63
+ tasks?: string | undefined;
64
+ counters?: string | undefined;
65
+ }
66
+
67
+ interface LiveBlock {
68
+ content: string;
69
+ kind: LiveTailKind;
70
+ }
71
+
72
+ interface RetainedTail extends LiveBlock {
73
+ anchorIndex: number;
74
+ anchorFingerprint: string | null;
75
+ }
76
+
77
+ interface SessionRetention {
78
+ tails: RetainedTail[];
79
+ lastFresh: FreshLiveTails | undefined;
80
+ /** Digest of every base message at the last apply; any earlier rewrite resets. */
81
+ baseFingerprints: Array<string | null>;
82
+ }
83
+
84
+ function freshBlocks(fresh: FreshLiveTails): LiveBlock[] {
85
+ const blocks: LiveBlock[] = [{ content: fresh.state, kind: "goal-state" }];
86
+ if (fresh.tasks) blocks.push({ content: fresh.tasks, kind: "goal-tasks" });
87
+ if (fresh.counters !== undefined) blocks.push({ content: fresh.counters, kind: "goal-counters" });
88
+ return blocks;
89
+ }
90
+
91
+ function kindBytes(blocks: readonly LiveBlock[], kind: LiveTailKind): number {
92
+ return blocks.reduce((n, block) => block.kind === kind ? n + Buffer.byteLength(block.content) : n, 0);
93
+ }
94
+
95
+ /** Full-message digest, or null (never a match) when serialization fails. */
96
+ function fingerprintMessage(message: unknown): string | null {
97
+ try {
98
+ const serialized = JSON.stringify(message) ?? "undefined";
99
+ return createHash("sha256").update(serialized).digest("base64");
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Tool results must stay adjacent to the call they answer, so a retained tail
107
+ * may never be inserted directly before one. Covers the Pi session role
108
+ * (`toolResult`), the provider wire role (`tool`, as seen by marker
109
+ * relocation), and result-bearing content blocks in either vocabulary — the
110
+ * call side needs no guard since inserting before a call keeps the pair
111
+ * adjacent.
112
+ */
113
+ function splitsToolBatch(next: unknown): boolean {
114
+ const record = asRecord(next);
115
+ const role = record?.role;
116
+ if (role === "toolResult" || role === "tool") return true;
117
+ const content = record?.content;
118
+ if (Array.isArray(content)) {
119
+ for (const block of content) {
120
+ const entry = asRecord(block);
121
+ if (!entry) continue;
122
+ const type = typeof entry.type === "string" ? entry.type : "";
123
+ if (type === "tool_result" || type === "toolResult") return true;
124
+ if ("toolCallId" in entry || "toolUseId" in entry) return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+
130
+ function makeLiveMessage(content: string): LiveMessage {
131
+ return {
132
+ role: "custom",
133
+ customType: LIVE_CONTEXT_TYPE,
134
+ content,
135
+ display: false,
136
+ timestamp: 0,
137
+ };
138
+ }
139
+
140
+ export class LiveTailRetention {
141
+ private readonly sessions = new Map<string, SessionRetention>();
142
+
143
+ apply<T>(sessionKey: string, base: readonly T[], fresh: FreshLiveTails | null): { messages: Array<T | LiveMessage>; transientContents: string[] } {
144
+ if (fresh === null) {
145
+ this.sessions.delete(sessionKey);
146
+ return { messages: [...base], transientContents: [] };
147
+ }
148
+ // Hash each message once per request. Extensions may reuse and mutate
149
+ // message objects, so object identity cannot cache a digest across requests.
150
+ const fingerprints = base.map(fingerprintMessage);
151
+ let session = this.sessions.get(sessionKey);
152
+ if (!session) {
153
+ session = { tails: [], lastFresh: undefined, baseFingerprints: [] };
154
+ if (this.sessions.size >= MAX_TRACKED_SESSIONS) {
155
+ const oldest = this.sessions.keys().next();
156
+ if (!oldest.done) this.sessions.delete(oldest.value);
157
+ }
158
+ this.sessions.set(sessionKey, session);
159
+ }
160
+ const reset = () => {
161
+ session!.tails = [];
162
+ session!.lastFresh = undefined;
163
+ session!.baseFingerprints = [];
164
+ };
165
+ // A changed state block supersedes every previously sent tail: start
166
+ // fresh rather than leave a stale objective or a cancelled scheduling
167
+ // instruction lingering mid-history. A dropped task snapshot or counter
168
+ // block resets too, so its last copy never reads as the current one.
169
+ if (session.lastFresh !== undefined && (session.lastFresh.state !== fresh.state
170
+ || (session.lastFresh.tasks !== undefined && !fresh.tasks)
171
+ || (session.lastFresh.counters !== undefined && fresh.counters === undefined))) reset();
172
+ // Tails anchored while history was empty would linger at the head once
173
+ // real history arrives. Reset once so fresh tails anchor at the tail.
174
+ if (session.tails.length > 0 && session.baseFingerprints.length === 0 && base.length > 0) reset();
175
+ // Any rewrite earlier than the anchors — middle-message edits, filter
176
+ // changes, checkpoint normalization — breaks the prefix even when the
177
+ // trailing message is identical, so verify the whole preceding history.
178
+ if (session.baseFingerprints.length > base.length) reset();
179
+ else {
180
+ for (let i = 0; i < session.baseFingerprints.length; i++) {
181
+ const current = fingerprints[i];
182
+ if (current === null || current !== session.baseFingerprints[i]) {
183
+ reset();
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ // An unsafe anchor invalidates the whole replay. Keeping an earlier
189
+ // policy while re-appending fresh blocks would duplicate that policy.
190
+ let valid = 0;
191
+ while (valid < session.tails.length) {
192
+ const tail = session.tails[valid]!;
193
+ if (tail.anchorIndex > base.length) break;
194
+ if (tail.anchorIndex > 0) {
195
+ const current = fingerprints[tail.anchorIndex - 1];
196
+ if (current === null || current !== tail.anchorFingerprint) break;
197
+ }
198
+ if (splitsToolBatch(base[tail.anchorIndex])) break;
199
+ valid++;
200
+ }
201
+ if (valid < session.tails.length) reset();
202
+ const blocks = freshBlocks(fresh);
203
+ // Append only blocks that differ from the last emitted block of the same
204
+ // kind, so unchanged content across repeats, tool loops, and history
205
+ // advances adds zero blocks while keeping the prefix literal. The state
206
+ // block never differs here: a changed state already reset above.
207
+ const previous = session.lastFresh === undefined ? [] : freshBlocks(session.lastFresh);
208
+ let appended = blocks.filter(block => !previous.some(prior => prior.kind === block.kind && prior.content === block.content));
209
+ // Overflow is measured against what this request actually appends: an
210
+ // unchanged request adds nothing and must never trigger a reset.
211
+ if (session.tails.length + appended.length > MAX_RETAINED_LIVE_TAILS
212
+ || kindBytes(session.tails, "goal-counters") + kindBytes(appended, "goal-counters") > MAX_RETAINED_COUNTER_BYTES
213
+ || kindBytes(session.tails, "goal-tasks") + kindBytes(appended, "goal-tasks") > MAX_RETAINED_TASK_BYTES) {
214
+ reset();
215
+ appended = blocks;
216
+ }
217
+ const messages: Array<T | LiveMessage> = [...base];
218
+ for (let i = session.tails.length - 1; i >= 0; i--) {
219
+ const tail = session.tails[i]!;
220
+ messages.splice(tail.anchorIndex, 0, makeLiveMessage(tail.content));
221
+ }
222
+ for (const block of appended) {
223
+ messages.push(makeLiveMessage(block.content));
224
+ session.tails.push({ anchorIndex: base.length, anchorFingerprint: fingerprints.at(-1) ?? null, content: block.content, kind: block.kind });
225
+ }
226
+ session.baseFingerprints = fingerprints;
227
+ session.lastFresh = { state: fresh.state, tasks: fresh.tasks || undefined, counters: fresh.counters };
228
+ return { messages, transientContents: [...new Set([...session.tails.map(tail => tail.content), ...blocks.map(block => block.content)])] };
229
+ }
230
+
231
+ contents(sessionKey: string): string[] | undefined {
232
+ return this.sessions.get(sessionKey)?.tails.map(tail => tail.content);
233
+ }
234
+
235
+ clear(sessionKey?: string): void {
236
+ if (sessionKey === undefined) this.sessions.clear();
237
+ else this.sessions.delete(sessionKey);
238
+ }
239
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Issue #26 — opt-in read-only Blocker Oracle.
3
+ *
4
+ * A cheap executor can benefit from ONE stronger, read-only consultation
5
+ * before giving up and asking the user. Hard safety properties (plan §64):
6
+ *
7
+ * - disabled by default; requires an explicit resolvable provider/model;
8
+ * - invoked only through a valid update_goal({status:"blocked"}) request;
9
+ * - the nested session gets ONLY read/grep/find/ls + a structured submit
10
+ * tool — no write/edit/bash, ever;
11
+ * - one successful consultation per blocker fingerprint (usage, timestamps,
12
+ * and revision never change the fingerprint);
13
+ * - actionable advice keeps the goal ACTIVE and arms a follow-up marker;
14
+ * blocking is only permitted after a meaningful work attempt or when the
15
+ * Oracle concludes needs_human/insufficient_context;
16
+ * - every phase is durably recorded in the ledger with bounded payloads.
17
+ */
18
+
19
+ import { Type } from "@earendil-works/pi-ai";
20
+ import { createHash } from "node:crypto";
21
+
22
+ import { defineTool, SessionManager, SettingsManager, type AgentToolResult, type ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+
24
+ import { makeAuditorResourceLoader, resolveAuditorSessionModelOptions } from "./goal-auditor.ts";
25
+ import type { GoalLedgerEvent } from "./goal-ledger.ts";
26
+ import type { ResolvedGoalOracleSettings } from "./goal-settings.ts";
27
+ import { safeIdPart, type GoalRecord } from "./goal-record.ts";
28
+
29
+ function escapePromptPayload(value: string): string {
30
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
31
+ }
32
+
33
+ // ── blocker fingerprint ─────────────────────────────────────────────────────
34
+
35
+ function normalizeBlockerReason(reason: string): string {
36
+ return reason.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
37
+ }
38
+
39
+ /** Stable short hash over task-tree shape + contracts (not usage/timestamps). */
40
+ function taskStateFingerprint(goal: GoalRecord): string {
41
+ const lines: string[] = [];
42
+ const walk = (tasks: NonNullable<GoalRecord["taskList"]>["tasks"], depth: number): void => {
43
+ for (const t of tasks) {
44
+ lines.push(`${depth}:${t.id}:${t.status}:${t.verificationContract ?? ""}`);
45
+ if (t.subtasks) walk(t.subtasks, depth + 1);
46
+ }
47
+ };
48
+ if (goal.taskList) walk(goal.taskList.tasks, 0);
49
+ return createHash("sha256").update(lines.join("|")).digest("hex").slice(0, 16);
50
+ }
51
+
52
+ export interface BlockerFingerprintInput {
53
+ goalId: string;
54
+ objectiveHash: string;
55
+ verificationContractHash: string;
56
+ taskStateHash: string;
57
+ currentTaskId?: string;
58
+ normalizedReason: string;
59
+ }
60
+
61
+ /**
62
+ * Distinguishes materially different blockers without changing when usage,
63
+ * elapsed time, or revision counters change.
64
+ */
65
+ export function buildBlockerFingerprint(goal: GoalRecord, reason: string): string {
66
+ const input: BlockerFingerprintInput = {
67
+ goalId: goal.id,
68
+ objectiveHash: createHash("sha256").update(goal.objective).digest("hex").slice(0, 16),
69
+ verificationContractHash: createHash("sha256").update(goal.verificationContract ?? "").digest("hex").slice(0, 16),
70
+ taskStateHash: taskStateFingerprint(goal),
71
+ currentTaskId: goal.currentTaskId,
72
+ normalizedReason: normalizeBlockerReason(reason),
73
+ };
74
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 24);
75
+ }
76
+
77
+ // ── ledger reconstruction ───────────────────────────────────────────────────
78
+
79
+ export interface OracleConsultState {
80
+ result?: { adviceId: string; disposition: "actionable" | "needs_human" | "insufficient_context"; summary: string };
81
+ failedAttempts: number;
82
+ lastFailure?: { errorCode: "config" | "provider" | "aborted" | "invalid_output"; message: string };
83
+ followupAttempted: boolean;
84
+ }
85
+
86
+ /** Reconstruct per-fingerprint consult state from raw ledger events. */
87
+ export function oracleStateForFingerprint(events: readonly GoalLedgerEvent[], goalId: string, fingerprint: string): OracleConsultState {
88
+ const state: OracleConsultState = { failedAttempts: 0, followupAttempted: false };
89
+ for (const event of events) {
90
+ if (event.type !== "oracle_started" && event.type !== "oracle_result" && event.type !== "oracle_failed" && event.type !== "oracle_followup_attempted") continue;
91
+ if (event.goalId !== goalId || event.fingerprint !== fingerprint) continue;
92
+ switch (event.type) {
93
+ case "oracle_result":
94
+ state.result = { adviceId: event.adviceId, disposition: event.disposition, summary: event.summary };
95
+ break;
96
+ case "oracle_failed":
97
+ state.failedAttempts += 1;
98
+ state.lastFailure = { errorCode: event.errorCode, message: event.message };
99
+ break;
100
+ case "oracle_followup_attempted":
101
+ state.followupAttempted = true;
102
+ break;
103
+ default:
104
+ break;
105
+ }
106
+ }
107
+ return state;
108
+ }
109
+
110
+ // ── structured advice + isolated session ────────────────────────────────────
111
+
112
+ const OracleAdviceSchema = Type.Object({
113
+ diagnosis: Type.String({ maxLength: 2000 }),
114
+ alternatives: Type.Array(
115
+ Type.Object({
116
+ title: Type.String({ maxLength: 200 }),
117
+ rationale: Type.String({ maxLength: 1000 }),
118
+ steps: Type.Array(Type.String({ maxLength: 400 }), { minItems: 1, maxItems: 8 }),
119
+ expectedEvidence: Type.Array(Type.String({ maxLength: 400 }), { maxItems: 8 }),
120
+ }),
121
+ { minItems: 1, maxItems: 4 },
122
+ ),
123
+ recommendedIndex: Type.Integer({ minimum: 0, maximum: 3 }),
124
+ unresolvedQuestions: Type.Array(Type.String({ maxLength: 400 }), { maxItems: 6 }),
125
+ disposition: Type.Union([
126
+ Type.Literal("actionable"),
127
+ Type.Literal("needs_human"),
128
+ Type.Literal("insufficient_context"),
129
+ ]),
130
+ });
131
+
132
+ export type OracleAdvice = {
133
+ diagnosis: string;
134
+ alternatives: Array<{ title: string; rationale: string; steps: string[]; expectedEvidence: string[] }>;
135
+ recommendedIndex: number;
136
+ unresolvedQuestions: string[];
137
+ disposition: "actionable" | "needs_human" | "insufficient_context";
138
+ };
139
+
140
+ export type OracleRunResult =
141
+ | { ok: true; advice: OracleAdvice }
142
+ | { ok: false; errorCode: "config" | "provider" | "aborted" | "invalid_output"; message: string };
143
+
144
+ export function buildBlockerOraclePrompt(args: {
145
+ goal: GoalRecord;
146
+ reason: string;
147
+ attemptedActions: string[];
148
+ recentEvidence: string;
149
+ }): string {
150
+ const g = args.goal;
151
+ return [
152
+ "You are a read-only blocker adviser.",
153
+ "Find concrete alternative paths. Do not mutate files.",
154
+ "Do not mark the goal complete or blocked.",
155
+ "Return advice only through submit_goal_oracle_advice.",
156
+ "",
157
+ "<objective>",
158
+ escapePromptPayload(g.objective),
159
+ "</objective>",
160
+ g.verificationContract?.trim() ? `<verification_contract>\n${escapePromptPayload(g.verificationContract.trim())}\n</verification_contract>` : "",
161
+ `<blocker_reason>\n${escapePromptPayload(args.reason)}\n</blocker_reason>`,
162
+ args.attemptedActions.length > 0 ? `<attempted_actions>\n${args.attemptedActions.map((a) => `- ${a}`).join("\n")}\n</attempted_actions>` : "",
163
+ args.recentEvidence.trim() ? `<recent_evidence>\n${escapePromptPayload(args.recentEvidence.slice(0, 2000))}\n</recent_evidence>` : "",
164
+ "",
165
+ "Inspect relevant files with read-only tools before advising when needed.",
166
+ ].filter(Boolean).join("\n\n");
167
+ }
168
+
169
+ /** Validate untrusted submitted advice against the bounded schema semantics. */
170
+ function validateAdvice(value: unknown): OracleAdvice | { error: string } {
171
+ const raw = value as Record<string, unknown> | null;
172
+ if (!raw || typeof raw !== "object") return { error: "advice must be an object" };
173
+ if (typeof raw.diagnosis !== "string" || !raw.diagnosis.trim()) return { error: "diagnosis required" };
174
+ if (!Array.isArray(raw.alternatives) || raw.alternatives.length < 1 || raw.alternatives.length > 4) {
175
+ return { error: "1-4 alternatives required" };
176
+ }
177
+ for (const alt of raw.alternatives as Array<Record<string, unknown>>) {
178
+ if (typeof alt.title !== "string" || typeof alt.rationale !== "string") return { error: "alternative title/rationale required" };
179
+ if (!Array.isArray(alt.steps) || alt.steps.length < 1 || alt.steps.some((s) => typeof s !== "string")) return { error: "steps must be non-empty strings" };
180
+ }
181
+ if (typeof raw.recommendedIndex !== "number" || raw.recommendedIndex < 0 || raw.recommendedIndex >= (raw.alternatives as unknown[]).length) {
182
+ return { error: "recommendedIndex out of range" };
183
+ }
184
+ if (raw.disposition !== "actionable" && raw.disposition !== "needs_human" && raw.disposition !== "insufficient_context") {
185
+ return { error: "disposition must be actionable|needs_human|insufficient_context" };
186
+ }
187
+ return value as OracleAdvice;
188
+ }
189
+
190
+ /** Resolve the EXPLICIT Oracle model; provider-only or missing config is refused. */
191
+ export function resolveOracleModel(
192
+ ctx: ExtensionContext,
193
+ settings: ResolvedGoalOracleSettings,
194
+ ): { ok: true; model: unknown } | { ok: false; message: string } {
195
+ if (!settings.provider || !settings.model) {
196
+ return {
197
+ ok: false,
198
+ message: "The blocker Oracle is enabled but not fully configured: set both oracle.provider and oracle.model in settings. The executor model is NOT used silently.",
199
+ };
200
+ }
201
+ const found = (ctx.modelRegistry as unknown as { find?: (provider: string, id: string) => unknown })?.find?.(settings.provider, settings.model);
202
+ if (!found) {
203
+ return { ok: false, message: `Configured Oracle model not found: ${settings.provider}/${settings.model}.` };
204
+ }
205
+ return { ok: true, model: found };
206
+ }
207
+
208
+ /**
209
+ * Run ONE isolated read-only Oracle consultation. `createSession` is
210
+ * injectable for tests (same seam as the completion auditor).
211
+ */
212
+ export async function runBlockerOracle(args: {
213
+ ctx: ExtensionContext;
214
+ goal: GoalRecord;
215
+ reason: string;
216
+ attemptedActions: string[];
217
+ settings: ResolvedGoalOracleSettings;
218
+ recentEvidence: string;
219
+ signal?: AbortSignal;
220
+ createSession?: (...args: unknown[]) => Promise<{ session: OracleSessionFactory }>;
221
+ }): Promise<OracleRunResult> {
222
+ const model = resolveOracleModel(args.ctx, args.settings);
223
+ if (!model.ok) return { ok: false, errorCode: "config", message: model.message };
224
+
225
+ const submitted: { settled: boolean; advice?: OracleAdvice } = { settled: false };
226
+ const submitTool = defineTool({
227
+ name: "submit_goal_oracle_advice",
228
+ label: "Submit Oracle Advice",
229
+ description: "Submit your structured blocker advice. This is the ONLY way to return a result.",
230
+ promptSnippet: "Submit structured advice via submit_goal_oracle_advice.",
231
+ parameters: OracleAdviceSchema,
232
+ async execute(_toolCallId, params) {
233
+ const validated = validateAdvice(params);
234
+ if ("error" in validated) {
235
+ return { content: [{ type: "text", text: `Invalid advice: ${validated.error}` }], details: {} };
236
+ }
237
+ submitted.advice = validated;
238
+ submitted.settled = true;
239
+ return { content: [{ type: "text", text: "Advice recorded." }], details: {} };
240
+ },
241
+ });
242
+
243
+ try {
244
+ const createSession = args.createSession ?? (await import("@earendil-works/pi-coding-agent")).createAgentSession as unknown as (...sessionArgs: unknown[]) => Promise<{ session: OracleSessionFactory }>;
245
+ // Read-only tool profile: NO bash/write/edit is exposed to the Oracle.
246
+ const { session } = await createSession({
247
+ cwd: args.ctx.cwd,
248
+ model: model.model,
249
+ thinkingLevel: args.settings.thinkingLevel,
250
+ ...resolveAuditorSessionModelOptions(args.ctx),
251
+ sessionManager: SessionManager.inMemory(args.ctx.cwd),
252
+ settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
253
+ ...(!args.settings.projectResources ? { resourceLoader: makeAuditorResourceLoader(
254
+ "You are a read-only blocker adviser. Inspect evidence and suggest concrete alternative paths. Never modify files or decide goal outcomes. Return advice through submit_goal_oracle_advice.",
255
+ ) } : {}),
256
+ tools: ["read", "grep", "find", "ls", "submit_goal_oracle_advice"],
257
+ customTools: [submitTool],
258
+ });
259
+
260
+ const abort = () => session.abort();
261
+ args.signal?.addEventListener("abort", abort, { once: true });
262
+ try {
263
+ if (args.signal?.aborted) return { ok: false, errorCode: "aborted", message: "Oracle consultation aborted." };
264
+ await session.prompt(buildBlockerOraclePrompt({
265
+ goal: args.goal,
266
+ reason: args.reason,
267
+ attemptedActions: args.attemptedActions.slice(0, 8),
268
+ recentEvidence: args.recentEvidence,
269
+ }));
270
+ } finally {
271
+ args.signal?.removeEventListener("abort", abort);
272
+ session.dispose?.();
273
+ }
274
+
275
+ if (args.signal?.aborted) return { ok: false, errorCode: "aborted", message: "Oracle consultation aborted." };
276
+ if (!submitted.settled || !submitted.advice) {
277
+ return { ok: false, errorCode: "invalid_output", message: "Oracle returned no structured advice." };
278
+ }
279
+ return { ok: true, advice: submitted.advice };
280
+ } catch (error) {
281
+ if (args.signal?.aborted) return { ok: false, errorCode: "aborted", message: "Oracle consultation aborted." };
282
+ return { ok: false, errorCode: "provider", message: error instanceof Error ? error.message : String(error) };
283
+ }
284
+ }
285
+
286
+ type OracleSessionFactory = {
287
+ prompt: (promptText: string) => Promise<void>;
288
+ abort: () => void;
289
+ dispose?: () => void;
290
+ subscribe?: (listener: (event: unknown) => void) => () => void;
291
+ };
292
+
293
+ // ── armed follow-up marker (session-scoped) ────────────────────────────────
294
+
295
+ interface ArmedAdvice {
296
+ goalId: string;
297
+ fingerprint: string;
298
+ adviceId: string;
299
+ text: string;
300
+ }
301
+
302
+ const armedByGoalId = new Map<string, ArmedAdvice>();
303
+
304
+ export function armOracleAdvice(goalId: string, fingerprint: string, advice: OracleAdvice): string {
305
+ const adviceId = `${fingerprint}-${safeIdPart(advice.alternatives[advice.recommendedIndex]?.title ?? "advice").slice(0, 24)}`;
306
+ const recommended = advice.alternatives[advice.recommendedIndex]!;
307
+ armedByGoalId.set(goalId, {
308
+ goalId,
309
+ fingerprint,
310
+ adviceId,
311
+ text: [
312
+ "Oracle suggested an alternative path:",
313
+ `${recommended.title} — ${recommended.rationale}`,
314
+ "Steps:",
315
+ ...recommended.steps.map((s, i) => ` ${i + 1}. ${s}`),
316
+ recommended.expectedEvidence.length > 0 ? `Expected evidence: ${recommended.expectedEvidence.join("; ")}` : "",
317
+ "Attempt this path before reporting blocked again.",
318
+ ].filter(Boolean).join("\n"),
319
+ });
320
+ return adviceId;
321
+ }
322
+
323
+ export function hasPendingOracleAdviceForFocusedGoal(goalId: string): boolean {
324
+ return armedByGoalId.has(goalId);
325
+ }
326
+
327
+ /** Consume the armed marker after a meaningful work attempt. */
328
+ export function consumeOracleFollowupMarker(goalId: string): ArmedAdvice | undefined {
329
+ const armed = armedByGoalId.get(goalId);
330
+ armedByGoalId.delete(goalId);
331
+ return armed;
332
+ }
333
+
334
+ export function renderActionableOracleAdvice(advice: OracleAdvice): string {
335
+ const recommended = advice.alternatives[advice.recommendedIndex]!;
336
+ return [
337
+ `Oracle diagnosis: ${advice.diagnosis}`,
338
+ `Recommended alternative: ${recommended.title} — ${recommended.rationale}`,
339
+ ...recommended.steps.map((s, i) => ` ${i + 1}. ${s}`),
340
+ "Try this path before calling update_goal({status:\"blocked\"}) again.",
341
+ ].join("\n");
342
+ }
343
+
344
+ export function renderOracleAdviceReminder(armed: ArmedAdvice): string {
345
+ return `${armed.text}\n(The same blocker fingerprint already received Oracle advice; no new consultation will run until you attempt it.)`;
346
+ }