@hobin/developer 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -3,9 +3,10 @@
3
3
  Adaptive product-development judgment for [Pi](https://pi.dev).
4
4
 
5
5
  Developer combines a small, branch-aware coordination extension with ten
6
- independent skills. It routes one concrete question at a time, records the
7
- resulting evidence, and preserves unresolved questions without turning product
8
- development into a fixed lifecycle.
6
+ independent skills. It routes one concrete question or green-to-green movement
7
+ at a time, records the resulting evidence, and preserves unresolved questions.
8
+ Its default topology gives feature work a useful backbone without turning every
9
+ task into a fixed lifecycle.
9
10
 
10
11
  ## Install
11
12
 
@@ -86,11 +87,26 @@ Developer adds two model-facing protocol tools:
86
87
  1. `developer_route_question` opens one route for one concrete question.
87
88
  2. The route target is `direct` or one currently available Developer skill.
88
89
  3. A skill route returns the exact Pi-discovered `SKILL.md` instructions and canonical
89
- path; a direct route keeps implementation tools available for the justified
90
- action. A behavior-preserving structural direct route also loads the focused
91
- execution profile that keeps edits small and returns to stable green landings.
90
+ path; a direct route declares one movement, its stable landing, and its narrow
91
+ verification. A behavior-preserving structural route also loads the focused
92
+ execution profile based on flocking-style movement and small tidyings.
92
93
  4. `developer_record_judgment` closes the route with a status, result, evidence,
93
- artifacts, and any newly opened questions.
94
+ artifacts, and any newly opened questions. Developer then routes again from
95
+ the stable landing before another movement.
96
+
97
+ The conditional default topology is:
98
+
99
+ ```text
100
+ clarify when needed → model consequential cases
101
+ → sketch the first feature implementation surface OR signal existing-code movement
102
+ → one direct green-to-green movement → re-route from evidence
103
+ → verify before completion
104
+ ```
105
+
106
+ This is not a mandatory phase sequence. A stage may be not applicable and new
107
+ evidence may route backward or sideways. One guard is deliberate: a resolved
108
+ model cannot flow straight into mutation. New feature work receives an initial
109
+ `sketch`; existing-code structural work receives a `signal` first.
94
110
 
95
111
  Product code is still read, edited, executed, and tested with Pi's normal tools.
96
112
  Developer's tools only route and record judgment; they do not implement product
@@ -130,8 +146,12 @@ These are routing states, not product-completion claims. In particular:
130
146
  - A resolved `specify` judgment is not user acceptance.
131
147
  - A resolved `verify` judgment is not timeless proof after later changes.
132
148
 
133
- Pending questions receive stable IDs. A later route revisits one by passing the
134
- exact ID; wording similarity is never used as identity.
149
+ Pending questions receive stable internal IDs, but users do not type them.
150
+ Selecting `/develop questions` focuses the question in branch state; the next
151
+ route automatically associates that focus. A sole pending question or an exact
152
+ question match is also associated automatically. Resolved and not-applicable
153
+ judgments remove the associated question immediately; needs-evidence and blocked
154
+ judgments retain the same identity.
135
155
 
136
156
  ## Skills
137
157
 
@@ -184,8 +204,9 @@ stored in tool-result details. Developer reconstructs state from the current
184
204
  session branch on startup and tree navigation, so a fork inherits only the
185
205
  events on its branch.
186
206
 
187
- The current event contract is `developer/v2`. Legacy `developer/v1` routing
188
- history can be replayed, but removed inferred fields are not revived.
207
+ The current event contract is `developer/v3`. Legacy `developer/v1` and
208
+ `developer/v2` routing history can be replayed, but new framing and verification
209
+ obligations are not retroactively inferred.
189
210
 
190
211
  Developer uses Pi's normal compaction. Each new agent turn receives current
191
212
  protocol state, and route results place identity and recovery metadata before
@@ -246,6 +267,17 @@ Load the workspace package into Pi without installing it:
246
267
  pi -e ./packages/developer
247
268
  ```
248
269
 
270
+ Launch the isolated modal fixture in a new Ghostty window for visual QA:
271
+
272
+ ```sh
273
+ ./packages/developer/scripts/ghostty-tui-qa.sh
274
+ ```
275
+
276
+ Choose **Inspect status** or **Revisit an open question**, then resize the window
277
+ while the modal is open. The fixture disables discovered extensions, skills,
278
+ tools, sessions, and network startup so it cannot mutate Developer state or call
279
+ a model.
280
+
249
281
  `check` validates package structure and deterministic behavior. `eval` launches
250
282
  the real Pi RPC surface without a model and covers package resources, commands,
251
283
  mode state, and strict tool gating. Maintainers can use `eval:json` and the live
@@ -17,6 +17,7 @@ import { Type } from "typebox";
17
17
 
18
18
  import { availablePackageSkills, renderSkillMethod } from "./skills.ts";
19
19
  import {
20
+ FOCUS_ENTRY,
20
21
  JUDGMENT_TOOL,
21
22
  MODE_ENTRY,
22
23
  PROTOCOL,
@@ -29,6 +30,7 @@ import {
29
30
  type DeveloperMode,
30
31
  type DeveloperState,
31
32
  type DirectExecutionProfile,
33
+ type FocusEvent,
32
34
  type JudgmentEvent,
33
35
  type ModeEvent,
34
36
  type RouteEvent,
@@ -107,6 +109,25 @@ function compactLine(value: string, maxChars = 160): string {
107
109
  return line.length <= maxChars ? line : `${line.slice(0, maxChars - 1)}…`;
108
110
  }
109
111
 
112
+ function normalizedQuestion(value: string): string {
113
+ return value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
114
+ }
115
+
116
+ function inferredQuestionId(
117
+ state: DeveloperState,
118
+ question: string,
119
+ explicitQuestionId?: string,
120
+ ): string | undefined {
121
+ if (explicitQuestionId) return explicitQuestionId;
122
+ if (state.focusedQuestionId) return state.focusedQuestionId;
123
+ const normalized = normalizedQuestion(question);
124
+ const exact = state.pendingQuestions.filter(
125
+ (pending) => normalizedQuestion(pending.question) === normalized,
126
+ );
127
+ if (exact.length === 1) return exact[0]?.id;
128
+ return state.pendingQuestions.length === 1 ? state.pendingQuestions[0]?.id : undefined;
129
+ }
130
+
110
131
  function summarizeState(state: DeveloperState): string {
111
132
  if (state.mode === "off") return "developer: off";
112
133
  const target = state.activeRoute ? state.activeRoute.owner : "none";
@@ -117,12 +138,14 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
117
138
  const lines = [
118
139
  "",
119
140
  `Developer protocol (${state.mode}) is active.`,
120
- "- Treat the protocol as adaptive routing, never as a fixed sequence of skills.",
121
- `- Call ${ROUTE_TOOL} only when one concrete development question needs focused judgment or direct action.`,
122
- "- Use owner=direct when the next local action is already justified; otherwise set owner to the available skill whose scope best fits the question.",
141
+ "- Use the default topology as a conditional backbone, not a rigid lifecycle: clarify meaning when needed -> model consequential cases -> sketch the first implementation surface for new behavior or signal the smallest structural movement in existing code -> execute one direct step -> verify current claims.",
142
+ "- Adapt away from that topology when evidence makes a stage not applicable, but never jump directly from a resolved model to mutation: use sketch for feature implementation or signal for existing-code structural movement first.",
143
+ `- Call ${ROUTE_TOOL} for exactly one concrete judgment or one green-to-green direct movement.`,
144
+ "- Use owner=direct only when the next local movement, stable landing, and narrow verification are already justified; otherwise choose the focused skill whose scope fits the current question.",
145
+ "- 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.",
123
146
  "- 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.",
124
- `- Follow the skill instructions returned by ${ROUTE_TOOL}, then close that route with ${JUDGMENT_TOOL}.`,
125
- "- Keep a direct route open through implementation and evidence collection; record its judgment afterward.",
147
+ `- 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.`,
148
+ "- 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.",
126
149
  "- Protocol state is routing bookkeeping. Idle never proves product completion, user acceptance, or current verification.",
127
150
  "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
128
151
  ];
@@ -133,6 +156,17 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
133
156
  );
134
157
  }
135
158
 
159
+ if (state.implementationFramingRequired) {
160
+ 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.",
162
+ );
163
+ }
164
+ if (state.verificationRequired) {
165
+ lines.push(
166
+ "Verification debt: a direct route changed artifacts after the last resolved verify judgment. Route verify before claiming completion or handing off as done.",
167
+ );
168
+ }
169
+
136
170
  if (state.activeRoute) {
137
171
  lines.push(
138
172
  `Active route: ${state.activeRoute.routeId} · ${state.activeRoute.owner} · ${state.activeRoute.question}`,
@@ -149,7 +183,7 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
149
183
  lines.push(`- ${question.id} · ${question.status} · ${question.question}`);
150
184
  }
151
185
  lines.push(
152
- `- To revisit one, pass its exact ID as open_question_id to ${ROUTE_TOOL}. Report any question that remains open at handoff.`,
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.",
153
187
  );
154
188
  }
155
189
  lines.push(
@@ -174,6 +208,7 @@ export default async function developer(pi: ExtensionAPI) {
174
208
  let availableSkills = new Map<string, Skill>();
175
209
  let state = initialState();
176
210
  let routeOpening = false;
211
+ const routesWithMutation = new Set<string>();
177
212
  let toolPolicyMemory: ToolPolicyMemory = { withheldBuiltins: new Set() };
178
213
 
179
214
  pi.registerFlag("develop-mode", {
@@ -206,7 +241,12 @@ export default async function developer(pi: ExtensionAPI) {
206
241
  "developer",
207
242
  ctx.mode === "tui" ? renderDeveloperFooter(state, ctx.ui.theme) : summarizeState(state),
208
243
  );
209
- if (!state.activeRoute && state.pendingQuestions.length === 0) {
244
+ if (
245
+ !state.activeRoute &&
246
+ state.pendingQuestions.length === 0 &&
247
+ !state.implementationFramingRequired &&
248
+ !state.verificationRequired
249
+ ) {
210
250
  ctx.ui.setWidget("developer", undefined);
211
251
  return;
212
252
  }
@@ -227,6 +267,8 @@ export default async function developer(pi: ExtensionAPI) {
227
267
  .slice(0, 3)
228
268
  .map((question) => `open · ${question.id} · ${compactLine(question.question)}`),
229
269
  ...(state.pendingQuestions.length > 3 ? [`open · +${state.pendingQuestions.length - 3} more`] : []),
270
+ ...(state.implementationFramingRequired ? ["next · sketch feature shape or signal structural movement"] : []),
271
+ ...(state.verificationRequired ? ["next · verify changed artifacts before completion"] : []),
230
272
  ];
231
273
  ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
232
274
  };
@@ -283,6 +325,21 @@ export default async function developer(pi: ExtensionAPI) {
283
325
  {
284
326
  ...SharedRouteParams,
285
327
  owner: Type.Literal("direct", { description: "Use Pi implementation tools for an already-justified action" }),
328
+ movement: Type.String({
329
+ minLength: 1,
330
+ maxLength: MAX_QUESTION_CHARS,
331
+ description: "One locally explainable behavioral or structural movement; not a multi-step implementation queue",
332
+ }),
333
+ stop_condition: Type.String({
334
+ minLength: 1,
335
+ maxLength: MAX_QUESTION_CHARS,
336
+ description: "The green, pausable, reviewable stable landing that ends this direct route",
337
+ }),
338
+ verification: Type.String({
339
+ minLength: 1,
340
+ maxLength: MAX_EVIDENCE_CHARS,
341
+ description: "The narrowest check that can catch the likely break in this movement",
342
+ }),
286
343
  execution_profile: Type.Optional(
287
344
  Type.Literal("behavior-preserving-structure", {
288
345
  description: "Load the focused structural-mutation protocol; omit for ordinary direct action",
@@ -297,11 +354,12 @@ export default async function developer(pi: ExtensionAPI) {
297
354
  name: ROUTE_TOOL,
298
355
  label: "Developer Route Question",
299
356
  description:
300
- "Route one concrete development question to direct action or one currently available @hobin/developer skill. This is adaptive routing, not a workflow sequence.",
357
+ "Route one concrete judgment or one green-to-green direct movement. Uses an adaptive default topology: model, then sketch for feature shape or signal for structural movement, direct stable landings, and verify before completion.",
301
358
  promptSnippet: "Choose how to handle one development question",
302
359
  promptGuidelines: [
303
360
  `Call ${ROUTE_TOOL} only when there is no active Developer route.`,
304
- `Use ${ROUTE_TOOL} with the most focused skill supported by current evidence; use owner=direct for an already-justified local action.`,
361
+ `Use ${ROUTE_TOOL} with the most focused skill supported by current evidence; owner=direct requires one movement, one stable landing, and one narrow verification.`,
362
+ `After a resolved model, use sketch for first feature implementation framing or signal for existing-code structural movement before direct mutation.`,
305
363
  ],
306
364
  parameters: RouteParams,
307
365
  executionMode: "sequential",
@@ -318,12 +376,22 @@ export default async function developer(pi: ExtensionAPI) {
318
376
  const reason = params.reason.trim();
319
377
  if (!question || !reason) fail("Question and reason must contain non-whitespace text.");
320
378
 
321
- const targetQuestionId = params.open_question_id?.trim() || undefined;
379
+ const explicitQuestionId = params.open_question_id?.trim() || undefined;
380
+ const targetQuestionId = inferredQuestionId(state, question, explicitQuestionId);
322
381
  if (targetQuestionId && !state.pendingQuestions.some((item) => item.id === targetQuestionId)) {
323
382
  fail(`Unknown pending question ID: ${targetQuestionId}`);
324
383
  }
325
384
 
326
385
  const owner = params.owner;
386
+ if (
387
+ owner === "direct" &&
388
+ state.implementationFramingRequired &&
389
+ (availableSkills.has("sketch") || availableSkills.has("signal"))
390
+ ) {
391
+ fail(
392
+ "The latest resolved model requires implementation framing before direct work. Route sketch for new feature shape or signal for existing-code structural movement.",
393
+ );
394
+ }
327
395
  const skill = owner === "direct" ? undefined : availableSkills.get(owner);
328
396
  if (owner !== "direct" && !skill) {
329
397
  fail(`Developer skill ${owner} is unavailable or disabled in the current Pi resource configuration.`);
@@ -351,6 +419,23 @@ export default async function developer(pi: ExtensionAPI) {
351
419
  "The next local action is already justified. Keep this route open while using Pi implementation tools and collecting evidence.",
352
420
  ].join("\n");
353
421
 
422
+ const directStep =
423
+ owner === "direct"
424
+ ? {
425
+ 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."
430
+ ).trim(),
431
+ verification: (
432
+ "verification" in params
433
+ ? params.verification
434
+ : "Run the narrowest relevant check and inspect the resulting diff or output."
435
+ ).trim(),
436
+ }
437
+ : undefined;
438
+
354
439
  const event: RouteEvent = {
355
440
  protocol: PROTOCOL,
356
441
  kind: "route",
@@ -362,6 +447,7 @@ export default async function developer(pi: ExtensionAPI) {
362
447
  targetQuestionId,
363
448
  methodLocation: skill?.filePath,
364
449
  executionProfile,
450
+ directStep,
365
451
  };
366
452
  const response = [
367
453
  `Route ID: ${event.routeId}`,
@@ -369,6 +455,14 @@ export default async function developer(pi: ExtensionAPI) {
369
455
  `Target: ${event.owner}`,
370
456
  skill ? `Skill location: ${skill.filePath}` : "Skill location: direct action; no skill file",
371
457
  executionProfile ? `Execution profile: ${executionProfile}` : "Execution profile: skill judgment",
458
+ ...(directStep
459
+ ? [
460
+ `Movement: ${directStep.movement}`,
461
+ `Stable landing: ${directStep.stopCondition}`,
462
+ `Narrow verification: ${directStep.verification}`,
463
+ "Stop this direct route at that landing, record the evidence, and route again before another movement.",
464
+ ]
465
+ : []),
372
466
  `Reason: ${event.reason}`,
373
467
  `Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
374
468
  targetQuestionId ? `Revisits pending question: ${targetQuestionId}` : "Revisits pending question: none",
@@ -397,11 +491,11 @@ export default async function developer(pi: ExtensionAPI) {
397
491
  context.lastComponent,
398
492
  );
399
493
  },
