@hobin/developer 0.1.5 → 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.
@@ -22,6 +22,10 @@ import { availablePackageSkills, renderSkillMethod } from "./skills.ts";
22
22
  import {
23
23
  FOCUS_ENTRY,
24
24
  JUDGMENT_TOOL,
25
+ MAX_RESPONSE_FIELDS,
26
+ MAX_RESPONSE_IDENTIFIER_CHARS,
27
+ MAX_RESPONSE_OPTIONS,
28
+ MAX_RESPONSE_TEXT_CHARS,
25
29
  MODE_ENTRY,
26
30
  PROTOCOL,
27
31
  ROUTE_TOOL,
@@ -30,8 +34,10 @@ import {
30
34
  developerSnapshot,
31
35
  initialState,
32
36
  normalizeDeveloperEvent,
37
+ parseChoiceResponseSpec,
33
38
  protocolState,
34
39
  reconstructState,
40
+ type ChoiceResponseSpec,
35
41
  type DeveloperMode,
36
42
  type DeveloperState,
37
43
  type DirectExecutionProfile,
@@ -57,10 +63,12 @@ import {
57
63
  import {
58
64
  DeveloperWidget,
59
65
  editQuestionResolutionRequest,
66
+ promptImmediateUserQuestion,
60
67
  renderDeveloperFooter,
61
68
  showDeveloperActionSelector,
62
69
  showDeveloperStatus,
63
70
  showPendingQuestionSelector,
71
+ type ImmediateQuestionDisposition,
64
72
  } from "./tui.ts";
65
73
 
66
74
  const PROTOCOL_TOOLS = [ROUTE_TOOL, JUDGMENT_TOOL] as const;
@@ -73,6 +81,7 @@ const structuralChangeMethodPath = resolve(
73
81
  );
74
82
  const MAX_PENDING_QUESTIONS = 20;
75
83
  const MAX_QUESTION_CHARS = 2_000;
84
+ const MAX_QUESTION_CONTEXT_CHARS = 8_000;
76
85
  const MAX_EVIDENCE_CHARS = 2_000;
77
86
  const MAX_RESULT_CHARS = 12_000;
78
87
  const MAX_ARTIFACT_CHARS = 4_096;
@@ -124,11 +133,35 @@ function compactLine(value: string, maxChars = 160): string {
124
133
  }
125
134
 
126
135
  function normalizedQuestion(value: string): string {
127
- 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[];
128
159
  }
129
160
 
130
161
  interface OpenQuestionInput {
131
162
  question: string;
163
+ context?: string;
164
+ response_spec?: ChoiceResponseSpecInput;
132
165
  status: PendingQuestionStatus;
133
166
  resolution_owner: Exclude<QuestionResolutionOwner, "unknown">;
134
167
  gate: QuestionGate;
@@ -161,6 +194,36 @@ function legacyOpenQuestion(question: string, judgmentStatus?: string): OpenQues
161
194
  };
162
195
  }
163
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
+
164
227
  function buildOpenedQuestions(
165
228
  input: OpenQuestionInput[],
166
229
  state: DeveloperState,
@@ -172,6 +235,8 @@ function buildOpenedQuestions(
172
235
  const openedQuestions: PendingQuestion[] = [];
173
236
  for (const [index, item] of input.entries()) {
174
237
  const question = item.question.trim();
238
+ const context = item.context?.trim() || undefined;
239
+ const responseSpec = buildChoiceResponseSpec(item.response_spec, item.resolution_owner);
175
240
  const resolutionCriteria = item.resolution_criteria.trim();
176
241
  if (!question || !resolutionCriteria) {
177
242
  fail("Each open question requires non-whitespace question and resolution_criteria text.");
@@ -182,6 +247,8 @@ function buildOpenedQuestions(
182
247
  const pendingQuestion: PendingQuestion = {
183
248
  id: questionId,
184
249
  question,
250
+ context,
251
+ responseSpec,
185
252
  status: item.status,
186
253
  resolutionOwner: item.resolution_owner,
187
254
  gate: item.gate,
@@ -195,7 +262,41 @@ function buildOpenedQuestions(
195
262
  return openedQuestions;
196
263
  }
197
264
 
198
- function buildQuestionUpdates(input: QuestionUpdateInput[], state: DeveloperState): QuestionUpdate[] {
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[] {
199
300
  const questionUpdates = input.map((update) => ({
200
301
  questionId: update.question_id.trim(),
201
302
  status: update.status,
@@ -205,15 +306,21 @@ function buildQuestionUpdates(input: QuestionUpdateInput[], state: DeveloperStat
205
306
  const knownQuestionIds = new Set(state.pendingQuestions.map((question) => question.id));
206
307
  const updatedIds = new Set<string>();
207
308
  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.`);
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.`);
210
313
  updatedIds.add(update.questionId);
211
314
  if (!update.result) fail(`Question update ${update.questionId} requires a non-empty result.`);
212
315
  if (
213
- (update.status === "resolved" || update.status === "not-applicable" || update.status === "blocked") &&
316
+ (update.status === "resolved" ||
317
+ update.status === "not-applicable" ||
318
+ update.status === "blocked") &&
214
319
  update.basis.length === 0
215
320
  ) {
216
- fail(`Question update ${update.questionId} with status ${update.status} requires concrete basis.`);
321
+ fail(
322
+ `Question update ${update.questionId} with status ${update.status} requires concrete basis.`,
323
+ );
217
324
  }
218
325
  }
219
326
  return questionUpdates;
@@ -280,7 +387,7 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
280
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.`,
281
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.",
282
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.",
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.",
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.",
284
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.",
285
392
  "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
286
393
  ];
@@ -294,7 +401,7 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
294
401
 
295
402
  if (state.implementationFramingRequired) {
296
403
  lines.push(
297
- "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.",
298
405
  );
299
406
  }
300
407
  if (state.verificationRequired) {
@@ -396,7 +503,12 @@ function expandedJudgment(event: JudgmentEvent, statusText: string, theme: Theme
396
503
  ),
397
504
  ),
398
505
  ...(event.questionUpdates ?? []).map((update) =>
399
- detailLine(theme, `question ${update.status}`, `${update.questionId} — ${update.result}`, "accent"),
506
+ detailLine(
507
+ theme,
508
+ `question ${update.status}`,
509
+ `${update.questionId} — ${update.result}`,
510
+ "accent",
511
+ ),
400
512
  ),
401
513
  ];
402
514
  if (evidence.length > 0) container.addChild(new Text(evidence.join("\n"), 0, 0));
@@ -465,8 +577,12 @@ export default async function developer(pi: ExtensionAPI) {
465
577
  ...state.pendingQuestions
466
578
  .slice(0, 3)
467
579
  .map((question) => `open · ${question.id} · ${compactLine(question.question)}`),
468
- ...(state.pendingQuestions.length > 3 ? [`open · +${state.pendingQuestions.length - 3} more`] : []),
469
- ...(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
+ : []),
470
586
  ...(state.verificationRequired ? ["next · verify changed artifacts before completion"] : []),
471
587
  ];
472
588
  ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
@@ -504,7 +620,10 @@ export default async function developer(pi: ExtensionAPI) {
504
620
  }),
505
621
  ),
506
622
  open_question_id: Type.Optional(
507
- 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
+ }),
508
627
  ),
509
628
  };
510
629
  const RouteAlternativeParam = Type.Object(
@@ -517,7 +636,8 @@ export default async function developer(pi: ExtensionAPI) {
517
636
  reason: Type.String({
518
637
  minLength: 1,
519
638
  maxLength: MAX_EVIDENCE_CHARS,
520
- description: "Why this skill would add no useful judgment before the proposed direct movement",
639
+ description:
640
+ "Why this skill would add no useful judgment before the proposed direct movement",
521
641
  }),
522
642
  },
523
643
  { additionalProperties: false },
@@ -533,16 +653,22 @@ export default async function developer(pi: ExtensionAPI) {
533
653
  description: "Exact skill name from the current Available Developer skills list",
534
654
  }),
535
655
  },
536
- { additionalProperties: false, description: "Route one question to a Developer skill" },
656
+ {
657
+ additionalProperties: false,
658
+ description: "Route one question to a Developer skill",
659
+ },
537
660
  ),
538
661
  Type.Object(
539
662
  {
540
663
  ...SharedRouteParams,
541
- 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
+ }),
542
667
  movement: Type.String({
543
668
  minLength: 1,
544
669
  maxLength: MAX_QUESTION_CHARS,
545
- 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",
546
672
  }),
547
673
  stop_condition: Type.String({
548
674
  minLength: 1,
@@ -563,11 +689,15 @@ export default async function developer(pi: ExtensionAPI) {
563
689
  ),
564
690
  execution_profile: Type.Optional(
565
691
  Type.Literal("behavior-preserving-structure", {
566
- 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",
567
694
  }),
568
695
  ),
569
696
  },
570
- { additionalProperties: false, description: "Route one already-justified direct action" },
697
+ {
698
+ additionalProperties: false,
699
+ description: "Route one already-justified direct action",
700
+ },
571
701
  ),
572
702
  ]);
573
703
 
@@ -587,10 +717,14 @@ export default async function developer(pi: ExtensionAPI) {
587
717
  executionMode: "sequential",
588
718
  renderShell: "self",
589
719
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
590
- 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.");
591
722
  if (state.activeRoute || routeOpening) {
592
- if (!state.activeRoute) fail("Another Developer route is currently opening. Wait for it to finish.");
593
- 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
+ );
594
728
  }
595
729
  routeOpening = true;
596
730
 
@@ -601,7 +735,10 @@ export default async function developer(pi: ExtensionAPI) {
601
735
 
602
736
  const explicitQuestionId = params.open_question_id?.trim() || undefined;
603
737
  const targetQuestionId = inferredQuestionId(state, question, explicitQuestionId);
604
- if (targetQuestionId && !state.pendingQuestions.some((item) => item.id === targetQuestionId)) {
738
+ if (
739
+ targetQuestionId &&
740
+ !state.pendingQuestions.some((item) => item.id === targetQuestionId)
741
+ ) {
605
742
  fail(`Unknown pending question ID: ${targetQuestionId}`);
606
743
  }
607
744
 
@@ -616,7 +753,9 @@ export default async function developer(pi: ExtensionAPI) {
616
753
  .join("; ")}. Resolve those questions first.`,
617
754
  );
618
755
  }
619
- const knownEvidence = (params.known_evidence ?? []).map((item) => item.trim()).filter(Boolean);
756
+ const knownEvidence = (params.known_evidence ?? [])
757
+ .map((item) => item.trim())
758
+ .filter(Boolean);
620
759
  const consideredAlternatives: RouteAlternative[] =
621
760
  owner === "direct" && "alternatives_considered" in params
622
761
  ? (params.alternatives_considered ?? []).map((alternative) => ({
@@ -629,12 +768,17 @@ export default async function developer(pi: ExtensionAPI) {
629
768
  fail(`Considered alternative ${alternative.owner} is unavailable or disabled.`);
630
769
  }
631
770
  }
632
- if (new Set(consideredAlternatives.map((alternative) => alternative.owner)).size !== consideredAlternatives.length) {
771
+ if (
772
+ new Set(consideredAlternatives.map((alternative) => alternative.owner)).size !==
773
+ consideredAlternatives.length
774
+ ) {
633
775
  fail("Each considered alternative must name a different available Developer skill.");
634
776
  }
635
777
  if (owner === "direct" && state.lastJudgment?.owner === "direct") {
636
778
  if (knownEvidence.length === 0) {
637
- fail("A consecutive direct route must cite evidence from the previous direct landing in known_evidence.");
779
+ fail(
780
+ "A consecutive direct route must cite evidence from the previous direct landing in known_evidence.",
781
+ );
638
782
  }
639
783
  if (availableSkills.size > 0 && consideredAlternatives.length === 0) {
640
784
  fail(
@@ -653,7 +797,9 @@ export default async function developer(pi: ExtensionAPI) {
653
797
  }
654
798
  const skill = owner === "direct" ? undefined : availableSkills.get(owner);
655
799
  if (owner !== "direct" && !skill) {
656
- 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
+ );
657
803
  }
658
804
  const requestedExecutionProfile =
659
805
  "execution_profile" in params ? params.execution_profile : undefined;
@@ -682,15 +828,13 @@ export default async function developer(pi: ExtensionAPI) {
682
828
  owner === "direct"
683
829
  ? {
684
830
  movement: ("movement" in params ? params.movement : question).trim(),
685
- stopCondition: (
686
- "stop_condition" in params
687
- ? params.stop_condition
688
- : "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."
689
834
  ).trim(),
690
- verification: (
691
- "verification" in params
692
- ? params.verification
693
- : "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."
694
838
  ).trim(),
695
839
  }
696
840
  : undefined;
@@ -713,8 +857,12 @@ export default async function developer(pi: ExtensionAPI) {
713
857
  `Route ID: ${event.routeId}`,
714
858
  `Question: ${event.question}`,
715
859
  `Target: ${event.owner}`,
716
- skill ? `Skill location: ${skill.filePath}` : "Skill location: direct action; no skill file",
717
- 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",
718
866
  ...(directStep
719
867
  ? [
720
868
  `Movement: ${directStep.movement}`,
@@ -732,7 +880,9 @@ export default async function developer(pi: ExtensionAPI) {
732
880
  .join(" | ")
733
881
  : "none"
734
882
  }`,
735
- targetQuestionId ? `Revisits pending question: ${targetQuestionId}` : "Revisits pending question: none",
883
+ targetQuestionId
884
+ ? `Revisits pending question: ${targetQuestionId}`
885
+ : "Revisits pending question: none",
736
886
  `When this route has done its job, call ${JUDGMENT_TOOL} with this exact route ID.`,
737
887
  "",
738
888
  "---",
@@ -741,7 +891,9 @@ export default async function developer(pi: ExtensionAPI) {
741
891
  ].join("\n");
742
892
  ensureSafeToolText(response, "Developer route result");
743
893
  if (!canApplyDeveloperEvent(state, event)) {
744
- fail("Developer machine guard rejected the route transition from the current branch state.");
894
+ fail(
895
+ "Developer machine guard rejected the route transition from the current branch state.",
896
+ );
745
897
  }
746
898
 
747
899
  state = applyDeveloperEvent(state, event);
@@ -755,7 +907,9 @@ export default async function developer(pi: ExtensionAPI) {
755
907
  renderCall(args, theme, context) {
756
908
  const owner = typeof args.owner === "string" && args.owner.length > 0 ? args.owner : "…";
757
909
  const question =
758
- 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
+ : "…";
759
913
  return reusableText(
760
914
  `${theme.fg("toolTitle", theme.bold(ROUTE_TOOL))} ${theme.fg("accent", owner)} ${theme.fg("muted", question)}`,
761
915
  context.lastComponent,
@@ -763,7 +917,10 @@ export default async function developer(pi: ExtensionAPI) {
763
917
  },
764
918
  renderResult(result, { expanded, isPartial }, theme, context) {
765
919
  if (isPartial) {
766
- return reusableText(theme.fg("warning", "routing development question…"), context.lastComponent);
920
+ return reusableText(
921
+ theme.fg("warning", "routing development question…"),
922
+ context.lastComponent,
923
+ );
767
924
  }
768
925
  if (context.isError) {
769
926
  return reusableText(
@@ -803,9 +960,66 @@ export default async function developer(pi: ExtensionAPI) {
803
960
  },
804
961
  });
805
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
+ );
806
1012
  const OpenQuestionParam = Type.Object(
807
1013
  {
808
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),
809
1023
  status: StringEnum(["open", "blocked"] as const, {
810
1024
  description: "Whether the required evidence or answer is currently obtainable",
811
1025
  }),
@@ -834,12 +1048,18 @@ export default async function developer(pi: ExtensionAPI) {
834
1048
  }),
835
1049
  status: StringEnum(["resolved", "not-applicable", "open", "blocked"] as const),
836
1050
  result: Type.String({ minLength: 1, maxLength: MAX_RESULT_CHARS }),
837
- basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), { maxItems: 20 }),
1051
+ basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
1052
+ maxItems: 20,
1053
+ }),
838
1054
  },
839
1055
  { additionalProperties: false },
840
1056
  );
841
1057
  const JudgmentParams = Type.Object({
842
- 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
+ }),
843
1063
  status: StringEnum(["resolved", "needs-evidence", "not-applicable", "blocked"] as const),
844
1064
  result: Type.String({
845
1065
  minLength: 1,
@@ -882,7 +1102,7 @@ export default async function developer(pi: ExtensionAPI) {
882
1102
  promptGuidelines: [
883
1103
  `Use ${JUDGMENT_TOOL} with the exact active Developer route ID.`,
884
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.`,
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.`,
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.`,
886
1106
  `Do not use ${JUDGMENT_TOOL} with resolved, not-applicable, or blocked status without at least one concrete basis.`,
887
1107
  ],
888
1108
  parameters: JudgmentParams,
@@ -905,9 +1125,10 @@ export default async function developer(pi: ExtensionAPI) {
905
1125
  },
906
1126
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
907
1127
  if (state.mode === "off") fail("Developer protocol is off.");
908
- if (!state.activeRoute) fail("There is no active Developer route to close.");
909
- if (params.route_id !== state.activeRoute.routeId) {
910
- 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}.`);
911
1132
  }
912
1133
 
913
1134
  const basis = params.basis.map((item) => item.trim()).filter(Boolean);
@@ -917,17 +1138,23 @@ export default async function developer(pi: ExtensionAPI) {
917
1138
  fail(`${params.status} judgments require at least one concrete basis.`);
918
1139
  }
919
1140
 
920
- const openedQuestions = buildOpenedQuestions(params.open_questions ?? [], state, params.route_id);
1141
+ const openedQuestions = buildOpenedQuestions(
1142
+ params.open_questions ?? [],
1143
+ state,
1144
+ params.route_id,
1145
+ );
921
1146
  if (
922
1147
  availableSkills.size === 0 &&
923
1148
  openedQuestions.some(
924
1149
  (question) => question.resolutionOwner === "agent" && question.gate === "before-direct",
925
1150
  )
926
1151
  ) {
927
- fail("An agent-owned before-direct question requires an available non-direct skill resolution path.");
1152
+ fail(
1153
+ "An agent-owned before-direct question requires an available non-direct skill resolution path.",
1154
+ );
928
1155
  }
929
1156
  const remainsOpen = params.status === "needs-evidence" || params.status === "blocked";
930
- if (remainsOpen && !state.activeRoute.targetQuestionId && openedQuestions.length === 0) {
1157
+ if (remainsOpen && !activeRoute.targetQuestionId && openedQuestions.length === 0) {
931
1158
  fail(
932
1159
  "A needs-evidence or blocked judgment for a new question must include at least one structured open_questions entry.",
933
1160
  );
@@ -943,10 +1170,14 @@ export default async function developer(pi: ExtensionAPI) {
943
1170
  if (openedQuestions.some((question) => updatedQuestionIds.has(question.id))) {
944
1171
  fail("A question cannot be opened and updated in the same judgment.");
945
1172
  }
946
- if (!remainsOpen && openedQuestions.length > 0) {
947
- fail("A resolved or not-applicable judgment cannot open new questions.");
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.");
948
1179
  }
949
- const targetQuestionId = state.activeRoute.targetQuestionId;
1180
+ const targetQuestionId = activeRoute.targetQuestionId;
950
1181
  const targetUpdate = targetQuestionId
951
1182
  ? questionUpdates.find((update) => update.questionId === targetQuestionId)
952
1183
  : undefined;
@@ -954,15 +1185,20 @@ export default async function developer(pi: ExtensionAPI) {
954
1185
  fail(`Focused question ${targetQuestionId} requires an explicit question_updates entry.`);
955
1186
  }
956
1187
  if (targetUpdate) {
957
- const closesTarget = targetUpdate.status === "resolved" || targetUpdate.status === "not-applicable";
1188
+ const closesTarget =
1189
+ targetUpdate.status === "resolved" || targetUpdate.status === "not-applicable";
958
1190
  if (!remainsOpen && !closesTarget) {
959
1191
  fail("A resolved focused judgment must explicitly resolve or dismiss its question.");
960
1192
  }
961
1193
  if (remainsOpen && openedQuestions.length === 0 && closesTarget) {
962
- fail("A focused question cannot close while its judgment reports no replacement evidence question.");
1194
+ fail(
1195
+ "A focused question cannot close while its judgment reports no replacement evidence question.",
1196
+ );
963
1197
  }
964
1198
  if (remainsOpen && openedQuestions.length > 0 && !closesTarget) {
965
- fail("Replacement questions require explicitly resolving or dismissing the broader focused question.");
1199
+ fail(
1200
+ "Replacement questions require explicitly resolving or dismissing the broader focused question.",
1201
+ );
966
1202
  }
967
1203
  }
968
1204
 
@@ -970,8 +1206,8 @@ export default async function developer(pi: ExtensionAPI) {
970
1206
  protocol: PROTOCOL,
971
1207
  kind: "judgment",
972
1208
  routeId: params.route_id,
973
- question: state.activeRoute.question,
974
- owner: state.activeRoute.owner,
1209
+ question: activeRoute.question,
1210
+ owner: activeRoute.owner,
975
1211
  status: params.status,
976
1212
  result,
977
1213
  basis,
@@ -981,7 +1217,9 @@ export default async function developer(pi: ExtensionAPI) {
981
1217
  changedArtifacts: routesWithMutation.has(params.route_id),
982
1218
  };
983
1219
  if (!canApplyDeveloperEvent(state, event)) {
984
- fail("Developer machine guard rejected the judgment transition from the current active route.");
1220
+ fail(
1221
+ "Developer machine guard rejected the judgment transition from the current active route.",
1222
+ );
985
1223
  }
986
1224
  const nextState = applyDeveloperEvent(state, event);
987
1225
  if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
@@ -990,11 +1228,31 @@ export default async function developer(pi: ExtensionAPI) {
990
1228
  );
991
1229
  }
992
1230
 
1231
+ const immediateQuestion =
1232
+ ctx.mode === "tui"
1233
+ ? firstImmediateUserQuestion(state.pendingQuestions, event.openedQuestions)
1234
+ : undefined;
1235
+ let immediateDisposition: ImmediateQuestionDisposition | undefined;
1236
+ if (immediateQuestion) {
1237
+ try {
1238
+ immediateDisposition = await promptImmediateUserQuestion(ctx, immediateQuestion);
1239
+ } catch {
1240
+ ctx.ui.notify(
1241
+ "Could not open the Developer decision prompt; the question remains open.",
1242
+ "warning",
1243
+ );
1244
+ }
1245
+ }
1246
+
993
1247
  const nextMessage = judgmentNextMessage(event, nextState);
994
1248
  const resolvedQuestionCount = event.questionUpdates.filter(
995
1249
  (update) => update.status === "resolved" || update.status === "not-applicable",
996
1250
  ).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}`;
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);
998
1256
  ensureSafeToolText(response, "Developer judgment result");
999
1257
 
1000
1258
  routesWithMutation.delete(params.route_id);
@@ -1006,7 +1264,9 @@ export default async function developer(pi: ExtensionAPI) {
1006
1264
  renderCall(args, theme, context) {
1007
1265
  const status = typeof args.status === "string" && args.status.length > 0 ? args.status : "…";
1008
1266
  const result =
1009
- typeof args.result === "string" && args.result.length > 0 ? compactJudgmentResult(args.result) : "…";
1267
+ typeof args.result === "string" && args.result.length > 0
1268
+ ? compactJudgmentResult(args.result)
1269
+ : "…";
1010
1270
  const statusText =
1011
1271
  status === "resolved"
1012
1272
  ? theme.fg("success", status)
@@ -1022,7 +1282,10 @@ export default async function developer(pi: ExtensionAPI) {
1022
1282
  },
1023
1283
  renderResult(result, { expanded, isPartial }, theme, context) {
1024
1284
  if (isPartial) {
1025
- return reusableText(theme.fg("warning", "recording development judgment…"), context.lastComponent);
1285
+ return reusableText(
1286
+ theme.fg("warning", "recording development judgment…"),
1287
+ context.lastComponent,
1288
+ );
1026
1289
  }
1027
1290
  if (context.isError) {
1028
1291
  return reusableText(
@@ -1048,10 +1311,7 @@ export default async function developer(pi: ExtensionAPI) {
1048
1311
 
1049
1312
  const refreshAvailableSkills = (ctx: ExtensionCommandContext) => {
1050
1313
  if (typeof ctx.getSystemPromptOptions !== "function") return;
1051
- availableSkills = availablePackageSkills(
1052
- ctx.getSystemPromptOptions().skills ?? [],
1053
- skillsRoot,
1054
- );
1314
+ availableSkills = availablePackageSkills(ctx.getSystemPromptOptions().skills ?? [], skillsRoot);
1055
1315
  };
1056
1316
 
1057
1317
  const statusMessage = () => {
@@ -1083,7 +1343,9 @@ export default async function developer(pi: ExtensionAPI) {
1083
1343
  ? state.judgmentHistory
1084
1344
  .slice(-10)
1085
1345
  .map((judgment) => {
1086
- const route = state.routeHistory.find((candidate) => candidate.routeId === judgment.routeId);
1346
+ const route = state.routeHistory.find(
1347
+ (candidate) => candidate.routeId === judgment.routeId,
1348
+ );
1087
1349
  const alternatives = (route?.consideredAlternatives ?? [])
1088
1350
  .map((alternative) => `${alternative.owner}: ${alternative.reason}`)
1089
1351
  .join("; ");
@@ -1120,11 +1382,44 @@ export default async function developer(pi: ExtensionAPI) {
1120
1382
  : null;
1121
1383
  },
1122
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
+
1123
1406
  let action = args.trim();
1124
1407
  if (!action && ctx.mode === "tui") {
1125
1408
  refreshAvailableSkills(ctx);
1126
- action = (await showDeveloperActionSelector(ctx, state)) ?? "";
1127
- 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
+ }
1128
1423
  }
1129
1424
  if (!action) action = "status";
1130
1425
  if (action === "on" || action === "strict" || action === "off") {
@@ -1155,28 +1450,19 @@ export default async function developer(pi: ExtensionAPI) {
1155
1450
  return;
1156
1451
  }
1157
1452
  if (ctx.mode === "tui") {
1158
- const questionId = await showPendingQuestionSelector(ctx, state.pendingQuestions);
1159
- if (!questionId) return;
1160
- const question = state.pendingQuestions.find((item) => item.id === questionId);
1161
- if (!question) return;
1162
- const focusEvent: FocusEvent = {
1163
- protocol: PROTOCOL,
1164
- kind: "focus",
1165
- questionId: question.id,
1166
- };
1167
- const request = await editQuestionResolutionRequest(ctx, question);
1168
- if (request === undefined) return;
1169
- pi.appendEntry(FOCUS_ENTRY, focusEvent);
1170
- state = applyDeveloperEvent(state, focusEvent);
1171
- refreshUI(ctx);
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" });
1179
- 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
+ }
1180
1466
  }
1181
1467
  ctx.ui.notify(
1182
1468
  state.pendingQuestions
@@ -1222,7 +1508,9 @@ export default async function developer(pi: ExtensionAPI) {
1222
1508
  pi.on("before_agent_start", (event) => {
1223
1509
  availableSkills = availablePackageSkills(event.systemPromptOptions.skills ?? [], skillsRoot);
1224
1510
  if (state.mode === "off") return;
1225
- return { systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]) };
1511
+ return {
1512
+ systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]),
1513
+ };
1226
1514
  });
1227
1515
 
1228
1516
  pi.on("tool_call", (event) => {
@@ -1239,7 +1527,8 @@ export default async function developer(pi: ExtensionAPI) {
1239
1527
  (question) => question.gate === "before-direct",
1240
1528
  );
1241
1529
  if (directBlockers.length > 0) {
1242
- const blockedAction = capability === "execute" ? "unrouted shell execution" : "artifact mutation";
1530
+ const blockedAction =
1531
+ capability === "execute" ? "unrouted shell execution" : "artifact mutation";
1243
1532
  return {
1244
1533
  block: true,
1245
1534
  reason: `Developer question gate blocks ${blockedAction} until resolved: ${directBlockers