@hobin/developer 0.1.4 → 0.1.6

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.
@@ -6,47 +6,69 @@ import { StringEnum } from "@earendil-works/pi-ai";
6
6
  import {
7
7
  DEFAULT_MAX_BYTES,
8
8
  DEFAULT_MAX_LINES,
9
+ getMarkdownTheme,
9
10
  keyHint,
10
11
  type ExtensionAPI,
11
12
  type ExtensionCommandContext,
12
13
  type ExtensionContext,
13
14
  type Skill,
15
+ type Theme,
16
+ type ThemeColor,
14
17
  } from "@earendil-works/pi-coding-agent";
15
- import { Text } from "@earendil-works/pi-tui";
16
- import { Type } from "typebox";
18
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
19
+ import { Type, type Static } from "typebox";
17
20
 
18
21
  import { availablePackageSkills, renderSkillMethod } from "./skills.ts";
19
22
  import {
20
23
  FOCUS_ENTRY,
21
24
  JUDGMENT_TOOL,
25
+ MAX_RESPONSE_FIELDS,
26
+ MAX_RESPONSE_IDENTIFIER_CHARS,
27
+ MAX_RESPONSE_OPTIONS,
28
+ MAX_RESPONSE_TEXT_CHARS,
22
29
  MODE_ENTRY,
23
30
  PROTOCOL,
24
31
  ROUTE_TOOL,
25
32
  applyDeveloperEvent,
33
+ canApplyDeveloperEvent,
34
+ developerSnapshot,
26
35
  initialState,
27
36
  normalizeDeveloperEvent,
37
+ parseChoiceResponseSpec,
28
38
  protocolState,
29
39
  reconstructState,
40
+ type ChoiceResponseSpec,
30
41
  type DeveloperMode,
31
42
  type DeveloperState,
32
43
  type DirectExecutionProfile,
33
44
  type FocusEvent,
34
45
  type JudgmentEvent,
35
46
  type ModeEvent,
47
+ type PendingQuestion,
48
+ type PendingQuestionStatus,
49
+ type QuestionGate,
50
+ type QuestionResolutionOwner,
51
+ type QuestionUpdate,
52
+ type QuestionUpdateStatus,
53
+ type RouteAlternative,
36
54
  type RouteEvent,
37
55
  } from "./state.ts";
38
56
  import {
39
- builtinMutationToolNames,
57
+ builtinControlledToolCapabilities,
58
+ isControlledToolAllowed,
40
59
  reconcileProtocolTools,
60
+ type ProtocolToolAccess,
41
61
  type ToolPolicyMemory,
42
62
  } from "./tool-policy.ts";
43
63
  import {
44
64
  DeveloperWidget,
45
- prepareQuestionPrompt,
65
+ editQuestionResolutionRequest,
66
+ promptImmediateUserQuestion,
46
67
  renderDeveloperFooter,
47
68
  showDeveloperActionSelector,
48
69
  showDeveloperStatus,
49
70
  showPendingQuestionSelector,
71
+ type ImmediateQuestionDisposition,
50
72
  } from "./tui.ts";
51
73
 
52
74
  const PROTOCOL_TOOLS = [ROUTE_TOOL, JUDGMENT_TOOL] as const;
@@ -59,8 +81,9 @@ const structuralChangeMethodPath = resolve(
59
81
  );
60
82
  const MAX_PENDING_QUESTIONS = 20;
61
83
  const MAX_QUESTION_CHARS = 2_000;
84
+ const MAX_QUESTION_CONTEXT_CHARS = 8_000;
62
85
  const MAX_EVIDENCE_CHARS = 2_000;
63
- const MAX_RESULT_CHARS = 4_000;
86
+ const MAX_RESULT_CHARS = 12_000;
64
87
  const MAX_ARTIFACT_CHARS = 4_096;
65
88
  const DEVELOPER_COMMAND_ACTIONS = ["on", "strict", "status", "questions", "off"] as const;
66
89
 
@@ -110,7 +133,211 @@ function compactLine(value: string, maxChars = 160): string {
110
133
  }
111
134
 
