@hobin/developer 0.1.6 → 0.1.8

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.
@@ -4,80 +4,85 @@ import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { StringEnum } from "@earendil-works/pi-ai";
6
6
  import {
7
- DEFAULT_MAX_BYTES,
8
- DEFAULT_MAX_LINES,
9
- getMarkdownTheme,
10
- keyHint,
11
- type ExtensionAPI,
12
- type ExtensionCommandContext,
13
- type ExtensionContext,
14
- type Skill,
15
- type Theme,
16
- type ThemeColor,
7
+ DEFAULT_MAX_BYTES,
8
+ DEFAULT_MAX_LINES,
9
+ getMarkdownTheme,
10
+ keyHint,
11
+ type ExtensionAPI,
12
+ type ExtensionCommandContext,
13
+ type ExtensionContext,
14
+ type Skill,
15
+ type Theme,
16
+ type ThemeColor,
17
17
  } from "@earendil-works/pi-coding-agent";
18
18
  import { Container, Markdown, Text } from "@earendil-works/pi-tui";
19
19
  import { Type, type Static } from "typebox";
20
20
 
21
21
  import { availablePackageSkills, renderSkillMethod } from "./skills.ts";
22
22
  import {
23
- FOCUS_ENTRY,
24
- JUDGMENT_TOOL,
25
- MAX_RESPONSE_FIELDS,
26
- MAX_RESPONSE_IDENTIFIER_CHARS,
27
- MAX_RESPONSE_OPTIONS,
28
- MAX_RESPONSE_TEXT_CHARS,
29
- MODE_ENTRY,
30
- PROTOCOL,
31
- ROUTE_TOOL,
32
- applyDeveloperEvent,
33
- canApplyDeveloperEvent,
34
- developerSnapshot,
35
- initialState,
36
- normalizeDeveloperEvent,
37
- parseChoiceResponseSpec,
38
- protocolState,
39
- reconstructState,
40
- type ChoiceResponseSpec,
41
- type DeveloperMode,
42
- type DeveloperState,
43
- type DirectExecutionProfile,
44
- type FocusEvent,
45
- type JudgmentEvent,
46
- type ModeEvent,
47
- type PendingQuestion,
48
- type PendingQuestionStatus,
49
- type QuestionGate,
50
- type QuestionResolutionOwner,
51
- type QuestionUpdate,
52
- type QuestionUpdateStatus,
53
- type RouteAlternative,
54
- type RouteEvent,
23
+ ACTIVATION_ENTRY,
24
+ FOCUS_ENTRY,
25
+ JUDGMENT_TOOL,
26
+ MAX_RESPONSE_FIELDS,
27
+ MAX_RESPONSE_IDENTIFIER_CHARS,
28
+ MAX_RESPONSE_OPTIONS,
29
+ MAX_RESPONSE_TEXT_CHARS,
30
+ PROTOCOL,
31
+ ROUTE_TOOL,
32
+ applyDeveloperEvent,
33
+ canApplyDeveloperEvent,
34
+ developerSnapshot,
35
+ initialState,
36
+ normalizeDeveloperEvent,
37
+ parseChoiceResponseSpec,
38
+ protocolState,
39
+ reconstructState,
40
+ type ActivationEvent,
41
+ type ChoiceResponseSpec,
42
+ type DeveloperState,
43
+ type ImplementationProfile,
44
+ type FocusEvent,
45
+ type JudgmentEvent,
46
+ type PendingQuestion,
47
+ type PendingQuestionStatus,
48
+ type QuestionGate,
49
+ type QuestionResolutionOwner,
50
+ type QuestionUpdate,
51
+ type QuestionUpdateStatus,
52
+ type RouteAlternative,
53
+ type RouteEvent,
55
54
  } from "./state.ts";
56
55
  import {
57
- builtinControlledToolCapabilities,
58
- isControlledToolAllowed,
59
- reconcileProtocolTools,
60
- type ProtocolToolAccess,
61
- type ToolPolicyMemory,
56
+ TOOL_POLICY_LIFECYCLE_ENTRY,
57
+ builtinControlledToolCapabilities,
58
+ isControlledToolAllowed,
59
+ reconcileProtocolTools,
60
+ reloadSafeToolPolicyMarker,
61
+ toolPolicyReloadRequiresRestart,
62
+ type ProtocolToolAccess,
63
+ type ToolPolicyMemory,
62
64
  } from "./tool-policy.ts";
63
65
  import {
64
- DeveloperWidget,
65
- editQuestionResolutionRequest,
66
- promptImmediateUserQuestion,
67
- renderDeveloperFooter,
68
- showDeveloperActionSelector,
69
- showDeveloperStatus,
70
- showPendingQuestionSelector,
71
- type ImmediateQuestionDisposition,
66
+ DeveloperWidget,
67
+ developerHistoryEntries,
68
+ editQuestionResolutionRequest,
69
+ promptImmediateUserQuestion,
70
+ renderDeveloperFooter,
71
+ showDeveloperHistoryDetail,
72
+ showDeveloperHistorySelector,
73
+ showDeveloperSettings,
74
+ showDeveloperStatus,
75
+ showPendingQuestionSelector,
76
+ type ImmediateQuestionDisposition,
72
77
  } from "./tui.ts";
73
78
 
74
79
  const PROTOCOL_TOOLS = [ROUTE_TOOL, JUDGMENT_TOOL] as const;
75
80
  const extensionRoot = dirname(fileURLToPath(import.meta.url));
76
81
  const skillsRoot = resolve(extensionRoot, "..", "skills");
77
82
  const structuralChangeMethodPath = resolve(
78
- extensionRoot,
79
- "references",
80
- "behavior-preserving-structural-change.md",
83
+ extensionRoot,
84
+ "references",
85
+ "behavior-preserving-structural-change.md",
81
86
  );
82
87
  const MAX_PENDING_QUESTIONS = 20;
83
88
  const MAX_QUESTION_CHARS = 2_000;
@@ -85,1478 +90,1784 @@ const MAX_QUESTION_CONTEXT_CHARS = 8_000;
85
90
  const MAX_EVIDENCE_CHARS = 2_000;
86
91
  const MAX_RESULT_CHARS = 12_000;
87
92
  const MAX_ARTIFACT_CHARS = 4_096;
88
- const DEVELOPER_COMMAND_ACTIONS = ["on", "strict", "status", "questions", "off"] as const;
93
+ const DEVELOPER_COMMAND_ACTIONS = ["on", "status", "questions", "off"] as const;
94
+ const TOOL_POLICY_RESTART_MESSAGE =
95
+ "Developer detected an in-process package reload from a version without reload-safe tool handoff. Restart the Pi process before enabling Developer; /reload and /develop off/on cannot safely reconstruct the prior built-in tool selection.";
89
96
 
90
97
  function textResult<T>(text: string, details: T) {
91
- return {
92
- content: [{ type: "text" as const, text }],
93
- details,
94
- };
98
+ return {
99
+ content: [{ type: "text" as const, text }],
100
+ details,
101
+ };
95
102
  }
96
103
 
