@hobin/developer 0.1.1 → 0.1.3

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.
Files changed (33) hide show
  1. package/README.md +61 -20
  2. package/SOURCES.md +104 -0
  3. package/extensions/developer.ts +202 -44
  4. package/extensions/references/behavior-preserving-structural-change.md +149 -0
  5. package/extensions/skills.ts +1 -16
  6. package/extensions/state.ts +96 -10
  7. package/extensions/tui.ts +140 -25
  8. package/package.json +11 -25
  9. package/skills/abstraction-review/references/field-card.md +7 -0
  10. package/skills/abstraction-review/references/recipe-cards.md +67 -0
  11. package/skills/abstraction-review/references/repair-table.md +5 -0
  12. package/skills/model/SKILL.md +6 -1
  13. package/skills/model/references/problem-modeling.md +294 -235
  14. package/skills/model/references/worked-models-and-specialized-techniques.md +218 -0
  15. package/skills/naming-judgment/SKILL.md +5 -3
  16. package/skills/naming-judgment/references/domain-naming.md +202 -0
  17. package/skills/schedule/SKILL.md +1 -1
  18. package/skills/schedule/references/structural-change-timing.md +80 -13
  19. package/skills/signal/SKILL.md +2 -2
  20. package/skills/signal/references/structural-movement.md +202 -0
  21. package/skills/sketch/SKILL.md +35 -8
  22. package/skills/sketch/references/abstraction-barriers-and-closure.md +160 -0
  23. package/skills/sketch/references/abstraction-composition-and-state.md +184 -0
  24. package/skills/sketch/references/composition-generative-recursion-and-accumulators.md +196 -0
  25. package/skills/sketch/references/data-driven-design.md +177 -0
  26. package/skills/sketch/references/data-shape-template-catalog.md +241 -0
  27. package/skills/sketch/references/generic-operations-and-languages.md +134 -0
  28. package/skills/sketch/references/processes-state-and-time.md +171 -0
  29. package/skills/sketch/references/responsibility-and-variation.md +245 -0
  30. package/skills/verify/references/verifier-selection-and-pass-but-wrong.md +61 -3
  31. package/skills/naming-judgment/references/elements-of-clojure-naming.md +0 -74
  32. package/skills/signal/references/flocking-and-structural-movement.md +0 -157
  33. package/skills/sketch/references/design-recipe-and-abstraction-barriers.md +0 -169
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { dirname, resolve } from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
 