112
135
  function normalizedQuestion(value: string): string {
113
- return value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
136
+ return value
137
+ .toLocaleLowerCase()
138
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
139
+ .trim();
140
+ }
141
+
142
+ interface ChoiceResponseOptionInput {
143
+ value: string;
144
+ label: string;
145
+ description?: string;
146
+ detail_prompt?: string;
147
+ }
148
+
149
+ interface ChoiceResponseFieldInput {
150
+ id: string;
151
+ prompt: string;
152
+ description?: string;
153
+ options: ChoiceResponseOptionInput[];
154
+ }
155
+
156
+ interface ChoiceResponseSpecInput {
157
+ kind: "choice-form";
158
+ fields: ChoiceResponseFieldInput[];
159
+ }
160
+
161
+ 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;
169
+ }
170
+
171
+ interface QuestionUpdateInput {
172
+ question_id: string;
173
+ status: QuestionUpdateStatus;
174
+ result: string;
175
+ basis: string[];
176
+ }
177
+
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
+ };
195
+ }
196
+
197
+ function buildChoiceResponseSpec(
198
+ input: ChoiceResponseSpecInput | undefined,
199
+ owner: OpenQuestionInput["resolution_owner"],
200
+ ): 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;
225
+ }
226
+
227
+ function buildOpenedQuestions(
228
+ input: OpenQuestionInput[],
229
+ state: DeveloperState,
230
+ routeId: string,
231
+ ): 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;
263
+ }
264
+
265
+ function firstImmediateUserQuestion(
266
+ previous: readonly PendingQuestion[],
267
+ opened: readonly PendingQuestion[],
268
+ ): 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
+ );
277
+ }
278
+
279
+ function immediateQuestionMessage(
280
+ question: PendingQuestion | undefined,
281
+ disposition: ImmediateQuestionDisposition | undefined,
282
+ ): 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.`;
294
+ }
295
+
296
+ function buildQuestionUpdates(
297
+ input: QuestionUpdateInput[],
298
+ state: DeveloperState,
299
+ ): 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;
327
+ }
328
+
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.`;
114
341
  }
115
342
 
116
343
  function inferredQuestionId(
@@ -124,8 +351,16 @@ function inferredQuestionId(
124
351
  const exact = state.pendingQuestions.filter(
125
352
  (pending) => normalizedQuestion(pending.question) === normalized,
126
353
  );
127
- if (exact.length === 1) return exact[0]?.id;
128
- return state.pendingQuestions.length === 1 ? state.pendingQuestions[0]?.id : undefined;
354
+ return exact.length === 1 ? exact[0]?.id : undefined;
355
+ }
356
+
357
+ 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
+ };
129
364
  }
130
365
 
131
366
  function summarizeState(state: DeveloperState): string {
@@ -145,20 +380,28 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
145
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.",
146
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.",
147
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.",
148
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.",
149
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.",
150
392
  "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
151
393
  ];
152
394
 
153
395
  if (state.mode === "strict") {
154
396
  lines.push(
155
- "- Strict mode withholds active Pi built-in edit, write, and bash tools unless the active route target is direct. It does not classify or sandbox other extensions' tools.",
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.",
156
399
  );
157
400
  }
158
401
 
159
402
  if (state.implementationFramingRequired) {
160
403
  lines.push(
161
- "Required next framing: the latest resolved model exposed implementation work. Route sketch before new feature mutation, or signal before an existing-code structural movement; direct is not ready yet.",
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.",
162
405
  );
163
406
  }
164
407
  if (state.verificationRequired) {
@@ -166,6 +409,19 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
166
409
  "Verification debt: a direct route changed artifacts after the last resolved verify judgment. Route verify before claiming completion or handing off as done.",
167
410
  );
168
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
+ }
169
425
 