97
- function resultText(result: { content: Array<{ type: string; text?: string }> }): string {
98
- return result.content
99
- .filter((item) => item.type === "text" && item.text)
100
- .map((item) => item.text)
101
- .join("\n");
104
+ function resultText(result: {
105
+ content: Array<{ type: string; text?: string }>;
106
+ }): string {
107
+ return result.content
108
+ .filter((item) => item.type === "text" && item.text)
109
+ .map((item) => item.text)
110
+ .join("\n");
102
111
  }
103
112
 
104
113
  function reusableText(content: string, lastComponent: unknown): Text {
105
- const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
106
- component.setText(content);
107
- return component;
114
+ const component =
115
+ lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
116
+ component.setText(content);
117
+ return component;
108
118
  }
109
119
 
110
120
  function ensureSafeToolText(text: string, label: string): void {
111
- const bytes = Buffer.byteLength(text, "utf8");
112
- const lines = text.split(/\r?\n/).length;
113
- if (bytes > DEFAULT_MAX_BYTES || lines > DEFAULT_MAX_LINES) {
114
- fail(
115
- `${label} exceeds Pi's tool-output limit (${bytes} bytes, ${lines} lines). Narrow the routed question or evidence.`,
116
- );
117
- }
121
+ const bytes = Buffer.byteLength(text, "utf8");
122
+ const lines = text.split(/\r?\n/).length;
123
+ if (bytes > DEFAULT_MAX_BYTES || lines > DEFAULT_MAX_LINES) {
124
+ fail(
125
+ `${label} exceeds Pi's tool-output limit (${bytes} bytes, ${lines} lines). Narrow the routed question or evidence.`,
126
+ );
127
+ }
118
128
  }
119
129
 
120
130
  function fail(message: string): never {
121
- throw new Error(message);
131
+ throw new Error(message);
122
132
  }
123
133
 
124
134
  function sameToolSet(left: string[], right: string[]): boolean {
125
- if (left.length !== right.length) return false;
126
- const rightSet = new Set(right);
127
- return left.every((tool) => rightSet.has(tool));
135
+ if (left.length !== right.length) return false;
136
+ const rightSet = new Set(right);
137
+ return left.every((tool) => rightSet.has(tool));
128
138
  }
129
139
 
130
140
  function compactLine(value: string, maxChars = 160): string {
131
- const line = value.replace(/\s+/g, " ").trim();
132
- return line.length <= maxChars ? line : `${line.slice(0, maxChars - 1)}…`;
141
+ const line = value.replace(/\s+/g, " ").trim();
142
+ return line.length <= maxChars ? line : `${line.slice(0, maxChars - 1)}…`;
133
143
  }
134
144
 
135
145
  function normalizedQuestion(value: string): string {
136
- return value
137
- .toLocaleLowerCase()
138
- .replace(/[^\p{L}\p{N}]+/gu, " ")
139
- .trim();
146
+ return value
147
+ .toLocaleLowerCase()
148
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
149
+ .trim();
140
150
  }
141
151
 
142
152
  interface ChoiceResponseOptionInput {
143
- value: string;
144
- label: string;
145
- description?: string;
146
- detail_prompt?: string;
153
+ value: string;
154
+ label: string;
155
+ description?: string;
156
+ detail_prompt?: string;
147
157
  }
148
158
 
149
159
  interface ChoiceResponseFieldInput {
150
- id: string;
151
- prompt: string;
152
- description?: string;
153
- options: ChoiceResponseOptionInput[];
160
+ id: string;
161
+ prompt: string;
162
+ description?: string;
163
+ options: ChoiceResponseOptionInput[];
154
164
  }
155
165
 
156
166
  interface ChoiceResponseSpecInput {
157
- kind: "choice-form";
158
- fields: ChoiceResponseFieldInput[];
167
+ kind: "choice-form";
168
+ fields: ChoiceResponseFieldInput[];
159
169
  }
160
170
 
161
171
  interface OpenQuestionInput {
162
- question: string;
163
- context?: string;
164
- response_spec?: ChoiceResponseSpecInput;
165
- status: PendingQuestionStatus;
166
- resolution_owner: Exclude<QuestionResolutionOwner, "unknown">;
167
- gate: QuestionGate;
168
- resolution_criteria: string;
172
+ question: string;
173
+ context?: string;
174
+ response_spec?: ChoiceResponseSpecInput;
175
+ status: PendingQuestionStatus;
176
+ resolution_owner: Exclude<QuestionResolutionOwner, "unknown">;
177
+ gate: QuestionGate;
178
+ resolution_criteria: string;
169
179
  }
170
180
 
171
181
  interface QuestionUpdateInput {
172
- question_id: string;
173
- status: QuestionUpdateStatus;
174
- result: string;
175
- basis: string[];
182
+ question_id: string;
183
+ status: QuestionUpdateStatus;
184
+ result: string;
185
+ basis: string[];
176
186
  }
177
187
 
178
- function legacyOpenQuestion(question: string, judgmentStatus?: string): OpenQuestionInput {
179
- if (judgmentStatus === "blocked") {
180
- return {
181
- question,
182
- status: "blocked",
183
- resolution_owner: "environment",
184
- gate: "before-completion",
185
- resolution_criteria: `Obtain evidence that settles: ${question}`,
186
- };
187
- }
188
- return {
189
- question,
190
- status: "open",
191
- resolution_owner: "agent",
192
- gate: "none",
193
- resolution_criteria: `Obtain evidence that settles: ${question}`,
194
- };
188
+ function legacyOpenQuestion(
189
+ question: string,
190
+ judgmentStatus?: string,
191
+ ): OpenQuestionInput {
192
+ if (judgmentStatus === "blocked") {
193
+ return {
194
+ question,
195
+ status: "blocked",
196
+ resolution_owner: "environment",
197
+ gate: "before-completion",
198
+ resolution_criteria: `Obtain evidence that settles: ${question}`,
199
+ };
200
+ }
201
+ return {
202
+ question,
203
+ status: "open",
204
+ resolution_owner: "agent",
205
+ gate: "none",
206
+ resolution_criteria: `Obtain evidence that settles: ${question}`,
207
+ };
195
208
  }
196
209
 
197
210
  function buildChoiceResponseSpec(
198
- input: ChoiceResponseSpecInput | undefined,
199
- owner: OpenQuestionInput["resolution_owner"],
211
+ input: ChoiceResponseSpecInput | undefined,
212
+ owner: OpenQuestionInput["resolution_owner"],
200
213
  ): ChoiceResponseSpec | undefined {
201
- if (!input) return undefined;
202
- if (owner !== "user") {
203
- fail("response_spec is valid only for user-owned questions.");
204
- }
205
- const responseSpec = parseChoiceResponseSpec({
206
- kind: input.kind,
207
- fields: input.fields.map((field) => ({
208
- id: field.id,
209
- prompt: field.prompt,
210
- description: field.description,
211
- options: field.options.map((option) => ({
212
- value: option.value,
213
- label: option.label,
214
- description: option.description,
215
- detailPrompt: option.detail_prompt,
216
- })),
217
- })),
218
- });
219
- if (!responseSpec) {
220
- fail(
221
- "response_spec must use unique field IDs and unique option values with non-whitespace text.",
222
- );
223
- }
224
- return responseSpec;
214
+ if (!input) return undefined;
215
+ if (owner !== "user") {
216
+ fail("response_spec is valid only for user-owned questions.");
217
+ }
218
+ const responseSpec = parseChoiceResponseSpec({
219
+ kind: input.kind,
220
+ fields: input.fields.map((field) => ({
221
+ id: field.id,
222
+ prompt: field.prompt,
223
+ description: field.description,
224
+ options: field.options.map((option) => ({
225
+ value: option.value,
226
+ label: option.label,
227
+ description: option.description,
228
+ detailPrompt: option.detail_prompt,
229
+ })),
230
+ })),
231
+ });
232
+ if (!responseSpec) {
233
+ fail(
234
+ "response_spec must use unique field IDs and unique option values with non-whitespace text.",
235
+ );
236
+ }
237
+ return responseSpec;
225
238
  }
226
239
 
227
240
  function buildOpenedQuestions(
228
- input: OpenQuestionInput[],
229
- state: DeveloperState,
230
- routeId: string,
241
+ input: OpenQuestionInput[],
242
+ state: DeveloperState,
243
+ routeId: string,
231
244
  ): PendingQuestion[] {
232
- const questionIds = new Map(
233
- state.pendingQuestions.map((question) => [normalizedQuestion(question.question), question.id]),
234
- );
235
- const openedQuestions: PendingQuestion[] = [];
236
- for (const [index, item] of input.entries()) {
237
- const question = item.question.trim();
238
- const context = item.context?.trim() || undefined;
239
- const responseSpec = buildChoiceResponseSpec(item.response_spec, item.resolution_owner);
240
- const resolutionCriteria = item.resolution_criteria.trim();
241
- if (!question || !resolutionCriteria) {
242
- fail("Each open question requires non-whitespace question and resolution_criteria text.");
243
- }
244
- const questionKey = normalizedQuestion(question);
245
- const questionId = questionIds.get(questionKey) ?? `question:${routeId}:open:${index + 1}`;
246
- questionIds.set(questionKey, questionId);
247
- const pendingQuestion: PendingQuestion = {
248
- id: questionId,
249
- question,
250
- context,
251
- responseSpec,
252
- status: item.status,
253
- resolutionOwner: item.resolution_owner,
254
- gate: item.gate,
255
- resolutionCriteria,
256
- sourceRouteId: routeId,
257
- };
258
- const duplicateIndex = openedQuestions.findIndex((candidate) => candidate.id === questionId);
259
- if (duplicateIndex === -1) openedQuestions.push(pendingQuestion);
260
- else openedQuestions[duplicateIndex] = pendingQuestion;
261
- }
262
- return openedQuestions;
245
+ const questionIds = new Map(
246
+ state.pendingQuestions.map((question) => [
247
+ normalizedQuestion(question.question),
248
+ question.id,
249
+ ]),
250
+ );
251
+ const openedQuestions: PendingQuestion[] = [];
252
+ for (const [index, item] of input.entries()) {
253
+ const question = item.question.trim();
254
+ const context = item.context?.trim() || undefined;
255
+ const responseSpec = buildChoiceResponseSpec(
256
+ item.response_spec,
257
+ item.resolution_owner,
258
+ );
259
+ const resolutionCriteria = item.resolution_criteria.trim();
260
+ if (!question || !resolutionCriteria) {
261
+ fail(
262
+ "Each open question requires non-whitespace question and resolution_criteria text.",
263
+ );
264
+ }
265
+ if (item.resolution_owner === "user" && item.gate !== "none" && !context) {
266
+ fail(
267
+ "User-owned questions with a before-implementation or before-completion gate require non-whitespace context that explains the decision before asking for an answer.",
268
+ );
269
+ }
270
+ const questionKey = normalizedQuestion(question);
271
+ const questionId =
272
+ questionIds.get(questionKey) ?? `question:${routeId}:open:${index + 1}`;
273
+ questionIds.set(questionKey, questionId);
274
+ const pendingQuestion: PendingQuestion = {
275
+ id: questionId,
276
+ question,
277
+ context,
278
+ responseSpec,
279
+ status: item.status,
280
+ resolutionOwner: item.resolution_owner,
281
+ gate: item.gate,
282
+ resolutionCriteria,
283
+ sourceRouteId: routeId,
284
+ };
285
+ const duplicateIndex = openedQuestions.findIndex(
286
+ (candidate) => candidate.id === questionId,
287
+ );
288
+ if (duplicateIndex === -1) openedQuestions.push(pendingQuestion);
289
+ else openedQuestions[duplicateIndex] = pendingQuestion;
290
+ }
291
+ return openedQuestions;
263
292
  }
264
293
 
265
294
  function firstImmediateUserQuestion(
266
- previous: readonly PendingQuestion[],
267
- opened: readonly PendingQuestion[],
295
+ previous: readonly PendingQuestion[],
296
+ opened: readonly PendingQuestion[],
268
297
  ): PendingQuestion | undefined {
269
- const previousIds = new Set(previous.map((question) => question.id));
270
- return opened.find(
271
- (question) =>
272
- !previousIds.has(question.id) &&
273
- question.status === "open" &&
274
- question.resolutionOwner === "user" &&
275
- question.gate === "before-direct",
276
- );
298
+ const previousIds = new Set(previous.map((question) => question.id));
299
+ return opened.find(
300
+ (question) =>
301
+ !previousIds.has(question.id) &&
302
+ question.status === "open" &&
303
+ question.resolutionOwner === "user" &&
304
+ question.gate === "before-implementation",
305
+ );
277
306
  }
278
307
 
279
308
  function immediateQuestionMessage(
280
- question: PendingQuestion | undefined,
281
- disposition: ImmediateQuestionDisposition | undefined,
309
+ question: PendingQuestion | undefined,
310
+ disposition: ImmediateQuestionDisposition | undefined,
282
311
  ): string {
283
- if (!question) return "";
284
- if (disposition?.kind === "answer") {
285
- return [
286
- "",
287
- `Immediate user answer for ${question.id}:`,
288
- disposition.request,
289
- "",
290
- `Route this exact question with open_question_id=${question.id}, use the answer as new evidence, and close it only through question_updates.`,
291
- ].join("\n");
292
- }
293
- return `\nThe user left ${question.id} open for later.`;
312
+ if (!question) return "";
313
+ if (disposition?.kind === "answer") {
314
+ return [
315
+ "",
316
+ `Immediate user answer for ${question.id}:`,
317
+ disposition.request,
318
+ "",
319
+ `Route this exact question with open_question_id=${question.id}, use the answer as new evidence, and close it only through question_updates.`,
320
+ ].join("\n");
321
+ }
322
+ return `\nThe user left ${question.id} open for later.`;
294
323
  }
295
324
 
296
325
  function buildQuestionUpdates(
297
- input: QuestionUpdateInput[],
298
- state: DeveloperState,
326
+ input: QuestionUpdateInput[],
327
+ state: DeveloperState,
299
328
  ): QuestionUpdate[] {
300
- const questionUpdates = input.map((update) => ({
301
- questionId: update.question_id.trim(),
302
- status: update.status,
303
- result: update.result.trim(),
304
- basis: update.basis.map((item) => item.trim()).filter(Boolean),
305
- }));
306
- const knownQuestionIds = new Set(state.pendingQuestions.map((question) => question.id));
307
- const updatedIds = new Set<string>();
308
- for (const update of questionUpdates) {
309
- if (!knownQuestionIds.has(update.questionId))
310
- fail(`Unknown pending question ID: ${update.questionId}`);
311
- if (updatedIds.has(update.questionId))
312
- fail(`Question ${update.questionId} was updated more than once.`);
313
- updatedIds.add(update.questionId);
314
- if (!update.result) fail(`Question update ${update.questionId} requires a non-empty result.`);
315
- if (
316
- (update.status === "resolved" ||
317
- update.status === "not-applicable" ||
318
- update.status === "blocked") &&
319
- update.basis.length === 0
320
- ) {
321
- fail(
322
- `Question update ${update.questionId} with status ${update.status} requires concrete basis.`,
323
- );
324
- }
325
- }
326
- return questionUpdates;
329
+ const questionUpdates = input.map((update) => ({
330
+ questionId: update.question_id.trim(),
331
+ status: update.status,
332
+ result: update.result.trim(),
333
+ basis: update.basis.map((item) => item.trim()).filter(Boolean),
334
+ }));
335
+ const knownQuestionIds = new Set(
336
+ state.pendingQuestions.map((question) => question.id),
337
+ );
338
+ const updatedIds = new Set<string>();
339
+ for (const update of questionUpdates) {
340
+ if (!knownQuestionIds.has(update.questionId))
341
+ fail(`Unknown pending question ID: ${update.questionId}`);
342
+ if (updatedIds.has(update.questionId))
343
+ fail(`Question ${update.questionId} was updated more than once.`);
344
+ updatedIds.add(update.questionId);
345
+ if (!update.result)
346
+ fail(`Question update ${update.questionId} requires a non-empty result.`);
347
+ if (
348
+ (update.status === "resolved" ||
349
+ update.status === "not-applicable" ||
350
+ update.status === "blocked") &&
351
+ update.basis.length === 0
352
+ ) {
353
+ fail(
354
+ `Question update ${update.questionId} with status ${update.status} requires concrete basis.`,
355
+ );
356
+ }
357
+ }
358
+ return questionUpdates;
327
359
  }
328
360
 
329
- function judgmentNextMessage(event: JudgmentEvent, nextState: DeveloperState): string {
330
- if (event.owner === "direct") {
331
- if (nextState.verificationRequired) {
332
- return "Stable landing recorded. Route again from the new evidence; verify is required before claiming completion.";
333
- }
334
- return "Stable landing recorded. Route again from the new evidence before selecting another movement.";
335
- }
336
- const next = protocolState(nextState);
337
- if (next === "idle") {
338
- return "Developer protocol is idle. This is routing state only and does not prove task completion.";
339
- }
340
- return `Developer protocol is ${next}. Address the current routing obligation before handoff.`;
361
+ function judgmentNextMessage(
362
+ event: JudgmentEvent,
363
+ nextState: DeveloperState,
364
+ ): string {
365
+ if (event.target === "implementation") {
366
+ if (nextState.verificationRequired) {
367
+ return "Stable landing recorded. Route again from the new evidence; verify is required before claiming completion.";
368
+ }
369
+ return "Stable landing recorded. Route again from the new evidence before selecting another movement.";
370
+ }
371
+ const next = protocolState(nextState);
372
+ if (next === "idle") {
373
+ return "Developer protocol is idle. This is routing state only and does not prove task completion.";
374
+ }
375
+ return `Developer protocol is ${next}. Address the current routing obligation before handoff.`;
341
376
  }
342
377
 
343
378
  function inferredQuestionId(
344
- state: DeveloperState,
345
- question: string,
346
- explicitQuestionId?: string,
379
+ state: DeveloperState,
380
+ question: string,
381
+ explicitQuestionId?: string,
347
382
  ): string | undefined {
348
- if (explicitQuestionId) return explicitQuestionId;
349
- if (state.focusedQuestionId) return state.focusedQuestionId;
350
- const normalized = normalizedQuestion(question);
351
- const exact = state.pendingQuestions.filter(
352
- (pending) => normalizedQuestion(pending.question) === normalized,
353
- );
354
- return exact.length === 1 ? exact[0]?.id : undefined;
383
+ if (explicitQuestionId) return explicitQuestionId;
384
+ if (state.focusedQuestionId) return state.focusedQuestionId;
385
+ const normalized = normalizedQuestion(question);
386
+ const exact = state.pendingQuestions.filter(
387
+ (pending) => normalizedQuestion(pending.question) === normalized,
388
+ );
389
+ return exact.length === 1 ? exact[0]?.id : undefined;
355
390
  }
356
391
 
357
392
  function protocolToolAccess(state: DeveloperState): ProtocolToolAccess {
358
- const snapshot = developerSnapshot(state);
359
- return {
360
- canExecute: snapshot.hasTag("execute"),
361
- canMutate: snapshot.hasTag("mutate"),
362
- hasBeforeDirectGate: snapshot.hasTag("blocks-direct"),
363
- };
393
+ const snapshot = developerSnapshot(state);
394
+ return {
395
+ allowsShell: snapshot.hasTag("execute"),
396
+ allowsArtifactTools: snapshot.hasTag("mutate"),
397
+ hasBeforeImplementationGate: snapshot.hasTag("blocks-implementation"),
398
+ };
364
399
  }
365
400
 
366
401
  function summarizeState(state: DeveloperState): string {
367
- if (state.mode === "off") return "developer: off";
368
- const target = state.activeRoute ? state.activeRoute.owner : "none";
369
- return `developer: ${state.mode} · target: ${target} · ${protocolState(state)}`;
402
+ if (!state.enabled) return "developer: off";
403
+ const target = state.activeRoute ? state.activeRoute.target : "none";
404
+ return `developer: on · target: ${target} · ${protocolState(state)}`;
370
405
  }
371
406
 
372
- function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): string {
373
- const lines = [
374
- "",
375
- `Developer protocol (${state.mode}) is active.`,
376
- "- Use the default topology as a conditional backbone, not a rigid lifecycle: clarify meaning when needed -> model consequential cases -> sketch the first implementation surface for new behavior or signal the smallest structural movement in existing code -> execute one direct step -> verify current claims.",
377
- "- Adapt away from that topology when evidence makes a stage not applicable, but never jump directly from a resolved model to mutation: use sketch for feature implementation or signal for existing-code structural movement first.",
378
- `- Call ${ROUTE_TOOL} for exactly one concrete judgment or one green-to-green direct movement.`,
379
- "- Use owner=direct only when the next local movement, stable landing, and narrow verification are already justified; otherwise choose the focused skill whose scope fits the current question.",
380
- "- A direct step has one observable difference and one structural or behavioral purpose. Stop when its failure is locally explainable and the repository is green, pausable, and reviewable.",
381
- "- For direct structural work intended to preserve behavior, set execution_profile=behavior-preserving-structure; omit the field for other direct actions and every skill route.",
382
- `- Follow the routed method, then close the route with ${JUDGMENT_TOOL}. After every direct stable landing, route again from the new evidence before selecting another movement.`,
383
- "- Consecutive direct routes are valid when the new evidence still justifies direct action. On a reroute after direct, explicitly reconsider the most plausible skill routes and record why they are not needed; do not choose direct by momentum.",
384
- "- Do not carry a predetermined implementation queue through multiple direct steps. Re-observe after each stable landing and reroute to a skill whenever meaning, cases, design, structural direction, timing, naming, or evidence becomes uncertain.",
385
- "- Protocol state is routing bookkeeping. Idle never proves product completion, user acceptance, or current verification.",
386
- "- Preserve each skill's inspectable output surface in judgment result Markdown: keep its tables, diagrams, matrices, timelines, or code blocks instead of flattening them into prose.",
387
- `- On every ${JUDGMENT_TOOL} call, re-check all pending questions against the new evidence. Use question_updates to resolve questions naturally even when the current route did not focus them.`,
388
- "- question_updates accepts only question IDs that were already listed as pending before the current route. Never use route_id or an ID for a new open_questions entry; use an empty array or omit question_updates when no pending question exists.",
389
- "- A focused question always requires an explicit question_updates entry. Resolve or dismiss it with concrete basis, retain it as open/blocked, or explicitly replace the broad question while opening narrower children.",
390
- "- Pending questions declare who can resolve them, what they block, and observable resolution criteria. Include context when detailed choices, examples, or constraints are needed to answer. For finite required user choices, also provide a choice-form response_spec with one field per decision; do not rely on parsing Markdown into controls. Never guess a user-owned answer or bypass a before-direct gate.",
391
- "- Use before-direct only when the missing answer can change whether or what artifact mutation is valid. An agent-owned before-direct question must be resolvable through a non-direct skill route using observation or evidence execution; it must never require the mutation it blocks.",
392
- "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
393
- ];
394
-
395
- if (state.mode === "strict") {
396
- lines.push(
397
- "- Strict mode withholds Pi built-in bash while idle, restores bash for a routed skill judgment so it can inspect or run evidence checks, and restores edit/write only for a direct route. It does not classify or sandbox other extensions' tools.",
398
- "- Use a skill judgment route, not a direct route, when repository discovery or verifier execution is the unresolved work. Direct remains the artifact-mutation lane.",
399
- );
400
- }
401
-
402
- if (state.implementationFramingRequired) {
403
- lines.push(
404
- "Direct gate: the latest resolved model exposed implementation work. Before direct mutation, route sketch for new feature shape or signal for existing-code structural movement; other judgments may still be routed first.",
405
- );
406
- }
407
- if (state.verificationRequired) {
408
- lines.push(
409
- "Verification debt: a direct route changed artifacts after the last resolved verify judgment. Route verify before claiming completion or handing off as done.",
410
- );
411
- }
412
- const completionGates = state.pendingQuestions.filter(
413
- (question) => question.gate === "before-completion" || question.gate === "before-direct",
414
- );
415
- if (completionGates.length > 0) {
416
- lines.push(
417
- `Completion gate: ${completionGates.map((question) => question.id).join(", ")} must be resolved before a completion claim.`,
418
- );
419
- }
420
- if (!state.activeRoute && state.rerouteRequired) {
421
- lines.push(
422
- "Rerouting checkpoint: use the previous direct landing as known_evidence. If direct is still right, include alternatives_considered for the plausible available skills and explain why each adds no useful judgment now.",
423
- );
424
- }
425
-
426
- if (state.activeRoute) {
427
- lines.push(
428
- `Active route: ${state.activeRoute.routeId} · ${state.activeRoute.owner} · ${state.activeRoute.question}`,
429
- );
430
- if (state.activeRoute.methodLocation) {
431
- lines.push(
432
- `Active skill location: ${state.activeRoute.methodLocation}. Read it again if compaction or a later turn no longer contains the full instructions.`,
433
- );
434
- }
435
- }
436
- if (state.pendingQuestions.length > 0) {
437
- lines.push("Pending Developer questions:");
438
- for (const question of state.pendingQuestions) {
439
- lines.push(
440
- `- ${question.id} · ${question.status} · owner=${question.resolutionOwner} · gate=${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
441
- );
442
- }
443
- lines.push(
444
- "- Revisit questions naturally. Developer associates an explicitly focused or exactly matching pending question; open_question_id is an internal disambiguator when wording is intentionally changed or several questions remain.",
445
- "- question_updates may resolve any pending ID from implementation, test, inspection, user, or environment evidence; each resolved update requires concrete basis.",
446
- );
447
- }
448
- lines.push(
449
- `Available Developer skills: ${availableSkillNames.length > 0 ? availableSkillNames.join(", ") : "none; use direct only"}.`,
450
- );
451
- return lines.join("\n");
407
+ function protocolPrompt(
408
+ state: DeveloperState,
409
+ availableSkillNames: string[],
410
+ ): string {
411
+ const lines = [
412
+ "",
413
+ "Developer protocol is active.",
414
+ "- Use the default topology as a conditional backbone, not a rigid lifecycle: clarify meaning when needed -> model consequential cases -> sketch the first implementation surface for new behavior or signal the smallest structural movement in existing code -> execute one implementation step -> verify current claims.",
415
+ "- Adapt away from that topology when evidence makes a stage not applicable, but never jump directly from a resolved model to mutation: use sketch for feature implementation or signal for existing-code structural movement first.",
416
+ `- Call ${ROUTE_TOOL} for exactly one concrete judgment or one green-to-green implementation movement.`,
417
+ "- Use target=implementation only when the next local movement, stable landing, and narrow verification are already justified; otherwise choose the focused skill whose scope fits the current question.",
418
+ "- An implementation step has one observable difference and one structural or behavioral purpose. Stop when its failure is locally explainable and the repository is green, pausable, and reviewable.",
419
+ "- For implementation structural work intended to preserve behavior, set execution_profile=behavior-preserving-structure; omit the field for other implementation actions and every skill route.",
420
+ `- Follow the routed method, then close the route with ${JUDGMENT_TOOL}. After every implementation stable landing, route again from the new evidence before selecting another movement.`,
421
+ "- Consecutive implementation routes are valid when the new evidence still justifies implementation action. On a reroute after implementation, explicitly reconsider the most plausible skill routes and record why they are not needed; do not choose implementation by momentum.",
422
+ "- Do not carry a predetermined implementation queue through multiple implementation steps. Re-observe after each stable landing and reroute to a skill whenever meaning, cases, design, structural direction, timing, naming, or evidence becomes uncertain.",
423
+ "- Protocol state is routing bookkeeping. Idle never proves product completion, user acceptance, or current verification.",
424
+ "- Preserve each skill's inspectable output surface in judgment result Markdown: keep its tables, diagrams, matrices, timelines, or code blocks instead of flattening them into prose.",
425
+ `- On every ${JUDGMENT_TOOL} call, re-check all pending questions against the new evidence. Use question_updates to resolve questions naturally even when the current route did not focus them.`,
426
+ "- question_updates accepts only question IDs that were already listed as pending before the current route. Never use route_id or an ID for a new open_questions entry; use an empty array or omit question_updates when no pending question exists.",
427
+ "- A focused question always requires an explicit question_updates entry. Resolve or dismiss it with concrete basis, retain it as open/blocked, or explicitly replace the broad question while opening narrower children.",
428
+ "- Pending questions declare who can resolve them, what they block, and observable resolution criteria. User-owned before-implementation or before-completion questions require context that explains the decision before asking for an answer. For finite required user choices, also provide a choice-form response_spec with one field per decision; do not rely on parsing Markdown into controls. Never guess a user-owned answer or bypass a before-implementation gate.",
429
+ "- Use before-implementation only when the missing answer can change whether or what artifact mutation is valid. An agent-owned before-implementation question must be resolvable through a non-implementation skill route using observation or evidence execution; it must never require the mutation it blocks.",
430
+ "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
431
+ "- Developer withholds Pi built-in bash while idle, restores bash for a routed skill judgment so it can inspect or run evidence checks, and restores edit/write only for an implementation route. It does not classify or sandbox other extensions' tools.",
432
+ "- Use a skill judgment route, not an implementation route, when repository discovery or verifier execution is the unresolved work. Implementation remains the artifact-mutation lane.",
433
+ ];
434
+
435
+ if (state.implementationFramingRequired) {
436
+ lines.push(
437
+ "Implementation gate: the latest resolved model exposed implementation work. Before implementation mutation, route sketch for new feature shape or signal for existing-code structural movement; other judgments may still be routed first.",
438
+ );
439
+ }
440
+ if (state.verificationRequired) {
441
+ lines.push(
442
+ "Verification debt: an implementation route changed artifacts after the last resolved verify judgment. Route verify before claiming completion or handing off as done.",
443
+ );
444
+ }
445
+ const completionGates = state.pendingQuestions.filter(
446
+ (question) =>
447
+ question.gate === "before-completion" ||
448
+ question.gate === "before-implementation",
449
+ );
450
+ if (completionGates.length > 0) {
451
+ lines.push(
452
+ `Completion gate: ${completionGates.map((question) => question.id).join(", ")} must be resolved before a completion claim.`,
453
+ );
454
+ }
455
+ if (!state.activeRoute && state.rerouteRequired) {
456
+ lines.push(
457
+ "Rerouting checkpoint: use the previous implementation landing as known_evidence. If implementation is still right, include alternatives_considered for the plausible available skills and explain why each adds no useful judgment now.",
458
+ );
459
+ }
460
+
461
+ if (state.activeRoute) {
462
+ lines.push(
463
+ `Active route: ${state.activeRoute.routeId} · ${state.activeRoute.target} · ${state.activeRoute.question}`,
464
+ );
465
+ if (state.activeRoute.methodLocation) {
466
+ lines.push(
467
+ `Active skill location: ${state.activeRoute.methodLocation}. Read it again if compaction or a later turn no longer contains the full instructions.`,
468
+ );
469
+ }
470
+ }
471
+ if (state.pendingQuestions.length > 0) {
472
+ lines.push("Pending Developer questions:");
473
+ for (const question of state.pendingQuestions) {
474
+ lines.push(
475
+ `- ${question.id} · ${question.status} · owner=${question.resolutionOwner} · gate=${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
476
+ );
477
+ }
478
+ lines.push(
479
+ "- Revisit questions naturally. Developer associates an explicitly focused or exactly matching pending question; open_question_id is an internal disambiguator when wording is intentionally changed or several questions remain.",
480
+ "- question_updates may resolve any pending ID from implementation, test, inspection, user, or environment evidence; each resolved update requires concrete basis.",
481
+ );
482
+ }
483
+ lines.push(
484
+ `Available Developer skills: ${availableSkillNames.length > 0 ? availableSkillNames.join(", ") : "none; use implementation only"}.`,
485
+ );
486
+ return lines.join("\n");
452
487
  }
453
488
 
454
489
  function routeRenderText(event: RouteEvent | undefined): string {
455
- if (!event) return "Route unavailable";
456
- const target = event.targetQuestionId ? ` · revisits ${event.targetQuestionId}` : "";
457
- const profile = event.executionProfile ? `/${event.executionProfile}` : "";
458
- return `${event.owner}${profile} · ${compactLine(event.question)}${target}`;
490
+ if (!event) return "Route unavailable";
491
+ const target = event.targetQuestionId
492
+ ? ` · revisits ${event.targetQuestionId}`
493
+ : "";
494
+ const profile = event.executionProfile ? `/${event.executionProfile}` : "";
495
+ return `${event.target}${profile} · ${compactLine(event.question)}${target}`;
459
496
  }
460
497
 
461
498
  function compactJudgmentResult(result: string, maxChars = 160): string {
462
- const firstContentLine = result
463
- .split(/\r?\n/)
464
- .map((line) => line.trim())
465
- .find((line) => line && !line.startsWith("```"));
466
- return compactLine(firstContentLine ?? result, maxChars);
499
+ const firstContentLine = result
500
+ .split(/\r?\n/)
501
+ .map((line) => line.trim())
502
+ .find((line) => line && !line.startsWith("```"));
503
+ return compactLine(firstContentLine ?? result, maxChars);
467
504
  }
468
505
 
469
506
  function judgmentRenderText(event: JudgmentEvent | undefined): string {
470
- if (!event) return "Judgment unavailable";
471
- return `${event.status} · ${compactJudgmentResult(event.result)}`;
507
+ if (!event) return "Judgment unavailable";
508
+ return `${event.status} · ${compactJudgmentResult(event.result)}`;
472
509
  }
473
510
 
474
511
  function detailLine(
475
- theme: Theme,
476
- label: string,
477
- value: string,
478
- valueColor: ThemeColor = "muted",
512
+ theme: Theme,
513
+ label: string,
514
+ value: string,
515
+ valueColor: ThemeColor = "muted",
479
516
  ): string {
480
- return `${theme.fg("dim", `${label} · `)}${theme.fg(valueColor, value)}`;
517
+ return `${theme.fg("dim", `${label} · `)}${theme.fg(valueColor, value)}`;
481
518
  }
482
519
 
483
- function expandedJudgment(event: JudgmentEvent, statusText: string, theme: Theme): Container {
484
- const container = new Container();
485
- const header = [
486
- statusText,
487
- detailLine(theme, "route", event.routeId, "text"),
488
- detailLine(theme, "target", event.owner, "accent"),
489
- detailLine(theme, "question", event.question, "text"),
490
- ];
491
- container.addChild(new Text(header.join("\n"), 0, 0));
492
- container.addChild(new Markdown(event.result, 0, 0, getMarkdownTheme()));
493
-
494
- const evidence = [
495
- ...event.basis.map((basis) => detailLine(theme, "basis", basis)),
496
- ...event.artifacts.map((artifact) => detailLine(theme, "artifact", artifact)),
497
- ...event.openedQuestions.map((question) =>
498
- detailLine(
499
- theme,
500
- `opened ${question.resolutionOwner}/${question.gate}`,
501
- `${question.question} resolves when: ${question.resolutionCriteria}`,
502
- "warning",
503
- ),
504
- ),
505
- ...(event.questionUpdates ?? []).map((update) =>
506
- detailLine(
507
- theme,
508
- `question ${update.status}`,
509
- `${update.questionId} — ${update.result}`,
510
- "accent",
511
- ),
512
- ),
513
- ];
514
- if (evidence.length > 0) container.addChild(new Text(evidence.join("\n"), 0, 0));
515
- return container;
520
+ function expandedJudgment(
521
+ event: JudgmentEvent,
522
+ statusText: string,
523
+ theme: Theme,
524
+ ): Container {
525
+ const container = new Container();
526
+ const header = [
527
+ statusText,
528
+ detailLine(theme, "route", event.routeId, "text"),
529
+ detailLine(theme, "target", event.target, "accent"),
530
+ detailLine(theme, "question", event.question, "text"),
531
+ ];
532
+ container.addChild(new Text(header.join("\n"), 0, 0));
533
+ container.addChild(new Markdown(event.result, 0, 0, getMarkdownTheme()));
534
+
535
+ const evidence = [
536
+ ...event.basis.map((basis) => detailLine(theme, "basis", basis)),
537
+ ...event.artifacts.map((artifact) =>
538
+ detailLine(theme, "artifact", artifact),
539
+ ),
540
+ ...event.openedQuestions.map((question) =>
541
+ detailLine(
542
+ theme,
543
+ `opened ${question.resolutionOwner}/${question.gate}`,
544
+ `${question.question} — resolves when: ${question.resolutionCriteria}`,
545
+ "warning",
546
+ ),
547
+ ),
548
+ ...(event.questionUpdates ?? []).map((update) =>
549
+ detailLine(
550
+ theme,
551
+ `question ${update.status}`,
552
+ `${update.questionId} — ${update.result}`,
553
+ "accent",
554
+ ),
555
+ ),
556
+ ];
557
+ if (evidence.length > 0)
558
+ container.addChild(new Text(evidence.join("\n"), 0, 0));
559
+ return container;
516
560
  }
517
561
 
518
562
  export default async function developer(pi: ExtensionAPI) {
519
- let availableSkills = new Map<string, Skill>();
520
- let state = initialState();
521
- let routeOpening = false;
522
- const routesWithMutation = new Set<string>();
523
- let toolPolicyMemory: ToolPolicyMemory = { withheldBuiltins: new Set() };
524
-
525
- pi.registerFlag("develop-mode", {
526
- description: "Start the Developer protocol in on or strict mode",
527
- type: "string",
528
- });
529
-
530
- const syncProtocolTools = () => {
531
- const current = pi.getActiveTools();
532
- const next = reconcileProtocolTools({
533
- activeTools: current,
534
- allTools: pi.getAllTools(),
535
- mode: state.mode,
536
- access: protocolToolAccess(state),
537
- protocolTools: PROTOCOL_TOOLS,
538
- memory: toolPolicyMemory,
539
- });
540
- toolPolicyMemory = next.memory;
541
- if (!sameToolSet(current, next.activeTools)) pi.setActiveTools(next.activeTools);
542
- };
543
-
544
- const refreshUI = (ctx: ExtensionContext) => {
545
- if (state.mode === "off") {
546
- ctx.ui.setStatus("developer", undefined);
547
- ctx.ui.setWidget("developer", undefined);
548
- return;
549
- }
550
-
551
- ctx.ui.setStatus(
552
- "developer",
553
- ctx.mode === "tui" ? renderDeveloperFooter(state, ctx.ui.theme) : summarizeState(state),
554
- );
555
- if (
556
- !state.activeRoute &&
557
- state.pendingQuestions.length === 0 &&
558
- !state.implementationFramingRequired &&
559
- !state.verificationRequired
560
- ) {
561
- ctx.ui.setWidget("developer", undefined);
562
- return;
563
- }
564
-
565
- if (ctx.mode === "tui") {
566
- const viewState = state;
567
- ctx.ui.setWidget("developer", (_tui, theme) => new DeveloperWidget(viewState, theme), {
568
- placement: "belowEditor",
569
- });
570
- return;
571
- }
572
-
573
- const lines = [
574
- ...(state.activeRoute
575
- ? [`route · ${state.activeRoute.owner} · ${compactLine(state.activeRoute.question)}`]
576
- : []),
577
- ...state.pendingQuestions
578
- .slice(0, 3)
579
- .map((question) => `open · ${question.id} · ${compactLine(question.question)}`),
580
- ...(state.pendingQuestions.length > 3
581
- ? [`open · +${state.pendingQuestions.length - 3} more`]
582
- : []),
583
- ...(state.implementationFramingRequired
584
- ? ["gate · frame implementation before direct (sketch or signal)"]
585
- : []),
586
- ...(state.verificationRequired ? ["next · verify changed artifacts before completion"] : []),
587
- ];
588
- ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
589
- };
590
-
591
- const reconstruct = (ctx: ExtensionContext) => {
592
- state = reconstructState(ctx.sessionManager.getBranch());
593
- syncProtocolTools();
594
- refreshUI(ctx);
595
- };
596
-
597
- const setMode = (mode: DeveloperMode, ctx: ExtensionContext) => {
598
- const event: ModeEvent = { protocol: PROTOCOL, kind: "mode", mode };
599
- pi.appendEntry(MODE_ENTRY, event);
600
- state = applyDeveloperEvent(state, event);
601
- syncProtocolTools();
602
- refreshUI(ctx);
603
- };
604
-
605
- const SharedRouteParams = {
606
- question: Type.String({
607
- minLength: 1,
608
- maxLength: MAX_QUESTION_CHARS,
609
- description: "The single concrete judgment or action question to route",
610
- }),
611
- reason: Type.String({
612
- minLength: 1,
613
- maxLength: MAX_QUESTION_CHARS,
614
- description: "Why this route target fits the current evidence",
615
- }),
616
- known_evidence: Type.Optional(
617
- Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
618
- maxItems: 12,
619
- description: "Evidence already known before routing",
620
- }),
621
- ),
622
- open_question_id: Type.Optional(
623
- Type.String({
624
- maxLength: 512,
625
- description: "Exact pending question ID when this route revisits one",
626
- }),
627
- ),
628
- };
629
- const RouteAlternativeParam = Type.Object(
630
- {
631
- owner: Type.String({
632
- minLength: 1,
633
- maxLength: 64,
634
- description: "Exact available Developer skill name that was reconsidered",
635
- }),
636
- reason: Type.String({
637
- minLength: 1,
638
- maxLength: MAX_EVIDENCE_CHARS,
639
- description:
640
- "Why this skill would add no useful judgment before the proposed direct movement",
641
- }),
642
- },
643
- { additionalProperties: false },
644
- );
645
- const RouteParams = Type.Union([
646
- Type.Object(
647
- {
648
- ...SharedRouteParams,
649
- owner: Type.String({
650
- minLength: 1,
651
- maxLength: 64,
652
- pattern: "^(?!direct$)[a-z0-9]+(?:-[a-z0-9]+)*$",
653
- description: "Exact skill name from the current Available Developer skills list",
654
- }),
655
- },
656
- {
657
- additionalProperties: false,
658
- description: "Route one question to a Developer skill",
659
- },
660
- ),
661
- Type.Object(
662
- {
663
- ...SharedRouteParams,
664
- owner: Type.Literal("direct", {
665
- description: "Use Pi implementation tools for an already-justified action",
666
- }),
667
- movement: Type.String({
668
- minLength: 1,
669
- maxLength: MAX_QUESTION_CHARS,
670
- description:
671
- "One locally explainable behavioral or structural movement; not a multi-step implementation queue",
672
- }),
673
- stop_condition: Type.String({
674
- minLength: 1,
675
- maxLength: MAX_QUESTION_CHARS,
676
- description: "The green, pausable, reviewable stable landing that ends this direct route",
677
- }),
678
- verification: Type.String({
679
- minLength: 1,
680
- maxLength: MAX_EVIDENCE_CHARS,
681
- description: "The narrowest check that can catch the likely break in this movement",
682
- }),
683
- alternatives_considered: Type.Optional(
684
- Type.Array(RouteAlternativeParam, {
685
- maxItems: 6,
686
- description:
687
- "On a reroute after direct, the plausible skill routes reconsidered and why each is unnecessary now",
688
- }),
689
- ),
690
- execution_profile: Type.Optional(
691
- Type.Literal("behavior-preserving-structure", {
692
- description:
693
- "Load the focused structural-mutation protocol; omit for ordinary direct action",
694
- }),
695
- ),
696
- },
697
- {
698
- additionalProperties: false,
699
- description: "Route one already-justified direct action",
700
- },
701
- ),
702
- ]);
703
-
704
- pi.registerTool({
705
- name: ROUTE_TOOL,
706
- label: "Developer Route Question",
707
- description:
708
- "Route one concrete judgment or one green-to-green direct movement. Uses an adaptive default topology: model, then sketch for feature shape or signal for structural movement, direct stable landings, and verify before completion.",
709
- promptSnippet: "Choose how to handle one development question",
710
- promptGuidelines: [
711
- `Call ${ROUTE_TOOL} only when there is no active Developer route.`,
712
- `Use ${ROUTE_TOOL} with the most focused skill supported by current evidence; owner=direct requires one movement, one stable landing, and one narrow verification.`,
713
- `When ${ROUTE_TOOL} follows a direct judgment with another direct route, cite the previous landing in known_evidence and record plausible skill routes in alternatives_considered instead of selecting direct by momentum.`,
714
- `After a resolved model, use sketch for first feature implementation framing or signal for existing-code structural movement before direct mutation.`,
715
- ],
716
- parameters: RouteParams,
717
- executionMode: "sequential",
718
- renderShell: "self",
719
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
720
- if (state.mode === "off")
721
- fail("Developer protocol is off. Run /develop on or /develop strict first.");
722
- if (state.activeRoute || routeOpening) {
723
- if (!state.activeRoute)
724
- fail("Another Developer route is currently opening. Wait for it to finish.");
725
- fail(
726
- `Route ${state.activeRoute.routeId} is still active. Record its judgment before routing another question.`,
727
- );
728
- }
729
- routeOpening = true;
730
-
731
- try {
732
- const question = params.question.trim();
733
- const reason = params.reason.trim();
734
- if (!question || !reason) fail("Question and reason must contain non-whitespace text.");
735
-
736
- const explicitQuestionId = params.open_question_id?.trim() || undefined;
737
- const targetQuestionId = inferredQuestionId(state, question, explicitQuestionId);
738
- if (
739
- targetQuestionId &&
740
- !state.pendingQuestions.some((item) => item.id === targetQuestionId)
741
- ) {
742
- fail(`Unknown pending question ID: ${targetQuestionId}`);
743
- }
744
-
745
- const owner = params.owner;
746
- const directBlockers = state.pendingQuestions.filter(
747
- (pending) => pending.gate === "before-direct",
748
- );
749
- if (owner === "direct" && directBlockers.length > 0) {
750
- fail(
751
- `Direct work is blocked by ${directBlockers
752
- .map((pending) => `${pending.id} (${pending.resolutionOwner}: ${pending.question})`)
753
- .join("; ")}. Resolve those questions first.`,
754
- );
755
- }
756
- const knownEvidence = (params.known_evidence ?? [])
757
- .map((item) => item.trim())
758
- .filter(Boolean);
759
- const consideredAlternatives: RouteAlternative[] =
760
- owner === "direct" && "alternatives_considered" in params
761
- ? (params.alternatives_considered ?? []).map((alternative) => ({
762
- owner: alternative.owner.trim(),
763
- reason: alternative.reason.trim(),
764
- }))
765
- : [];
766
- for (const alternative of consideredAlternatives) {
767
- if (!availableSkills.has(alternative.owner)) {
768
- fail(`Considered alternative ${alternative.owner} is unavailable or disabled.`);
769
- }
770
- }
771
- if (
772
- new Set(consideredAlternatives.map((alternative) => alternative.owner)).size !==
773
- consideredAlternatives.length
774
- ) {
775
- fail("Each considered alternative must name a different available Developer skill.");
776
- }
777
- if (owner === "direct" && state.lastJudgment?.owner === "direct") {
778
- if (knownEvidence.length === 0) {
779
- fail(
780
- "A consecutive direct route must cite evidence from the previous direct landing in known_evidence.",
781
- );
782
- }
783
- if (availableSkills.size > 0 && consideredAlternatives.length === 0) {
784
- fail(
785
- "A consecutive direct route must record the plausible available skill routes in alternatives_considered and explain why they are not needed now.",
786
- );
787
- }
788
- }
789
- if (
790
- owner === "direct" &&
791
- state.implementationFramingRequired &&
792
- (availableSkills.has("sketch") || availableSkills.has("signal"))
793
- ) {
794
- fail(
795
- "The latest resolved model requires implementation framing before direct work. Route sketch for new feature shape or signal for existing-code structural movement.",
796
- );
797
- }
798
- const skill = owner === "direct" ? undefined : availableSkills.get(owner);
799
- if (owner !== "direct" && !skill) {
800
- fail(
801
- `Developer skill ${owner} is unavailable or disabled in the current Pi resource configuration.`,
802
- );
803
- }
804
- const requestedExecutionProfile =
805
- "execution_profile" in params ? params.execution_profile : undefined;
806
- if (owner !== "direct" && requestedExecutionProfile !== undefined) {
807
- fail("execution_profile is valid only when owner=direct.");
808
- }
809
- const executionProfile: DirectExecutionProfile | undefined =
810
- owner === "direct" ? (requestedExecutionProfile ?? "ordinary") : undefined;
811
-
812
- const method =
813
- owner !== "direct"
814
- ? await renderSkillMethod(skill!)
815
- : executionProfile === "behavior-preserving-structure"
816
- ? [
817
- '<developer-direct-profile name="behavior-preserving-structure">',
818
- (await readFile(structuralChangeMethodPath, "utf8")).trim(),
819
- "</developer-direct-profile>",
820
- ].join("\n")
821
- : [
822
- "# Direct action",
823
- "",
824
- "The next local action is already justified. Keep this route open while using Pi implementation tools and collecting evidence.",
825
- ].join("\n");
826
-
827
- const directStep =
828
- owner === "direct"
829
- ? {
830
- movement: ("movement" in params ? params.movement : question).trim(),
831
- stopCondition: ("stop_condition" in params
832
- ? params.stop_condition
833
- : "Reach a green, pausable, reviewable stable landing."
834
- ).trim(),
835
- verification: ("verification" in params
836
- ? params.verification
837
- : "Run the narrowest relevant check and inspect the resulting diff or output."
838
- ).trim(),
839
- }
840
- : undefined;
841
-
842
- const event: RouteEvent = {
843
- protocol: PROTOCOL,
844
- kind: "route",
845
- routeId: `route:${toolCallId}`,
846
- question,
847
- owner,
848
- reason,
849
- knownEvidence,
850
- consideredAlternatives,
851
- targetQuestionId,
852
- methodLocation: skill?.filePath,
853
- executionProfile,
854
- directStep,
855
- };
856
- const response = [
857
- `Route ID: ${event.routeId}`,
858
- `Question: ${event.question}`,
859
- `Target: ${event.owner}`,
860
- skill
861
- ? `Skill location: ${skill.filePath}`
862
- : "Skill location: direct action; no skill file",
863
- executionProfile
864
- ? `Execution profile: ${executionProfile}`
865
- : "Execution profile: skill judgment",
866
- ...(directStep
867
- ? [
868
- `Movement: ${directStep.movement}`,
869
- `Stable landing: ${directStep.stopCondition}`,
870
- `Narrow verification: ${directStep.verification}`,
871
- "Stop this direct route at that landing, record the evidence, and route again before another movement.",
872
- ]
873
- : []),
874
- `Reason: ${event.reason}`,
875
- `Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
876
- `Alternatives reconsidered: ${
877
- event.consideredAlternatives.length > 0
878
- ? event.consideredAlternatives
879
- .map((alternative) => `${alternative.owner} — ${alternative.reason}`)
880
- .join(" | ")
881
- : "none"
882
- }`,
883
- targetQuestionId
884
- ? `Revisits pending question: ${targetQuestionId}`
885
- : "Revisits pending question: none",
886
- `When this route has done its job, call ${JUDGMENT_TOOL} with this exact route ID.`,
887
- "",
888
- "---",
889
- "",
890
- method,
891
- ].join("\n");
892
- ensureSafeToolText(response, "Developer route result");
893
- if (!canApplyDeveloperEvent(state, event)) {
894
- fail(
895
- "Developer machine guard rejected the route transition from the current branch state.",
896
- );
897
- }
898
-
899
- state = applyDeveloperEvent(state, event);
900
- syncProtocolTools();
901
- refreshUI(ctx);
902
- return textResult(response, event);
903
- } finally {
904
- routeOpening = false;
905
- }
906
- },
907
- renderCall(args, theme, context) {
908
- const owner = typeof args.owner === "string" && args.owner.length > 0 ? args.owner : "…";
909
- const question =
910
- typeof args.question === "string" && args.question.length > 0
911
- ? compactLine(args.question)
912
- : "…";
913
- return reusableText(
914
- `${theme.fg("toolTitle", theme.bold(ROUTE_TOOL))} ${theme.fg("accent", owner)} ${theme.fg("muted", question)}`,
915
- context.lastComponent,
916
- );
917
- },
918
- renderResult(result, { expanded, isPartial }, theme, context) {
919
- if (isPartial) {
920
- return reusableText(
921
- theme.fg("warning", "routing development question…"),
922
- context.lastComponent,
923
- );
924
- }
925
- if (context.isError) {
926
- return reusableText(
927
- theme.fg("error", resultText(result) || "Developer route failed"),
928
- context.lastComponent,
929
- );
930
- }
931
- const event = result.details as RouteEvent | undefined;
932
- let text = theme.fg("success", `routed ${routeRenderText(event)}`);
933
- if (expanded && event) {
934
- text += `\n${detailLine(theme, "route", event.routeId, "text")}`;
935
- text += `\n${detailLine(theme, "question", event.question, "text")}`;
936
- text += `\n${detailLine(theme, "reason", event.reason)}`;
937
- if (event.knownEvidence.length > 0) {
938
- for (const evidence of event.knownEvidence) {
939
- text += `\n${detailLine(theme, "evidence", evidence)}`;
940
- }
941
- } else {
942
- text += `\n${detailLine(theme, "evidence", "none recorded before routing", "warning")}`;
943
- }
944
- for (const alternative of event.consideredAlternatives ?? []) {
945
- text += `\n${detailLine(theme, `considered ${alternative.owner}`, alternative.reason)}`;
946
- }
947
- text += `\n${detailLine(theme, "revisits", event.targetQuestionId ?? "none")}`;
948
- text += `\n${detailLine(theme, "skill", event.methodLocation ?? "direct action")}`;
949
- if (event.executionProfile) {
950
- text += `\n${detailLine(theme, "profile", event.executionProfile)}`;
951
- }
952
- if (event.directStep) {
953
- text += `\n${detailLine(theme, "movement", event.directStep.movement, "text")}`;
954
- text += `\n${detailLine(theme, "landing", event.directStep.stopCondition)}`;
955
- text += `\n${detailLine(theme, "verify", event.directStep.verification)}`;
956
- }
957
- }
958
- if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
959
- return reusableText(text, context.lastComponent);
960
- },
961
- });
962
-
963
- const ChoiceResponseOptionParam = Type.Object(
964
- {
965
- value: Type.String({
966
- minLength: 1,
967
- maxLength: MAX_RESPONSE_IDENTIFIER_CHARS,
968
- pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$",
969
- }),
970
- label: Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS }),
971
- description: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS })),
972
- detail_prompt: Type.Optional(
973
- Type.String({
974
- minLength: 1,
975
- maxLength: MAX_RESPONSE_TEXT_CHARS,
976
- description: "Required non-empty detail when this option is selected",
977
- }),
978
- ),
979
- },
980
- { additionalProperties: false },
981
- );
982
- const ChoiceResponseFieldParam = Type.Object(
983
- {
984
- id: Type.String({
985
- minLength: 1,
986
- maxLength: MAX_RESPONSE_IDENTIFIER_CHARS,
987
- pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$",
988
- }),
989
- prompt: Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS }),
990
- description: Type.Optional(Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS })),
991
- options: Type.Array(ChoiceResponseOptionParam, {
992
- minItems: 2,
993
- maxItems: MAX_RESPONSE_OPTIONS,
994
- }),
995
- },
996
- { additionalProperties: false },
997
- );
998
- const ChoiceResponseSpecParam = Type.Object(
999
- {
1000
- kind: Type.Literal("choice-form"),
1001
- fields: Type.Array(ChoiceResponseFieldParam, {
1002
- minItems: 1,
1003
- maxItems: MAX_RESPONSE_FIELDS,
1004
- }),
1005
- },
1006
- {
1007
- additionalProperties: false,
1008
- description:
1009
- "Explicit controls for finite user-owned decisions; use one required single-choice field per decision instead of expecting the TUI to parse Markdown context",
1010
- },
1011
- );
1012
- const OpenQuestionParam = Type.Object(
1013
- {
1014
- question: Type.String({ minLength: 1, maxLength: MAX_QUESTION_CHARS }),
1015
- context: Type.Optional(
1016
- Type.String({
1017
- maxLength: MAX_QUESTION_CONTEXT_CHARS,
1018
- description:
1019
- "Detailed Markdown choices, examples, or constraints needed to resolve the question",
1020
- }),
1021
- ),
1022
- response_spec: Type.Optional(ChoiceResponseSpecParam),
1023
- status: StringEnum(["open", "blocked"] as const, {
1024
- description: "Whether the required evidence or answer is currently obtainable",
1025
- }),
1026
- resolution_owner: StringEnum(["agent", "user", "environment"] as const, {
1027
- description: "Who can provide the evidence or decision that resolves this question",
1028
- }),
1029
- gate: StringEnum(["none", "before-direct", "before-completion"] as const, {
1030
- description:
1031
- "What work this question blocks; an agent before-direct question must be resolvable through a non-direct evidence route",
1032
- }),
1033
- resolution_criteria: Type.String({
1034
- minLength: 1,
1035
- maxLength: MAX_EVIDENCE_CHARS,
1036
- description: "Observable evidence or answer that will settle the question",
1037
- }),
1038
- },
1039
- { additionalProperties: false },
1040
- );
1041
- const QuestionUpdateParam = Type.Object(
1042
- {
1043
- question_id: Type.String({
1044
- minLength: 1,
1045
- maxLength: 512,
1046
- description:
1047
- "Exact ID of a question that was pending before this route; never route_id or a newly opened question ID",
1048
- }),
1049
- status: StringEnum(["resolved", "not-applicable", "open", "blocked"] as const),
1050
- result: Type.String({ minLength: 1, maxLength: MAX_RESULT_CHARS }),
1051
- basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
1052
- maxItems: 20,
1053
- }),
1054
- },
1055
- { additionalProperties: false },
1056
- );
1057
- const JudgmentParams = Type.Object({
1058
- route_id: Type.String({
1059
- minLength: 1,
1060
- maxLength: 512,
1061
- description: `Exact route ID returned by ${ROUTE_TOOL}`,
1062
- }),
1063
- status: StringEnum(["resolved", "needs-evidence", "not-applicable", "blocked"] as const),
1064
- result: Type.String({
1065
- minLength: 1,
1066
- maxLength: MAX_RESULT_CHARS,
1067
- description:
1068
- "The resulting judgment in Markdown; preserve the routed skill's inspectable tables, diagrams, matrices, timelines, or code blocks",
1069
- }),
1070
- basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
1071
- maxItems: 20,
1072
- description: "Evidence supporting the judgment or blocker",
1073
- }),
1074
- open_questions: Type.Optional(
1075
- Type.Array(OpenQuestionParam, {
1076
- maxItems: 10,
1077
- description:
1078
- "New unresolved questions with explicit resolution owner, gate, and observable resolution criteria",
1079
- }),
1080
- ),
1081
- question_updates: Type.Optional(
1082
- Type.Array(QuestionUpdateParam, {
1083
- maxItems: 20,
1084
- description:
1085
- "Updates to questions already pending before this route; use [] when none exist, and never put route_id or newly opened questions here",
1086
- }),
1087
- ),
1088
- artifacts: Type.Optional(
1089
- Type.Array(Type.String({ maxLength: MAX_ARTIFACT_CHARS }), {
1090
- maxItems: 20,
1091
- description: "Relevant paths, commands, tests, or outputs",
1092
- }),
1093
- ),
1094
- });
1095
-
1096
- pi.registerTool({
1097
- name: JUDGMENT_TOOL,
1098
- label: "Developer Record Judgment",
1099
- description:
1100
- "Close the active Developer route with its result, evidence, newly opened questions, and relevant artifacts. This records a local judgment, not task completion.",
1101
- promptSnippet: "Record evidence and close the active development route",
1102
- promptGuidelines: [
1103
- `Use ${JUDGMENT_TOOL} with the exact active Developer route ID.`,
1104
- `Use ${JUDGMENT_TOOL} result as Markdown and preserve the routed skill's inspectable tables, diagrams, matrices, timelines, and code blocks instead of reducing them to prose.`,
1105
- `Use ${JUDGMENT_TOOL} open_questions with resolution_owner, gate, and resolution_criteria; for finite required user decisions, add a choice-form response_spec with one field per decision. Use question_updates whenever current evidence settles or changes any existing pending question.`,
1106
- `Do not use ${JUDGMENT_TOOL} with resolved, not-applicable, or blocked status without at least one concrete basis.`,
1107
- ],
1108
- parameters: JudgmentParams,
1109
- executionMode: "sequential",
1110
- renderShell: "self",
1111
- prepareArguments(args): Static<typeof JudgmentParams> {
1112
- const input = args as Static<typeof JudgmentParams> & {
1113
- status?: string;
1114
- open_questions?: unknown[];
1115
- };
1116
- if (!input || typeof input !== "object" || !Array.isArray(input.open_questions)) {
1117
- return args as Static<typeof JudgmentParams>;
1118
- }
1119
- return {
1120
- ...input,
1121
- open_questions: input.open_questions.map((question) =>
1122
- typeof question === "string" ? legacyOpenQuestion(question, input.status) : question,
1123
- ) as Static<typeof JudgmentParams>["open_questions"],
1124
- };
1125
- },
1126
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1127
- if (state.mode === "off") fail("Developer protocol is off.");
1128
- const activeRoute = state.activeRoute;
1129
- if (!activeRoute) fail("There is no active Developer route to close.");
1130
- if (params.route_id !== activeRoute.routeId) {
1131
- fail(`Route ID mismatch. Active route is ${activeRoute.routeId}.`);
1132
- }
1133
-
1134
- const basis = params.basis.map((item) => item.trim()).filter(Boolean);
1135
- const result = params.result.trim();
1136
- if (!result) fail("A judgment result must contain non-whitespace text.");
1137
- if (params.status !== "needs-evidence" && basis.length === 0) {
1138
- fail(`${params.status} judgments require at least one concrete basis.`);
1139
- }
1140
-
1141
- const openedQuestions = buildOpenedQuestions(
1142
- params.open_questions ?? [],
1143
- state,
1144
- params.route_id,
1145
- );
1146
- if (
1147
- availableSkills.size === 0 &&
1148
- openedQuestions.some(
1149
- (question) => question.resolutionOwner === "agent" && question.gate === "before-direct",
1150
- )
1151
- ) {
1152
- fail(
1153
- "An agent-owned before-direct question requires an available non-direct skill resolution path.",
1154
- );
1155
- }
1156
- const remainsOpen = params.status === "needs-evidence" || params.status === "blocked";
1157
- if (remainsOpen && !activeRoute.targetQuestionId && openedQuestions.length === 0) {
1158
- fail(
1159
- "A needs-evidence or blocked judgment for a new question must include at least one structured open_questions entry.",
1160
- );
1161
- }
1162
-
1163
- if (state.pendingQuestions.length > 0 && params.question_updates === undefined) {
1164
- fail(
1165
- "Pending questions exist. Include question_updates (an empty array is valid) after rechecking them against this route's evidence.",
1166
- );
1167
- }
1168
- const questionUpdates = buildQuestionUpdates(params.question_updates ?? [], state);
1169
- const updatedQuestionIds = new Set(questionUpdates.map((update) => update.questionId));
1170
- if (openedQuestions.some((question) => updatedQuestionIds.has(question.id))) {
1171
- fail("A question cannot be opened and updated in the same judgment.");
1172
- }
1173
- const reopensCurrentQuestion = openedQuestions.some(
1174
- (question) =>
1175
- normalizedQuestion(question.question) === normalizedQuestion(activeRoute.question),
1176
- );
1177
- if (!remainsOpen && reopensCurrentQuestion) {
1178
- fail("A resolved or not-applicable judgment cannot reopen its own question.");
1179
- }
1180
- const targetQuestionId = activeRoute.targetQuestionId;
1181
- const targetUpdate = targetQuestionId
1182
- ? questionUpdates.find((update) => update.questionId === targetQuestionId)
1183
- : undefined;
1184
- if (targetQuestionId && !targetUpdate) {
1185
- fail(`Focused question ${targetQuestionId} requires an explicit question_updates entry.`);
1186
- }
1187
- if (targetUpdate) {
1188
- const closesTarget =
1189
- targetUpdate.status === "resolved" || targetUpdate.status === "not-applicable";
1190
- if (!remainsOpen && !closesTarget) {
1191
- fail("A resolved focused judgment must explicitly resolve or dismiss its question.");
1192
- }
1193
- if (remainsOpen && openedQuestions.length === 0 && closesTarget) {
1194
- fail(
1195
- "A focused question cannot close while its judgment reports no replacement evidence question.",
1196
- );
1197
- }
1198
- if (remainsOpen && openedQuestions.length > 0 && !closesTarget) {
1199
- fail(
1200
- "Replacement questions require explicitly resolving or dismissing the broader focused question.",
1201
- );
1202
- }
1203
- }
1204
-
1205
- const event: JudgmentEvent = {
1206
- protocol: PROTOCOL,
1207
- kind: "judgment",
1208
- routeId: params.route_id,
1209
- question: activeRoute.question,
1210
- owner: activeRoute.owner,
1211
- status: params.status,
1212
- result,
1213
- basis,
1214
- openedQuestions,
1215
- questionUpdates,
1216
- artifacts: (params.artifacts ?? []).map((item) => item.trim()).filter(Boolean),
1217
- changedArtifacts: routesWithMutation.has(params.route_id),
1218
- };
1219
- if (!canApplyDeveloperEvent(state, event)) {
1220
- fail(
1221
- "Developer machine guard rejected the judgment transition from the current active route.",
1222
- );
1223
- }
1224
- const nextState = applyDeveloperEvent(state, event);
1225
- if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
1226
- fail(
1227
- `Developer protocol would retain ${nextState.pendingQuestions.length} pending questions; resolve or consolidate them before opening more.`,
1228
- );
1229
- }
1230
-
1231
- const immediateQuestion =
1232
- ctx.mode === "tui"
1233
- ? firstImmediateUserQuestion(state.pendingQuestions, event.openedQuestions)
1234
- : undefined;
1235
- let immediateDisposition: ImmediateQuestionDisposition | undefined;
1236
- if (immediateQuestion) {
1237
- try {
1238
- immediateDisposition = await promptImmediateUserQuestion(ctx, immediateQuestion);
1239
- } catch {
1240
- ctx.ui.notify(
1241
- "Could not open the Developer decision prompt; the question remains open.",
1242
- "warning",
1243
- );
1244
- }
1245
- }
1246
-
1247
- const nextMessage = judgmentNextMessage(event, nextState);
1248
- const resolvedQuestionCount = event.questionUpdates.filter(
1249
- (update) => update.status === "resolved" || update.status === "not-applicable",
1250
- ).length;
1251
- const response =
1252
- `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\n` +
1253
- `Question updates: ${resolvedQuestionCount} resolved, ${event.questionUpdates.length - resolvedQuestionCount} retained or blocked.\n` +
1254
- nextMessage +
1255
- immediateQuestionMessage(immediateQuestion, immediateDisposition);
1256
- ensureSafeToolText(response, "Developer judgment result");
1257
-
1258
- routesWithMutation.delete(params.route_id);
1259
- state = nextState;
1260
- syncProtocolTools();
1261
- refreshUI(ctx);
1262
- return textResult(response, event);
1263
- },
1264
- renderCall(args, theme, context) {
1265
- const status = typeof args.status === "string" && args.status.length > 0 ? args.status : "…";
1266
- const result =
1267
- typeof args.result === "string" && args.result.length > 0
1268
- ? compactJudgmentResult(args.result)
1269
- : "…";
1270
- const statusText =
1271
- status === "resolved"
1272
- ? theme.fg("success", status)
1273
- : status === "needs-evidence"
1274
- ? theme.fg("warning", status)
1275
- : status === "blocked"
1276
- ? theme.fg("error", status)
1277
- : theme.fg("muted", status);
1278
- return reusableText(
1279
- `${theme.fg("toolTitle", theme.bold(JUDGMENT_TOOL))} ${statusText} ${theme.fg("muted", result)}`,
1280
- context.lastComponent,
1281
- );
1282
- },
1283
- renderResult(result, { expanded, isPartial }, theme, context) {
1284
- if (isPartial) {
1285
- return reusableText(
1286
- theme.fg("warning", "recording development judgment…"),
1287
- context.lastComponent,
1288
- );
1289
- }
1290
- if (context.isError) {
1291
- return reusableText(
1292
- theme.fg("error", resultText(result) || "Developer judgment failed"),
1293
- context.lastComponent,
1294
- );
1295
- }
1296
- const event = result.details as JudgmentEvent | undefined;
1297
- const summary = judgmentRenderText(event);
1298
- let text =
1299
- event?.status === "resolved"
1300
- ? theme.fg("success", summary)
1301
- : event?.status === "needs-evidence"
1302
- ? theme.fg("warning", summary)
1303
- : event?.status === "blocked"
1304
- ? theme.fg("error", summary)
1305
- : theme.fg("muted", summary);
1306
- if (expanded && event) return expandedJudgment(event, text, theme);
1307
- if (event) text += ` · ${keyHint("app.tools.expand", "details")}`;
1308
- return reusableText(text, context.lastComponent);
1309
- },
1310
- });
1311
-
1312
- const refreshAvailableSkills = (ctx: ExtensionCommandContext) => {
1313
- if (typeof ctx.getSystemPromptOptions !== "function") return;
1314
- availableSkills = availablePackageSkills(ctx.getSystemPromptOptions().skills ?? [], skillsRoot);
1315
- };
1316
-
1317
- const statusMessage = () => {
1318
- const active = state.activeRoute
1319
- ? `${state.activeRoute.routeId} · ${state.activeRoute.owner} · ${state.activeRoute.question}`
1320
- : "none";
1321
- const pending =
1322
- state.pendingQuestions.length > 0
1323
- ? state.pendingQuestions
1324
- .map(
1325
- (question) =>
1326
- `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
1327
- )
1328
- .join(" | ")
1329
- : "none";
1330
- const last = state.lastJudgment
1331
- ? `${state.lastJudgment.status} · ${state.lastJudgment.result}`
1332
- : "none";
1333
- const basis =
1334
- state.lastJudgment && state.lastJudgment.basis.length > 0
1335
- ? state.lastJudgment.basis.join(" | ")
1336
- : "none";
1337
- const artifacts =
1338
- state.lastJudgment && state.lastJudgment.artifacts.length > 0
1339
- ? state.lastJudgment.artifacts.join(" | ")
1340
- : "none";
1341
- const history =
1342
- state.judgmentHistory.length > 0
1343
- ? state.judgmentHistory
1344
- .slice(-10)
1345
- .map((judgment) => {
1346
- const route = state.routeHistory.find(
1347
- (candidate) => candidate.routeId === judgment.routeId,
1348
- );
1349
- const alternatives = (route?.consideredAlternatives ?? [])
1350
- .map((alternative) => `${alternative.owner}: ${alternative.reason}`)
1351
- .join("; ");
1352
- return `${judgment.owner} · ${judgment.status} · ${judgment.result}${
1353
- alternatives ? ` · considered ${alternatives}` : ""
1354
- }`;
1355
- })
1356
- .join("\n")
1357
- : "none";
1358
- return (
1359
- `${summarizeState(state)}` +
1360
- `\nactive: ${active}` +
1361
- `\nlast: ${last}` +
1362
- `\nbasis: ${basis}` +
1363
- `\nartifacts: ${artifacts}` +
1364
- `\nimplementation framing: ${state.implementationFramingRequired ? "required" : "clear"}` +
1365
- `\ncheckpoint: ${state.rerouteRequired ? "reroute required" : "ready"}` +
1366
- `\nverification: ${state.verificationRequired ? "required" : "current"}` +
1367
- `\nhistory:\n${history}` +
1368
- `\nactive tools: ${pi.getActiveTools().join(", ")}` +
1369
- `\navailable skills: ${[...availableSkills.keys()].join(", ") || "none"}` +
1370
- `\npending: ${pending}` +
1371
- "\nprotocol state is not a product-completion claim"
1372
- );
1373
- };
1374
-
1375
- pi.registerCommand("develop", {
1376
- description: "Control or inspect Developer: /develop on | strict | status | questions | off",
1377
- getArgumentCompletions(prefix) {
1378
- const normalized = prefix.trim();
1379
- const matches = DEVELOPER_COMMAND_ACTIONS.filter((action) => action.startsWith(normalized));
1380
- return matches.length > 0
1381
- ? matches.map((action) => ({ value: action, label: action }))
1382
- : null;
1383
- },
1384
- handler: async (args, ctx) => {
1385
- const submitQuestionResolution = async (question: PendingQuestion): Promise<boolean> => {
1386
- const request = await editQuestionResolutionRequest(ctx, question);
1387
- if (request === undefined) return false;
1388
- const focusEvent: FocusEvent = {
1389
- protocol: PROTOCOL,
1390
- kind: "focus",
1391
- questionId: question.id,
1392
- };
1393
- pi.appendEntry(FOCUS_ENTRY, focusEvent);
1394
- state = applyDeveloperEvent(state, focusEvent);
1395
- refreshUI(ctx);
1396
- ctx.ui.setEditorText("");
1397
- ctx.ui.notify(
1398
- "Question response submitted. It remains open until Developer records a resolved or not-applicable judgment.",
1399
- "info",
1400
- );
1401
- if (ctx.isIdle()) pi.sendUserMessage(request);
1402
- else pi.sendUserMessage(request, { deliverAs: "followUp" });
1403
- return true;
1404
- };
1405
-
1406
- let action = args.trim();
1407
- if (!action && ctx.mode === "tui") {
1408
- refreshAvailableSkills(ctx);
1409
- while (true) {
1410
- const selection = await showDeveloperActionSelector(ctx, state);
1411
- if (!selection) return;
1412
- if (selection.kind === "question") {
1413
- const question = state.pendingQuestions.find(
1414
- (item) => item.id === selection.questionId,
1415
- );
1416
- if (!question) return;
1417
- if (await submitQuestionResolution(question)) return;
1418
- continue;
1419
- }
1420
- action = selection.value;
1421
- break;
1422
- }
1423
- }
1424
- if (!action) action = "status";
1425
- if (action === "on" || action === "strict" || action === "off") {
1426
- if (
1427
- action === "off" &&
1428
- ctx.mode === "tui" &&
1429
- (state.activeRoute || state.pendingQuestions.length > 0)
1430
- ) {
1431
- const work = [
1432
- ...(state.activeRoute ? ["the active route"] : []),
1433
- ...(state.pendingQuestions.length > 0
1434
- ? [`${state.pendingQuestions.length} open question(s)`]
1435
- : []),
1436
- ].join(" and ");
1437
- const confirmed = await ctx.ui.confirm(
1438
- "Turn off Developer?",
1439
- `This clears ${work} from the current protocol state. Existing session history remains.`,
1440
- );
1441
- if (!confirmed) return;
1442
- }
1443
- setMode(action, ctx);
1444
- ctx.ui.notify(`Developer mode: ${action}`, "info");
1445
- return;
1446
- }
1447
- if (action === "questions") {
1448
- if (state.pendingQuestions.length === 0) {
1449
- ctx.ui.notify("Developer has no open questions on the current branch.", "info");
1450
- return;
1451
- }
1452
- if (ctx.mode === "tui") {
1453
- const soleQuestion =
1454
- state.pendingQuestions.length === 1 ? state.pendingQuestions[0] : undefined;
1455
- if (soleQuestion) {
1456
- await submitQuestionResolution(soleQuestion);
1457
- return;
1458
- }
1459
- while (true) {
1460
- const questionId = await showPendingQuestionSelector(ctx, state.pendingQuestions);
1461
- if (!questionId) return;
1462
- const question = state.pendingQuestions.find((item) => item.id === questionId);
1463
- if (!question) return;
1464
- if (await submitQuestionResolution(question)) return;
1465
- }
1466
- }
1467
- ctx.ui.notify(
1468
- state.pendingQuestions
1469
- .map(
1470
- (question) =>
1471
- `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question}\n resolves when: ${question.resolutionCriteria}`,
1472
- )
1473
- .join("\n"),
1474
- "info",
1475
- );
1476
- return;
1477
- }
1478
- if (action === "status") {
1479
- refreshAvailableSkills(ctx);
1480
- if (ctx.mode === "tui") {
1481
- await showDeveloperStatus(ctx, {
1482
- state,
1483
- activeTools: pi.getActiveTools(),
1484
- availableSkills: [...availableSkills.keys()],
1485
- });
1486
- } else {
1487
- ctx.ui.notify(statusMessage(), "info");
1488
- }
1489
- return;
1490
- }
1491
- ctx.ui.notify("Usage: /develop on | strict | status | questions | off", "warning");
1492
- },
1493
- });
1494
-
1495
- const entryRendererAPI = pi as ExtensionAPI & {
1496
- registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"];
1497
- };
1498
- entryRendererAPI.registerEntryRenderer?.(MODE_ENTRY, (entry, _options, theme) => {
1499
- const event = normalizeDeveloperEvent(entry.data);
1500
- if (!event || event.kind !== "mode") return undefined;
1501
- return new Text(
1502
- `${theme.fg("toolTitle", theme.bold("Developer mode"))} ${theme.fg("accent", event.mode)}`,
1503
- 0,
1504
- 0,
1505
- );
1506
- });
1507
-
1508
- pi.on("before_agent_start", (event) => {
1509
- availableSkills = availablePackageSkills(event.systemPromptOptions.skills ?? [], skillsRoot);
1510
- if (state.mode === "off") return;
1511
- return {
1512
- systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]),
1513
- };
1514
- });
1515
-
1516
- pi.on("tool_call", (event) => {
1517
- const capability = builtinControlledToolCapabilities(pi.getAllTools()).get(event.toolName);
1518
- if (!capability) return;
1519
-
1520
- const access = protocolToolAccess(state);
1521
- if (isControlledToolAllowed({ mode: state.mode, capability, access })) {
1522
- if (access.canMutate && state.activeRoute) routesWithMutation.add(state.activeRoute.routeId);
1523
- return;
1524
- }
1525
-
1526
- const directBlockers = state.pendingQuestions.filter(
1527
- (question) => question.gate === "before-direct",
1528
- );
1529
- if (directBlockers.length > 0) {
1530
- const blockedAction =
1531
- capability === "execute" ? "unrouted shell execution" : "artifact mutation";
1532
- return {
1533
- block: true,
1534
- reason: `Developer question gate blocks ${blockedAction} until resolved: ${directBlockers
1535
- .map((question) => `${question.id} (${question.resolutionOwner}: ${question.question})`)
1536
- .join("; ")}. A non-direct judgment route may still use bash to obtain evidence.`,
1537
- };
1538
- }
1539
-
1540
- return {
1541
- block: true,
1542
- reason:
1543
- capability === "execute"
1544
- ? `Developer strict mode requires an active judgment or direct route before Pi built-in bash can execute evidence checks.`
1545
- : `Developer strict mode requires an active ${ROUTE_TOOL} targeting direct action (owner=direct) before Pi built-in edit or write.`,
1546
- };
1547
- });
1548
-
1549
- pi.on("session_start", (_event, ctx) => {
1550
- reconstruct(ctx);
1551
- const startupMode = pi.getFlag("develop-mode");
1552
- if ((startupMode === "on" || startupMode === "strict") && state.mode !== startupMode) {
1553
- setMode(startupMode, ctx);
1554
- }
1555
- });
1556
- pi.on("session_tree", (_event, ctx) => reconstruct(ctx));
1557
- pi.on("agent_settled", (_event, ctx) => refreshUI(ctx));
1558
- pi.on("session_shutdown", (_event, ctx) => {
1559
- ctx.ui.setStatus("developer", undefined);
1560
- ctx.ui.setWidget("developer", undefined);
1561
- });
563
+ let availableSkills = new Map<string, Skill>();
564
+ let state = initialState();
565
+ let routeOpening = false;
566
+ const routesWithMutation = new Set<string>();
567
+ let toolPolicyMemory: ToolPolicyMemory = { withheldBuiltins: new Set() };
568
+ let toolPolicyRestartRequired = false;
569
+
570
+ pi.registerFlag("develop", {
571
+ description: "Start with the Developer protocol enabled",
572
+ type: "boolean",
573
+ default: false,
574
+ });
575
+
576
+ const syncProtocolTools = () => {
577
+ const current = pi.getActiveTools();
578
+ const next = reconcileProtocolTools({
579
+ activeTools: current,
580
+ allTools: pi.getAllTools(),
581
+ enabled: state.enabled,
582
+ access: protocolToolAccess(state),
583
+ protocolTools: PROTOCOL_TOOLS,
584
+ memory: toolPolicyMemory,
585
+ });
586
+ toolPolicyMemory = next.memory;
587
+ if (!sameToolSet(current, next.activeTools))
588
+ pi.setActiveTools(next.activeTools);
589
+ };
590
+
591
+ const releaseProtocolTools = () => {
592
+ const current = pi.getActiveTools();
593
+ const next = reconcileProtocolTools({
594
+ activeTools: current,
595
+ allTools: pi.getAllTools(),
596
+ enabled: false,
597
+ access: protocolToolAccess(state),
598
+ protocolTools: PROTOCOL_TOOLS,
599
+ memory: toolPolicyMemory,
600
+ });
601
+ toolPolicyMemory = next.memory;
602
+ if (!sameToolSet(current, next.activeTools))
603
+ pi.setActiveTools(next.activeTools);
604
+ };
605
+
606
+ const refreshUI = (ctx: ExtensionContext) => {
607
+ if (toolPolicyRestartRequired) {
608
+ ctx.ui.setStatus("developer", "developer · restart required");
609
+ ctx.ui.setWidget(
610
+ "developer",
611
+ ["blocked · restart Pi to reset Developer tool access"],
612
+ { placement: "belowEditor" },
613
+ );
614
+ return;
615
+ }
616
+ if (!state.enabled) {
617
+ ctx.ui.setStatus("developer", undefined);
618
+ ctx.ui.setWidget("developer", undefined);
619
+ return;
620
+ }
621
+
622
+ ctx.ui.setStatus(
623
+ "developer",
624
+ ctx.mode === "tui"
625
+ ? renderDeveloperFooter(state, ctx.ui.theme)
626
+ : summarizeState(state),
627
+ );
628
+ if (
629
+ !state.activeRoute &&
630
+ state.pendingQuestions.length === 0 &&
631
+ !state.implementationFramingRequired &&
632
+ !state.verificationRequired
633
+ ) {
634
+ ctx.ui.setWidget("developer", undefined);
635
+ return;
636
+ }
637
+
638
+ if (ctx.mode === "tui") {
639
+ const viewState = state;
640
+ ctx.ui.setWidget(
641
+ "developer",
642
+ (_tui, theme) => new DeveloperWidget(viewState, theme),
643
+ {
644
+ placement: "belowEditor",
645
+ },
646
+ );
647
+ return;
648
+ }
649
+
650
+ const lines = [
651
+ ...(state.activeRoute
652
+ ? [
653
+ `route · ${state.activeRoute.target} · ${compactLine(state.activeRoute.question)}`,
654
+ ]
655
+ : []),
656
+ ...state.pendingQuestions
657
+ .slice(0, 3)
658
+ .map(
659
+ (question) =>
660
+ `open · ${question.id} · ${compactLine(question.question)}`,
661
+ ),
662
+ ...(state.pendingQuestions.length > 3
663
+ ? [`open · +${state.pendingQuestions.length - 3} more`]
664
+ : []),
665
+ ...(state.implementationFramingRequired
666
+ ? ["gate · frame implementation before mutation (sketch or signal)"]
667
+ : []),
668
+ ...(state.verificationRequired
669
+ ? ["next · verify changed artifacts before completion"]
670
+ : []),
671
+ ];
672
+ ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
673
+ };
674
+
675
+ const reconstruct = (ctx: ExtensionContext) => {
676
+ state = toolPolicyRestartRequired
677
+ ? initialState()
678
+ : reconstructState(ctx.sessionManager.getBranch());
679
+ syncProtocolTools();
680
+ refreshUI(ctx);
681
+ };
682
+
683
+ const setEnabled = (enabled: boolean, ctx: ExtensionContext): boolean => {
684
+ if (enabled && toolPolicyRestartRequired) {
685
+ ctx.ui.notify(TOOL_POLICY_RESTART_MESSAGE, "error");
686
+ refreshUI(ctx);
687
+ return false;
688
+ }
689
+ const event: ActivationEvent = {
690
+ protocol: PROTOCOL,
691
+ kind: "activation",
692
+ enabled,
693
+ };
694
+ pi.appendEntry(ACTIVATION_ENTRY, event);
695
+ state = applyDeveloperEvent(state, event);
696
+ syncProtocolTools();
697
+ refreshUI(ctx);
698
+ return true;
699
+ };
700
+
701
+ const SharedRouteParams = {
702
+ question: Type.String({
703
+ minLength: 1,
704
+ maxLength: MAX_QUESTION_CHARS,
705
+ description: "The single concrete judgment or action question to route",
706
+ }),
707
+ reason: Type.String({
708
+ minLength: 1,
709
+ maxLength: MAX_QUESTION_CHARS,
710
+ description: "Why this route target fits the current evidence",
711
+ }),
712
+ known_evidence: Type.Optional(
713
+ Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
714
+ maxItems: 12,
715
+ description: "Evidence already known before routing",
716
+ }),
717
+ ),
718
+ open_question_id: Type.Optional(
719
+ Type.String({
720
+ maxLength: 512,
721
+ description: "Exact pending question ID when this route revisits one",
722
+ }),
723
+ ),
724
+ };
725
+ const RouteAlternativeParam = Type.Object(
726
+ {
727
+ target: Type.String({
728
+ minLength: 1,
729
+ maxLength: 64,
730
+ description:
731
+ "Exact available Developer skill name that was reconsidered",
732
+ }),
733
+ reason: Type.String({
734
+ minLength: 1,
735
+ maxLength: MAX_EVIDENCE_CHARS,
736
+ description:
737
+ "Why this skill would add no useful judgment before the proposed implementation movement",
738
+ }),
739
+ },
740
+ { additionalProperties: false },
741
+ );
742
+ const RouteParams = Type.Union([
743
+ Type.Object(
744
+ {
745
+ ...SharedRouteParams,
746
+ target: Type.String({
747
+ minLength: 1,
748
+ maxLength: 64,
749
+ pattern: "^(?!implementation$)[a-z0-9]+(?:-[a-z0-9]+)*$",
750
+ description:
751
+ "Exact skill name from the current Available Developer skills list",
752
+ }),
753
+ },
754
+ {
755
+ additionalProperties: false,
756
+ description: "Route one question to a Developer skill",
757
+ },
758
+ ),
759
+ Type.Object(
760
+ {
761
+ ...SharedRouteParams,
762
+ target: Type.Literal("implementation", {
763
+ description:
764
+ "Use Pi implementation tools for an already-justified action",
765
+ }),
766
+ movement: Type.String({
767
+ minLength: 1,
768
+ maxLength: MAX_QUESTION_CHARS,
769
+ description:
770
+ "One locally explainable behavioral or structural movement; not a multi-step implementation queue",
771
+ }),
772
+ stop_condition: Type.String({
773
+ minLength: 1,
774
+ maxLength: MAX_QUESTION_CHARS,
775
+ description:
776
+ "The green, pausable, reviewable stable landing that ends this implementation route",
777
+ }),
778
+ verification: Type.String({
779
+ minLength: 1,
780
+ maxLength: MAX_EVIDENCE_CHARS,
781
+ description:
782
+ "The narrowest check that can catch the likely break in this movement",
783
+ }),
784
+ alternatives_considered: Type.Optional(
785
+ Type.Array(RouteAlternativeParam, {
786
+ maxItems: 6,
787
+ description:
788
+ "On a reroute after implementation, the plausible skill routes reconsidered and why each is unnecessary now",
789
+ }),
790
+ ),
791
+ execution_profile: Type.Optional(
792
+ Type.Literal("behavior-preserving-structure", {
793
+ description:
794
+ "Load the focused structural-mutation protocol; omit for ordinary implementation action",
795
+ }),
796
+ ),
797
+ },
798
+ {
799
+ additionalProperties: false,
800
+ description: "Route one already-justified implementation action",
801
+ },
802
+ ),
803
+ ]);
804
+
805
+ pi.registerTool({
806
+ name: ROUTE_TOOL,
807
+ label: "Developer Route Question",
808
+ description:
809
+ "Route one concrete judgment or one green-to-green implementation movement. Uses an adaptive default topology: model, then sketch for feature shape or signal for structural movement, implementation stable landings, and verify before completion.",
810
+ promptSnippet: "Choose how to handle one development question",
811
+ promptGuidelines: [
812
+ `Call ${ROUTE_TOOL} only when there is no active Developer route.`,
813
+ `Use ${ROUTE_TOOL} with the most focused skill supported by current evidence; target=implementation requires one movement, one stable landing, and one narrow verification.`,
814
+ `When ${ROUTE_TOOL} follows an implementation judgment with another implementation route, cite the previous landing in known_evidence and record plausible skill routes in alternatives_considered instead of selecting implementation by momentum.`,
815
+ `After a resolved model, use sketch for first feature implementation framing or signal for existing-code structural movement before implementation mutation.`,
816
+ ],
817
+ parameters: RouteParams,
818
+ executionMode: "sequential",
819
+ renderShell: "self",
820
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
821
+ if (!state.enabled)
822
+ fail("Developer protocol is off. Run /develop on first.");
823
+ if (state.activeRoute || routeOpening) {
824
+ if (!state.activeRoute)
825
+ fail(
826
+ "Another Developer route is currently opening. Wait for it to finish.",
827
+ );
828
+ fail(
829
+ `Route ${state.activeRoute.routeId} is still active. Record its judgment before routing another question.`,
830
+ );
831
+ }
832
+ routeOpening = true;
833
+
834
+ try {
835
+ const question = params.question.trim();
836
+ const reason = params.reason.trim();
837
+ if (!question || !reason)
838
+ fail("Question and reason must contain non-whitespace text.");
839
+
840
+ const explicitQuestionId = params.open_question_id?.trim() || undefined;
841
+ const targetQuestionId = inferredQuestionId(
842
+ state,
843
+ question,
844
+ explicitQuestionId,
845
+ );
846
+ if (
847
+ targetQuestionId &&
848
+ !state.pendingQuestions.some((item) => item.id === targetQuestionId)
849
+ ) {
850
+ fail(`Unknown pending question ID: ${targetQuestionId}`);
851
+ }
852
+
853
+ const target = params.target;
854
+ const implementationBlockers = state.pendingQuestions.filter(
855
+ (pending) => pending.gate === "before-implementation",
856
+ );
857
+ if (target === "implementation" && implementationBlockers.length > 0) {
858
+ fail(
859
+ `Implementation work is blocked by ${implementationBlockers
860
+ .map(
861
+ (pending) =>
862
+ `${pending.id} (${pending.resolutionOwner}: ${pending.question})`,
863
+ )
864
+ .join("; ")}. Resolve those questions first.`,
865
+ );
866
+ }
867
+ const knownEvidence = (params.known_evidence ?? [])
868
+ .map((item) => item.trim())
869
+ .filter(Boolean);
870
+ const consideredAlternatives: RouteAlternative[] =
871
+ target === "implementation" && "alternatives_considered" in params
872
+ ? (params.alternatives_considered ?? []).map((alternative) => ({
873
+ target: alternative.target.trim(),
874
+ reason: alternative.reason.trim(),
875
+ }))
876
+ : [];
877
+ for (const alternative of consideredAlternatives) {
878
+ if (!availableSkills.has(alternative.target)) {
879
+ fail(
880
+ `Considered alternative ${alternative.target} is unavailable or disabled.`,
881
+ );
882
+ }
883
+ }
884
+ if (
885
+ new Set(
886
+ consideredAlternatives.map((alternative) => alternative.target),
887
+ ).size !== consideredAlternatives.length
888
+ ) {
889
+ fail(
890
+ "Each considered alternative must name a different available Developer skill.",
891
+ );
892
+ }
893
+ if (
894
+ target === "implementation" &&
895
+ state.lastJudgment?.target === "implementation"
896
+ ) {
897
+ if (knownEvidence.length === 0) {
898
+ fail(
899
+ "A consecutive implementation route must cite evidence from the previous implementation landing in known_evidence.",
900
+ );
901
+ }
902
+ if (availableSkills.size > 0 && consideredAlternatives.length === 0) {
903
+ fail(
904
+ "A consecutive implementation route must record the plausible available skill routes in alternatives_considered and explain why they are not needed now.",
905
+ );
906
+ }
907
+ }
908
+ if (
909
+ target === "implementation" &&
910
+ state.implementationFramingRequired &&
911
+ (availableSkills.has("sketch") || availableSkills.has("signal"))
912
+ ) {
913
+ fail(
914
+ "The latest resolved model requires implementation framing before implementation work. Route sketch for new feature shape or signal for existing-code structural movement.",
915
+ );
916
+ }
917
+ const skill =
918
+ target === "implementation" ? undefined : availableSkills.get(target);
919
+ if (target !== "implementation" && !skill) {
920
+ fail(
921
+ `Developer skill ${target} is unavailable or disabled in the current Pi resource configuration.`,
922
+ );
923
+ }
924
+ const requestedExecutionProfile =
925
+ "execution_profile" in params ? params.execution_profile : undefined;
926
+ if (
927
+ target !== "implementation" &&
928
+ requestedExecutionProfile !== undefined
929
+ ) {
930
+ fail("execution_profile is valid only when target=implementation.");
931
+ }
932
+ const executionProfile: ImplementationProfile | undefined =
933
+ target === "implementation"
934
+ ? (requestedExecutionProfile ?? "ordinary")
935
+ : undefined;
936
+
937
+ const method =
938
+ target !== "implementation"
939
+ ? await renderSkillMethod(skill!)
940
+ : executionProfile === "behavior-preserving-structure"
941
+ ? [
942
+ '<developer-implementation-profile name="behavior-preserving-structure">',
943
+ (await readFile(structuralChangeMethodPath, "utf8")).trim(),
944
+ "</developer-implementation-profile>",
945
+ ].join("\n")
946
+ : [
947
+ "# Implementation action",
948
+ "",
949
+ "The next local action is already justified. Keep this route open while using Pi implementation tools and collecting evidence.",
950
+ ].join("\n");
951
+
952
+ const implementationStep =
953
+ target === "implementation"
954
+ ? {
955
+ movement: ("movement" in params
956
+ ? params.movement
957
+ : question
958
+ ).trim(),
959
+ stopCondition: ("stop_condition" in params
960
+ ? params.stop_condition
961
+ : "Reach a green, pausable, reviewable stable landing."
962
+ ).trim(),
963
+ verification: ("verification" in params
964
+ ? params.verification
965
+ : "Run the narrowest relevant check and inspect the resulting diff or output."
966
+ ).trim(),
967
+ }
968
+ : undefined;
969
+
970
+ const event: RouteEvent = {
971
+ protocol: PROTOCOL,
972
+ kind: "route",
973
+ routeId: `route:${toolCallId}`,
974
+ question,
975
+ target,
976
+ reason,
977
+ knownEvidence,
978
+ consideredAlternatives,
979
+ targetQuestionId,
980
+ methodLocation: skill?.filePath,
981
+ executionProfile,
982
+ implementationStep,
983
+ };
984
+ const response = [
985
+ `Route ID: ${event.routeId}`,
986
+ `Question: ${event.question}`,
987
+ `Target: ${event.target}`,
988
+ skill
989
+ ? `Skill location: ${skill.filePath}`
990
+ : "Skill location: implementation action; no skill file",
991
+ executionProfile
992
+ ? `Execution profile: ${executionProfile}`
993
+ : "Execution profile: skill judgment",
994
+ ...(implementationStep
995
+ ? [
996
+ `Movement: ${implementationStep.movement}`,
997
+ `Stable landing: ${implementationStep.stopCondition}`,
998
+ `Narrow verification: ${implementationStep.verification}`,
999
+ "Stop this implementation route at that landing, record the evidence, and route again before another movement.",
1000
+ ]
1001
+ : []),
1002
+ `Reason: ${event.reason}`,
1003
+ `Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
1004
+ `Alternatives reconsidered: ${
1005
+ event.consideredAlternatives.length > 0
1006
+ ? event.consideredAlternatives
1007
+ .map(
1008
+ (alternative) =>
1009
+ `${alternative.target} — ${alternative.reason}`,
1010
+ )
1011
+ .join(" | ")
1012
+ : "none"
1013
+ }`,
1014
+ targetQuestionId
1015
+ ? `Revisits pending question: ${targetQuestionId}`
1016
+ : "Revisits pending question: none",
1017
+ `When this route has done its job, call ${JUDGMENT_TOOL} with this exact route ID.`,
1018
+ "",
1019
+ "---",
1020
+ "",
1021
+ method,
1022
+ ].join("\n");
1023
+ ensureSafeToolText(response, "Developer route result");
1024
+ if (!canApplyDeveloperEvent(state, event)) {
1025
+ fail(
1026
+ "Developer machine guard rejected the route transition from the current branch state.",
1027
+ );
1028
+ }
1029
+
1030
+ state = applyDeveloperEvent(state, event);
1031
+ syncProtocolTools();
1032
+ refreshUI(ctx);
1033
+ return textResult(response, event);
1034
+ } finally {
1035
+ routeOpening = false;
1036
+ }
1037
+ },
1038
+ renderCall(args, theme, context) {
1039
+ const target =
1040
+ typeof args.target === "string" && args.target.length > 0
1041
+ ? args.target
1042
+ : "…";
1043
+ const question =
1044
+ typeof args.question === "string" && args.question.length > 0
1045
+ ? compactLine(args.question)
1046
+ : "…";
1047
+ return reusableText(
1048
+ `${theme.fg("toolTitle", theme.bold(ROUTE_TOOL))} ${theme.fg("accent", target)} ${theme.fg("muted", question)}`,
1049
+ context.lastComponent,
1050
+ );
1051
+ },
1052
+ renderResult(result, { expanded, isPartial }, theme, context) {
1053
+ if (isPartial) {
1054
+ return reusableText(
1055
+ theme.fg("warning", "routing development question…"),
1056
+ context.lastComponent,
1057
+ );
1058
+ }
1059
+ if (context.isError) {
1060
+ return reusableText(
1061
+ theme.fg("error", resultText(result) || "Developer route failed"),
1062
+ context.lastComponent,
1063
+ );
1064
+ }
1065
+ const event = result.details as RouteEvent | undefined;
1066
+ let text = theme.fg("success", `routed ${routeRenderText(event)}`);
1067
+ if (expanded && event) {
1068
+ text += `\n${detailLine(theme, "route", event.routeId, "text")}`;
1069
+ text += `\n${detailLine(theme, "question", event.question, "text")}`;
1070
+ text += `\n${detailLine(theme, "reason", event.reason)}`;
1071
+ if (event.knownEvidence.length > 0) {
1072
+ for (const evidence of event.knownEvidence) {
1073
+ text += `\n${detailLine(theme, "evidence", evidence)}`;
1074
+ }
1075
+ } else {
1076
+ text += `\n${detailLine(theme, "evidence", "none recorded before routing", "warning")}`;
1077
+ }
1078
+ for (const alternative of event.consideredAlternatives ?? []) {
1079
+ text += `\n${detailLine(theme, `considered ${alternative.target}`, alternative.reason)}`;
1080
+ }
1081
+ text += `\n${detailLine(theme, "revisits", event.targetQuestionId ?? "none")}`;
1082
+ text += `\n${detailLine(theme, "skill", event.methodLocation ?? "implementation action")}`;
1083
+ if (event.executionProfile) {
1084
+ text += `\n${detailLine(theme, "profile", event.executionProfile)}`;
1085
+ }
1086
+ if (event.implementationStep) {
1087
+ text += `\n${detailLine(theme, "movement", event.implementationStep.movement, "text")}`;
1088
+ text += `\n${detailLine(theme, "landing", event.implementationStep.stopCondition)}`;
1089
+ text += `\n${detailLine(theme, "verify", event.implementationStep.verification)}`;
1090
+ }
1091
+ }
1092
+ if (!expanded && event)
1093
+ text += ` · ${keyHint("app.tools.expand", "details")}`;
1094
+ return reusableText(text, context.lastComponent);
1095
+ },
1096
+ });
1097
+
1098
+ const ChoiceResponseOptionParam = Type.Object(
1099
+ {
1100
+ value: Type.String({
1101
+ minLength: 1,
1102
+ maxLength: MAX_RESPONSE_IDENTIFIER_CHARS,
1103
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$",
1104
+ }),
1105
+ label: Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS }),
1106
+ description: Type.Optional(
1107
+ Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS }),
1108
+ ),
1109
+ detail_prompt: Type.Optional(
1110
+ Type.String({
1111
+ minLength: 1,
1112
+ maxLength: MAX_RESPONSE_TEXT_CHARS,
1113
+ description: "Required non-empty detail when this option is selected",
1114
+ }),
1115
+ ),
1116
+ },
1117
+ { additionalProperties: false },
1118
+ );
1119
+ const ChoiceResponseFieldParam = Type.Object(
1120
+ {
1121
+ id: Type.String({
1122
+ minLength: 1,
1123
+ maxLength: MAX_RESPONSE_IDENTIFIER_CHARS,
1124
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$",
1125
+ }),
1126
+ prompt: Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS }),
1127
+ description: Type.Optional(
1128
+ Type.String({ minLength: 1, maxLength: MAX_RESPONSE_TEXT_CHARS }),
1129
+ ),
1130
+ options: Type.Array(ChoiceResponseOptionParam, {
1131
+ minItems: 2,
1132
+ maxItems: MAX_RESPONSE_OPTIONS,
1133
+ }),
1134
+ },
1135
+ { additionalProperties: false },
1136
+ );
1137
+ const ChoiceResponseSpecParam = Type.Object(
1138
+ {
1139
+ kind: Type.Literal("choice-form"),
1140
+ fields: Type.Array(ChoiceResponseFieldParam, {
1141
+ minItems: 1,
1142
+ maxItems: MAX_RESPONSE_FIELDS,
1143
+ }),
1144
+ },
1145
+ {
1146
+ additionalProperties: false,
1147
+ description:
1148
+ "Explicit controls for finite user-owned decisions; use one required single-choice field per decision instead of expecting the TUI to parse Markdown context",
1149
+ },
1150
+ );
1151
+ const OpenQuestionParam = Type.Object(
1152
+ {
1153
+ question: Type.String({ minLength: 1, maxLength: MAX_QUESTION_CHARS }),
1154
+ context: Type.Optional(
1155
+ Type.String({
1156
+ maxLength: MAX_QUESTION_CONTEXT_CHARS,
1157
+ description:
1158
+ "Explanation shown before answer controls; required for user-owned before-implementation or before-completion questions",
1159
+ }),
1160
+ ),
1161
+ response_spec: Type.Optional(ChoiceResponseSpecParam),
1162
+ status: StringEnum(["open", "blocked"] as const, {
1163
+ description:
1164
+ "Whether the required evidence or answer is currently obtainable",
1165
+ }),
1166
+ resolution_owner: StringEnum(["agent", "user", "environment"] as const, {
1167
+ description:
1168
+ "Who can provide the evidence or decision that resolves this question",
1169
+ }),
1170
+ gate: StringEnum(
1171
+ ["none", "before-implementation", "before-completion"] as const,
1172
+ {
1173
+ description:
1174
+ "What work this question blocks; an agent before-implementation question must be resolvable through a non-implementation evidence route",
1175
+ },
1176
+ ),
1177
+ resolution_criteria: Type.String({
1178
+ minLength: 1,
1179
+ maxLength: MAX_EVIDENCE_CHARS,
1180
+ description:
1181
+ "Observable evidence or answer that will settle the question",
1182
+ }),
1183
+ },
1184
+ { additionalProperties: false },
1185
+ );
1186
+ const QuestionUpdateParam = Type.Object(
1187
+ {
1188
+ question_id: Type.String({
1189
+ minLength: 1,
1190
+ maxLength: 512,
1191
+ description:
1192
+ "Exact ID of a question that was pending before this route; never route_id or a newly opened question ID",
1193
+ }),
1194
+ status: StringEnum([
1195
+ "resolved",
1196
+ "not-applicable",
1197
+ "open",
1198
+ "blocked",
1199
+ ] as const),
1200
+ result: Type.String({ minLength: 1, maxLength: MAX_RESULT_CHARS }),
1201
+ basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
1202
+ maxItems: 20,
1203
+ }),
1204
+ },
1205
+ { additionalProperties: false },
1206
+ );
1207
+ const JudgmentParams = Type.Object({
1208
+ route_id: Type.String({
1209
+ minLength: 1,
1210
+ maxLength: 512,
1211
+ description: `Exact route ID returned by ${ROUTE_TOOL}`,
1212
+ }),
1213
+ status: StringEnum([
1214
+ "resolved",
1215
+ "needs-evidence",
1216
+ "not-applicable",
1217
+ "blocked",
1218
+ ] as const),
1219
+ result: Type.String({
1220
+ minLength: 1,
1221
+ maxLength: MAX_RESULT_CHARS,
1222
+ description:
1223
+ "The resulting judgment in Markdown; preserve the routed skill's inspectable tables, diagrams, matrices, timelines, or code blocks",
1224
+ }),
1225
+ basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
1226
+ maxItems: 20,
1227
+ description: "Evidence supporting the judgment or blocker",
1228
+ }),
1229
+ open_questions: Type.Optional(
1230
+ Type.Array(OpenQuestionParam, {
1231
+ maxItems: 10,
1232
+ description:
1233
+ "New unresolved questions with explicit resolution target, gate, and observable resolution criteria",
1234
+ }),
1235
+ ),
1236
+ question_updates: Type.Optional(
1237
+ Type.Array(QuestionUpdateParam, {
1238
+ maxItems: 20,
1239
+ description:
1240
+ "Updates to questions already pending before this route; use [] when none exist, and never put route_id or newly opened questions here",
1241
+ }),
1242
+ ),
1243
+ artifacts: Type.Optional(
1244
+ Type.Array(Type.String({ maxLength: MAX_ARTIFACT_CHARS }), {
1245
+ maxItems: 20,
1246
+ description: "Relevant paths, commands, tests, or outputs",
1247
+ }),
1248
+ ),
1249
+ });
1250
+
1251
+ pi.registerTool({
1252
+ name: JUDGMENT_TOOL,
1253
+ label: "Developer Record Judgment",
1254
+ description:
1255
+ "Close the active Developer route with its result, evidence, newly opened questions, and relevant artifacts. This records a local judgment, not task completion.",
1256
+ promptSnippet: "Record evidence and close the active development route",
1257
+ promptGuidelines: [
1258
+ `Use ${JUDGMENT_TOOL} with the exact active Developer route ID.`,
1259
+ `Use ${JUDGMENT_TOOL} result as Markdown and preserve the routed skill's inspectable tables, diagrams, matrices, timelines, and code blocks instead of reducing them to prose.`,
1260
+ `Use ${JUDGMENT_TOOL} open_questions with resolution_owner, gate, and resolution_criteria. User-owned gated questions require explanatory context before controls; for finite required decisions, add a choice-form response_spec with one field per decision. Use question_updates whenever current evidence settles or changes any existing pending question.`,
1261
+ `Do not use ${JUDGMENT_TOOL} with resolved, not-applicable, or blocked status without at least one concrete basis.`,
1262
+ ],
1263
+ parameters: JudgmentParams,
1264
+ executionMode: "sequential",
1265
+ renderShell: "self",
1266
+ prepareArguments(args): Static<typeof JudgmentParams> {
1267
+ const input = args as Static<typeof JudgmentParams> & {
1268
+ status?: string;
1269
+ open_questions?: unknown[];
1270
+ };
1271
+ if (
1272
+ !input ||
1273
+ typeof input !== "object" ||
1274
+ !Array.isArray(input.open_questions)
1275
+ ) {
1276
+ return args as Static<typeof JudgmentParams>;
1277
+ }
1278
+ return {
1279
+ ...input,
1280
+ open_questions: input.open_questions.map((question) =>
1281
+ typeof question === "string"
1282
+ ? legacyOpenQuestion(question, input.status)
1283
+ : question,
1284
+ ) as Static<typeof JudgmentParams>["open_questions"],
1285
+ };
1286
+ },
1287
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1288
+ if (!state.enabled) fail("Developer protocol is off.");
1289
+ const activeRoute = state.activeRoute;
1290
+ if (!activeRoute) fail("There is no active Developer route to close.");
1291
+ if (params.route_id !== activeRoute.routeId) {
1292
+ fail(`Route ID mismatch. Active route is ${activeRoute.routeId}.`);
1293
+ }
1294
+
1295
+ const basis = params.basis.map((item) => item.trim()).filter(Boolean);
1296
+ const result = params.result.trim();
1297
+ if (!result) fail("A judgment result must contain non-whitespace text.");
1298
+ if (params.status !== "needs-evidence" && basis.length === 0) {
1299
+ fail(`${params.status} judgments require at least one concrete basis.`);
1300
+ }
1301
+
1302
+ const openedQuestions = buildOpenedQuestions(
1303
+ params.open_questions ?? [],
1304
+ state,
1305
+ params.route_id,
1306
+ );
1307
+ if (
1308
+ availableSkills.size === 0 &&
1309
+ openedQuestions.some(
1310
+ (question) =>
1311
+ question.resolutionOwner === "agent" &&
1312
+ question.gate === "before-implementation",
1313
+ )
1314
+ ) {
1315
+ fail(
1316
+ "An agent-owned before-implementation question requires an available non-implementation skill resolution path.",
1317
+ );
1318
+ }
1319
+ const remainsOpen =
1320
+ params.status === "needs-evidence" || params.status === "blocked";
1321
+ if (
1322
+ remainsOpen &&
1323
+ !activeRoute.targetQuestionId &&
1324
+ openedQuestions.length === 0
1325
+ ) {
1326
+ fail(
1327
+ "A needs-evidence or blocked judgment for a new question must include at least one structured open_questions entry.",
1328
+ );
1329
+ }
1330
+
1331
+ if (
1332
+ state.pendingQuestions.length > 0 &&
1333
+ params.question_updates === undefined
1334
+ ) {
1335
+ fail(
1336
+ "Pending questions exist. Include question_updates (an empty array is valid) after rechecking them against this route's evidence.",
1337
+ );
1338
+ }
1339
+ const questionUpdates = buildQuestionUpdates(
1340
+ params.question_updates ?? [],
1341
+ state,
1342
+ );
1343
+ const updatedQuestionIds = new Set(
1344
+ questionUpdates.map((update) => update.questionId),
1345
+ );
1346
+ if (
1347
+ openedQuestions.some((question) => updatedQuestionIds.has(question.id))
1348
+ ) {
1349
+ fail("A question cannot be opened and updated in the same judgment.");
1350
+ }
1351
+ const reopensCurrentQuestion = openedQuestions.some(
1352
+ (question) =>
1353
+ normalizedQuestion(question.question) ===
1354
+ normalizedQuestion(activeRoute.question),
1355
+ );
1356
+ if (!remainsOpen && reopensCurrentQuestion) {
1357
+ fail(
1358
+ "A resolved or not-applicable judgment cannot reopen its own question.",
1359
+ );
1360
+ }
1361
+ const targetQuestionId = activeRoute.targetQuestionId;
1362
+ const targetUpdate = targetQuestionId
1363
+ ? questionUpdates.find(
1364
+ (update) => update.questionId === targetQuestionId,
1365
+ )
1366
+ : undefined;
1367
+ if (targetQuestionId && !targetUpdate) {
1368
+ fail(
1369
+ `Focused question ${targetQuestionId} requires an explicit question_updates entry.`,
1370
+ );
1371
+ }
1372
+ if (targetUpdate) {
1373
+ const closesTarget =
1374
+ targetUpdate.status === "resolved" ||
1375
+ targetUpdate.status === "not-applicable";
1376
+ if (!remainsOpen && !closesTarget) {
1377
+ fail(
1378
+ "A resolved focused judgment must explicitly resolve or dismiss its question.",
1379
+ );
1380
+ }
1381
+ if (remainsOpen && openedQuestions.length === 0 && closesTarget) {
1382
+ fail(
1383
+ "A focused question cannot close while its judgment reports no replacement evidence question.",
1384
+ );
1385
+ }
1386
+ if (remainsOpen && openedQuestions.length > 0 && !closesTarget) {
1387
+ fail(
1388
+ "Replacement questions require explicitly resolving or dismissing the broader focused question.",
1389
+ );
1390
+ }
1391
+ }
1392
+
1393
+ const event: JudgmentEvent = {
1394
+ protocol: PROTOCOL,
1395
+ kind: "judgment",
1396
+ routeId: params.route_id,
1397
+ question: activeRoute.question,
1398
+ target: activeRoute.target,
1399
+ status: params.status,
1400
+ result,
1401
+ basis,
1402
+ openedQuestions,
1403
+ questionUpdates,
1404
+ artifacts: (params.artifacts ?? [])
1405
+ .map((item) => item.trim())
1406
+ .filter(Boolean),
1407
+ changedArtifacts: routesWithMutation.has(params.route_id),
1408
+ };
1409
+ if (!canApplyDeveloperEvent(state, event)) {
1410
+ fail(
1411
+ "Developer machine guard rejected the judgment transition from the current active route.",
1412
+ );
1413
+ }
1414
+ const nextState = applyDeveloperEvent(state, event);
1415
+ if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
1416
+ fail(
1417
+ `Developer protocol would retain ${nextState.pendingQuestions.length} pending questions; resolve or consolidate them before opening more.`,
1418
+ );
1419
+ }
1420
+
1421
+ const immediateQuestion =
1422
+ ctx.mode === "tui"
1423
+ ? firstImmediateUserQuestion(
1424
+ state.pendingQuestions,
1425
+ event.openedQuestions,
1426
+ )
1427
+ : undefined;
1428
+ let immediateDisposition: ImmediateQuestionDisposition | undefined;
1429
+ if (immediateQuestion) {
1430
+ try {
1431
+ immediateDisposition = await promptImmediateUserQuestion(
1432
+ ctx,
1433
+ immediateQuestion,
1434
+ );
1435
+ } catch {
1436
+ ctx.ui.notify(
1437
+ "Could not open the Developer decision prompt; the question remains open.",
1438
+ "warning",
1439
+ );
1440
+ }
1441
+ }
1442
+
1443
+ const nextMessage = judgmentNextMessage(event, nextState);
1444
+ const resolvedQuestionCount = event.questionUpdates.filter(
1445
+ (update) =>
1446
+ update.status === "resolved" || update.status === "not-applicable",
1447
+ ).length;
1448
+ const response =
1449
+ `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\n` +
1450
+ `Question updates: ${resolvedQuestionCount} resolved, ${event.questionUpdates.length - resolvedQuestionCount} retained or blocked.\n` +
1451
+ nextMessage +
1452
+ immediateQuestionMessage(immediateQuestion, immediateDisposition);
1453
+ ensureSafeToolText(response, "Developer judgment result");
1454
+
1455
+ routesWithMutation.delete(params.route_id);
1456
+ state = nextState;
1457
+ syncProtocolTools();
1458
+ refreshUI(ctx);
1459
+ return textResult(response, event);
1460
+ },
1461
+ renderCall(args, theme, context) {
1462
+ const status =
1463
+ typeof args.status === "string" && args.status.length > 0
1464
+ ? args.status
1465
+ : "…";
1466
+ const result =
1467
+ typeof args.result === "string" && args.result.length > 0
1468
+ ? compactJudgmentResult(args.result)
1469
+ : "";
1470
+ const statusText =
1471
+ status === "resolved"
1472
+ ? theme.fg("success", status)
1473
+ : status === "needs-evidence"
1474
+ ? theme.fg("warning", status)
1475
+ : status === "blocked"
1476
+ ? theme.fg("error", status)
1477
+ : theme.fg("muted", status);
1478
+ return reusableText(
1479
+ `${theme.fg("toolTitle", theme.bold(JUDGMENT_TOOL))} ${statusText} ${theme.fg("muted", result)}`,
1480
+ context.lastComponent,
1481
+ );
1482
+ },
1483
+ renderResult(result, { expanded, isPartial }, theme, context) {
1484
+ if (isPartial) {
1485
+ return reusableText(
1486
+ theme.fg("warning", "recording development judgment…"),
1487
+ context.lastComponent,
1488
+ );
1489
+ }
1490
+ if (context.isError) {
1491
+ return reusableText(
1492
+ theme.fg("error", resultText(result) || "Developer judgment failed"),
1493
+ context.lastComponent,
1494
+ );
1495
+ }
1496
+ const event = result.details as JudgmentEvent | undefined;
1497
+ const summary = judgmentRenderText(event);
1498
+ let text =
1499
+ event?.status === "resolved"
1500
+ ? theme.fg("success", summary)
1501
+ : event?.status === "needs-evidence"
1502
+ ? theme.fg("warning", summary)
1503
+ : event?.status === "blocked"
1504
+ ? theme.fg("error", summary)
1505
+ : theme.fg("muted", summary);
1506
+ if (expanded && event) return expandedJudgment(event, text, theme);
1507
+ if (event) text += ` · ${keyHint("app.tools.expand", "details")}`;
1508
+ return reusableText(text, context.lastComponent);
1509
+ },
1510
+ });
1511
+
1512
+ const refreshAvailableSkills = (ctx: ExtensionCommandContext) => {
1513
+ if (typeof ctx.getSystemPromptOptions !== "function") return;
1514
+ availableSkills = availablePackageSkills(
1515
+ ctx.getSystemPromptOptions().skills ?? [],
1516
+ skillsRoot,
1517
+ );
1518
+ };
1519
+
1520
+ const statusMessage = () => {
1521
+ const active = state.activeRoute
1522
+ ? `${state.activeRoute.routeId} · ${state.activeRoute.target} · ${state.activeRoute.question}`
1523
+ : "none";
1524
+ const pending =
1525
+ state.pendingQuestions.length > 0
1526
+ ? state.pendingQuestions
1527
+ .map(
1528
+ (question) =>
1529
+ `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
1530
+ )
1531
+ .join(" | ")
1532
+ : "none";
1533
+ const last = state.lastJudgment
1534
+ ? `${state.lastJudgment.status} · ${state.lastJudgment.result}`
1535
+ : "none";
1536
+ const basis =
1537
+ state.lastJudgment && state.lastJudgment.basis.length > 0
1538
+ ? state.lastJudgment.basis.join(" | ")
1539
+ : "none";
1540
+ const artifacts =
1541
+ state.lastJudgment && state.lastJudgment.artifacts.length > 0
1542
+ ? state.lastJudgment.artifacts.join(" | ")
1543
+ : "none";
1544
+ const history =
1545
+ state.judgmentHistory.length > 0
1546
+ ? state.judgmentHistory
1547
+ .slice(-10)
1548
+ .map((judgment) => {
1549
+ const route = state.routeHistory.find(
1550
+ (candidate) => candidate.routeId === judgment.routeId,
1551
+ );
1552
+ const alternatives = (route?.consideredAlternatives ?? [])
1553
+ .map(
1554
+ (alternative) =>
1555
+ `${alternative.target}: ${alternative.reason}`,
1556
+ )
1557
+ .join("; ");
1558
+ return `${judgment.target} · ${judgment.status} · ${judgment.result}${
1559
+ alternatives ? ` · considered ${alternatives}` : ""
1560
+ }`;
1561
+ })
1562
+ .join("\n")
1563
+ : "none";
1564
+ return (
1565
+ `${summarizeState(state)}` +
1566
+ `\nactive: ${active}` +
1567
+ `\nlast: ${last}` +
1568
+ `\nbasis: ${basis}` +
1569
+ `\nartifacts: ${artifacts}` +
1570
+ `\nimplementation framing: ${state.implementationFramingRequired ? "required" : "clear"}` +
1571
+ `\ncheckpoint: ${state.rerouteRequired ? "reroute required" : "ready"}` +
1572
+ `\nverification: ${state.verificationRequired ? "required" : "current"}` +
1573
+ `\nhistory:\n${history}` +
1574
+ `\nactive tools: ${pi.getActiveTools().join(", ")}` +
1575
+ `\navailable skills: ${[...availableSkills.keys()].join(", ") || "none"}` +
1576
+ `\npending: ${pending}` +
1577
+ "\nprotocol state is not a product-completion claim"
1578
+ );
1579
+ };
1580
+
1581
+ pi.registerCommand("develop", {
1582
+ description:
1583
+ "Control or inspect Developer: /develop on | status | questions | off",
1584
+ getArgumentCompletions(prefix) {
1585
+ const normalized = prefix.trim();
1586
+ const matches = DEVELOPER_COMMAND_ACTIONS.filter((action) =>
1587
+ action.startsWith(normalized),
1588
+ );
1589
+ return matches.length > 0
1590
+ ? matches.map((action) => ({ value: action, label: action }))
1591
+ : null;
1592
+ },
1593
+ handler: async (args, ctx) => {
1594
+ const submitQuestionResolution = async (
1595
+ question: PendingQuestion,
1596
+ ): Promise<boolean> => {
1597
+ const request = await editQuestionResolutionRequest(ctx, question);
1598
+ if (request === undefined) return false;
1599
+ const focusEvent: FocusEvent = {
1600
+ protocol: PROTOCOL,
1601
+ kind: "focus",
1602
+ questionId: question.id,
1603
+ };
1604
+ pi.appendEntry(FOCUS_ENTRY, focusEvent);
1605
+ state = applyDeveloperEvent(state, focusEvent);
1606
+ refreshUI(ctx);
1607
+ ctx.ui.setEditorText("");
1608
+ ctx.ui.notify(
1609
+ "Question response submitted. It remains open until Developer records a resolved or not-applicable judgment.",
1610
+ "info",
1611
+ );
1612
+ if (ctx.isIdle()) pi.sendUserMessage(request);
1613
+ else pi.sendUserMessage(request, { deliverAs: "followUp" });
1614
+ return true;
1615
+ };
1616
+
1617
+ const setAndNotifyEnabled = (enabled: boolean): boolean => {
1618
+ if (!setEnabled(enabled, ctx)) return false;
1619
+ ctx.ui.notify(`Developer: ${enabled ? "on" : "off"}`, "info");
1620
+ return true;
1621
+ };
1622
+
1623
+ const turnOff = async (): Promise<boolean> => {
1624
+ if (
1625
+ ctx.mode === "tui" &&
1626
+ (state.activeRoute || state.pendingQuestions.length > 0)
1627
+ ) {
1628
+ const work = [
1629
+ ...(state.activeRoute ? ["the active route"] : []),
1630
+ ...(state.pendingQuestions.length > 0
1631
+ ? [`${state.pendingQuestions.length} open question(s)`]
1632
+ : []),
1633
+ ].join(" and ");
1634
+ const confirmed = await ctx.ui.confirm(
1635
+ "Turn off Developer?",
1636
+ `This clears ${work} from the current protocol state. Existing session history remains.`,
1637
+ );
1638
+ if (!confirmed) return false;
1639
+ }
1640
+ setAndNotifyEnabled(false);
1641
+ return true;
1642
+ };
1643
+
1644
+ const inspectStatus = async () => {
1645
+ if (toolPolicyRestartRequired) {
1646
+ ctx.ui.notify(TOOL_POLICY_RESTART_MESSAGE, "error");
1647
+ refreshUI(ctx);
1648
+ return;
1649
+ }
1650
+ refreshAvailableSkills(ctx);
1651
+ if (ctx.mode === "tui") {
1652
+ await showDeveloperStatus(ctx, {
1653
+ state,
1654
+ activeTools: pi.getActiveTools(),
1655
+ availableSkills: [...availableSkills.keys()],
1656
+ });
1657
+ } else {
1658
+ ctx.ui.notify(statusMessage(), "info");
1659
+ }
1660
+ };
1661
+
1662
+ const inspectHistory = async (): Promise<void> => {
1663
+ if (state.judgmentHistory.length === 0) {
1664
+ ctx.ui.notify(
1665
+ "Developer has no judgment history on this branch.",
1666
+ "info",
1667
+ );
1668
+ return;
1669
+ }
1670
+ let selectedRouteId: string | undefined;
1671
+ while (true) {
1672
+ const routeId = await showDeveloperHistorySelector(
1673
+ ctx,
1674
+ state,
1675
+ selectedRouteId,
1676
+ );
1677
+ if (!routeId) return;
1678
+ selectedRouteId = routeId;
1679
+ const entry = developerHistoryEntries(state).find(
1680
+ (candidate) => candidate.id === routeId,
1681
+ );
1682
+ if (!entry) continue;
1683
+ await showDeveloperHistoryDetail(ctx, entry);
1684
+ }
1685
+ };
1686
+
1687
+ const answerPendingQuestion = async (): Promise<boolean> => {
1688
+ if (state.pendingQuestions.length === 0) {
1689
+ ctx.ui.notify(
1690
+ "Developer has no open questions on the current branch.",
1691
+ "info",
1692
+ );
1693
+ return false;
1694
+ }
1695
+ if (ctx.mode === "tui") {
1696
+ const soleQuestion =
1697
+ state.pendingQuestions.length === 1
1698
+ ? state.pendingQuestions[0]
1699
+ : undefined;
1700
+ if (soleQuestion) return submitQuestionResolution(soleQuestion);
1701
+ while (true) {
1702
+ const questionId = await showPendingQuestionSelector(
1703
+ ctx,
1704
+ state.pendingQuestions,
1705
+ );
1706
+ if (!questionId) return false;
1707
+ const question = state.pendingQuestions.find(
1708
+ (item) => item.id === questionId,
1709
+ );
1710
+ if (!question) return false;
1711
+ if (await submitQuestionResolution(question)) return true;
1712
+ }
1713
+ }
1714
+ ctx.ui.notify(
1715
+ state.pendingQuestions
1716
+ .map(
1717
+ (question) =>
1718
+ `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question}\n resolves when: ${question.resolutionCriteria}`,
1719
+ )
1720
+ .join("\n"),
1721
+ "info",
1722
+ );
1723
+ return false;
1724
+ };
1725
+
1726
+ const action = args.trim();
1727
+ if (!action && ctx.mode === "tui") {
1728
+ refreshAvailableSkills(ctx);
1729
+ while (true) {
1730
+ const selection = await showDeveloperSettings(ctx, {
1731
+ read: () => state,
1732
+ commitActivation(enabled) {
1733
+ setAndNotifyEnabled(enabled);
1734
+ return state;
1735
+ },
1736
+ });
1737
+ if (!selection) return;
1738
+ if (selection.kind === "questions") {
1739
+ if (await answerPendingQuestion()) return;
1740
+ continue;
1741
+ }
1742
+ if (selection.kind === "history") {
1743
+ await inspectHistory();
1744
+ continue;
1745
+ }
1746
+ await inspectStatus();
1747
+ }
1748
+ }
1749
+
1750
+ if (!action || action === "status") {
1751
+ await inspectStatus();
1752
+ return;
1753
+ }
1754
+ if (action === "on") {
1755
+ setAndNotifyEnabled(true);
1756
+ return;
1757
+ }
1758
+ if (action === "off") {
1759
+ await turnOff();
1760
+ return;
1761
+ }
1762
+ if (action === "questions") {
1763
+ await answerPendingQuestion();
1764
+ return;
1765
+ }
1766
+ ctx.ui.notify("Usage: /develop on | status | questions | off", "warning");
1767
+ },
1768
+ });
1769
+
1770
+ const entryRendererAPI = pi as ExtensionAPI & {
1771
+ registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"];
1772
+ };
1773
+ entryRendererAPI.registerEntryRenderer?.(
1774
+ ACTIVATION_ENTRY,
1775
+ (entry, _options, theme) => {
1776
+ const event = normalizeDeveloperEvent(entry.data);
1777
+ if (!event || event.kind !== "activation") return undefined;
1778
+ return new Text(
1779
+ `${theme.fg("toolTitle", theme.bold("Developer"))} ${theme.fg("accent", event.enabled ? "on" : "off")}`,
1780
+ 0,
1781
+ 0,
1782
+ );
1783
+ },
1784
+ );
1785
+
1786
+ pi.on("before_agent_start", (event) => {
1787
+ availableSkills = availablePackageSkills(
1788
+ event.systemPromptOptions.skills ?? [],
1789
+ skillsRoot,
1790
+ );
1791
+ if (!state.enabled) return;
1792
+ return {
1793
+ systemPrompt:
1794
+ event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]),
1795
+ };
1796
+ });
1797
+
1798
+ pi.on("tool_call", (event) => {
1799
+ const capability = builtinControlledToolCapabilities(pi.getAllTools()).get(
1800
+ event.toolName,
1801
+ );
1802
+ if (!capability) return;
1803
+
1804
+ const access = protocolToolAccess(state);
1805
+ if (
1806
+ isControlledToolAllowed({ enabled: state.enabled, capability, access })
1807
+ ) {
1808
+ if (access.allowsArtifactTools && state.activeRoute)
1809
+ routesWithMutation.add(state.activeRoute.routeId);
1810
+ return;
1811
+ }
1812
+
1813
+ const implementationBlockers = state.pendingQuestions.filter(
1814
+ (question) => question.gate === "before-implementation",
1815
+ );
1816
+ if (implementationBlockers.length > 0) {
1817
+ const blockedAction =
1818
+ capability === "shell"
1819
+ ? "unrouted shell execution"
1820
+ : "artifact mutation";
1821
+ return {
1822
+ block: true,
1823
+ reason: `Developer question gate blocks ${blockedAction} until resolved: ${implementationBlockers
1824
+ .map(
1825
+ (question) =>
1826
+ `${question.id} (${question.resolutionOwner}: ${question.question})`,
1827
+ )
1828
+ .join(
1829
+ "; ",
1830
+ )}. A non-implementation judgment route may still use bash to obtain evidence.`,
1831
+ };
1832
+ }
1833
+
1834
+ return {
1835
+ block: true,
1836
+ reason:
1837
+ capability === "shell"
1838
+ ? "Developer requires an active judgment or implementation route before Pi built-in bash can execute evidence checks."
1839
+ : `Developer requires an active ${ROUTE_TOOL} targeting implementation (target=implementation) before Pi built-in edit or write.`,
1840
+ };
1841
+ });
1842
+
1843
+ pi.on("session_start", (event, ctx) => {
1844
+ const branch = ctx.sessionManager.getBranch();
1845
+ toolPolicyRestartRequired =
1846
+ event.reason === "reload" &&
1847
+ toolPolicyReloadRequiresRestart({
1848
+ entries: branch,
1849
+ protocol: PROTOCOL,
1850
+ protocolTools: PROTOCOL_TOOLS,
1851
+ });
1852
+ reconstruct(ctx);
1853
+ if (toolPolicyRestartRequired) {
1854
+ ctx.ui.notify(TOOL_POLICY_RESTART_MESSAGE, "error");
1855
+ return;
1856
+ }
1857
+ const startEnabled = pi.getFlag("develop");
1858
+ if (startEnabled === true && !state.enabled) setEnabled(true, ctx);
1859
+ });
1860
+ pi.on("session_tree", (_event, ctx) => reconstruct(ctx));
1861
+ pi.on("agent_settled", (_event, ctx) => refreshUI(ctx));
1862
+ pi.on("session_shutdown", (_event, ctx) => {
1863
+ releaseProtocolTools();
1864
+ if (!toolPolicyRestartRequired) {
1865
+ pi.appendEntry(
1866
+ TOOL_POLICY_LIFECYCLE_ENTRY,
1867
+ reloadSafeToolPolicyMarker(PROTOCOL),
1868
+ );
1869
+ }
1870
+ ctx.ui.setStatus("developer", undefined);
1871
+ ctx.ui.setWidget("developer", undefined);
1872
+ });
1562
1873
  }