400
- renderResult(result, { expanded, isPartial, isError }, theme, context) {
494
+ renderResult(result, { expanded, isPartial }, theme, context) {
401
495
  if (isPartial) {
402
496
  return reusableText(theme.fg("warning", "routing development question…"), context.lastComponent);
403
497
  }
404
- if (isError) {
498
+ if (context.isError) {
405
499
  return reusableText(
406
500
  theme.fg("error", resultText(result) || "Developer route failed"),
407
501
  context.lastComponent,
@@ -425,6 +519,11 @@ export default async function developer(pi: ExtensionAPI) {
425
519
  if (event.executionProfile) {
426
520
  text += `\n${theme.fg("dim", `profile · ${event.executionProfile}`)}`;
427
521
  }
522
+ 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}`)}`;
526
+ }
428
527
  }
429
528
  if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
430
529
  return reusableText(text, context.lastComponent);
@@ -503,6 +602,7 @@ export default async function developer(pi: ExtensionAPI) {
503
602
  basis,
504
603
  openedQuestions,
505
604
  artifacts: (params.artifacts ?? []).map((item) => item.trim()).filter(Boolean),
605
+ changedArtifacts: routesWithMutation.has(params.route_id),
506
606
  };
507
607
  const nextState = applyDeveloperEvent(state, event);
508
608
  if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
@@ -513,12 +613,17 @@ export default async function developer(pi: ExtensionAPI) {
513
613
 
514
614
  const next = protocolState(nextState);
515
615
  const nextMessage =
516
- next === "idle"
517
- ? "Developer protocol is idle. This is routing state only and does not prove task completion."
518
- : `Developer protocol is ${next}. Address or report the pending question before handoff.`;
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.`;
519
623
  const response = `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\n${nextMessage}`;
520
624
  ensureSafeToolText(response, "Developer judgment result");
521
625
 
626
+ routesWithMutation.delete(params.route_id);
522
627
  state = nextState;
523
628
  syncProtocolTools();
524
629
  refreshUI(ctx);
@@ -541,11 +646,11 @@ export default async function developer(pi: ExtensionAPI) {
541
646
  context.lastComponent,
542
647
  );
543
648
  },
544
- renderResult(result, { expanded, isPartial, isError }, theme, context) {
649
+ renderResult(result, { expanded, isPartial }, theme, context) {
545
650
  if (isPartial) {
546
651
  return reusableText(theme.fg("warning", "recording development judgment…"), context.lastComponent);
547
652
  }
548
- if (isError) {
653
+ if (context.isError) {
549
654
  return reusableText(
550
655
  theme.fg("error", resultText(result) || "Developer judgment failed"),
551
656
  context.lastComponent,
@@ -668,8 +773,16 @@ export default async function developer(pi: ExtensionAPI) {
668
773
  if (!questionId) return;
669
774
  const question = state.pendingQuestions.find((item) => item.id === questionId);
670
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);
671
784
  prepareQuestionPrompt(ctx, question);
672
- ctx.ui.notify("Open question loaded into the editor for review.", "info");
785
+ ctx.ui.notify("Open question focused and loaded into the editor for review.", "info");
673
786
  return;
674
787
  }
675
788
  ctx.ui.notify(
@@ -717,10 +830,12 @@ export default async function developer(pi: ExtensionAPI) {
717
830
  });
718
831
 
719
832
  pi.on("tool_call", (event) => {
720
- if (state.mode !== "strict") return;
721
833
  const mutationBuiltins = builtinMutationToolNames(pi.getAllTools());
722
- if (!mutationBuiltins.has(event.toolName)) return;
723
- if (state.activeRoute?.owner === "direct") return;
834
+ if (mutationBuiltins.has(event.toolName) && state.activeRoute?.owner === "direct") {
835
+ routesWithMutation.add(state.activeRoute.routeId);
836
+ return;
837
+ }
838
+ if (state.mode !== "strict" || !mutationBuiltins.has(event.toolName)) return;
724
839
  return {
725
840
  block: true,
726
841
  reason: `Developer strict mode requires an active ${ROUTE_TOOL} targeting direct action (owner=direct) before Pi built-in edit, write, or bash.`,
@@ -1,8 +1,9 @@
1
1
  # Behavior-Preserving Structural Change
2
2
 
3
- Use this protocol only after a `direct` route has justified a concrete structural
4
- move. It governs mutation; it does not discover the move, approve an abstraction,
5
- or decide its timing.
3
+ Use this protocol only after a `direct` route has justified one concrete structural
4
+ movement and its stable landing. It governs that single green-to-green mutation;
5
+ it does not discover the move, approve an abstraction, decide its timing, or carry
6
+ a multi-step implementation queue across landings.
6
7
 
7
8
  ## Entry Contract
8
9
 
@@ -42,13 +43,14 @@ valid. Do not rely on a final large diff to explain which step changed behavior.
42
43
 
43
44
  ## Evidence Rhythm
44
45
 
45
- After each meaningful movement:
46
+ Within the routed movement:
46
47
 
47
48
  1. run the narrowest check that can catch the likely break;
48
49
  2. inspect the diff for mixed behavior and structural changes;
49
50
  3. reduce the step when the failure cannot be explained locally;
50
- 4. return to a green, deployable state before selecting another movement;
51
- 5. re-observe the code instead of following a predetermined final design.
51
+ 4. return to the declared green, deployable stable landing;
52
+ 5. close the direct route there and let Developer re-observe and route the next
53
+ question instead of following a predetermined final design.
52
54
 
53
55
  A passing test is useful only when it exercises the preserved behavior. When no
54
56
  cheap verifier exists, keep the movement smaller and record the residual risk.
@@ -63,13 +65,15 @@ A stable landing is not merely a green command. It is a state where:
63
65
  - the diff has one explainable structural purpose;
64
66
  - the next decision can be made from the new evidence.
65
67
 
66
- Stop there when the accepted structural purpose is complete. Further cleanup,
67
- polymorphism, factory design, or public abstraction requires its own current
68
- pressure and judgment.
68
+ Stop and close the direct route there even when the wider accepted task still has
69
+ work remaining. Developer must select the next movement or focused judgment from
70
+ the new evidence. Further caller movement, cleanup, polymorphism, factory design,
71
+ or public abstraction requires another route and its own current pressure.
69
72
 
70
73
  ## Reroute Conditions
71
74
 
72
- Leave direct execution and open a focused judgment when the movement reveals:
75
+ Close direct execution at the latest stable landing and open a focused judgment
76
+ when the movement reveals:
73
77
 
74
78
  - an unexpected product behavior or policy choice: `specify`;
75
79
  - missing cases, transition rules, or replacement obligations: `model`;
@@ -1,6 +1,8 @@
1
- export const PROTOCOL = "developer/v2" as const;
1
+ export const PROTOCOL = "developer/v3" as const;
2
+ export const PREVIOUS_PROTOCOL = "developer/v2" as const;
2
3
  export const LEGACY_PROTOCOL = "developer/v1" as const;
3
4
  export const MODE_ENTRY = "developer.mode" as const;
5
+ export const FOCUS_ENTRY = "developer.question-focus" as const;
4
6
  export const ROUTE_TOOL = "developer_route_question" as const;
5
7
  export const JUDGMENT_TOOL = "developer_record_judgment" as const;
6
8
  export const LEGACY_ROUTE_TOOL = "route_question" as const;
@@ -17,6 +19,18 @@ export interface ModeEvent {
17
19
  mode: DeveloperMode;
18
20
  }
19
21
 
22
+ export interface FocusEvent {
23
+ protocol: typeof PROTOCOL;
24
+ kind: "focus";
25
+ questionId: string;
26
+ }
27
+
28
+ export interface DirectStepContract {
29
+ movement: string;
30
+ stopCondition: string;
31
+ verification: string;
32
+ }
33
+
20
34
  export interface RouteEvent {
21
35
  protocol: typeof PROTOCOL;
22
36
  kind: "route";
@@ -28,6 +42,7 @@ export interface RouteEvent {
28
42
  targetQuestionId?: string;
29
43
  methodLocation?: string;
30
44
  executionProfile?: DirectExecutionProfile;
45
+ directStep?: DirectStepContract;
31
46
  }
32
47
 
33
48
  export interface PendingQuestion {
@@ -48,9 +63,10 @@ export interface JudgmentEvent {
48
63
  basis: string[];
49
64
  openedQuestions: PendingQuestion[];
50
65
  artifacts: string[];
66
+ changedArtifacts: boolean;
51
67
  }
52
68
 
53
- export type DeveloperEvent = ModeEvent | RouteEvent | JudgmentEvent;
69
+ export type DeveloperEvent = ModeEvent | FocusEvent | RouteEvent | JudgmentEvent;
54
70
 
55
71
  export interface DeveloperState {
56
72
  mode: DeveloperMode;
@@ -58,19 +74,25 @@ export interface DeveloperState {
58
74
  lastRoute?: RouteEvent;
59
75
  lastJudgment?: JudgmentEvent;
60
76
  pendingQuestions: PendingQuestion[];
77
+ focusedQuestionId?: string;
78
+ implementationFramingRequired: boolean;
79
+ verificationRequired: boolean;
61
80
  }
62
81
 
63
82
  export const initialState = (): DeveloperState => ({
64
83
  mode: "off",
65
84
  pendingQuestions: [],
85
+ implementationFramingRequired: false,
86
+ verificationRequired: false,
66
87
  });
67
88
 
68
- export type ProtocolState = "idle" | "needs-judgment" | "needs-evidence" | "blocked";
89
+ export type ProtocolState = "idle" | "needs-judgment" | "needs-evidence" | "needs-verification" | "blocked";
69
90
 
70
91
  export function protocolState(state: DeveloperState): ProtocolState {
71
92
  if (state.activeRoute) return "needs-judgment";
72
93
  if (state.pendingQuestions.some((question) => question.status === "blocked")) return "blocked";
73
94
  if (state.pendingQuestions.length > 0) return "needs-evidence";
95
+ if (state.verificationRequired) return "needs-verification";
74
96
  return "idle";
75
97
  }
76
98
 
@@ -84,12 +106,19 @@ export function applyDeveloperEvent(state: DeveloperState, event: DeveloperEvent
84
106
  return { ...state, mode: event.mode };
85
107
  }
86
108
 
109
+ if (event.kind === "focus") {
110
+ if (!state.pendingQuestions.some((question) => question.id === event.questionId)) return state;
111
+ return { ...state, focusedQuestionId: event.questionId };
112
+ }
113
+
87
114
  if (event.kind === "route") {
88
115
  if (state.activeRoute) return state;
89
116
  return {
90
117
  ...state,
91
118
  activeRoute: event,
92
119
  lastRoute: event,
120
+ focusedQuestionId:
121
+ event.targetQuestionId === state.focusedQuestionId ? undefined : state.focusedQuestionId,
93
122
  };
94
123
  }
95
124
 
@@ -106,7 +135,7 @@ export function applyDeveloperEvent(state: DeveloperState, event: DeveloperEvent
106
135
  pending = upsertQuestion(pending, {
107
136
  id: route.targetQuestionId,
108
137
  question: route.question,
109
- status: event.status,
138
+ status: event.status === "blocked" ? "blocked" : "needs-evidence",
110
139
  sourceRouteId: route.routeId,
111
140
  });
112
141
  }
@@ -114,18 +143,35 @@ export function applyDeveloperEvent(state: DeveloperState, event: DeveloperEvent
114
143
  pending = upsertQuestion(pending, {
115
144
  id: `question:${route.routeId}`,
116
145
  question: route.question,
117
- status: event.status,
146
+ status: event.status === "blocked" ? "blocked" : "needs-evidence",
118
147
  sourceRouteId: route.routeId,
119
148
  });
120
149
  }
121
150
 
122
151
  for (const question of event.openedQuestions) pending = upsertQuestion(pending, question);
123
152
 
153
+ const closesFramingGate =
154
+ (route.owner === "sketch" || route.owner === "signal") &&
155
+ (event.status === "resolved" || event.status === "not-applicable");
156
+ const opensFramingGate = route.owner === "model" && event.status === "resolved";
157
+ let implementationFramingRequired = state.implementationFramingRequired;
158
+ if (opensFramingGate) implementationFramingRequired = true;
159
+ if (closesFramingGate) implementationFramingRequired = false;
160
+
161
+ let verificationRequired = state.verificationRequired;
162
+ if (route.owner === "direct" && event.changedArtifacts) verificationRequired = true;
163
+ if (route.owner === "verify" && event.status === "resolved") verificationRequired = false;
164
+
124
165
  return {
125
166
  ...state,
126
167
  activeRoute: undefined,
127
168
  lastJudgment: judgment,
128
169
  pendingQuestions: pending,
170
+ focusedQuestionId: pending.some((question) => question.id === state.focusedQuestionId)
171
+ ? state.focusedQuestionId
172
+ : undefined,
173
+ implementationFramingRequired,
174
+ verificationRequired,
129
175
  };
130
176
  }
131
177
 
@@ -149,6 +195,22 @@ function isDirectExecutionProfile(value: unknown): value is DirectExecutionProfi
149
195
  return value === "ordinary" || value === "behavior-preserving-structure";
150
196
  }
151
197
 
198
+ function parseDirectStep(value: unknown): DirectStepContract | undefined {
199
+ if (!isObject(value)) return undefined;
200
+ if (
201
+ typeof value.movement !== "string" ||
202
+ typeof value.stopCondition !== "string" ||
203
+ typeof value.verification !== "string"
204
+ ) {
205
+ return undefined;
206
+ }
207
+ return {
208
+ movement: value.movement,
209
+ stopCondition: value.stopCondition,
210
+ verification: value.verification,
211
+ };
212
+ }
213
+
152
214
  function parsePendingQuestion(value: unknown): PendingQuestion | undefined {
153
215
  if (!isObject(value)) return undefined;
154
216
  if (
@@ -169,13 +231,22 @@ function parsePendingQuestion(value: unknown): PendingQuestion | undefined {
169
231
 
170
232
  export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefined {
171
233
  if (!isObject(value)) return undefined;
172
- if (value.protocol !== PROTOCOL && value.protocol !== LEGACY_PROTOCOL) return undefined;
234
+ if (
235
+ value.protocol !== PROTOCOL &&
236
+ value.protocol !== PREVIOUS_PROTOCOL &&
237
+ value.protocol !== LEGACY_PROTOCOL
238
+ ) return undefined;
173
239
 
174
240
  if (value.kind === "mode") {
175
241
  if (!isMode(value.mode)) return undefined;
176
242
  return { protocol: PROTOCOL, kind: "mode", mode: value.mode };
177
243
  }
178
244
 
245
+ if (value.kind === "focus") {
246
+ if (value.protocol !== PROTOCOL || typeof value.questionId !== "string") return undefined;
247
+ return { protocol: PROTOCOL, kind: "focus", questionId: value.questionId };
248
+ }
249
+
179
250
  if (value.kind === "route") {
180
251
  if (
181
252
  typeof value.routeId !== "string" ||
@@ -185,7 +256,8 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
185
256
  !isStringArray(value.knownEvidence) ||
186
257
  (value.targetQuestionId !== undefined && typeof value.targetQuestionId !== "string") ||
187
258
  (value.methodLocation !== undefined && typeof value.methodLocation !== "string") ||
188
- (value.executionProfile !== undefined && !isDirectExecutionProfile(value.executionProfile))
259
+ (value.executionProfile !== undefined && !isDirectExecutionProfile(value.executionProfile)) ||
260
+ (value.directStep !== undefined && !parseDirectStep(value.directStep))
189
261
  ) {
190
262
  return undefined;
191
263
  }
@@ -200,6 +272,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
200
272
  targetQuestionId: value.targetQuestionId,
201
273
  methodLocation: value.methodLocation,
202
274
  executionProfile: value.executionProfile,
275
+ directStep: value.directStep === undefined ? undefined : parseDirectStep(value.directStep),
203
276
  };
204
277
  }
205
278
 
@@ -231,9 +304,10 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
231
304
  id: `question:${value.routeId}:legacy:${index + 1}`,
232
305
  question,
233
306
  status: "needs-evidence",
234
- sourceRouteId: value.routeId,
307
+ sourceRouteId: String(value.routeId),
235
308
  })),
236
309
  artifacts: value.artifacts,
310
+ changedArtifacts: false,
237
311
  };
238
312
  }
239
313
 
@@ -251,6 +325,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
251
325
  basis: value.basis,
252
326
  openedQuestions: openedQuestions as PendingQuestion[],
253
327
  artifacts: value.artifacts,
328
+ changedArtifacts: typeof value.changedArtifacts === "boolean" ? value.changedArtifacts : false,
254
329
  };
255
330
  }
256
331
 
@@ -261,7 +336,7 @@ interface BranchEntryLike {
261
336
  message?: { role?: string; toolName?: string; details?: unknown };
262
337
  }
263
338
 
264
- const DEVELOPER_TOOL_NAMES = new Set([
339
+ const DEVELOPER_TOOL_NAMES = new Set<string>([
265
340
  ROUTE_TOOL,
266
341
  JUDGMENT_TOOL,
267
342
  LEGACY_ROUTE_TOOL,
@@ -269,7 +344,10 @@ const DEVELOPER_TOOL_NAMES = new Set([
269
344
  ]);
270
345
 
271
346
  export function eventFromBranchEntry(entry: BranchEntryLike): DeveloperEvent | undefined {
272
- if (entry.type === "custom" && entry.customType === MODE_ENTRY) {
347
+ if (
348
+ entry.type === "custom" &&
349
+ (entry.customType === MODE_ENTRY || entry.customType === FOCUS_ENTRY)
350
+ ) {
273
351
  return normalizeDeveloperEvent(entry.data);
274
352
  }
275
353
  if (
package/extensions/tui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
- DynamicBorder,
3
2
  type ExtensionCommandContext,
3
+ type KeybindingsManager,
4
4
  type Theme,
5
5
  type ThemeColor,
6
6
  } from "@earendil-works/pi-coding-agent";
@@ -8,7 +8,6 @@ import {
8
8
  Container,
9
9
  matchesKey,
10
10
  type SelectItem,
11
- SelectList,
12
11
  Text,
13
12
  truncateToWidth,
14
13
  visibleWidth,
@@ -32,7 +31,7 @@ function modeName(mode: DeveloperMode): string {
32
31
 
33
32
  function protocolColor(value: ProtocolState): ThemeColor {
34
33
  if (value === "blocked") return "error";
35
- if (value === "needs-evidence") return "warning";
34
+ if (value === "needs-evidence" || value === "needs-verification") return "warning";
36
35
  if (value === "needs-judgment") return "accent";
37
36
  return "dim";
38
37
  }
@@ -96,7 +95,7 @@ export function pendingQuestionItems(questions: PendingQuestion[]): SelectItem[]
96
95
  return questions.map((question) => ({
97
96
  value: question.id,
98
97
  label: question.question,
99
- description: `${question.status} · ${question.id}`,
98
+ description: question.status,
100
99
  }));
101
100
  }
102
101
 
@@ -106,7 +105,124 @@ interface SelectDialogOptions {
106
105
  items: SelectItem[];
107
106
  width: number;
108
107
  maxVisible: number;
109
- maxPrimaryColumnWidth?: number;
108
+ selectedLabelMaxLines: number;
109
+ selectedDescriptionMaxLines: number;
110
+ }
111
+
112
+ function boundedWrappedLines(content: string, width: number, maxLines: number, ellipsis: string): string[] {
113
+ const contentWidth = Math.max(1, width);
114
+ const wrapped = wrapTextWithAnsi(content, contentWidth);
115
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
116
+ if (wrapped.length > visible.length && visible.length > 0) {
117
+ const last = visible.length - 1;
118
+ visible[last] = truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + ellipsis;
119
+ }
120
+ return visible;
121
+ }
122
+
123
+ function renderModalFrame(container: Container, theme: Theme, width: number): string[] {
124
+ if (width < 3) return container.render(Math.max(1, width));
125
+
126
+ const innerWidth = width - 2;
127
+ const border = (text: string) => theme.fg("borderAccent", text);
128
+ const rows = container
129
+ .render(innerWidth)
130
+ .map((line) => `${border("│")}${truncateToWidth(line, innerWidth, "…", true)}${border("│")}`);
131
+ return [border(`╭${"─".repeat(innerWidth)}╮`), ...rows, border(`╰${"─".repeat(innerWidth)}╯`)];
132
+ }
133
+
134
+ class WrappedSelectList {
135
+ private selectedIndex = 0;
136
+ private readonly items: SelectItem[];
137
+ private readonly keybindings: KeybindingsManager;
138
+ private readonly maxVisible: number;
139
+ private readonly selectedDescriptionMaxLines: number;
140
+ private readonly selectedLabelMaxLines: number;
141
+ private readonly theme: Theme;
142
+ onSelect?: (item: SelectItem) => void;
143
+ onCancel?: () => void;
144
+
145
+ constructor(
146
+ items: SelectItem[],
147
+ maxVisible: number,
148
+ theme: Theme,
149
+ keybindings: KeybindingsManager,
150
+ options: {
151
+ selectedLabelMaxLines: number;
152
+ selectedDescriptionMaxLines: number;
153
+ },
154
+ ) {
155
+ this.items = items;
156
+ this.maxVisible = maxVisible;
157
+ this.theme = theme;
158
+ this.keybindings = keybindings;
159
+ this.selectedLabelMaxLines = options.selectedLabelMaxLines;
160
+ this.selectedDescriptionMaxLines = options.selectedDescriptionMaxLines;
161
+ }
162
+
163
+ render(width: number): string[] {
164
+ if (this.items.length === 0) return [this.theme.fg("warning", " No items")];
165
+
166
+ const lines: string[] = [];
167
+ const startIndex = Math.max(
168
+ 0,
169
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible),
170
+ );
171
+ const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
172
+
173
+ for (let index = startIndex; index < endIndex; index += 1) {
174
+ const item = this.items[index];
175
+ if (!item) continue;
176
+ if (index !== this.selectedIndex) {
177
+ lines.push(` ${truncateToWidth(item.label, Math.max(1, width - 2), "…")}`);
178
+ continue;
179
+ }
180
+
181
+ const labels = boundedWrappedLines(
182
+ this.theme.fg("accent", item.label),
183
+ width - 2,
184
+ this.selectedLabelMaxLines,
185
+ this.theme.fg("dim", "…"),
186
+ );
187
+ lines.push(`${this.theme.fg("accent", "→ ")}${labels[0] ?? ""}`);
188
+ for (const line of labels.slice(1)) lines.push(` ${line}`);
189
+
190
+ if (item.description) {
191
+ const descriptions = boundedWrappedLines(
192
+ this.theme.fg("muted", item.description),
193
+ width - 4,
194
+ this.selectedDescriptionMaxLines,
195
+ this.theme.fg("dim", "…"),
196
+ );
197
+ for (const line of descriptions) lines.push(` ${line}`);
198
+ }
199
+ }
200
+
201
+ if (startIndex > 0 || endIndex < this.items.length) {
202
+ lines.push(
203
+ this.theme.fg(
204
+ "dim",
205
+ truncateToWidth(` (${this.selectedIndex + 1}/${this.items.length})`, Math.max(1, width - 2), ""),
206
+ ),
207
+ );
208
+ }
209
+ return lines;
210
+ }
211
+
212
+ handleInput(data: string): void {
213
+ if (this.keybindings.matches(data, "tui.select.up")) {
214
+ this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1;
215
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
216
+ this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
217
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
218
+ const selected = this.items[this.selectedIndex];
219
+ if (selected) this.onSelect?.(selected);
220
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
221
+ this.onCancel?.();
222
+ }
223
+ }
224
+
225
+ invalidate(): void {}
110
226
  }
111
227
 
112
228
  async function showSelectDialog(
@@ -114,45 +230,39 @@ async function showSelectDialog(
114
230
  options: SelectDialogOptions,
115
231
  ): Promise<string | undefined> {
116
232
  const result = await ctx.ui.custom<string | null>(
117
- (tui, theme, _keybindings, done) => {
233
+ (tui, theme, keybindings, done) => {
118
234
  const container = new Container();
119
235
  const title = new Text("", 1, 0);
120
236
  const subtitle = new Text("", 1, 0);
121
237
  const hint = new Text("", 1, 0);
122
238
  const updateText = () => {
123
- title.setText(theme.fg("accent", theme.bold(options.title)));
239
+ title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
124
240
  subtitle.setText(theme.fg("muted", options.subtitle));
125
241
  hint.setText(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"));
126
242
  };
127
243
  updateText();
128
244
 
129
- const list = new SelectList(
245
+ const list = new WrappedSelectList(
130
246
  options.items,
131
247
  Math.min(options.items.length, options.maxVisible),
248
+ theme,
249
+ keybindings,
132
250
  {
133
- selectedPrefix: (text) => theme.fg("accent", text),
134
- selectedText: (text) => theme.fg("accent", text),
135
- description: (text) => theme.fg("muted", text),
136
- scrollInfo: (text) => theme.fg("dim", text),
137
- noMatch: (text) => theme.fg("warning", text),
251
+ selectedLabelMaxLines: options.selectedLabelMaxLines,
252
+ selectedDescriptionMaxLines: options.selectedDescriptionMaxLines,
138
253
  },
139
- options.maxPrimaryColumnWidth
140
- ? { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: options.maxPrimaryColumnWidth }
141
- : undefined,
142
254
  );
143
255
  list.onSelect = (item) => done(item.value);
144
256
  list.onCancel = () => done(null);
145
257
 
146
- container.addChild(new DynamicBorder((text) => theme.fg("borderAccent", text)));
147
258
  container.addChild(title);
148
259
  container.addChild(subtitle);
149
260
  container.addChild(list);
150
261
  container.addChild(hint);
151
- container.addChild(new DynamicBorder((text) => theme.fg("borderAccent", text)));
152
262
 
153
263
  return {
154
264
  render(width: number) {
155
- return container.render(width);
265
+ return renderModalFrame(container, theme, width);
156
266
  },
157
267
  invalidate() {
158
268
  updateText();
@@ -169,7 +279,10 @@ async function showSelectDialog(
169
279
  overlayOptions: {
170
280
  anchor: "center",
171
281
  width: options.width,
172
- maxHeight: Math.min(options.maxVisible + 7, 20),
282
+ maxHeight: Math.min(
283
+ options.maxVisible + options.selectedLabelMaxLines + options.selectedDescriptionMaxLines + 7,
284
+ 24,
285
+ ),
173
286
  margin: 1,
174
287
  },
175
288
  },
@@ -187,6 +300,8 @@ export async function showDeveloperActionSelector(
187
300
  items: developerActionItems(state),
188
301
  width: 78,
189
302
  maxVisible: 6,
303
+ selectedLabelMaxLines: 2,
304
+ selectedDescriptionMaxLines: 2,
190
305
  });
191
306
  if (result === "status" || result === "questions" || result === "on" || result === "strict" || result === "off") {
192
307
  return result;
@@ -205,7 +320,8 @@ export async function showPendingQuestionSelector(
205
320
  items: pendingQuestionItems(questions),
206
321
  width: 96,
207
322
  maxVisible: 10,
208
- maxPrimaryColumnWidth: 52,
323
+ selectedLabelMaxLines: 5,
324
+ selectedDescriptionMaxLines: 1,
209
325
  });
210
326
  }
211
327
 
@@ -241,6 +357,12 @@ export class DeveloperWidget {
241
357
  if (this.state.pendingQuestions.length > 3) {
242
358
  lines.push(this.theme.fg("dim", ` +${this.state.pendingQuestions.length - 3} more open questions`));
243
359
  }
360
+ if (this.state.implementationFramingRequired) {
361
+ lines.push(this.theme.fg("warning", "→ next · sketch feature shape or signal structural movement"));
362
+ }
363
+ if (this.state.verificationRequired) {
364
+ lines.push(this.theme.fg("warning", "→ next · verify changed artifacts before completion"));
365
+ }
244
366
  return lines;
245
367
  }
246
368
 
@@ -259,91 +381,123 @@ export class DeveloperStatusPanel {
259
381
  private readonly view: DeveloperStatusView;
260
382
  private readonly theme: Theme;
261
383
  private readonly onClose: () => void;
262
-
263
- constructor(view: DeveloperStatusView, theme: Theme, onClose: () => void) {
384
+ private readonly requestRender: () => void;
385
+ private readonly viewportHeight: number;
386
+ private scrollOffset = 0;
387
+ private maxScrollOffset = 0;
388
+
389
+ constructor(
390
+ view: DeveloperStatusView,
391
+ theme: Theme,
392
+ onClose: () => void,
393
+ options: { viewportHeight?: number; requestRender?: () => void } = {},
394
+ ) {
264
395
  this.view = view;
265
396
  this.theme = theme;
266
397
  this.onClose = onClose;
398
+ this.viewportHeight = options.viewportHeight ?? 24;
399
+ this.requestRender = options.requestRender ?? (() => {});
267
400
  }
268
401
 
269
402
  handleInput(data: string): void {
270
403
  if (matchesKey(data, "escape") || matchesKey(data, "enter") || matchesKey(data, "ctrl+c")) {
271
404
  this.onClose();
405
+ return;
272
406
  }
407
+ if (matchesKey(data, "up")) this.scrollOffset = Math.max(0, this.scrollOffset - 1);
408
+ else if (matchesKey(data, "down")) {
409
+ this.scrollOffset = Math.min(this.maxScrollOffset, this.scrollOffset + 1);
410
+ } else return;
411
+ this.invalidate();
412
+ this.requestRender();
273
413
  }
274
414
 
275
415
  render(width: number): string[] {
276
416
  if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
277
417
 
278
- const panelWidth = Math.max(20, width);
279
- const innerWidth = Math.max(18, panelWidth - 2);
418
+ const panelWidth = Math.max(1, width);
419
+ const innerWidth = Math.max(1, panelWidth - 2);
280
420
  const rows: string[] = [];
281
- const border = (text: string) => this.theme.fg("borderMuted", text);
421
+ const border = (text: string) => this.theme.fg("borderAccent", text);
282
422
  const row = (content = "") => `${border("│")}${truncateToWidth(content, innerWidth, "…", true)}${border("│")}`;
283
423
  const addWrapped = (label: string, value: string, color: ThemeColor = "muted", maxLines = 2) => {
284
- const prefix = ` ${this.theme.fg("dim", `${label} ·`)} `;
285
- const wrapped = wrapTextWithAnsi(prefix + this.theme.fg(color, value), innerWidth).slice(0, maxLines);
286
- for (const line of wrapped) rows.push(row(line));
424
+ const labelText = `${label} ·`;
425
+ const plainPrefix = ` ${labelText} `;
426
+ const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
427
+ const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
428
+ const wrapped = wrapTextWithAnsi(this.theme.fg(color, value.trim()), contentWidth);
429
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
430
+ if (wrapped.length > visible.length && visible.length > 0) {
431
+ const last = visible.length - 1;
432
+ visible[last] =
433
+ truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + this.theme.fg("dim", "…");
434
+ }
435
+ rows.push(row(styledPrefix + (visible[0] ?? "")));
436
+ const hangingIndent = " ".repeat(visibleWidth(plainPrefix));
437
+ for (const line of visible.slice(1)) rows.push(row(hangingIndent + line));
287
438
  };
288
439
  const section = (title: string) => rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
289
440
 
290
441
  rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
291
- rows.push(row(` ${this.theme.fg("accent", this.theme.bold("Developer status"))}`));
442
+ rows.push(row(` ${this.theme.fg("accent", this.theme.bold("Developer status"))}`));
292
443
  rows.push(row());
293
444
 
294
445
  const state = this.view.state;
295
446
  const currentProtocol = protocolState(state);
296
- rows.push(
297
- row(
298
- ` mode ${this.theme.fg(modeColor(state.mode), modeName(state.mode))}` +
299
- this.theme.fg("dim", " · ") +
300
- `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
301
- this.theme.fg("dim", " · ") +
302
- `target ${this.theme.fg("muted", state.activeRoute?.owner ?? "none")}`,
303
- ),
304
- );
447
+ const summary =
448
+ `mode ${this.theme.fg(modeColor(state.mode), modeName(state.mode))}` +
449
+ this.theme.fg("dim", " · ") +
450
+ `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
451
+ this.theme.fg("dim", " · ") +
452
+ `target ${this.theme.fg("muted", state.activeRoute?.owner ?? "none")}`;
453
+ for (const line of wrapTextWithAnsi(summary, Math.max(1, innerWidth - 2))) rows.push(row(` ${line}`));
305
454
  rows.push(row());
306
455
 
307
456
  section("Active route");
308
457
  if (state.activeRoute) {
309
458
  addWrapped("id", state.activeRoute.routeId, "dim", 1);
310
- addWrapped("question", state.activeRoute.question, "text");
311
- addWrapped("reason", state.activeRoute.reason);
459
+ addWrapped("question", state.activeRoute.question, "text", 3);
460
+ addWrapped("reason", state.activeRoute.reason, "muted", 2);
312
461
  addWrapped("skill", state.activeRoute.methodLocation ?? "direct action", "dim", 1);
313
462
  addWrapped("known evidence", String(state.activeRoute.knownEvidence.length), "muted", 1);
314
463
  } else {
315
- addWrapped("state", "No route is currently waiting for judgment.", "dim", 1);
464
+ addWrapped("state", "No route is currently waiting for judgment.", "dim", 2);
316
465
  }
317
466
 
318
467
  rows.push(row());
319
468
  section(`Open questions · ${state.pendingQuestions.length}`);
320
469
  if (state.pendingQuestions.length === 0) {
321
- addWrapped("state", "No unresolved Developer questions.", "dim", 1);
470
+ addWrapped("state", "No unresolved Developer questions.", "dim", 2);
322
471
  } else {
323
472
  for (const question of state.pendingQuestions.slice(0, 4)) {
324
473
  addWrapped(
325
474
  question.status === "blocked" ? "blocked" : "needs evidence",
326
475
  question.question,
327
476
  question.status === "blocked" ? "error" : "warning",
328
- 1,
477
+ 2,
329
478
  );
330
479
  }
331
480
  if (state.pendingQuestions.length > 4) {
332
- addWrapped("more", String(state.pendingQuestions.length - 4), "dim", 1);
481
+ addWrapped("more", `${state.pendingQuestions.length - 4} additional open questions`, "dim", 1);
333
482
  }
334
483
  }
335
484
 
336
485
  rows.push(row());
337
486
  section("Last judgment");
338
487
  if (state.lastJudgment) {
339
- addWrapped("status", state.lastJudgment.status, protocolColor(
340
- state.lastJudgment.status === "blocked"
341
- ? "blocked"
342
- : state.lastJudgment.status === "needs-evidence"
343
- ? "needs-evidence"
344
- : "idle",
345
- ), 1);
346
- addWrapped("result", state.lastJudgment.result, "muted");
488
+ addWrapped(
489
+ "status",
490
+ state.lastJudgment.status,
491
+ protocolColor(
492
+ state.lastJudgment.status === "blocked"
493
+ ? "blocked"
494
+ : state.lastJudgment.status === "needs-evidence"
495
+ ? "needs-evidence"
496
+ : "idle",
497
+ ),
498
+ 1,
499
+ );
500
+ addWrapped("result", state.lastJudgment.result, "muted", 3);
347
501
  addWrapped(
348
502
  "evidence",
349
503
  `${state.lastJudgment.basis.length} basis · ${state.lastJudgment.artifacts.length} artifacts`,
@@ -351,7 +505,7 @@ export class DeveloperStatusPanel {
351
505
  1,
352
506
  );
353
507
  } else {
354
- addWrapped("state", "No judgment has been recorded on this branch.", "dim", 1);
508
+ addWrapped("state", "No judgment has been recorded on this branch.", "dim", 2);
355
509
  }
356
510
 
357
511
  rows.push(row());
@@ -362,12 +516,27 @@ export class DeveloperStatusPanel {
362
516
  1,
363
517
  );
364
518
  rows.push(row());
365
- rows.push(row(` ${this.theme.fg("dim", "enter/esc close · /develop questions revisits open work")}`));
366
- rows.push(border(`╰${"─".repeat(innerWidth)}╯`));
519
+
520
+ const header = rows.slice(0, 2);
521
+ const body = rows.slice(2);
522
+ const bodyCapacity = Math.max(1, this.viewportHeight - 4);
523
+ this.maxScrollOffset = Math.max(0, body.length - bodyCapacity);
524
+ this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset);
525
+ const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + bodyCapacity);
526
+ const position =
527
+ body.length > bodyCapacity
528
+ ? `↑↓ scroll · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
529
+ : "enter/esc close · /develop questions revisits open work";
530
+ const visibleRows = [
531
+ ...header,
532
+ ...visibleBody,
533
+ row(` ${this.theme.fg("dim", position)}`),
534
+ border(`╰${"─".repeat(innerWidth)}╯`),
535
+ ];
367
536
 
368
537
  this.cachedWidth = width;
369
- this.cachedLines = rows;
370
- return rows;
538
+ this.cachedLines = visibleRows;
539
+ return visibleRows;
371
540
  }
372
541
 
373
542
  invalidate(): void {
@@ -376,16 +545,28 @@ export class DeveloperStatusPanel {
376
545
  }
377
546
  }
378
547
 
379
- export async function showDeveloperStatus(
380
- ctx: ExtensionCommandContext,
381
- view: DeveloperStatusView,
382
- ): Promise<void> {
383
- await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
384
- new DeveloperStatusPanel(view, theme, () => done()));
548
+ export async function showDeveloperStatus(ctx: ExtensionCommandContext, view: DeveloperStatusView): Promise<void> {
549
+ await ctx.ui.custom<void>(
550
+ (tui, theme, _keybindings, done) =>
551
+ new DeveloperStatusPanel(view, theme, () => done(), {
552
+ viewportHeight: Math.max(6, Math.min(28, tui.terminal.rows - 2)),
553
+ requestRender: () => tui.requestRender(),
554
+ }),
555
+ {
556
+ overlay: true,
557
+ overlayOptions: {
558
+ anchor: "center",
559
+ width: 92,
560
+ minWidth: 56,
561
+ maxHeight: 28,
562
+ margin: 1,
563
+ },
564
+ },
565
+ );
385
566
  }
386
567
 
387
568
  export function prepareQuestionPrompt(ctx: ExtensionCommandContext, question: PendingQuestion): void {
388
- const prompt = `Revisit Developer question ${question.id}: ${question.question}`;
569
+ const prompt = `Revisit this Developer question: ${question.question}`;
389
570
  const current = ctx.ui.getEditorText();
390
571
  ctx.ui.setEditorText(current.trim() ? `${current.trimEnd()}\n\n${prompt}` : prompt);
391
572
  }
package/package.json CHANGED
@@ -1,14 +1,9 @@
1
1
  {
2
2
  "name": "@hobin/developer",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Adaptive product-development reasoning and evidence for Pi.",
5
5
  "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "developer",
10
- "skills"
11
- ],
6
+ "keywords": ["pi-package", "pi", "developer", "skills"],
12
7
  "license": "MIT",
13
8
  "author": {
14
9
  "name": "dev-hobin",
@@ -26,33 +21,23 @@
26
21
  "publishConfig": {
27
22
  "access": "public"
28
23
  },
29
- "files": [
30
- "extensions",
31
- "skills",
32
- "README.md",
33
- "SOURCES.md",
34
- "LICENSE"
35
- ],
24
+ "files": ["extensions", "skills", "README.md", "SOURCES.md", "LICENSE"],
36
25
  "engines": {
37
26
  "node": ">=22.19.0"
38
27
  },
28
+ "scripts": {
29
+ "check": "node scripts/check-package.mjs && node --test tests/*.test.ts",
30
+ "eval": "node scripts/eval-rpc.mjs",
31
+ "eval:json": "node scripts/eval-json.mjs"
32
+ },
39
33
  "pi": {
40
- "extensions": [
41
- "./extensions/developer.ts"
42
- ],
43
- "skills": [
44
- "./skills"
45
- ]
34
+ "extensions": ["./extensions/developer.ts"],
35
+ "skills": ["./skills"]
46
36
  },
47
37
  "peerDependencies": {
48
38
  "@earendil-works/pi-ai": "*",
49
39
  "@earendil-works/pi-coding-agent": "*",
50
40
  "@earendil-works/pi-tui": "*",
51
41
  "typebox": "*"
52
- },
53
- "scripts": {
54
- "check": "node scripts/check-package.mjs && node --test tests/*.test.ts",
55
- "eval": "node scripts/eval-rpc.mjs",
56
- "eval:json": "node scripts/eval-json.mjs"
57
42
  }
58
- }
43
+ }