@hobin/developer 0.1.3 → 0.1.5

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,14 +6,17 @@ 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 {
@@ -23,6 +26,8 @@ import {
23
26
  PROTOCOL,
24
27
  ROUTE_TOOL,
25
28
  applyDeveloperEvent,
29
+ canApplyDeveloperEvent,
30
+ developerSnapshot,
26
31
  initialState,
27
32
  normalizeDeveloperEvent,
28
33
  protocolState,
@@ -33,16 +38,25 @@ import {
33
38
  type FocusEvent,
34
39
  type JudgmentEvent,
35
40
  type ModeEvent,
41
+ type PendingQuestion,
42
+ type PendingQuestionStatus,
43
+ type QuestionGate,
44
+ type QuestionResolutionOwner,
45
+ type QuestionUpdate,
46
+ type QuestionUpdateStatus,
47
+ type RouteAlternative,
36
48
  type RouteEvent,
37
49
  } from "./state.ts";
38
50
  import {
39
- builtinMutationToolNames,
51
+ builtinControlledToolCapabilities,
52
+ isControlledToolAllowed,
40
53
  reconcileProtocolTools,
54
+ type ProtocolToolAccess,
41
55
  type ToolPolicyMemory,
42
56
  } from "./tool-policy.ts";
43
57
  import {
44
58
  DeveloperWidget,
45
- prepareQuestionPrompt,
59
+ editQuestionResolutionRequest,
46
60
  renderDeveloperFooter,
47
61
  showDeveloperActionSelector,
48
62
  showDeveloperStatus,
@@ -60,7 +74,7 @@ const structuralChangeMethodPath = resolve(
60
74
  const MAX_PENDING_QUESTIONS = 20;
61
75
  const MAX_QUESTION_CHARS = 2_000;
62
76
  const MAX_EVIDENCE_CHARS = 2_000;
63
- const MAX_RESULT_CHARS = 4_000;
77
+ const MAX_RESULT_CHARS = 12_000;
64
78
  const MAX_ARTIFACT_CHARS = 4_096;
65
79
  const DEVELOPER_COMMAND_ACTIONS = ["on", "strict", "status", "questions", "off"] as const;
66
80
 
@@ -113,6 +127,112 @@ function normalizedQuestion(value: string): string {
113
127
  return value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
114
128
  }
115
129
 
130
+ interface OpenQuestionInput {
131
+ question: string;
132
+ status: PendingQuestionStatus;
133
+ resolution_owner: Exclude<QuestionResolutionOwner, "unknown">;
134
+ gate: QuestionGate;
135
+ resolution_criteria: string;
136
+ }
137
+
138
+ interface QuestionUpdateInput {
139
+ question_id: string;
140
+ status: QuestionUpdateStatus;
141
+ result: string;
142
+ basis: string[];
143
+ }
144
+
145
+ function legacyOpenQuestion(question: string, judgmentStatus?: string): OpenQuestionInput {
146
+ if (judgmentStatus === "blocked") {
147
+ return {
148
+ question,
149
+ status: "blocked",
150
+ resolution_owner: "environment",
151
+ gate: "before-completion",
152
+ resolution_criteria: `Obtain evidence that settles: ${question}`,
153
+ };
154
+ }
155
+ return {
156
+ question,
157
+ status: "open",
158
+ resolution_owner: "agent",
159
+ gate: "none",
160
+ resolution_criteria: `Obtain evidence that settles: ${question}`,
161
+ };
162
+ }
163
+
164
+ function buildOpenedQuestions(
165
+ input: OpenQuestionInput[],
166
+ state: DeveloperState,
167
+ routeId: string,
168
+ ): PendingQuestion[] {
169
+ const questionIds = new Map(
170
+ state.pendingQuestions.map((question) => [normalizedQuestion(question.question), question.id]),
171
+ );
172
+ const openedQuestions: PendingQuestion[] = [];
173
+ for (const [index, item] of input.entries()) {
174
+ const question = item.question.trim();
175
+ const resolutionCriteria = item.resolution_criteria.trim();
176
+ if (!question || !resolutionCriteria) {
177
+ fail("Each open question requires non-whitespace question and resolution_criteria text.");
178
+ }
179
+ const questionKey = normalizedQuestion(question);
180
+ const questionId = questionIds.get(questionKey) ?? `question:${routeId}:open:${index + 1}`;
181
+ questionIds.set(questionKey, questionId);
182
+ const pendingQuestion: PendingQuestion = {
183
+ id: questionId,
184
+ question,
185
+ status: item.status,
186
+ resolutionOwner: item.resolution_owner,
187
+ gate: item.gate,
188
+ resolutionCriteria,
189
+ sourceRouteId: routeId,
190
+ };
191
+ const duplicateIndex = openedQuestions.findIndex((candidate) => candidate.id === questionId);
192
+ if (duplicateIndex === -1) openedQuestions.push(pendingQuestion);
193
+ else openedQuestions[duplicateIndex] = pendingQuestion;
194
+ }
195
+ return openedQuestions;
196
+ }
197
+
198
+ function buildQuestionUpdates(input: QuestionUpdateInput[], state: DeveloperState): QuestionUpdate[] {
199
+ const questionUpdates = input.map((update) => ({
200
+ questionId: update.question_id.trim(),
201
+ status: update.status,
202
+ result: update.result.trim(),
203
+ basis: update.basis.map((item) => item.trim()).filter(Boolean),
204
+ }));
205
+ const knownQuestionIds = new Set(state.pendingQuestions.map((question) => question.id));
206
+ const updatedIds = new Set<string>();
207
+ for (const update of questionUpdates) {
208
+ if (!knownQuestionIds.has(update.questionId)) fail(`Unknown pending question ID: ${update.questionId}`);
209
+ if (updatedIds.has(update.questionId)) fail(`Question ${update.questionId} was updated more than once.`);
210
+ updatedIds.add(update.questionId);
211
+ if (!update.result) fail(`Question update ${update.questionId} requires a non-empty result.`);
212
+ if (
213
+ (update.status === "resolved" || update.status === "not-applicable" || update.status === "blocked") &&
214
+ update.basis.length === 0
215
+ ) {
216
+ fail(`Question update ${update.questionId} with status ${update.status} requires concrete basis.`);
217
+ }
218
+ }
219
+ return questionUpdates;
220
+ }
221
+
222
+ function judgmentNextMessage(event: JudgmentEvent, nextState: DeveloperState): string {
223
+ if (event.owner === "direct") {
224
+ if (nextState.verificationRequired) {
225
+ return "Stable landing recorded. Route again from the new evidence; verify is required before claiming completion.";
226
+ }
227
+ return "Stable landing recorded. Route again from the new evidence before selecting another movement.";
228
+ }
229
+ const next = protocolState(nextState);
230
+ if (next === "idle") {
231
+ return "Developer protocol is idle. This is routing state only and does not prove task completion.";
232
+ }
233
+ return `Developer protocol is ${next}. Address the current routing obligation before handoff.`;
234
+ }
235
+
116
236
  function inferredQuestionId(
117
237
  state: DeveloperState,
118
238
  question: string,
@@ -124,8 +244,16 @@ function inferredQuestionId(
124
244
  const exact = state.pendingQuestions.filter(
125
245
  (pending) => normalizedQuestion(pending.question) === normalized,
126
246
  );
127
- if (exact.length === 1) return exact[0]?.id;
128
- return state.pendingQuestions.length === 1 ? state.pendingQuestions[0]?.id : undefined;
247
+ return exact.length === 1 ? exact[0]?.id : undefined;
248
+ }
249
+
250
+ function protocolToolAccess(state: DeveloperState): ProtocolToolAccess {
251
+ const snapshot = developerSnapshot(state);
252
+ return {
253
+ canExecute: snapshot.hasTag("execute"),
254
+ canMutate: snapshot.hasTag("mutate"),
255
+ hasBeforeDirectGate: snapshot.hasTag("blocks-direct"),
256
+ };
129
257
  }
130
258
 
131
259
  function summarizeState(state: DeveloperState): string {
@@ -145,14 +273,22 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
145
273
  "- 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
274
  "- 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
275
  `- 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.`,
276
+ "- 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
277
  "- 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
278
  "- Protocol state is routing bookkeeping. Idle never proves product completion, user acceptance, or current verification.",
279
+ "- 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.",
280
+ `- 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.`,
281
+ "- 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.",
282
+ "- 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.",
283
+ "- Pending questions declare who can resolve them, what they block, and observable resolution criteria. Never guess a user-owned answer or bypass a before-direct gate.",
284
+ "- 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
285
  "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
151
286
  ];
152
287
 
153
288
  if (state.mode === "strict") {
154
289
  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.",
290
+ "- 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.",
291
+ "- 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
292
  );
157
293
  }
158
294
 
@@ -166,6 +302,19 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
166
302
  "Verification debt: a direct route changed artifacts after the last resolved verify judgment. Route verify before claiming completion or handing off as done.",
167
303
  );
168
304
  }
305
+ const completionGates = state.pendingQuestions.filter(
306
+ (question) => question.gate === "before-completion" || question.gate === "before-direct",
307
+ );
308
+ if (completionGates.length > 0) {
309
+ lines.push(
310
+ `Completion gate: ${completionGates.map((question) => question.id).join(", ")} must be resolved before a completion claim.`,
311
+ );
312
+ }
313
+ if (!state.activeRoute && state.rerouteRequired) {
314
+ lines.push(
315
+ "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.",
316
+ );
317
+ }
169
318
 
170
319
  if (state.activeRoute) {
171
320
  lines.push(
@@ -180,10 +329,13 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
180
329
  if (state.pendingQuestions.length > 0) {
181
330
  lines.push("Pending Developer questions:");
182
331
  for (const question of state.pendingQuestions) {
183
- lines.push(`- ${question.id} · ${question.status} · ${question.question}`);
332
+ lines.push(
333
+ `- ${question.id} · ${question.status} · owner=${question.resolutionOwner} · gate=${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
334
+ );
184
335
  }
185
336
  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.",
337
+ "- 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.",
338
+ "- question_updates may resolve any pending ID from implementation, test, inspection, user, or environment evidence; each resolved update requires concrete basis.",
187
339
  );
188
340
  }
189
341
  lines.push(
@@ -199,9 +351,56 @@ function routeRenderText(event: RouteEvent | undefined): string {
199
351
  return `${event.owner}${profile} · ${compactLine(event.question)}${target}`;
200
352
  }
201
353
 
354
+ function compactJudgmentResult(result: string, maxChars = 160): string {
355
+ const firstContentLine = result
356
+ .split(/\r?\n/)
357
+ .map((line) => line.trim())
358
+ .find((line) => line && !line.startsWith("```"));
359
+ return compactLine(firstContentLine ?? result, maxChars);
360
+ }
361
+
202
362
  function judgmentRenderText(event: JudgmentEvent | undefined): string {
203
363
  if (!event) return "Judgment unavailable";
204
- return `${event.status} · ${compactLine(event.result)}`;
364
+ return `${event.status} · ${compactJudgmentResult(event.result)}`;
365
+ }
366
+
367
+ function detailLine(
368
+ theme: Theme,
369
+ label: string,
370
+ value: string,
371
+ valueColor: ThemeColor = "muted",
372
+ ): string {
373
+ return `${theme.fg("dim", `${label} · `)}${theme.fg(valueColor, value)}`;
374
+ }
375
+
376
+ function expandedJudgment(event: JudgmentEvent, statusText: string, theme: Theme): Container {
377
+ const container = new Container();
378
+ const header = [
379
+ statusText,
380
+ detailLine(theme, "route", event.routeId, "text"),
381
+ detailLine(theme, "target", event.owner, "accent"),
382
+ detailLine(theme, "question", event.question, "text"),
383
+ ];
384
+ container.addChild(new Text(header.join("\n"), 0, 0));
385
+ container.addChild(new Markdown(event.result, 0, 0, getMarkdownTheme()));
386
+
387
+ const evidence = [
388
+ ...event.basis.map((basis) => detailLine(theme, "basis", basis)),
389
+ ...event.artifacts.map((artifact) => detailLine(theme, "artifact", artifact)),
390
+ ...event.openedQuestions.map((question) =>
391
+ detailLine(
392
+ theme,
393
+ `opened ${question.resolutionOwner}/${question.gate}`,
394
+ `${question.question} — resolves when: ${question.resolutionCriteria}`,
395
+ "warning",
396
+ ),
397
+ ),
398
+ ...(event.questionUpdates ?? []).map((update) =>
399
+ detailLine(theme, `question ${update.status}`, `${update.questionId} — ${update.result}`, "accent"),
400
+ ),
401
+ ];
402
+ if (evidence.length > 0) container.addChild(new Text(evidence.join("\n"), 0, 0));
403
+ return container;
205
404
  }
206
405
 
207
406
  export default async function developer(pi: ExtensionAPI) {
@@ -222,7 +421,7 @@ export default async function developer(pi: ExtensionAPI) {
222
421
  activeTools: current,
223
422
  allTools: pi.getAllTools(),
224
423
  mode: state.mode,
225
- directRouteOpen: state.activeRoute?.owner === "direct",
424
+ access: protocolToolAccess(state),
226
425
  protocolTools: PROTOCOL_TOOLS,
227
426
  memory: toolPolicyMemory,
228
427
  });
@@ -308,6 +507,21 @@ export default async function developer(pi: ExtensionAPI) {
308
507
  Type.String({ maxLength: 512, description: "Exact pending question ID when this route revisits one" }),
309
508
  ),
310
509
  };
510
+ const RouteAlternativeParam = Type.Object(
511
+ {
512
+ owner: Type.String({
513
+ minLength: 1,
514
+ maxLength: 64,
515
+ description: "Exact available Developer skill name that was reconsidered",
516
+ }),
517
+ reason: Type.String({
518
+ minLength: 1,
519
+ maxLength: MAX_EVIDENCE_CHARS,
520
+ description: "Why this skill would add no useful judgment before the proposed direct movement",
521
+ }),
522
+ },
523
+ { additionalProperties: false },
524
+ );
311
525
  const RouteParams = Type.Union([
312
526
  Type.Object(
313
527
  {
@@ -340,6 +554,13 @@ export default async function developer(pi: ExtensionAPI) {
340
554
  maxLength: MAX_EVIDENCE_CHARS,
341
555
  description: "The narrowest check that can catch the likely break in this movement",
342
556
  }),
557
+ alternatives_considered: Type.Optional(
558
+ Type.Array(RouteAlternativeParam, {
559
+ maxItems: 6,
560
+ description:
561
+ "On a reroute after direct, the plausible skill routes reconsidered and why each is unnecessary now",
562
+ }),
563
+ ),
343
564
  execution_profile: Type.Optional(
344
565
  Type.Literal("behavior-preserving-structure", {
345
566
  description: "Load the focused structural-mutation protocol; omit for ordinary direct action",
@@ -359,10 +580,12 @@ export default async function developer(pi: ExtensionAPI) {
359
580
  promptGuidelines: [
360
581
  `Call ${ROUTE_TOOL} only when there is no active Developer route.`,
361
582
  `Use ${ROUTE_TOOL} with the most focused skill supported by current evidence; owner=direct requires one movement, one stable landing, and one narrow verification.`,
583
+ `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
584
  `After a resolved model, use sketch for first feature implementation framing or signal for existing-code structural movement before direct mutation.`,
363
585
  ],
364
586
  parameters: RouteParams,
365
587
  executionMode: "sequential",
588
+ renderShell: "self",
366
589
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
367
590
  if (state.mode === "off") fail("Developer protocol is off. Run /develop on or /develop strict first.");
368
591
  if (state.activeRoute || routeOpening) {
@@ -383,6 +606,42 @@ export default async function developer(pi: ExtensionAPI) {
383
606
  }
384
607
 
385
608
  const owner = params.owner;
609
+ const directBlockers = state.pendingQuestions.filter(
610
+ (pending) => pending.gate === "before-direct",
611
+ );
612
+ if (owner === "direct" && directBlockers.length > 0) {
613
+ fail(
614
+ `Direct work is blocked by ${directBlockers
615
+ .map((pending) => `${pending.id} (${pending.resolutionOwner}: ${pending.question})`)
616
+ .join("; ")}. Resolve those questions first.`,
617
+ );
618
+ }
619
+ const knownEvidence = (params.known_evidence ?? []).map((item) => item.trim()).filter(Boolean);
620
+ const consideredAlternatives: RouteAlternative[] =
621
+ owner === "direct" && "alternatives_considered" in params
622
+ ? (params.alternatives_considered ?? []).map((alternative) => ({
623
+ owner: alternative.owner.trim(),
624
+ reason: alternative.reason.trim(),
625
+ }))
626
+ : [];
627
+ for (const alternative of consideredAlternatives) {
628
+ if (!availableSkills.has(alternative.owner)) {
629
+ fail(`Considered alternative ${alternative.owner} is unavailable or disabled.`);
630
+ }
631
+ }
632
+ if (new Set(consideredAlternatives.map((alternative) => alternative.owner)).size !== consideredAlternatives.length) {
633
+ fail("Each considered alternative must name a different available Developer skill.");
634
+ }
635
+ if (owner === "direct" && state.lastJudgment?.owner === "direct") {
636
+ if (knownEvidence.length === 0) {
637
+ fail("A consecutive direct route must cite evidence from the previous direct landing in known_evidence.");
638
+ }
639
+ if (availableSkills.size > 0 && consideredAlternatives.length === 0) {
640
+ fail(
641
+ "A consecutive direct route must record the plausible available skill routes in alternatives_considered and explain why they are not needed now.",
642
+ );
643
+ }
644
+ }
386
645
  if (
387
646
  owner === "direct" &&
388
647
  state.implementationFramingRequired &&
@@ -443,7 +702,8 @@ export default async function developer(pi: ExtensionAPI) {
443
702
  question,
444
703
  owner,
445
704
  reason,
446
- knownEvidence: (params.known_evidence ?? []).map((item) => item.trim()).filter(Boolean),
705
+ knownEvidence,
706
+ consideredAlternatives,
447
707
  targetQuestionId,
448
708
  methodLocation: skill?.filePath,
449
709
  executionProfile,
@@ -465,6 +725,13 @@ export default async function developer(pi: ExtensionAPI) {
465
725
  : []),
466
726
  `Reason: ${event.reason}`,
467
727
  `Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
728
+ `Alternatives reconsidered: ${
729
+ event.consideredAlternatives.length > 0
730
+ ? event.consideredAlternatives
731
+ .map((alternative) => `${alternative.owner} — ${alternative.reason}`)
732
+ .join(" | ")
733
+ : "none"
734
+ }`,
468
735
  targetQuestionId ? `Revisits pending question: ${targetQuestionId}` : "Revisits pending question: none",
469
736
  `When this route has done its job, call ${JUDGMENT_TOOL} with this exact route ID.`,
470
737
  "",
@@ -473,6 +740,9 @@ export default async function developer(pi: ExtensionAPI) {
473
740
  method,
474
741
  ].join("\n");
475
742
  ensureSafeToolText(response, "Developer route result");
743
+ if (!canApplyDeveloperEvent(state, event)) {
744
+ fail("Developer machine guard rejected the route transition from the current branch state.");
745
+ }
476
746
 
477
747
  state = applyDeveloperEvent(state, event);
478
748
  syncProtocolTools();
@@ -504,25 +774,28 @@ export default async function developer(pi: ExtensionAPI) {
504
774
  const event = result.details as RouteEvent | undefined;
505
775
  let text = theme.fg("success", `routed ${routeRenderText(event)}`);
506
776
  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}`)}`;
777
+ text += `\n${detailLine(theme, "route", event.routeId, "text")}`;
778
+ text += `\n${detailLine(theme, "question", event.question, "text")}`;
779
+ text += `\n${detailLine(theme, "reason", event.reason)}`;
510
780
  if (event.knownEvidence.length > 0) {
511
781
  for (const evidence of event.knownEvidence) {
512
- text += `\n${theme.fg("dim", `evidence · ${evidence}`)}`;
782
+ text += `\n${detailLine(theme, "evidence", evidence)}`;
513
783
  }
514
784
  } else {
515
- text += `\n${theme.fg("dim", "evidence · none recorded before routing")}`;
785
+ text += `\n${detailLine(theme, "evidence", "none recorded before routing", "warning")}`;
786
+ }
787
+ for (const alternative of event.consideredAlternatives ?? []) {
788
+ text += `\n${detailLine(theme, `considered ${alternative.owner}`, alternative.reason)}`;
516
789
  }
517
- text += `\n${theme.fg("dim", `revisits · ${event.targetQuestionId ?? "none"}`)}`;
518
- text += `\n${theme.fg("dim", `skill · ${event.methodLocation ?? "direct action"}`)}`;
790
+ text += `\n${detailLine(theme, "revisits", event.targetQuestionId ?? "none")}`;
791
+ text += `\n${detailLine(theme, "skill", event.methodLocation ?? "direct action")}`;
519
792
  if (event.executionProfile) {
520
- text += `\n${theme.fg("dim", `profile · ${event.executionProfile}`)}`;
793
+ text += `\n${detailLine(theme, "profile", event.executionProfile)}`;
521
794
  }
522
795
  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}`)}`;
796
+ text += `\n${detailLine(theme, "movement", event.directStep.movement, "text")}`;
797
+ text += `\n${detailLine(theme, "landing", event.directStep.stopCondition)}`;
798
+ text += `\n${detailLine(theme, "verify", event.directStep.verification)}`;
526
799
  }
527
800
  }
528
801
  if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
@@ -530,22 +803,66 @@ export default async function developer(pi: ExtensionAPI) {
530
803
  },
531
804
  });
532
805
 
806
+ const OpenQuestionParam = Type.Object(
807
+ {
808
+ question: Type.String({ minLength: 1, maxLength: MAX_QUESTION_CHARS }),
809
+ status: StringEnum(["open", "blocked"] as const, {
810
+ description: "Whether the required evidence or answer is currently obtainable",
811
+ }),
812
+ resolution_owner: StringEnum(["agent", "user", "environment"] as const, {
813
+ description: "Who can provide the evidence or decision that resolves this question",
814
+ }),
815
+ gate: StringEnum(["none", "before-direct", "before-completion"] as const, {
816
+ description:
817
+ "What work this question blocks; an agent before-direct question must be resolvable through a non-direct evidence route",
818
+ }),
819
+ resolution_criteria: Type.String({
820
+ minLength: 1,
821
+ maxLength: MAX_EVIDENCE_CHARS,
822
+ description: "Observable evidence or answer that will settle the question",
823
+ }),
824
+ },
825
+ { additionalProperties: false },
826
+ );
827
+ const QuestionUpdateParam = Type.Object(
828
+ {
829
+ question_id: Type.String({
830
+ minLength: 1,
831
+ maxLength: 512,
832
+ description:
833
+ "Exact ID of a question that was pending before this route; never route_id or a newly opened question ID",
834
+ }),
835
+ status: StringEnum(["resolved", "not-applicable", "open", "blocked"] as const),
836
+ result: Type.String({ minLength: 1, maxLength: MAX_RESULT_CHARS }),
837
+ basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), { maxItems: 20 }),
838
+ },
839
+ { additionalProperties: false },
840
+ );
533
841
  const JudgmentParams = Type.Object({
534
842
  route_id: Type.String({ minLength: 1, maxLength: 512, description: `Exact route ID returned by ${ROUTE_TOOL}` }),
535
843
  status: StringEnum(["resolved", "needs-evidence", "not-applicable", "blocked"] as const),
536
844
  result: Type.String({
537
845
  minLength: 1,
538
846
  maxLength: MAX_RESULT_CHARS,
539
- description: "The resulting judgment in concrete terms",
847
+ description:
848
+ "The resulting judgment in Markdown; preserve the routed skill's inspectable tables, diagrams, matrices, timelines, or code blocks",
540
849
  }),
541
850
  basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
542
851
  maxItems: 20,
543
852
  description: "Evidence supporting the judgment or blocker",
544
853
  }),
545
854
  open_questions: Type.Optional(
546
- Type.Array(Type.String({ maxLength: MAX_QUESTION_CHARS }), {
855
+ Type.Array(OpenQuestionParam, {
547
856
  maxItems: 10,
548
- description: "New questions that remain unresolved",
857
+ description:
858
+ "New unresolved questions with explicit resolution owner, gate, and observable resolution criteria",
859
+ }),
860
+ ),
861
+ question_updates: Type.Optional(
862
+ Type.Array(QuestionUpdateParam, {
863
+ maxItems: 20,
864
+ description:
865
+ "Updates to questions already pending before this route; use [] when none exist, and never put route_id or newly opened questions here",
549
866
  }),
550
867
  ),
551
868
  artifacts: Type.Optional(
@@ -564,10 +881,28 @@ export default async function developer(pi: ExtensionAPI) {
564
881
  promptSnippet: "Record evidence and close the active development route",
565
882
  promptGuidelines: [
566
883
  `Use ${JUDGMENT_TOOL} with the exact active Developer route ID.`,
884
+ `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.`,
885
+ `Use ${JUDGMENT_TOOL} open_questions with resolution_owner, gate, and resolution_criteria; use question_updates whenever current evidence settles or changes any existing pending question.`,
567
886
  `Do not use ${JUDGMENT_TOOL} with resolved, not-applicable, or blocked status without at least one concrete basis.`,
568
887
  ],
569
888
  parameters: JudgmentParams,
570
889
  executionMode: "sequential",
890
+ renderShell: "self",
891
+ prepareArguments(args): Static<typeof JudgmentParams> {
892
+ const input = args as Static<typeof JudgmentParams> & {
893
+ status?: string;
894
+ open_questions?: unknown[];
895
+ };
896
+ if (!input || typeof input !== "object" || !Array.isArray(input.open_questions)) {
897
+ return args as Static<typeof JudgmentParams>;
898
+ }
899
+ return {
900
+ ...input,
901
+ open_questions: input.open_questions.map((question) =>
902
+ typeof question === "string" ? legacyOpenQuestion(question, input.status) : question,
903
+ ) as Static<typeof JudgmentParams>["open_questions"],
904
+ };
905
+ },
571
906
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
572
907
  if (state.mode === "off") fail("Developer protocol is off.");
573
908
  if (!state.activeRoute) fail("There is no active Developer route to close.");
@@ -582,15 +917,55 @@ export default async function developer(pi: ExtensionAPI) {
582
917
  fail(`${params.status} judgments require at least one concrete basis.`);
583
918
  }
584
919
 
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
- }));
920
+ const openedQuestions = buildOpenedQuestions(params.open_questions ?? [], state, params.route_id);
921
+ if (
922
+ availableSkills.size === 0 &&
923
+ openedQuestions.some(
924
+ (question) => question.resolutionOwner === "agent" && question.gate === "before-direct",
925
+ )
926
+ ) {
927
+ fail("An agent-owned before-direct question requires an available non-direct skill resolution path.");
928
+ }
929
+ const remainsOpen = params.status === "needs-evidence" || params.status === "blocked";
930
+ if (remainsOpen && !state.activeRoute.targetQuestionId && openedQuestions.length === 0) {
931
+ fail(
932
+ "A needs-evidence or blocked judgment for a new question must include at least one structured open_questions entry.",
933
+ );
934
+ }
935
+
936
+ if (state.pendingQuestions.length > 0 && params.question_updates === undefined) {
937
+ fail(
938
+ "Pending questions exist. Include question_updates (an empty array is valid) after rechecking them against this route's evidence.",
939
+ );
940
+ }
941
+ const questionUpdates = buildQuestionUpdates(params.question_updates ?? [], state);
942
+ const updatedQuestionIds = new Set(questionUpdates.map((update) => update.questionId));
943
+ if (openedQuestions.some((question) => updatedQuestionIds.has(question.id))) {
944
+ fail("A question cannot be opened and updated in the same judgment.");
945
+ }
946
+ if (!remainsOpen && openedQuestions.length > 0) {
947
+ fail("A resolved or not-applicable judgment cannot open new questions.");
948
+ }
949
+ const targetQuestionId = state.activeRoute.targetQuestionId;
950
+ const targetUpdate = targetQuestionId
951
+ ? questionUpdates.find((update) => update.questionId === targetQuestionId)
952
+ : undefined;
953
+ if (targetQuestionId && !targetUpdate) {
954
+ fail(`Focused question ${targetQuestionId} requires an explicit question_updates entry.`);
955
+ }
956
+ if (targetUpdate) {
957
+ const closesTarget = targetUpdate.status === "resolved" || targetUpdate.status === "not-applicable";
958
+ if (!remainsOpen && !closesTarget) {
959
+ fail("A resolved focused judgment must explicitly resolve or dismiss its question.");
960
+ }
961
+ if (remainsOpen && openedQuestions.length === 0 && closesTarget) {
962
+ fail("A focused question cannot close while its judgment reports no replacement evidence question.");
963
+ }
964
+ if (remainsOpen && openedQuestions.length > 0 && !closesTarget) {
965
+ fail("Replacement questions require explicitly resolving or dismissing the broader focused question.");
966
+ }
967
+ }
968
+
594
969
  const event: JudgmentEvent = {
595
970
  protocol: PROTOCOL,
596
971
  kind: "judgment",
@@ -601,9 +976,13 @@ export default async function developer(pi: ExtensionAPI) {
601
976
  result,
602
977
  basis,
603
978
  openedQuestions,
979
+ questionUpdates,
604
980
  artifacts: (params.artifacts ?? []).map((item) => item.trim()).filter(Boolean),
605
981
  changedArtifacts: routesWithMutation.has(params.route_id),
606
982
  };
983
+ if (!canApplyDeveloperEvent(state, event)) {
984
+ fail("Developer machine guard rejected the judgment transition from the current active route.");
985
+ }
607
986
  const nextState = applyDeveloperEvent(state, event);
608
987
  if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
609
988
  fail(
@@ -611,16 +990,11 @@ export default async function developer(pi: ExtensionAPI) {
611
990
  );
612
991
  }
613
992
 
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}`;
993
+ const nextMessage = judgmentNextMessage(event, nextState);
994
+ const resolvedQuestionCount = event.questionUpdates.filter(
995
+ (update) => update.status === "resolved" || update.status === "not-applicable",
996
+ ).length;
997
+ const response = `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\nQuestion updates: ${resolvedQuestionCount} resolved, ${event.questionUpdates.length - resolvedQuestionCount} retained or blocked.\n${nextMessage}`;
624
998
  ensureSafeToolText(response, "Developer judgment result");
625
999
 
626
1000
  routesWithMutation.delete(params.route_id);
@@ -632,7 +1006,7 @@ export default async function developer(pi: ExtensionAPI) {
632
1006
  renderCall(args, theme, context) {
633
1007
  const status = typeof args.status === "string" && args.status.length > 0 ? args.status : "…";
634
1008
  const result =
635
- typeof args.result === "string" && args.result.length > 0 ? compactLine(args.result) : "…";
1009
+ typeof args.result === "string" && args.result.length > 0 ? compactJudgmentResult(args.result) : "…";
636
1010
  const statusText =
637
1011
  status === "resolved"
638
1012
  ? theme.fg("success", status)
@@ -666,18 +1040,8 @@ export default async function developer(pi: ExtensionAPI) {
666
1040
  : event?.status === "blocked"
667
1041
  ? theme.fg("error", summary)
668
1042
  : 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")}`;
1043
+ if (expanded && event) return expandedJudgment(event, text, theme);
1044
+ if (event) text += ` · ${keyHint("app.tools.expand", "details")}`;
681
1045
  return reusableText(text, context.lastComponent);
682
1046
  },
683
1047
  });
@@ -697,7 +1061,10 @@ export default async function developer(pi: ExtensionAPI) {
697
1061
  const pending =
698
1062
  state.pendingQuestions.length > 0
699
1063
  ? state.pendingQuestions
700
- .map((question) => `${question.id} · ${question.status} · ${question.question}`)
1064
+ .map(
1065
+ (question) =>
1066
+ `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question} · resolves when: ${question.resolutionCriteria}`,
1067
+ )
701
1068
  .join(" | ")
702
1069
  : "none";
703
1070
  const last = state.lastJudgment
@@ -711,12 +1078,31 @@ export default async function developer(pi: ExtensionAPI) {
711
1078
  state.lastJudgment && state.lastJudgment.artifacts.length > 0
712
1079
  ? state.lastJudgment.artifacts.join(" | ")
713
1080
  : "none";
1081
+ const history =
1082
+ state.judgmentHistory.length > 0
1083
+ ? state.judgmentHistory
1084
+ .slice(-10)
1085
+ .map((judgment) => {
1086
+ const route = state.routeHistory.find((candidate) => candidate.routeId === judgment.routeId);
1087
+ const alternatives = (route?.consideredAlternatives ?? [])
1088
+ .map((alternative) => `${alternative.owner}: ${alternative.reason}`)
1089
+ .join("; ");
1090
+ return `${judgment.owner} · ${judgment.status} · ${judgment.result}${
1091
+ alternatives ? ` · considered ${alternatives}` : ""
1092
+ }`;
1093
+ })
1094
+ .join("\n")
1095
+ : "none";
714
1096
  return (
715
1097
  `${summarizeState(state)}` +
716
1098
  `\nactive: ${active}` +
717
1099
  `\nlast: ${last}` +
718
1100
  `\nbasis: ${basis}` +
719
1101
  `\nartifacts: ${artifacts}` +
1102
+ `\nimplementation framing: ${state.implementationFramingRequired ? "required" : "clear"}` +
1103
+ `\ncheckpoint: ${state.rerouteRequired ? "reroute required" : "ready"}` +
1104
+ `\nverification: ${state.verificationRequired ? "required" : "current"}` +
1105
+ `\nhistory:\n${history}` +
720
1106
  `\nactive tools: ${pi.getActiveTools().join(", ")}` +
721
1107
  `\navailable skills: ${[...availableSkills.keys()].join(", ") || "none"}` +
722
1108
  `\npending: ${pending}` +
@@ -778,16 +1164,26 @@ export default async function developer(pi: ExtensionAPI) {
778
1164
  kind: "focus",
779
1165
  questionId: question.id,
780
1166
  };
1167
+ const request = await editQuestionResolutionRequest(ctx, question);
1168
+ if (request === undefined) return;
781
1169
  pi.appendEntry(FOCUS_ENTRY, focusEvent);
782
1170
  state = applyDeveloperEvent(state, focusEvent);
783
1171
  refreshUI(ctx);
784
- prepareQuestionPrompt(ctx, question);
785
- ctx.ui.notify("Open question focused and loaded into the editor for review.", "info");
1172
+ ctx.ui.setEditorText("");
1173
+ ctx.ui.notify(
1174
+ "Question response submitted. It remains open until Developer records a resolved or not-applicable judgment.",
1175
+ "info",
1176
+ );
1177
+ if (ctx.isIdle()) pi.sendUserMessage(request);
1178
+ else pi.sendUserMessage(request, { deliverAs: "followUp" });
786
1179
  return;
787
1180
  }
788
1181
  ctx.ui.notify(
789
1182
  state.pendingQuestions
790
- .map((question) => `${question.id} · ${question.status} · ${question.question}`)
1183
+ .map(
1184
+ (question) =>
1185
+ `${question.id} · ${question.status} · ${question.resolutionOwner}/${question.gate} · ${question.question}\n resolves when: ${question.resolutionCriteria}`,
1186
+ )
791
1187
  .join("\n"),
792
1188
  "info",
793
1189
  );
@@ -830,15 +1226,34 @@ export default async function developer(pi: ExtensionAPI) {
830
1226
  });
831
1227
 
832
1228
  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);
1229
+ const capability = builtinControlledToolCapabilities(pi.getAllTools()).get(event.toolName);
1230
+ if (!capability) return;
1231
+
1232
+ const access = protocolToolAccess(state);
1233
+ if (isControlledToolAllowed({ mode: state.mode, capability, access })) {
1234
+ if (access.canMutate && state.activeRoute) routesWithMutation.add(state.activeRoute.routeId);
836
1235
  return;
837
1236
  }
838
- if (state.mode !== "strict" || !mutationBuiltins.has(event.toolName)) return;
1237
+
1238
+ const directBlockers = state.pendingQuestions.filter(
1239
+ (question) => question.gate === "before-direct",
1240
+ );
1241
+ if (directBlockers.length > 0) {
1242
+ const blockedAction = capability === "execute" ? "unrouted shell execution" : "artifact mutation";
1243
+ return {
1244
+ block: true,
1245
+ reason: `Developer question gate blocks ${blockedAction} until resolved: ${directBlockers
1246
+ .map((question) => `${question.id} (${question.resolutionOwner}: ${question.question})`)
1247
+ .join("; ")}. A non-direct judgment route may still use bash to obtain evidence.`,
1248
+ };
1249
+ }
1250
+
839
1251
  return {
840
1252
  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.`,
1253
+ reason:
1254
+ capability === "execute"
1255
+ ? `Developer strict mode requires an active judgment or direct route before Pi built-in bash can execute evidence checks.`
1256
+ : `Developer strict mode requires an active ${ROUTE_TOOL} targeting direct action (owner=direct) before Pi built-in edit or write.`,
842
1257
  };
843
1258
  });
844
1259