@hobin/developer 0.1.0
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/LICENSE +21 -0
- package/README.md +231 -0
- package/extensions/developer.ts +700 -0
- package/extensions/skills.ts +89 -0
- package/extensions/state.ts +283 -0
- package/extensions/tool-policy.ts +65 -0
- package/extensions/tui.ts +391 -0
- package/package.json +43 -0
- package/skills/abstraction-review/SKILL.md +87 -0
- package/skills/abstraction-review/references/field-card.md +209 -0
- package/skills/abstraction-review/references/recipe-cards.md +255 -0
- package/skills/abstraction-review/references/repair-table.md +98 -0
- package/skills/abstraction-review/references/worked-examples.md +306 -0
- package/skills/adversarial-eval/SKILL.md +87 -0
- package/skills/model/SKILL.md +76 -0
- package/skills/model/references/problem-modeling.md +262 -0
- package/skills/naming-judgment/SKILL.md +77 -0
- package/skills/naming-judgment/references/elements-of-clojure-naming.md +74 -0
- package/skills/schedule/SKILL.md +71 -0
- package/skills/signal/SKILL.md +75 -0
- package/skills/sketch/SKILL.md +70 -0
- package/skills/specify/SKILL.md +67 -0
- package/skills/verify/SKILL.md +71 -0
- package/skills/visualize/SKILL.md +65 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MAX_BYTES,
|
|
7
|
+
DEFAULT_MAX_LINES,
|
|
8
|
+
keyHint,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionCommandContext,
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
type Skill,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
availablePackageSkills,
|
|
19
|
+
loadCandidateSkills,
|
|
20
|
+
renderSkillMethod,
|
|
21
|
+
} from "./skills.ts";
|
|
22
|
+
import {
|
|
23
|
+
JUDGMENT_TOOL,
|
|
24
|
+
MODE_ENTRY,
|
|
25
|
+
PROTOCOL,
|
|
26
|
+
ROUTE_TOOL,
|
|
27
|
+
applyDeveloperEvent,
|
|
28
|
+
initialState,
|
|
29
|
+
normalizeDeveloperEvent,
|
|
30
|
+
protocolState,
|
|
31
|
+
reconstructState,
|
|
32
|
+
type DeveloperMode,
|
|
33
|
+
type DeveloperState,
|
|
34
|
+
type JudgmentEvent,
|
|
35
|
+
type ModeEvent,
|
|
36
|
+
type RouteEvent,
|
|
37
|
+
} from "./state.ts";
|
|
38
|
+
import {
|
|
39
|
+
builtinMutationToolNames,
|
|
40
|
+
reconcileProtocolTools,
|
|
41
|
+
type ToolPolicyMemory,
|
|
42
|
+
} from "./tool-policy.ts";
|
|
43
|
+
import {
|
|
44
|
+
DeveloperWidget,
|
|
45
|
+
prepareQuestionPrompt,
|
|
46
|
+
renderDeveloperFooter,
|
|
47
|
+
showDeveloperActionSelector,
|
|
48
|
+
showDeveloperStatus,
|
|
49
|
+
showPendingQuestionSelector,
|
|
50
|
+
} from "./tui.ts";
|
|
51
|
+
|
|
52
|
+
const PROTOCOL_TOOLS = [ROUTE_TOOL, JUDGMENT_TOOL] as const;
|
|
53
|
+
const skillsRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
54
|
+
const MAX_PENDING_QUESTIONS = 20;
|
|
55
|
+
const MAX_QUESTION_CHARS = 2_000;
|
|
56
|
+
const MAX_EVIDENCE_CHARS = 2_000;
|
|
57
|
+
const MAX_RESULT_CHARS = 4_000;
|
|
58
|
+
const MAX_ARTIFACT_CHARS = 4_096;
|
|
59
|
+
const DEVELOPER_COMMAND_ACTIONS = ["on", "strict", "status", "questions", "off"] as const;
|
|
60
|
+
|
|
61
|
+
function textResult<T>(text: string, details: T) {
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text" as const, text }],
|
|
64
|
+
details,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resultText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
69
|
+
return result.content
|
|
70
|
+
.filter((item) => item.type === "text" && item.text)
|
|
71
|
+
.map((item) => item.text)
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reusableText(content: string, lastComponent: unknown): Text {
|
|
76
|
+
const component = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
77
|
+
component.setText(content);
|
|
78
|
+
return component;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureSafeToolText(text: string, label: string): void {
|
|
82
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
83
|
+
const lines = text.split(/\r?\n/).length;
|
|
84
|
+
if (bytes > DEFAULT_MAX_BYTES || lines > DEFAULT_MAX_LINES) {
|
|
85
|
+
fail(
|
|
86
|
+
`${label} exceeds Pi's tool-output limit (${bytes} bytes, ${lines} lines). Narrow the routed question or evidence.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fail(message: string): never {
|
|
92
|
+
throw new Error(message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sameToolSet(left: string[], right: string[]): boolean {
|
|
96
|
+
if (left.length !== right.length) return false;
|
|
97
|
+
const rightSet = new Set(right);
|
|
98
|
+
return left.every((tool) => rightSet.has(tool));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function compactLine(value: string, maxChars = 160): string {
|
|
102
|
+
const line = value.replace(/\s+/g, " ").trim();
|
|
103
|
+
return line.length <= maxChars ? line : `${line.slice(0, maxChars - 1)}…`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function summarizeState(state: DeveloperState): string {
|
|
107
|
+
if (state.mode === "off") return "developer: off";
|
|
108
|
+
const target = state.activeRoute ? state.activeRoute.owner : "none";
|
|
109
|
+
return `developer: ${state.mode} · target: ${target} · ${protocolState(state)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function protocolPrompt(state: DeveloperState, availableSkillNames: string[]): string {
|
|
113
|
+
const lines = [
|
|
114
|
+
"",
|
|
115
|
+
`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.",
|
|
121
|
+
"- Protocol state is routing bookkeeping. Idle never proves product completion, user acceptance, or current verification.",
|
|
122
|
+
"- Product files are changed with Pi implementation tools. Developer protocol tools only route and record judgments.",
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
if (state.mode === "strict") {
|
|
126
|
+
lines.push(
|
|
127
|
+
"- Strict mode withholds active Pi built-in edit, write, and bash tools unless the active route target is direct. It does not classify or sandbox other extensions' tools.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (state.activeRoute) {
|
|
132
|
+
lines.push(
|
|
133
|
+
`Active route: ${state.activeRoute.routeId} · ${state.activeRoute.owner} · ${state.activeRoute.question}`,
|
|
134
|
+
);
|
|
135
|
+
if (state.activeRoute.methodLocation) {
|
|
136
|
+
lines.push(
|
|
137
|
+
`Active skill location: ${state.activeRoute.methodLocation}. Read it again if compaction or a later turn no longer contains the full instructions.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (state.pendingQuestions.length > 0) {
|
|
142
|
+
lines.push("Pending Developer questions:");
|
|
143
|
+
for (const question of state.pendingQuestions) {
|
|
144
|
+
lines.push(`- ${question.id} · ${question.status} · ${question.question}`);
|
|
145
|
+
}
|
|
146
|
+
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.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
lines.push(
|
|
151
|
+
`Available Developer skills: ${availableSkillNames.length > 0 ? availableSkillNames.join(", ") : "none; use direct only"}.`,
|
|
152
|
+
);
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function routeRenderText(event: RouteEvent | undefined): string {
|
|
157
|
+
if (!event) return "Route unavailable";
|
|
158
|
+
const target = event.targetQuestionId ? ` · revisits ${event.targetQuestionId}` : "";
|
|
159
|
+
return `${event.owner} · ${compactLine(event.question)}${target}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function judgmentRenderText(event: JudgmentEvent | undefined): string {
|
|
163
|
+
if (!event) return "Judgment unavailable";
|
|
164
|
+
return `${event.status} · ${compactLine(event.result)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default async function developer(pi: ExtensionAPI) {
|
|
168
|
+
const candidates = loadCandidateSkills(skillsRoot);
|
|
169
|
+
let availableSkills = new Map<string, Skill>();
|
|
170
|
+
let state = initialState();
|
|
171
|
+
let routeOpening = false;
|
|
172
|
+
let toolPolicyMemory: ToolPolicyMemory = { withheldBuiltins: new Set() };
|
|
173
|
+
|
|
174
|
+
pi.registerFlag("develop-mode", {
|
|
175
|
+
description: "Start the Developer protocol in on or strict mode",
|
|
176
|
+
type: "string",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const syncProtocolTools = () => {
|
|
180
|
+
const current = pi.getActiveTools();
|
|
181
|
+
const next = reconcileProtocolTools({
|
|
182
|
+
activeTools: current,
|
|
183
|
+
allTools: pi.getAllTools(),
|
|
184
|
+
mode: state.mode,
|
|
185
|
+
directRouteOpen: state.activeRoute?.owner === "direct",
|
|
186
|
+
protocolTools: PROTOCOL_TOOLS,
|
|
187
|
+
memory: toolPolicyMemory,
|
|
188
|
+
});
|
|
189
|
+
toolPolicyMemory = next.memory;
|
|
190
|
+
if (!sameToolSet(current, next.activeTools)) pi.setActiveTools(next.activeTools);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const refreshUI = (ctx: ExtensionContext) => {
|
|
194
|
+
if (state.mode === "off") {
|
|
195
|
+
ctx.ui.setStatus("developer", undefined);
|
|
196
|
+
ctx.ui.setWidget("developer", undefined);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
ctx.ui.setStatus(
|
|
201
|
+
"developer",
|
|
202
|
+
ctx.mode === "tui" ? renderDeveloperFooter(state, ctx.ui.theme) : summarizeState(state),
|
|
203
|
+
);
|
|
204
|
+
if (!state.activeRoute && state.pendingQuestions.length === 0) {
|
|
205
|
+
ctx.ui.setWidget("developer", undefined);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (ctx.mode === "tui") {
|
|
210
|
+
const viewState = state;
|
|
211
|
+
ctx.ui.setWidget("developer", (_tui, theme) => new DeveloperWidget(viewState, theme), {
|
|
212
|
+
placement: "belowEditor",
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const lines = [
|
|
218
|
+
...(state.activeRoute
|
|
219
|
+
? [`route · ${state.activeRoute.owner} · ${compactLine(state.activeRoute.question)}`]
|
|
220
|
+
: []),
|
|
221
|
+
...state.pendingQuestions
|
|
222
|
+
.slice(0, 3)
|
|
223
|
+
.map((question) => `open · ${question.id} · ${compactLine(question.question)}`),
|
|
224
|
+
...(state.pendingQuestions.length > 3 ? [`open · +${state.pendingQuestions.length - 3} more`] : []),
|
|
225
|
+
];
|
|
226
|
+
ctx.ui.setWidget("developer", lines, { placement: "belowEditor" });
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const reconstruct = (ctx: ExtensionContext) => {
|
|
230
|
+
state = reconstructState(ctx.sessionManager.getBranch());
|
|
231
|
+
syncProtocolTools();
|
|
232
|
+
refreshUI(ctx);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const setMode = (mode: DeveloperMode, ctx: ExtensionContext) => {
|
|
236
|
+
const event: ModeEvent = { protocol: PROTOCOL, kind: "mode", mode };
|
|
237
|
+
pi.appendEntry(MODE_ENTRY, event);
|
|
238
|
+
state = applyDeveloperEvent(state, event);
|
|
239
|
+
syncProtocolTools();
|
|
240
|
+
refreshUI(ctx);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const RouteParams = Type.Object({
|
|
244
|
+
question: Type.String({
|
|
245
|
+
minLength: 1,
|
|
246
|
+
maxLength: MAX_QUESTION_CHARS,
|
|
247
|
+
description: "The single concrete judgment or action question to route",
|
|
248
|
+
}),
|
|
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
|
+
reason: Type.String({
|
|
255
|
+
minLength: 1,
|
|
256
|
+
maxLength: MAX_QUESTION_CHARS,
|
|
257
|
+
description: "Why this route target fits the current evidence",
|
|
258
|
+
}),
|
|
259
|
+
known_evidence: Type.Optional(
|
|
260
|
+
Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
|
|
261
|
+
maxItems: 12,
|
|
262
|
+
description: "Evidence already known before routing",
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
open_question_id: Type.Optional(
|
|
266
|
+
Type.String({ maxLength: 512, description: "Exact pending question ID when this route revisits one" }),
|
|
267
|
+
),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
pi.registerTool({
|
|
271
|
+
name: ROUTE_TOOL,
|
|
272
|
+
label: "Developer Route Question",
|
|
273
|
+
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.",
|
|
275
|
+
promptSnippet: "Choose how to handle one development question",
|
|
276
|
+
promptGuidelines: [
|
|
277
|
+
`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.`,
|
|
279
|
+
],
|
|
280
|
+
parameters: RouteParams,
|
|
281
|
+
executionMode: "sequential",
|
|
282
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
283
|
+
if (state.mode === "off") fail("Developer protocol is off. Run /develop on or /develop strict first.");
|
|
284
|
+
if (state.activeRoute || routeOpening) {
|
|
285
|
+
if (!state.activeRoute) fail("Another Developer route is currently opening. Wait for it to finish.");
|
|
286
|
+
fail(`Route ${state.activeRoute.routeId} is still active. Record its judgment before routing another question.`);
|
|
287
|
+
}
|
|
288
|
+
routeOpening = true;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const question = params.question.trim();
|
|
292
|
+
const reason = params.reason.trim();
|
|
293
|
+
if (!question || !reason) fail("Question and reason must contain non-whitespace text.");
|
|
294
|
+
|
|
295
|
+
const targetQuestionId = params.open_question_id?.trim() || undefined;
|
|
296
|
+
if (targetQuestionId && !state.pendingQuestions.some((item) => item.id === targetQuestionId)) {
|
|
297
|
+
fail(`Unknown pending question ID: ${targetQuestionId}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const owner = params.owner;
|
|
301
|
+
const skill = owner === "direct" ? undefined : availableSkills.get(owner);
|
|
302
|
+
if (owner !== "direct" && !skill) {
|
|
303
|
+
fail(`Developer skill ${owner} is unavailable or disabled in the current Pi resource configuration.`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const method =
|
|
307
|
+
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!);
|
|
314
|
+
|
|
315
|
+
const event: RouteEvent = {
|
|
316
|
+
protocol: PROTOCOL,
|
|
317
|
+
kind: "route",
|
|
318
|
+
routeId: `route:${toolCallId}`,
|
|
319
|
+
question,
|
|
320
|
+
owner,
|
|
321
|
+
reason,
|
|
322
|
+
knownEvidence: (params.known_evidence ?? []).map((item) => item.trim()).filter(Boolean),
|
|
323
|
+
targetQuestionId,
|
|
324
|
+
methodLocation: skill?.filePath,
|
|
325
|
+
};
|
|
326
|
+
const response = [
|
|
327
|
+
`Route ID: ${event.routeId}`,
|
|
328
|
+
`Question: ${event.question}`,
|
|
329
|
+
`Target: ${event.owner}`,
|
|
330
|
+
skill ? `Skill location: ${skill.filePath}` : "Skill location: direct action; no skill file",
|
|
331
|
+
`Reason: ${event.reason}`,
|
|
332
|
+
`Known evidence: ${event.knownEvidence.length > 0 ? event.knownEvidence.join(" | ") : "none"}`,
|
|
333
|
+
targetQuestionId ? `Revisits pending question: ${targetQuestionId}` : "Revisits pending question: none",
|
|
334
|
+
`When this route has done its job, call ${JUDGMENT_TOOL} with this exact route ID.`,
|
|
335
|
+
"",
|
|
336
|
+
"---",
|
|
337
|
+
"",
|
|
338
|
+
method,
|
|
339
|
+
].join("\n");
|
|
340
|
+
ensureSafeToolText(response, "Developer route result");
|
|
341
|
+
|
|
342
|
+
state = applyDeveloperEvent(state, event);
|
|
343
|
+
syncProtocolTools();
|
|
344
|
+
refreshUI(ctx);
|
|
345
|
+
return textResult(response, event);
|
|
346
|
+
} finally {
|
|
347
|
+
routeOpening = false;
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
renderCall(args, theme, context) {
|
|
351
|
+
const owner = typeof args.owner === "string" && args.owner.length > 0 ? args.owner : "…";
|
|
352
|
+
const question =
|
|
353
|
+
typeof args.question === "string" && args.question.length > 0 ? compactLine(args.question) : "…";
|
|
354
|
+
return reusableText(
|
|
355
|
+
`${theme.fg("toolTitle", theme.bold(ROUTE_TOOL))} ${theme.fg("accent", owner)} ${theme.fg("muted", question)}`,
|
|
356
|
+
context.lastComponent,
|
|
357
|
+
);
|
|
358
|
+
},
|
|
359
|
+
renderResult(result, { expanded, isPartial, isError }, theme, context) {
|
|
360
|
+
if (isPartial) {
|
|
361
|
+
return reusableText(theme.fg("warning", "routing development question…"), context.lastComponent);
|
|
362
|
+
}
|
|
363
|
+
if (isError) {
|
|
364
|
+
return reusableText(
|
|
365
|
+
theme.fg("error", resultText(result) || "Developer route failed"),
|
|
366
|
+
context.lastComponent,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const event = result.details as RouteEvent | undefined;
|
|
370
|
+
let text = theme.fg("success", `routed ${routeRenderText(event)}`);
|
|
371
|
+
if (expanded && event) {
|
|
372
|
+
text += `\n${theme.fg("dim", `route · ${event.routeId}`)}`;
|
|
373
|
+
text += `\n${theme.fg("dim", `question · ${event.question}`)}`;
|
|
374
|
+
text += `\n${theme.fg("dim", `reason · ${event.reason}`)}`;
|
|
375
|
+
if (event.knownEvidence.length > 0) {
|
|
376
|
+
for (const evidence of event.knownEvidence) {
|
|
377
|
+
text += `\n${theme.fg("dim", `evidence · ${evidence}`)}`;
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
text += `\n${theme.fg("dim", "evidence · none recorded before routing")}`;
|
|
381
|
+
}
|
|
382
|
+
text += `\n${theme.fg("dim", `revisits · ${event.targetQuestionId ?? "none"}`)}`;
|
|
383
|
+
text += `\n${theme.fg("dim", `skill · ${event.methodLocation ?? "direct action"}`)}`;
|
|
384
|
+
}
|
|
385
|
+
if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
|
|
386
|
+
return reusableText(text, context.lastComponent);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const JudgmentParams = Type.Object({
|
|
391
|
+
route_id: Type.String({ minLength: 1, maxLength: 512, description: `Exact route ID returned by ${ROUTE_TOOL}` }),
|
|
392
|
+
status: StringEnum(["resolved", "needs-evidence", "not-applicable", "blocked"] as const),
|
|
393
|
+
result: Type.String({
|
|
394
|
+
minLength: 1,
|
|
395
|
+
maxLength: MAX_RESULT_CHARS,
|
|
396
|
+
description: "The resulting judgment in concrete terms",
|
|
397
|
+
}),
|
|
398
|
+
basis: Type.Array(Type.String({ maxLength: MAX_EVIDENCE_CHARS }), {
|
|
399
|
+
maxItems: 20,
|
|
400
|
+
description: "Evidence supporting the judgment or blocker",
|
|
401
|
+
}),
|
|
402
|
+
open_questions: Type.Optional(
|
|
403
|
+
Type.Array(Type.String({ maxLength: MAX_QUESTION_CHARS }), {
|
|
404
|
+
maxItems: 10,
|
|
405
|
+
description: "New questions that remain unresolved",
|
|
406
|
+
}),
|
|
407
|
+
),
|
|
408
|
+
artifacts: Type.Optional(
|
|
409
|
+
Type.Array(Type.String({ maxLength: MAX_ARTIFACT_CHARS }), {
|
|
410
|
+
maxItems: 20,
|
|
411
|
+
description: "Relevant paths, commands, tests, or outputs",
|
|
412
|
+
}),
|
|
413
|
+
),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
pi.registerTool({
|
|
417
|
+
name: JUDGMENT_TOOL,
|
|
418
|
+
label: "Developer Record Judgment",
|
|
419
|
+
description:
|
|
420
|
+
"Close the active Developer route with its result, evidence, newly opened questions, and relevant artifacts. This records a local judgment, not task completion.",
|
|
421
|
+
promptSnippet: "Record evidence and close the active development route",
|
|
422
|
+
promptGuidelines: [
|
|
423
|
+
`Use ${JUDGMENT_TOOL} with the exact active Developer route ID.`,
|
|
424
|
+
`Do not use ${JUDGMENT_TOOL} with resolved, not-applicable, or blocked status without at least one concrete basis.`,
|
|
425
|
+
],
|
|
426
|
+
parameters: JudgmentParams,
|
|
427
|
+
executionMode: "sequential",
|
|
428
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
429
|
+
if (state.mode === "off") fail("Developer protocol is off.");
|
|
430
|
+
if (!state.activeRoute) fail("There is no active Developer route to close.");
|
|
431
|
+
if (params.route_id !== state.activeRoute.routeId) {
|
|
432
|
+
fail(`Route ID mismatch. Active route is ${state.activeRoute.routeId}.`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const basis = params.basis.map((item) => item.trim()).filter(Boolean);
|
|
436
|
+
const result = params.result.trim();
|
|
437
|
+
if (!result) fail("A judgment result must contain non-whitespace text.");
|
|
438
|
+
if (params.status !== "needs-evidence" && basis.length === 0) {
|
|
439
|
+
fail(`${params.status} judgments require at least one concrete basis.`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const openedQuestions = (params.open_questions ?? [])
|
|
443
|
+
.map((item) => item.trim())
|
|
444
|
+
.filter(Boolean)
|
|
445
|
+
.map((question, index) => ({
|
|
446
|
+
id: `question:${params.route_id}:open:${index + 1}`,
|
|
447
|
+
question,
|
|
448
|
+
status: "needs-evidence" as const,
|
|
449
|
+
sourceRouteId: params.route_id,
|
|
450
|
+
}));
|
|
451
|
+
const event: JudgmentEvent = {
|
|
452
|
+
protocol: PROTOCOL,
|
|
453
|
+
kind: "judgment",
|
|
454
|
+
routeId: params.route_id,
|
|
455
|
+
question: state.activeRoute.question,
|
|
456
|
+
owner: state.activeRoute.owner,
|
|
457
|
+
status: params.status,
|
|
458
|
+
result,
|
|
459
|
+
basis,
|
|
460
|
+
openedQuestions,
|
|
461
|
+
artifacts: (params.artifacts ?? []).map((item) => item.trim()).filter(Boolean),
|
|
462
|
+
};
|
|
463
|
+
const nextState = applyDeveloperEvent(state, event);
|
|
464
|
+
if (nextState.pendingQuestions.length > MAX_PENDING_QUESTIONS) {
|
|
465
|
+
fail(
|
|
466
|
+
`Developer protocol would retain ${nextState.pendingQuestions.length} pending questions; resolve or consolidate them before opening more.`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const next = protocolState(nextState);
|
|
471
|
+
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.`;
|
|
475
|
+
const response = `Recorded ${event.status} judgment for ${event.routeId}: ${event.result}\n${nextMessage}`;
|
|
476
|
+
ensureSafeToolText(response, "Developer judgment result");
|
|
477
|
+
|
|
478
|
+
state = nextState;
|
|
479
|
+
syncProtocolTools();
|
|
480
|
+
refreshUI(ctx);
|
|
481
|
+
return textResult(response, event);
|
|
482
|
+
},
|
|
483
|
+
renderCall(args, theme, context) {
|
|
484
|
+
const status = typeof args.status === "string" && args.status.length > 0 ? args.status : "…";
|
|
485
|
+
const result =
|
|
486
|
+
typeof args.result === "string" && args.result.length > 0 ? compactLine(args.result) : "…";
|
|
487
|
+
const statusText =
|
|
488
|
+
status === "resolved"
|
|
489
|
+
? theme.fg("success", status)
|
|
490
|
+
: status === "needs-evidence"
|
|
491
|
+
? theme.fg("warning", status)
|
|
492
|
+
: status === "blocked"
|
|
493
|
+
? theme.fg("error", status)
|
|
494
|
+
: theme.fg("muted", status);
|
|
495
|
+
return reusableText(
|
|
496
|
+
`${theme.fg("toolTitle", theme.bold(JUDGMENT_TOOL))} ${statusText} ${theme.fg("muted", result)}`,
|
|
497
|
+
context.lastComponent,
|
|
498
|
+
);
|
|
499
|
+
},
|
|
500
|
+
renderResult(result, { expanded, isPartial, isError }, theme, context) {
|
|
501
|
+
if (isPartial) {
|
|
502
|
+
return reusableText(theme.fg("warning", "recording development judgment…"), context.lastComponent);
|
|
503
|
+
}
|
|
504
|
+
if (isError) {
|
|
505
|
+
return reusableText(
|
|
506
|
+
theme.fg("error", resultText(result) || "Developer judgment failed"),
|
|
507
|
+
context.lastComponent,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
const event = result.details as JudgmentEvent | undefined;
|
|
511
|
+
const summary = judgmentRenderText(event);
|
|
512
|
+
let text =
|
|
513
|
+
event?.status === "resolved"
|
|
514
|
+
? theme.fg("success", summary)
|
|
515
|
+
: event?.status === "needs-evidence"
|
|
516
|
+
? theme.fg("warning", summary)
|
|
517
|
+
: event?.status === "blocked"
|
|
518
|
+
? theme.fg("error", summary)
|
|
519
|
+
: theme.fg("muted", summary);
|
|
520
|
+
if (expanded && event) {
|
|
521
|
+
text += `\n${theme.fg("dim", `route · ${event.routeId}`)}`;
|
|
522
|
+
text += `\n${theme.fg("dim", `target · ${event.owner}`)}`;
|
|
523
|
+
text += `\n${theme.fg("dim", `question · ${event.question}`)}`;
|
|
524
|
+
text += `\n${theme.fg("dim", `result · ${event.result}`)}`;
|
|
525
|
+
for (const basis of event.basis) text += `\n${theme.fg("dim", `basis · ${basis}`)}`;
|
|
526
|
+
for (const artifact of event.artifacts) text += `\n${theme.fg("dim", `artifact · ${artifact}`)}`;
|
|
527
|
+
for (const question of event.openedQuestions) {
|
|
528
|
+
text += `\n${theme.fg("warning", `opened · ${question.id} · ${question.question}`)}`;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (!expanded && event) text += ` · ${keyHint("app.tools.expand", "details")}`;
|
|
532
|
+
return reusableText(text, context.lastComponent);
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const refreshAvailableSkills = (ctx: ExtensionCommandContext) => {
|
|
537
|
+
if (typeof ctx.getSystemPromptOptions !== "function") return;
|
|
538
|
+
availableSkills = availablePackageSkills(
|
|
539
|
+
ctx.getSystemPromptOptions().skills ?? [],
|
|
540
|
+
candidates,
|
|
541
|
+
skillsRoot,
|
|
542
|
+
);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const statusMessage = () => {
|
|
546
|
+
const active = state.activeRoute
|
|
547
|
+
? `${state.activeRoute.routeId} · ${state.activeRoute.owner} · ${state.activeRoute.question}`
|
|
548
|
+
: "none";
|
|
549
|
+
const pending =
|
|
550
|
+
state.pendingQuestions.length > 0
|
|
551
|
+
? state.pendingQuestions
|
|
552
|
+
.map((question) => `${question.id} · ${question.status} · ${question.question}`)
|
|
553
|
+
.join(" | ")
|
|
554
|
+
: "none";
|
|
555
|
+
const last = state.lastJudgment
|
|
556
|
+
? `${state.lastJudgment.status} · ${state.lastJudgment.result}`
|
|
557
|
+
: "none";
|
|
558
|
+
const basis =
|
|
559
|
+
state.lastJudgment && state.lastJudgment.basis.length > 0
|
|
560
|
+
? state.lastJudgment.basis.join(" | ")
|
|
561
|
+
: "none";
|
|
562
|
+
const artifacts =
|
|
563
|
+
state.lastJudgment && state.lastJudgment.artifacts.length > 0
|
|
564
|
+
? state.lastJudgment.artifacts.join(" | ")
|
|
565
|
+
: "none";
|
|
566
|
+
return (
|
|
567
|
+
`${summarizeState(state)}` +
|
|
568
|
+
`\nactive: ${active}` +
|
|
569
|
+
`\nlast: ${last}` +
|
|
570
|
+
`\nbasis: ${basis}` +
|
|
571
|
+
`\nartifacts: ${artifacts}` +
|
|
572
|
+
`\nactive tools: ${pi.getActiveTools().join(", ")}` +
|
|
573
|
+
`\navailable skills: ${[...availableSkills.keys()].join(", ") || "none"}` +
|
|
574
|
+
`\npending: ${pending}` +
|
|
575
|
+
"\nprotocol state is not a product-completion claim"
|
|
576
|
+
);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
pi.registerCommand("develop", {
|
|
580
|
+
description: "Control or inspect Developer: /develop on | strict | status | questions | off",
|
|
581
|
+
getArgumentCompletions(prefix) {
|
|
582
|
+
const normalized = prefix.trim();
|
|
583
|
+
const matches = DEVELOPER_COMMAND_ACTIONS.filter((action) => action.startsWith(normalized));
|
|
584
|
+
return matches.length > 0
|
|
585
|
+
? matches.map((action) => ({ value: action, label: action }))
|
|
586
|
+
: null;
|
|
587
|
+
},
|
|
588
|
+
handler: async (args, ctx) => {
|
|
589
|
+
let action = args.trim();
|
|
590
|
+
if (!action && ctx.mode === "tui") {
|
|
591
|
+
refreshAvailableSkills(ctx);
|
|
592
|
+
action = (await showDeveloperActionSelector(ctx, state)) ?? "";
|
|
593
|
+
if (!action) return;
|
|
594
|
+
}
|
|
595
|
+
if (!action) action = "status";
|
|
596
|
+
if (action === "on" || action === "strict" || action === "off") {
|
|
597
|
+
if (
|
|
598
|
+
action === "off" &&
|
|
599
|
+
ctx.mode === "tui" &&
|
|
600
|
+
(state.activeRoute || state.pendingQuestions.length > 0)
|
|
601
|
+
) {
|
|
602
|
+
const work = [
|
|
603
|
+
...(state.activeRoute ? ["the active route"] : []),
|
|
604
|
+
...(state.pendingQuestions.length > 0
|
|
605
|
+
? [`${state.pendingQuestions.length} open question(s)`]
|
|
606
|
+
: []),
|
|
607
|
+
].join(" and ");
|
|
608
|
+
const confirmed = await ctx.ui.confirm(
|
|
609
|
+
"Turn off Developer?",
|
|
610
|
+
`This clears ${work} from the current protocol state. Existing session history remains.`,
|
|
611
|
+
);
|
|
612
|
+
if (!confirmed) return;
|
|
613
|
+
}
|
|
614
|
+
setMode(action, ctx);
|
|
615
|
+
ctx.ui.notify(`Developer mode: ${action}`, "info");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (action === "questions") {
|
|
619
|
+
if (state.pendingQuestions.length === 0) {
|
|
620
|
+
ctx.ui.notify("Developer has no open questions on the current branch.", "info");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (ctx.mode === "tui") {
|
|
624
|
+
const questionId = await showPendingQuestionSelector(ctx, state.pendingQuestions);
|
|
625
|
+
if (!questionId) return;
|
|
626
|
+
const question = state.pendingQuestions.find((item) => item.id === questionId);
|
|
627
|
+
if (!question) return;
|
|
628
|
+
prepareQuestionPrompt(ctx, question);
|
|
629
|
+
ctx.ui.notify("Open question loaded into the editor for review.", "info");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
ctx.ui.notify(
|
|
633
|
+
state.pendingQuestions
|
|
634
|
+
.map((question) => `${question.id} · ${question.status} · ${question.question}`)
|
|
635
|
+
.join("\n"),
|
|
636
|
+
"info",
|
|
637
|
+
);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (action === "status") {
|
|
641
|
+
refreshAvailableSkills(ctx);
|
|
642
|
+
if (ctx.mode === "tui") {
|
|
643
|
+
await showDeveloperStatus(ctx, {
|
|
644
|
+
state,
|
|
645
|
+
activeTools: pi.getActiveTools(),
|
|
646
|
+
availableSkills: [...availableSkills.keys()],
|
|
647
|
+
});
|
|
648
|
+
} else {
|
|
649
|
+
ctx.ui.notify(statusMessage(), "info");
|
|
650
|
+
}
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
ctx.ui.notify("Usage: /develop on | strict | status | questions | off", "warning");
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const entryRendererAPI = pi as ExtensionAPI & {
|
|
658
|
+
registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"];
|
|
659
|
+
};
|
|
660
|
+
entryRendererAPI.registerEntryRenderer?.(MODE_ENTRY, (entry, _options, theme) => {
|
|
661
|
+
const event = normalizeDeveloperEvent(entry.data);
|
|
662
|
+
if (!event || event.kind !== "mode") return undefined;
|
|
663
|
+
return new Text(
|
|
664
|
+
`${theme.fg("toolTitle", theme.bold("Developer mode"))} ${theme.fg("accent", event.mode)}`,
|
|
665
|
+
0,
|
|
666
|
+
0,
|
|
667
|
+
);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.on("before_agent_start", (event) => {
|
|
671
|
+
availableSkills = availablePackageSkills(event.systemPromptOptions.skills ?? [], candidates, skillsRoot);
|
|
672
|
+
if (state.mode === "off") return;
|
|
673
|
+
return { systemPrompt: event.systemPrompt + protocolPrompt(state, [...availableSkills.keys()]) };
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
pi.on("tool_call", (event) => {
|
|
677
|
+
if (state.mode !== "strict") return;
|
|
678
|
+
const mutationBuiltins = builtinMutationToolNames(pi.getAllTools());
|
|
679
|
+
if (!mutationBuiltins.has(event.toolName)) return;
|
|
680
|
+
if (state.activeRoute?.owner === "direct") return;
|
|
681
|
+
return {
|
|
682
|
+
block: true,
|
|
683
|
+
reason: `Developer strict mode requires an active ${ROUTE_TOOL} targeting direct action (owner=direct) before Pi built-in edit, write, or bash.`,
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
pi.on("session_start", (_event, ctx) => {
|
|
688
|
+
reconstruct(ctx);
|
|
689
|
+
const startupMode = pi.getFlag("develop-mode");
|
|
690
|
+
if ((startupMode === "on" || startupMode === "strict") && state.mode !== startupMode) {
|
|
691
|
+
setMode(startupMode, ctx);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
pi.on("session_tree", (_event, ctx) => reconstruct(ctx));
|
|
695
|
+
pi.on("agent_settled", (_event, ctx) => refreshUI(ctx));
|
|
696
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
697
|
+
ctx.ui.setStatus("developer", undefined);
|
|
698
|
+
ctx.ui.setWidget("developer", undefined);
|
|
699
|
+
});
|
|
700
|
+
}
|