@@ -14,12 +15,9 @@ import {
14
15
  import { Text } from "@earendil-works/pi-tui";
15
16
  import { Type } from "typebox";
16
17
 
18
+ import { availablePackageSkills, renderSkillMethod } from "./skills.ts";
17
19
  import {
18
- availablePackageSkills,
19
- loadCandidateSkills,
20
- renderSkillMethod,
21
- } from "./skills.ts";
22
- import {
20
+ FOCUS_ENTRY,
23
21
  JUDGMENT_TOOL,
24
22
  MODE_ENTRY,
25
23
  PROTOCOL,
@@ -31,6 +29,8 @@ import {
31
29
  reconstructState,
32
30
  type DeveloperMode,
33
31
  type DeveloperState,
32
+ type DirectExecutionProfile,
33
+ type FocusEvent,
34
34
  type JudgmentEvent,
35
35
  type ModeEvent,
36
36
  type RouteEvent,
@@ -50,7 +50,13 @@ import {
50
50
  } from "./tui.ts";
51
51
 
52
52
  const PROTOCOL_TOOLS = [ROUTE_TOOL, JUDGMENT_TOOL] as const;
53
- const skillsRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills");
53
+ const extensionRoot = dirname(fileURLToPath(import.meta.url));
54
+ const skillsRoot = resolve(extensionRoot, "..", "skills");
55
+ const structuralChangeMethodPath = resolve(
56
+ extensionRoot,
57
+ "references",
58
+ "behavior-preserving-structural-change.md",
59
+ );
54
60
  const MAX_PENDING_QUESTIONS = 20;
55
61
  const MAX_QUESTION_CHARS = 2_000;
56
62
  const MAX_EVIDENCE_CHARS = 2_000;
@@ -103,6 +109,25 @@ function compactLine(value: string, maxChars = 160): string {
103
109
  return line.length <= maxChars ? line : `${line.slice(0, maxChars - 1)}…`;
104
110
  }
105
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
+
106
131
  function summarizeState(state: DeveloperState): string {
107
132
  if (state.mode === "off") return "developer: off";
108
133
  const target = state.activeRoute ? state.activeRoute.owner : "none";
@@ -113,11 +138,14 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
113
138
  const lines = [
114
139
  "",
115
140
  `Developer protocol (${state.mode}) is active.`,
116
- "- Treat the protocol as adaptive routing, never as a fixed sequence of skills.",
117
- `- Call ${ROUTE_TOOL} only when one concrete development question needs focused judgment or direct action.`,
118
- "- Use owner=direct when the next local action is already justified; otherwise set owner to the available skill whose scope best fits the question.",
119
- `- Follow the skill instructions returned by ${ROUTE_TOOL}, then close that route with ${JUDGMENT_TOOL}.`,
120
- "- Keep a direct route open through implementation and evidence collection; record its judgment afterward.",
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.",
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.",
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.",
121
149
  "- Protocol state is routing bookkeeping. Idle never proves product completion, user acceptance, or current verification.",
122
150
  "- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
123
151
  ];
@@ -128,6 +156,17 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
128
156
  );
129
157
  }
130
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
+
131
170
  if (state.activeRoute) {
132
171
  lines.push(
133
172
  `Active route: ${state.activeRoute.routeId} · ${state.activeRoute.owner} · ${state.activeRoute.question}`,
@@ -144,7 +183,7 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
144
183
  lines.push(`- ${question.id} · ${question.status} · ${question.question}`);
145
184
  }
146
185
  lines.push(
147
- `- 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.",
148
187
  );
149
188
  }
150
189
  lines.push(
@@ -156,7 +195,8 @@ function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): s
156
195
  function routeRenderText(event: RouteEvent | undefined): string {
157
196
  if (!event) return "Route unavailable";
158
197
  const target = event.targetQuestionId ? ` · revisits ${event.targetQuestionId}` : "";
159
- return `${event.owner} · ${compactLine(event.question)}${target}`;
198
+ const profile = event.executionProfile ? `/${event.executionProfile}` : "";
199
+ return `${event.owner}${profile} · ${compactLine(event.question)}${target}`;
160
200
  }
161
201
 
162
202
  function judgmentRenderText(event: JudgmentEvent | undefined): string {
@@ -165,10 +205,10 @@ function judgmentRenderText(event: JudgmentEvent | undefined): string {
165
205
  }
166
206
 
167
207
  export default async function developer(pi: ExtensionAPI) {
168
- const candidates = loadCandidateSkills(skillsRoot);
169
208
  let availableSkills = new Map<string, Skill>();
170
209
  let state = initialState();
171
210
  let routeOpening = false;
211
+ const routesWithMutation = new Set<string>();
172
212
  let toolPolicyMemory: ToolPolicyMemory = { withheldBuiltins: new Set() };
173
213
 
174
214
  pi.registerFlag("develop-mode", {
@@ -201,7 +241,12 @@ export default async function developer(pi: ExtensionAPI) {
201
241
  "developer",
202
242
  ctx.mode === "tui" ? renderDeveloperFooter(state, ctx.ui.theme) : summarizeState(state),
203
243
  );
204
- if (!state.activeRoute && state.pendingQuestions.length === 0) {
244
+ if (
245
+ !state.activeRoute &&
246
+ state.pendingQuestions.length === 0 &&
247
+ !state.implementationFramingRequired &&
248
+ !state.verificationRequired
249
+ ) {
205
250
  ctx.ui.setWidget("developer", undefined);
206
251
  return;
207
252
  }
@@ -222,6 +267,8 @@ export default async function developer(pi: ExtensionAPI) {
222
267
  .slice(0, 3)
223
268
  .map((question) => `open · ${question.id} · ${compactLine(question.question)}`),
224
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"] : []),
225
272
  ];
226
273
  ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
227
274
  };
@@ -240,17 +287,12 @@ export default async function developer(pi: ExtensionAPI) {
240
287
  refreshUI(ctx);
241
288
  };
242
289
 
243
- const RouteParams = Type.Object({
290
+ const SharedRouteParams = {
244
291
  question: Type.String({
245
292
  minLength: 1,
246
293
  maxLength: MAX_QUESTION_CHARS,
247
294
  description: "The single concrete judgment or action question to route",
248
295
  }),
249
- owner: Type.String({
250
- minLength: 1,
251
- maxLength: 64,
252
- description: "direct, or an exact skill name from the current Available Developer skills list",
253
- }),
254
296
  reason: Type.String({
255
297
  minLength: 1,
256
298
  maxLength: MAX_QUESTION_CHARS,
@@ -265,17 +307,59 @@ export default async function developer(pi: ExtensionAPI) {
265
307
  open_question_id: Type.Optional(
266
308
  Type.String({ maxLength: 512, description: "Exact pending question ID when this route revisits one" }),
267
309
  ),
268
- });
310
+ };
311
+ const RouteParams = Type.Union([
312
+ Type.Object(
313
+ {
314
+ ...SharedRouteParams,
315
+ owner: Type.String({
316
+ minLength: 1,
317
+ maxLength: 64,
318
+ pattern: "^(?!direct$)[a-z0-9]+(?:-[a-z0-9]+)*$",
319
+ description: "Exact skill name from the current Available Developer skills list",
320
+ }),
321
+ },
322
+ { additionalProperties: false, description: "Route one question to a Developer skill" },
323
+ ),
324
+ Type.Object(
325
+ {
326
+ ...SharedRouteParams,
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
+ }),
343
+ execution_profile: Type.Optional(
344
+ Type.Literal("behavior-preserving-structure", {
345
+ description: "Load the focused structural-mutation protocol; omit for ordinary direct action",
346
+ }),
347
+ ),
348
+ },
349
+ { additionalProperties: false, description: "Route one already-justified direct action" },
350
+ ),
351
+ ]);
269
352
 
270
353
  pi.registerTool({
271
354
  name: ROUTE_TOOL,
272
355
  label: "Developer Route Question",
273
356
  description:
274
- "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.",
275
358
  promptSnippet: "Choose how to handle one development question",
276
359
  promptGuidelines: [
277
360
  `Call ${ROUTE_TOOL} only when there is no active Developer route.`,
278
- `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.`,
279
363
  ],
280
364
  parameters: RouteParams,
281
365
  executionMode: "sequential",
@@ -292,25 +376,65 @@ export default async function developer(pi: ExtensionAPI) {
292
376
  const reason = params.reason.trim();
293
377
  if (!question || !reason) fail("Question and reason must contain non-whitespace text.");
294
378
 
295
- const targetQuestionId = params.open_question_id?.trim() || undefined;
379
+ const explicitQuestionId = params.open_question_id?.trim() || undefined;
380
+ const targetQuestionId = inferredQuestionId(state, question, explicitQuestionId);
296
381
  if (targetQuestionId && !state.pendingQuestions.some((item) => item.id === targetQuestionId)) {
297
382
  fail(`Unknown pending question ID: ${targetQuestionId}`);
298
383
  }
299
384
 
300
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
+ }
301
395
  const skill = owner === "direct" ? undefined : availableSkills.get(owner);
302
396
  if (owner !== "direct" && !skill) {
303
397
  fail(`Developer skill ${owner} is unavailable or disabled in the current Pi resource configuration.`);
304
398
  }
399
+ const requestedExecutionProfile =
400
+ "execution_profile" in params ? params.execution_profile : undefined;
401
+ if (owner !== "direct" && requestedExecutionProfile !== undefined) {
402
+ fail("execution_profile is valid only when owner=direct.");
403
+ }
404
+ const executionProfile: DirectExecutionProfile | undefined =
405
+ owner === "direct" ? (requestedExecutionProfile ?? "ordinary") : undefined;
305
406
 
306
407
  const method =
408
+ owner !== "direct"
409
+ ? await renderSkillMethod(skill!)
410
+ : executionProfile === "behavior-preserving-structure"
411
+ ? [
412
+ '<developer-direct-profile name="behavior-preserving-structure">',
413
+ (await readFile(structuralChangeMethodPath, "utf8")).trim(),
414
+ "</developer-direct-profile>",
415
+ ].join("\n")
416
+ : [
417
+ "# Direct action",
418
+ "",
419
+ "The next local action is already justified. Keep this route open while using Pi implementation tools and collecting evidence.",
420
+ ].join("\n");
421
+
422
+ const directStep =
307
423
  owner === "direct"
308
- ? [
309
- "# Direct action",
310
- "",
311
- "The next local action is already justified. Keep this route open while using Pi implementation tools and collecting evidence.",
312
- ].join("\n")
313
- : await renderSkillMethod(skill!);
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;
314
438
 
315
439
  const event: RouteEvent = {
316
440
  protocol: PROTOCOL,
@@ -322,12 +446,23 @@ export default async function developer(pi: ExtensionAPI) {
322
446
  knownEvidence: (params.known_evidence ?? []).map((item) => item.trim()).filter(Boolean),
323
447
  targetQuestionId,
324
448
  methodLocation: skill?.filePath,
449
+ executionProfile,
450
+ directStep,
325
451
  };
326
452
  const response = [
327
453
  `Route ID: ${event.routeId}`,
328
454
  `Question: ${event.question}`,
329
455
  `Target: ${event.owner}`,
330
456
  skill ? `Skill location: ${skill.filePath}` : "Skill location: direct action; no skill file",
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
+ : []),
331
466
  `Reason: ${event.reason}`,
332
467
  `Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
333
468
  targetQuestionId ? `Revisits pending question: ${targetQuestionId}` : "Revisits pending question: none",
@@ -356,11 +491,11 @@ export default async function developer(pi: ExtensionAPI) {
356
491
  context.lastComponent,
357
492
  );
358
493
  },
359
- renderResult(result, { expanded, isPartial, isError }, theme, context) {
494
+ renderResult(result, { expanded, isPartial }, theme, context) {
360
495
  if (isPartial) {
361
496
  return reusableText(theme.fg("warning", "routing development question…"), context.lastComponent);
362
497
  }
363
- if (isError) {
498
+ if (context.isError) {
364
499
  return reusableText(
365
500
  theme.fg("error", resultText(result) || "Developer route failed"),
366
501
  context.lastComponent,
@@ -381,6 +516,14 @@ export default async function developer(pi: ExtensionAPI) {
381
516
  }
382
517
  text += `\n${theme.fg("dim", `revisits · ${event.targetQuestionId ?? "none"}`)}`;
383
518
  text += `\n${theme.fg("dim", `skill · ${event.methodLocation ?? "direct action"}`)}`;
519
+ if (event.executionProfile) {
520
+ text += `\n${theme.fg("dim", `profile · ${event.executionProfile}`)}`;
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
+ }
384
527
  }
385
528
  if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
386
529
  return reusableText(text, context.lastComponent);
@@ -459,6 +602,7 @@ export default async function developer(pi: ExtensionAPI) {
459
602
  basis,
460
603
  openedQuestions,
461
604
  artifacts: (params.artifacts ?? []).map((item) => item.trim()).filter(Boolean),
605
+ changedArtifacts: routesWithMutation.has(params.route_id),
462
606
  };
463
607
  const nextState = applyDeveloperEvent(state, event);
464
608
  if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
@@ -469,12 +613,17 @@ export default async function developer(pi: ExtensionAPI) {
469
613
 
470
614
  const next = protocolState(nextState);
471
615
  const nextMessage =
472
- next === "idle"
473
- ? "Developer protocol is idle. This is routing state only and does not prove task completion."
474
- : `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.`;
475
623
  const response = `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\n${nextMessage}`;
476
624
  ensureSafeToolText(response, "Developer judgment result");
477
625
 
626
+ routesWithMutation.delete(params.route_id);
478
627
  state = nextState;
479
628
  syncProtocolTools();
480
629
  refreshUI(ctx);
@@ -497,11 +646,11 @@ export default async function developer(pi: ExtensionAPI) {
497
646
  context.lastComponent,
498
647
  );
499
648
  },
500
- renderResult(result, { expanded, isPartial, isError }, theme, context) {
649
+ renderResult(result, { expanded, isPartial }, theme, context) {
501
650
  if (isPartial) {
502
651
  return reusableText(theme.fg("warning", "recording development judgment…"), context.lastComponent);
503
652
  }
504
- if (isError) {
653
+ if (context.isError) {
505
654
  return reusableText(
506
655
  theme.fg("error", resultText(result) || "Developer judgment failed"),
507
656
  context.lastComponent,
@@ -537,7 +686,6 @@ export default async function developer(pi: ExtensionAPI) {
537
686
  if (typeof ctx.getSystemPromptOptions !== "function") return;
538
687
  availableSkills = availablePackageSkills(
539
688
  ctx.getSystemPromptOptions().skills ?? [],
540
- candidates,
541
689
  skillsRoot,
542
690
  );
543
691
  };
@@ -625,8 +773,16 @@ export default async function developer(pi: ExtensionAPI) {
625
773
  if (!questionId) return;
626
774
  const question = state.pendingQuestions.find((item) => item.id === questionId);
627
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);
628
784
  prepareQuestionPrompt(ctx, question);
629
- 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");
630
786
  return;
631
787
  }
632
788
  ctx.ui.notify(
@@ -668,16 +824,18 @@ export default async function developer(pi: ExtensionAPI) {
668
824
  });
669
825
 
670
826
  pi.on("before_agent_start", (event) => {
671
- availableSkills = availablePackageSkills(event.systemPromptOptions.skills ?? [], candidates, skillsRoot);
827
+ availableSkills = availablePackageSkills(event.systemPromptOptions.skills ?? [], skillsRoot);
672
828
  if (state.mode === "off") return;
673
829
  return { systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]) };
674
830
  });
675
831
 
676
832
  pi.on("tool_call", (event) => {
677
- if (state.mode !== "strict") return;
678
833
  const mutationBuiltins = builtinMutationToolNames(pi.getAllTools());
679
- if (!mutationBuiltins.has(event.toolName)) return;
680
- 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;
681
839
  return {
682
840
  block: true,
683
841
  reason: `Developer strict mode requires an active ${ROUTE_TOOL} targeting direct action (owner=direct) before Pi built-in edit, write, or bash.`,
@@ -0,0 +1,149 @@
1
+ # Behavior-Preserving Structural Change
2
+
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.
7
+
8
+ ## Entry Contract
9
+
10
+ Before editing, state:
11
+
12
+ ```text
13
+ Behavior to preserve: the narrow observable claim
14
+ Structural move: one concrete rearrangement
15
+ Pressure: why the accepted task needs or benefits from it now
16
+ Baseline evidence: the cheapest relevant check that is currently green
17
+ Affected surface: files, callers, persisted forms, or public boundaries
18
+ Stop: the next stable, pausable state
19
+ ```
20
+
21
+ If the behavior claim, timing, or candidate is still under judgment, close or
22
+ pause the direct route and route that question to the appropriate leaf.
23
+
24
+ ## Smallest Green Transformation
25
+
26
+ Keep behavior and structure distinct in reasoning even if they eventually share
27
+ a commit. Prefer the smallest movement whose failure can be localized.
28
+
29
+ For replacement-shaped refactors, use this progression:
30
+
31
+ ```text
32
+ new shape parses
33
+ -> new shape executes
34
+ -> callers use its result
35
+ -> replaced code becomes unreachable
36
+ -> replaced code is deleted
37
+ -> relevant evidence is green
38
+ ```
39
+
40
+ For movement across a boundary, update one sender or caller at a time when the
41
+ old and new forms can safely coexist. Confirm that each intermediate state is
42
+ valid. Do not rely on a final large diff to explain which step changed behavior.
43
+
44
+ ## Evidence Rhythm
45
+
46
+ Within the routed movement:
47
+
48
+ 1. run the narrowest check that can catch the likely break;
49
+ 2. inspect the diff for mixed behavior and structural changes;
50
+ 3. reduce the step when the failure cannot be explained locally;
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.
54
+
55
+ A passing test is useful only when it exercises the preserved behavior. When no
56
+ cheap verifier exists, keep the movement smaller and record the residual risk.
57
+
58
+ ## Stable Landing
59
+
60
+ A stable landing is not merely a green command. It is a state where:
61
+
62
+ - the preserved behavior has relevant evidence;
63
+ - names and intermediate compatibility shapes are honest enough to pause;
64
+ - no dead replacement path or half-moved caller remains accidentally active;
65
+ - the diff has one explainable structural purpose;
66
+ - the next decision can be made from the new evidence.
67
+
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.
72
+
73
+ ## Reroute Conditions
74
+
75
+ Close direct execution at the latest stable landing and open a focused judgment
76
+ when the movement reveals:
77
+
78
+ - an unexpected product behavior or policy choice: `specify`;
79
+ - missing cases, transition rules, or replacement obligations: `model`;
80
+ - unresolved ownership, collaboration, or data flow: `sketch`;
81
+ - a new comparison or uncertain structural direction: `signal`;
82
+ - a responsibility, role, boundary, or abstraction candidate: `abstraction-review`;
83
+ - a disputed name or vocabulary boundary: `naming-judgment`;
84
+ - a question of now, after, or never: `schedule`;
85
+ - weak or irrelevant evidence: `verify`.
86
+
87
+ This is adaptive rerouting, not a required sequence.
88
+
89
+ ## Worked Mutation Trace
90
+
91
+ Accepted move: replace duplicated schedule conversion in two callers with the
92
+ already-reviewed pure `toScheduleContent` function.
93
+
94
+ ```text
95
+ Behavior to preserve:
96
+ both callers produce the same ScheduleContent or domain error for the
97
+ characterized table of variants
98
+ Structural move:
99
+ introduce the function, move one caller, then the second, then delete the
100
+ duplicate expressions
101
+ Baseline evidence:
102
+ focused table test plus both caller integration tests are green
103
+ Affected surface:
104
+ converter module, two callers, no persistence format
105
+ Stop:
106
+ both callers use the converter; old expressions are unreachable and removed
107
+ ```
108
+
109
+ Mutation sequence:
110
+
111
+ ```text
112
+ 1. add the pure function with one characterized caller's exact behavior
113
+ 2. run the conversion table
114
+ 3. switch caller A; run its integration and inspect the diff
115
+ 4. switch caller B; run its integration and inspect the diff
116
+ 5. search for the replaced expression and remove dead code
117
+ 6. run the focused table, integrations, typecheck, and packed-source check
118
+ ```
119
+
120
+ If caller B reveals that `null` and missing have different product meanings, do
121
+ not broaden the helper until everything passes. Return that policy to `model`;
122
+ caller A remains at a stable landing. If both callers move cleanly, stop.
123
+ Creating a converter class or registry is a new judgment, not “finishing” this
124
+ refactor.
125
+
126
+ ## Failure Checks
127
+
128
+ The protocol is being misused when:
129
+
130
+ - tests are changed merely to preserve green output after behavior drift;
131
+ - several differences are removed before the first can be inspected;
132
+ - a class, strategy, polymorphic hierarchy, or factory was chosen in advance;
133
+ - behavior work is hidden inside a structural label;
134
+ - the route continues after a new human-owned policy question appears;
135
+ - an intermediate state cannot safely run or be reviewed;
136
+ - cleanup continues past the accepted pressure because the code could be nicer.
137
+
138
+ ## Source Trace
139
+
140
+ - Sandi Metz, Katrina Owen, and TJ Stankus, *99 Bottles of OOP*, Second
141
+ Edition, version 2.2.2, 2024: chapters 3-5 on methodical transformations,
142
+ flocking, gradual refactoring, stable landings, and sender-by-sender movement.
143
+ - Kent Beck, *Tidy First?: A Personal Exercise in Empirical Software Design*,
144
+ O'Reilly Media, 2023: separating behavior and structure, keeping tidyings
145
+ small, and managing structural change as optionality.
146
+ - Matthias Felleisen, Robert Bruce Findler, Matthew Flatt, and Shriram
147
+ Krishnamurthi, *How to Design Programs, Second Edition*, MIT Press, 2018:
148
+ iterative refinement and propagating a changed data design through dependent
149
+ artifacts.
@@ -5,7 +5,6 @@ import { isAbsolute, relative, resolve } from "node:path";
5
5
  import {
6
6
  DEFAULT_MAX_BYTES,
7
7
  DEFAULT_MAX_LINES,
8
- loadSkillsFromDir,
9
8
  stripFrontmatter,
10
9
  type Skill,
11
10
  } from "@earendil-works/pi-coding-agent";
@@ -33,30 +32,16 @@ export function isWithinRoot(root: string, path: string): boolean {
33
32
  return relation === "" || (!relation.startsWith("..") && !isAbsolute(relation));
34
33
  }
35
34
 
36
- export function loadCandidateSkills(skillsRoot: string): Map<string, Skill> {
37
- const loaded = loadSkillsFromDir({ dir: skillsRoot, source: "@hobin/developer" });
38
- const candidates = new Map<string, Skill>();
39
-
40
- for (const skill of loaded.skills) {
41
- if (candidates.has(skill.name)) throw new Error(`Duplicate Developer skill name: ${skill.name}`);
42
- candidates.set(skill.name, skill);
43
- }
44
-
45
- if (candidates.size === 0) throw new Error(`No Developer skills found in ${skillsRoot}`);
46
- return candidates;
47
- }
48
-
49
35
  export function availablePackageSkills(
50
36
  loadedSkills: Skill[],
51
- candidates: ReadonlyMap<string, Skill>,
52
37
  skillsRoot: string,
53
38
  ): Map<string, Skill> {
54
39
  const available = new Map<string, Skill>();
55
40
 
56
41
  for (const skill of loadedSkills) {
57
42
  if (skill.disableModelInvocation) continue;
58
- if (!candidates.has(skill.name)) continue;
59
43
  if (!isWithinRoot(skillsRoot, skill.filePath)) continue;
44
+ if (available.has(skill.name)) throw new Error(`Duplicate Pi-loaded Developer skill name: ${skill.name}`);
60
45
  available.set(skill.name, skill);
61
46
  }
62
47