170
426
  if (state.activeRoute) {
171
427
  lines.push(
@@ -180,10 +436,13 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
180
436
  if (state.pendingQuestions.length > 0) {
181
437
  lines.push("Pending Developer questions:");
182
438
  for (const question of state.pendingQuestions) {
183
- lines.push(`- ${question.id} · ${question.status} · ${question.question}`);
439
+ lines.push(
440
+ `- ${question.id} · ${question.status} · owner=${question.resolutionOwner} · gate=${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
441
+ );
184
442
  }
185
443
  lines.push(
186
- "- Revisit questions naturally. Developer automatically associates a focused, sole, or exactly matching pending question; open_question_id is an internal disambiguator only when several questions remain.",
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.",
187
446
  );
188
447
  }
189
448
  lines.push(
@@ -199,9 +458,61 @@ function routeRenderText(event: RouteEvent | undefined): string {
199
458
  return `${event.owner}${profile} · ${compactLine(event.question)}${target}`;
200
459
  }
201
460
 
461
+ 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);
467
+ }
468
+
202
469
  function judgmentRenderText(event: JudgmentEvent | undefined): string {
203
470
  if (!event) return "Judgment unavailable";
204
- return `${event.status} · ${compactLine(event.result)}`;
471
+ return `${event.status} · ${compactJudgmentResult(event.result)}`;
472
+ }
473
+
474
+ function detailLine(
475
+ theme: Theme,
476
+ label: string,
477
+ value: string,
478
+ valueColor: ThemeColor = "muted",
479
+ ): string {
480
+ return `${theme.fg("dim", `${label} · `)}${theme.fg(valueColor, value)}`;
481
+ }
482
+
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;
205
516
  }
206
517
 
207
518
  export default async function developer(pi: ExtensionAPI) {
@@ -222,7 +533,7 @@ export default async function developer(pi: ExtensionAPI) {
222
533
  activeTools: current,
223
534
  allTools: pi.getAllTools(),
224
535
  mode: state.mode,
225
- directRouteOpen: state.activeRoute?.owner === "direct",
536
+ access: protocolToolAccess(state),
226
537
  protocolTools: PROTOCOL_TOOLS,
227
538
  memory: toolPolicyMemory,
228
539
  });
@@ -266,8 +577,12 @@ export default async function developer(pi: ExtensionAPI) {
266
577
  ...state.pendingQuestions
267
578
  .slice(0, 3)
268
579
  .map((question) => `open · ${question.id} · ${compactLine(question.question)}`),
269
- ...(state.pendingQuestions.length > 3 ? [`open · +${state.pendingQuestions.length - 3} more`] : []),
270
- ...(state.implementationFramingRequired ? ["next · sketch feature shape or signal structural movement"] : []),
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
+ : []),
271
586
  ...(state.verificationRequired ? ["next · verify changed artifacts before completion"] : []),
272
587
  ];
273
588
  ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
@@ -305,9 +620,28 @@ export default async function developer(pi: ExtensionAPI) {
305
620
  }),
306
621
  ),
307
622
  open_question_id: Type.Optional(
308
- Type.String({ maxLength: 512, description: "Exact pending question ID when this route revisits one" }),
623
+ Type.String({
624
+ maxLength: 512,
625
+ description: "Exact pending question ID when this route revisits one",
626
+ }),
309
627
  ),
310
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
+ );
311
645
  const RouteParams = Type.Union([
312
646
  Type.Object(
313
647
  {
@@ -319,16 +653,22 @@ export default async function developer(pi: ExtensionAPI) {
319
653
  description: "Exact skill name from the current Available Developer skills list",
320
654
  }),
321
655
  },
322
- { additionalProperties: false, description: "Route one question to a Developer skill" },
656
+ {
657
+ additionalProperties: false,
658
+ description: "Route one question to a Developer skill",
659
+ },
323
660
  ),
324
661
  Type.Object(
325
662
  {
326
663
  ...SharedRouteParams,
327
- owner: Type.Literal("direct", { description: "Use Pi implementation tools for an already-justified action" }),
664
+ owner: Type.Literal("direct", {
665
+ description: "Use Pi implementation tools for an already-justified action",
666
+ }),
328
667
  movement: Type.String({
329
668
  minLength: 1,
330
669
  maxLength: MAX_QUESTION_CHARS,
331
- description: "One locally explainable behavioral or structural movement; not a multi-step implementation queue",
670
+ description:
671
+ "One locally explainable behavioral or structural movement; not a multi-step implementation queue",
332
672
  }),
333
673
  stop_condition: Type.String({
334
674
  minLength: 1,
@@ -340,13 +680,24 @@ export default async function developer(pi: ExtensionAPI) {
340
680
  maxLength: MAX_EVIDENCE_CHARS,
341
681
  description: "The narrowest check that can catch the likely break in this movement",
342
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
+ ),
343
690
  execution_profile: Type.Optional(
344
691
  Type.Literal("behavior-preserving-structure", {
345
- description: "Load the focused structural-mutation protocol; omit for ordinary direct action",
692
+ description:
693
+ "Load the focused structural-mutation protocol; omit for ordinary direct action",
346
694
  }),
347
695
  ),
348
696
  },
349
- { additionalProperties: false, description: "Route one already-justified direct action" },
697
+ {
698
+ additionalProperties: false,
699
+ description: "Route one already-justified direct action",
700
+ },
350
701
  ),
351
702
  ]);
352
703
 
@@ -359,15 +710,21 @@ export default async function developer(pi: ExtensionAPI) {
359
710
  promptGuidelines: [
360
711
  `Call ${ROUTE_TOOL} only when there is no active Developer route.`,
361
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.`,
362
714
  `After a resolved model, use sketch for first feature implementation framing or signal for existing-code structural movement before direct mutation.`,
363
715
  ],
364
716
  parameters: RouteParams,
365
717
  executionMode: "sequential",
718
+ renderShell: "self",
366
719
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
367
- if (state.mode === "off") fail("Developer protocol is off. Run /develop on or /develop strict first.");
720
+ if (state.mode === "off")
721
+ fail("Developer protocol is off. Run /develop on or /develop strict first.");
368
722
  if (state.activeRoute || routeOpening) {
369
- if (!state.activeRoute) fail("Another Developer route is currently opening. Wait for it to finish.");
370
- fail(`Route ${state.activeRoute.routeId} is still active. Record its judgment before routing another question.`);
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
+ );
371
728
  }
372
729
  routeOpening = true;
373
730
 
@@ -378,11 +735,57 @@ export default async function developer(pi: ExtensionAPI) {
378
735
 
379
736
  const explicitQuestionId = params.open_question_id?.trim() || undefined;
380
737
  const targetQuestionId = inferredQuestionId(state, question, explicitQuestionId);
381
- if (targetQuestionId && !state.pendingQuestions.some((item) => item.id === targetQuestionId)) {
738
+ if (
739
+ targetQuestionId &&
740
+ !state.pendingQuestions.some((item) => item.id === targetQuestionId)
741
+ ) {
382
742
  fail(`Unknown pending question ID: ${targetQuestionId}`);
383
743
  }
384
744
 
385
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
+ }
386
789
  if (
387
790
  owner === "direct" &&
388
791
  state.implementationFramingRequired &&
@@ -394,7 +797,9 @@ export default async function developer(pi: ExtensionAPI) {
394
797
  }
395
798
  const skill = owner === "direct" ? undefined : availableSkills.get(owner);
396
799
  if (owner !== "direct" && !skill) {
397
- fail(`Developer skill ${owner} is unavailable or disabled in the current Pi resource configuration.`);
800
+ fail(
801
+ `Developer skill ${owner} is unavailable or disabled in the current Pi resource configuration.`,
802
+ );
398
803
  }
399
804
  const requestedExecutionProfile =
400
805
  "execution_profile" in params ? params.execution_profile : undefined;
@@ -423,15 +828,13 @@ export default async function developer(pi: ExtensionAPI) {
423
828
  owner === "direct"
424
829
  ? {
425
830
  movement: ("movement" in params ? params.movement : question).trim(),
426
- stopCondition: (
427
- "stop_condition" in params
428
- ? params.stop_condition
429
- : "Reach a green, pausable, reviewable stable landing."
831
+ stopCondition: ("stop_condition" in params
832
+ ? params.stop_condition
833
+ : "Reach a green, pausable, reviewable stable landing."
430
834
  ).trim(),
431
- verification: (
432
- "verification" in params
433
- ? params.verification
434
- : "Run the narrowest relevant check and inspect the resulting diff or output."
835
+ verification: ("verification" in params
836
+ ? params.verification
837
+ : "Run the narrowest relevant check and inspect the resulting diff or output."
435
838
  ).trim(),
436
839
  }
437
840
  : undefined;
@@ -443,7 +846,8 @@ export default async function developer(pi: ExtensionAPI) {
443
846
  question,
444
847
  owner,
445
848
  reason,
446
- knownEvidence: (params.known_evidence ?? []).map((item) => item.trim()).filter(Boolean),
849
+ knownEvidence,
850
+ consideredAlternatives,
447
851
  targetQuestionId,
448
852
  methodLocation: skill?.filePath,
449
853
  executionProfile,
@@ -453,8 +857,12 @@ export default async function developer(pi: ExtensionAPI) {
453
857
  `Route ID: ${event.routeId}`,
454
858
  `Question: ${event.question}`,
455
859
  `Target: ${event.owner}`,
456
- skill ? `Skill location: ${skill.filePath}` : "Skill location: direct action; no skill file",
457
- executionProfile ? `Execution profile: ${executionProfile}` : "Execution profile: skill judgment",
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",
458
866
  ...(directStep
459
867
  ? [
460
868
  `Movement: ${directStep.movement}`,
@@ -465,7 +873,16 @@ export default async function developer(pi: ExtensionAPI) {
465
873
  : []),
466
874
  `Reason: ${event.reason}`,
467
875
  `Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
468
- targetQuestionId ? `Revisits pending question: ${targetQuestionId}` : "Revisits pending question: 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",
469
886
  `When this route has done its job, call ${JUDGMENT_TOOL} with this exact route ID.`,
470
887
  "",
471
888
  "---",
@@ -473,6 +890,11 @@ export default async function developer(pi: ExtensionAPI) {
473
890
  method,
474
891
  ].join("\n");
475
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
+ }
476
898
 
477
899
  state = applyDeveloperEvent(state, event);
478
900
  syncProtocolTools();
@@ -485,7 +907,9 @@ export default async function developer(pi: ExtensionAPI) {
485
907
  renderCall(args, theme, context) {
486
908
  const owner = typeof args.owner === "string" && args.owner.length > 0 ? args.owner : "…";
487
909
  const question =
488
- typeof args.question === "string" && args.question.length > 0 ? compactLine(args.question) : "…";
910
+ typeof args.question === "string" && args.question.length > 0
911
+ ? compactLine(args.question)
912
+ : "…";
489
913
  return reusableText(
490
914
  `${theme.fg("toolTitle", theme.bold(ROUTE_TOOL))} ${theme.fg("accent", owner)} ${theme.fg("muted", question)}`,
491
915
  context.lastComponent,
@@ -493,7 +917,10 @@ export default async function developer(pi: ExtensionAPI) {
493
917
  },
494
918
  renderResult(result, { expanded, isPartial }, theme, context) {
495
919
  if (isPartial) {
496
- return reusableText(theme.fg("warning", "routing development question…"), context.lastComponent);
920
+ return reusableText(
921
+ theme.fg("warning", "routing development question…"),
922
+ context.lastComponent,
923
+ );
497
924
  }
498
925
  if (context.isError) {
499
926
  return reusableText(
@@ -504,25 +931,28 @@ export default async function developer(pi: ExtensionAPI) {
504
931
  const event = result.details as RouteEvent | undefined;
505
932
  let text = theme.fg("success", `routed ${routeRenderText(event)}`);
506
933
  if (expanded && event) {
507
- text += `\n${theme.fg("dim", `route · ${event.routeId}`)}`;
508
- text += `\n${theme.fg("dim", `question · ${event.question}`)}`;
509
- text += `\n${theme.fg("dim", `reason · ${event.reason}`)}`;
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)}`;
510
937
  if (event.knownEvidence.length > 0) {
511
938
  for (const evidence of event.knownEvidence) {
512
- text += `\n${theme.fg("dim", `evidence · ${evidence}`)}`;
939
+ text += `\n${detailLine(theme, "evidence", evidence)}`;
513
940
  }
514
941
  } else {
515
- text += `\n${theme.fg("dim", "evidence · none recorded before routing")}`;
942
+ text += `\n${detailLine(theme, "evidence", "none recorded before routing", "warning")}`;
516
943
  }
517
- text += `\n${theme.fg("dim", `revisits · ${event.targetQuestionId ?? "none"}`)}`;
518
- text += `\n${theme.fg("dim", `skill · ${event.methodLocation ?? "direct action"}`)}`;
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")}`;
519
949
  if (event.executionProfile) {
520
- text += `\n${theme.fg("dim", `profile · ${event.executionProfile}`)}`;
950
+ text += `\n${detailLine(theme, "profile", event.executionProfile)}`;
521
951
  }
522
952
  if (event.directStep) {
523
- text += `\n${theme.fg("dim", `movement · ${event.directStep.movement}`)}`;
524
- text += `\n${theme.fg("dim", `landing · ${event.directStep.stopCondition}`)}`;
525
- text += `\n${theme.fg("dim", `verify · ${event.directStep.verification}`)}`;
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)}`;
526
956
  }
527
957
  }
528
958
  if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
@@ -530,22 +960,129 @@ export default async function developer(pi: ExtensionAPI) {
530
960
  },
531
961
  });
532
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
+ );
533
1057
  const JudgmentParams = Type.Object({
534
- route_id: Type.String({ minLength: 1, maxLength: 512, description: `Exact route ID returned by ${ROUTE_TOOL}` }),
1058
+ route_id: Type.String({
1059
+ minLength: 1,
1060
+ maxLength: 512,
1061
+ description: `Exact route ID returned by ${ROUTE_TOOL}`,
1062
+ }),
535
1063
  status: StringEnum(["resolved", "needs-evidence", "not-applicable", "blocked"] as const),
536
1064
  result: Type.String({
537
1065
  minLength: 1,
538
1066
  maxLength: MAX_RESULT_CHARS,
539
- description: "The resulting judgment in concrete terms",
1067
+ description:
1068
+ "The resulting judgment in Markdown; preserve the routed skill's inspectable tables, diagrams, matrices, timelines, or code blocks",
540
1069
  }),
541
1070
  basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
542
1071
  maxItems: 20,
543
1072
  description: "Evidence supporting the judgment or blocker",
544
1073
  }),
545
1074
  open_questions: Type.Optional(
546
- Type.Array(Type.String({ maxLength: MAX_QUESTION_CHARS }), {
1075
+ Type.Array(OpenQuestionParam, {
547
1076
  maxItems: 10,
548
- description: "New questions that remain unresolved",
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",
549
1086
  }),
550
1087
  ),
551
1088
  artifacts: Type.Optional(
@@ -564,15 +1101,34 @@ export default async function developer(pi: ExtensionAPI) {
564
1101
  promptSnippet: "Record evidence and close the active development route",
565
1102
  promptGuidelines: [
566
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.`,
567
1106
  `Do not use ${JUDGMENT_TOOL} with resolved, not-applicable, or blocked status without at least one concrete basis.`,
568
1107
  ],
569
1108
  parameters: JudgmentParams,
570
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
+ },
571
1126
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
572
1127
  if (state.mode === "off") fail("Developer protocol is off.");
573
- if (!state.activeRoute) fail("There is no active Developer route to close.");
574
- if (params.route_id !== state.activeRoute.routeId) {
575
- fail(`Route ID mismatch. Active route is ${state.activeRoute.routeId}.`);
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}.`);
576
1132
  }
577
1133
 
578
1134
  const basis = params.basis.map((item) => item.trim()).filter(Boolean);
@@ -582,28 +1138,89 @@ export default async function developer(pi: ExtensionAPI) {
582
1138
  fail(`${params.status} judgments require at least one concrete basis.`);
583
1139
  }
584
1140
 
585
- const openedQuestions = (params.open_questions ?? [])
586
- .map((item) => item.trim())
587
- .filter(Boolean)
588
- .map((question, index) => ({
589
- id: `question:${params.route_id}:open:${index + 1}`,
590
- question,
591
- status: "needs-evidence" as const,
592
- sourceRouteId: params.route_id,
593
- }));
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
+
594
1205
  const event: JudgmentEvent = {
595
1206
  protocol: PROTOCOL,
596
1207
  kind: "judgment",
597
1208
  routeId: params.route_id,
598
- question: state.activeRoute.question,
599
- owner: state.activeRoute.owner,
1209
+ question: activeRoute.question,
1210
+ owner: activeRoute.owner,
600
1211
  status: params.status,
601
1212
  result,
602
1213
  basis,
603
1214
  openedQuestions,
1215
+ questionUpdates,
604
1216
  artifacts: (params.artifacts ?? []).map((item) => item.trim()).filter(Boolean),
605
1217
  changedArtifacts: routesWithMutation.has(params.route_id),
606
1218
  };
1219
+ if (!canApplyDeveloperEvent(state, event)) {
1220
+ fail(
1221
+ "Developer machine guard rejected the judgment transition from the current active route.",
1222
+ );
1223
+ }
607
1224
  const nextState = applyDeveloperEvent(state, event);
608
1225
  if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
609
1226
  fail(
@@ -611,16 +1228,31 @@ export default async function developer(pi: ExtensionAPI) {
611
1228
  );
612
1229
  }
613
1230
 
614
- const next = protocolState(nextState);
615
- const nextMessage =
616
- event.owner === "direct"
617
- ? nextState.verificationRequired
618
- ? "Stable landing recorded. Route again from the new evidence; verify is required before claiming completion."
619
- : "Stable landing recorded. Route again from the new evidence before selecting another movement."
620
- : next === "idle"
621
- ? "Developer protocol is idle. This is routing state only and does not prove task completion."
622
- : `Developer protocol is ${next}. Address the current routing obligation before handoff.`;
623
- const response = `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\n${nextMessage}`;
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);
624
1256
  ensureSafeToolText(response, "Developer judgment result");
625
1257
 
626
1258
  routesWithMutation.delete(params.route_id);
@@ -632,7 +1264,9 @@ export default async function developer(pi: ExtensionAPI) {
632
1264
  renderCall(args, theme, context) {
633
1265
  const status = typeof args.status === "string" && args.status.length > 0 ? args.status : "…";
634
1266
  const result =
635
- typeof args.result === "string" && args.result.length > 0 ? compactLine(args.result) : "…";
1267
+ typeof args.result === "string" && args.result.length > 0
1268
+ ? compactJudgmentResult(args.result)
1269
+ : "…";
636
1270
  const statusText =
637
1271
  status === "resolved"
638
1272
  ? theme.fg("success", status)
@@ -648,7 +1282,10 @@ export default async function developer(pi: ExtensionAPI) {
648
1282
  },
649
1283
  renderResult(result, { expanded, isPartial }, theme, context) {
650
1284
  if (isPartial) {
651
- return reusableText(theme.fg("warning", "recording development judgment…"), context.lastComponent);
1285
+ return reusableText(
1286
+ theme.fg("warning", "recording development judgment…"),
1287
+ context.lastComponent,
1288
+ );
652
1289
  }
653
1290
  if (context.isError) {
654
1291
  return reusableText(
@@ -666,28 +1303,15 @@ export default async function developer(pi: ExtensionAPI) {
666
1303
  : event?.status === "blocked"
667
1304
  ? theme.fg("error", summary)
668
1305
  : theme.fg("muted", summary);
669
- if (expanded && event) {
670
- text += `\n${theme.fg("dim", `route · ${event.routeId}`)}`;
671
- text += `\n${theme.fg("dim", `target · ${event.owner}`)}`;
672
- text += `\n${theme.fg("dim", `question · ${event.question}`)}`;
673
- text += `\n${theme.fg("dim", `result · ${event.result}`)}`;
674
- for (const basis of event.basis) text += `\n${theme.fg("dim", `basis · ${basis}`)}`;
675
- for (const artifact of event.artifacts) text += `\n${theme.fg("dim", `artifact · ${artifact}`)}`;
676
- for (const question of event.openedQuestions) {
677
- text += `\n${theme.fg("warning", `opened · ${question.id} · ${question.question}`)}`;
678
- }
679
- }
680
- if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
1306
+ if (expanded && event) return expandedJudgment(event, text, theme);
1307
+ if (event) text += ` · ${keyHint("app.tools.expand", "details")}`;
681
1308
  return reusableText(text, context.lastComponent);
682
1309
  },
683
1310
  });
684
1311
 
685
1312
  const refreshAvailableSkills = (ctx: ExtensionCommandContext) => {
686
1313
  if (typeof ctx.getSystemPromptOptions !== "function") return;
687
- availableSkills = availablePackageSkills(
688
- ctx.getSystemPromptOptions().skills ?? [],
689
- skillsRoot,
690
- );
1314
+ availableSkills = availablePackageSkills(ctx.getSystemPromptOptions().skills ?? [], skillsRoot);
691
1315
  };
692
1316
 
693
1317
  const statusMessage = () => {
@@ -697,7 +1321,10 @@ export default async function developer(pi: ExtensionAPI) {
697
1321
  const pending =
698
1322
  state.pendingQuestions.length > 0
699
1323
  ? state.pendingQuestions
700
- .map((question) => `${question.id} · ${question.status} · ${question.question}`)
1324
+ .map(
1325
+ (question) =>
1326
+ `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
1327
+ )
701
1328
  .join(" | ")
702
1329
  : "none";
703
1330
  const last = state.lastJudgment
@@ -711,12 +1338,33 @@ export default async function developer(pi: ExtensionAPI) {
711
1338
  state.lastJudgment && state.lastJudgment.artifacts.length > 0
712
1339
  ? state.lastJudgment.artifacts.join(" | ")
713
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";
714
1358
  return (
715
1359
  `${summarizeState(state)}` +
716
1360
  `\nactive: ${active}` +
717
1361
  `\nlast: ${last}` +
718
1362
  `\nbasis: ${basis}` +
719
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}` +
720
1368
  `\nactive tools: ${pi.getActiveTools().join(", ")}` +
721
1369
  `\navailable skills: ${[...availableSkills.keys()].join(", ") || "none"}` +
722
1370
  `\npending: ${pending}` +
@@ -734,11 +1382,44 @@ export default async function developer(pi: ExtensionAPI) {
734
1382
  : null;
735
1383
  },
736
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
+
737
1406
  let action = args.trim();
738
1407
  if (!action && ctx.mode === "tui") {
739
1408
  refreshAvailableSkills(ctx);
740
- action = (await showDeveloperActionSelector(ctx, state)) ?? "";
741
- if (!action) return;
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
+ }
742
1423
  }
743
1424
  if (!action) action = "status";
744
1425
  if (action === "on" || action === "strict" || action === "off") {
@@ -769,25 +1450,26 @@ export default async function developer(pi: ExtensionAPI) {
769
1450
  return;
770
1451
  }
771
1452
  if (ctx.mode === "tui") {
772
- const questionId = await showPendingQuestionSelector(ctx, state.pendingQuestions);
773
- if (!questionId) return;
774
- const question = state.pendingQuestions.find((item) => item.id === questionId);
775
- if (!question) return;
776
- const focusEvent: FocusEvent = {
777
- protocol: PROTOCOL,
778
- kind: "focus",
779
- questionId: question.id,
780
- };
781
- pi.appendEntry(FOCUS_ENTRY, focusEvent);
782
- state = applyDeveloperEvent(state, focusEvent);
783
- refreshUI(ctx);
784
- prepareQuestionPrompt(ctx, question);
785
- ctx.ui.notify("Open question focused and loaded into the editor for review.", "info");
786
- return;
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
+ }
787
1466
  }
788
1467
  ctx.ui.notify(
789
1468
  state.pendingQuestions
790
- .map((question) => `${question.id} · ${question.status} · ${question.question}`)
1469
+ .map(
1470
+ (question) =>
1471
+ `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question}\n resolves when: ${question.resolutionCriteria}`,
1472
+ )
791
1473
  .join("\n"),
792
1474
  "info",
793
1475
  );
@@ -826,19 +1508,41 @@ export default async function developer(pi: ExtensionAPI) {
826
1508
  pi.on("before_agent_start", (event) => {
827
1509
  availableSkills = availablePackageSkills(event.systemPromptOptions.skills ?? [], skillsRoot);
828
1510
  if (state.mode === "off") return;
829
- return { systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]) };
1511
+ return {
1512
+ systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]),
1513
+ };
830
1514
  });
831
1515
 
832
1516
  pi.on("tool_call", (event) => {
833
- const mutationBuiltins = builtinMutationToolNames(pi.getAllTools());
834
- if (mutationBuiltins.has(event.toolName) && state.activeRoute?.owner === "direct") {
835
- routesWithMutation.add(state.activeRoute.routeId);
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);
836
1523
  return;
837
1524
  }
838
- if (state.mode !== "strict" || !mutationBuiltins.has(event.toolName)) return;
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
+
839
1540
  return {
840
1541
  block: true,
841
- reason: `Developer strict mode requires an active ${ROUTE_TOOL} targeting direct action (owner=direct) before Pi built-in edit, write, or bash.`,
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.`,
842
1546
  };
843
1547
  });
844
1548