@jwilger/pi-development-system 0.81.0 → 0.82.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/README.md CHANGED
@@ -22,8 +22,9 @@ what the current phase expects. When you say a slice is finished with no review
22
22
  it tells you to start one. The slash commands (`/devsys-start`, `/devsys-plan`, `/devsys-review`)
23
23
  are shortcuts to the same tools.
24
24
 
25
- With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools and
26
- the `judge_*` Jev wrappers are reached through scripts; without it they are declared directly.
25
+ With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools are
26
+ reached through scripts and the `judge_*` Jev wrappers exist for scripts only; without codemode
27
+ the rarely used tools are declared directly and the wrappers are absent.
27
28
 
28
29
  ## Development
29
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -9,7 +9,7 @@ import { phaseGuide } from "./phase-guide.ts";
9
9
  * system prompt's state section; calling the tool returns the guidance for it.
10
10
  */
11
11
  export const DEVSYS_TOOL_DESCRIPTION =
12
- "What the development system expects in the current workflow phase (named in the system prompt's Current state), and which tools to use. Call it when unsure what comes next.";
12
+ "What the development system expects in the current workflow phase (the system prompt names it as of the start of the prompt; this call is live), and which tools to use. Call it when unsure what comes next.";
13
13
 
14
14
  /** `devsys`: a model-only tool that returns the current phase's guidance. */
15
15
  export function createPhaseTool(deps: { state: SessionState }): ToolDefinition {
@@ -1,6 +1,6 @@
1
1
  import type { DevsysState } from "../core/types.ts";
2
2
  import type { Jev } from "../jev/client.ts";
3
- import { type Intent, judgeIntent } from "../jev/questions/intent.ts";
3
+ import { type Intent, judgeIntent, requestText } from "../jev/questions/intent.ts";
4
4
 
5
5
  /** Probability at or above which an intent is acted on. */
6
6
  export const INTENT_AT = 0.7;
@@ -35,7 +35,7 @@ export async function intentLineFor(
35
35
  state: DevsysState,
36
36
  jev: Jev,
37
37
  ): Promise<string | undefined> {
38
- if (state.phase !== "idle" || prompt.trim() === "" || jev.availability() === "offline") {
38
+ if (state.phase !== "idle" || requestText(prompt) === "" || jev.availability() === "offline") {
39
39
  return undefined;
40
40
  }
41
41
  const judged = await judgeIntent(jev, { prompt, phase: state.phase });
@@ -28,7 +28,7 @@ export function buildPrinciplesSection(nonNegotiables: string): string {
28
28
  */
29
29
  export function buildStateSection(state: DevsysState): string {
30
30
  const lines = [
31
- "## Current state",
31
+ "## Workflow state at the start of this prompt (call the devsys tool for the live phase)",
32
32
  `- phase: ${state.phase}`,
33
33
  `- sizing: ${state.sizing ?? "none"}`,
34
34
  `- active slice: ${state.activeSlice ?? "none"}`,
@@ -79,7 +79,7 @@ export async function judgeTestChange(
79
79
  ? { before: input.before ?? "", after: "" }
80
80
  : changeWindow(input.before ?? "", input.after);
81
81
  const state = {
82
- path: input.path,
82
+ path: clip(input.path).slice(0, 400),
83
83
  before: clip(windowed.before),
84
84
  after: input.after === undefined ? "" : clip(windowed.after),
85
85
  deleted: input.after === undefined,
@@ -1,21 +1,30 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
1
+ // biome-ignore-all lint/suspicious/noUnnecessaryConditions: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
+ // biome-ignore-all lint/suspicious/useAwait: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
3
+ // biome-ignore-all lint/complexity/noVoid: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
4
+ // biome-ignore-all lint/style/noNestedTernary: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
5
+ // biome-ignore-all lint/style/noNonNullAssertion: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
6
+ // biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
7
  import { randomUUID } from "node:crypto";
3
- import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
5
10
  import { fuzzyFilter } from "@earendil-works/pi-tui";
6
- import { ConfigStore } from "./prefs/config.ts";
7
- import { IMPORT_REQUEST_PREFIX, markImportOffered, offerAgentImport } from "./prefs/agent-import.ts";
11
+ import { createInheritedToolSource } from "./orch/inherited-tools.ts";
8
12
  import { ThreadManager } from "./orch/manager.ts";
13
+ import { subagentPrompt } from "./orch/prompt.ts";
14
+ import { createDriverFactory } from "./orch/runtime.ts";
15
+ import { agentTools } from "./orch/tools.ts";
16
+ import {
17
+ IMPORT_REQUEST_PREFIX,
18
+ markImportOffered,
19
+ offerAgentImport,
20
+ } from "./prefs/agent-import.ts";
21
+ import { ConfigStore } from "./prefs/config.ts";
9
22
  import { loadManagerSettings } from "./prefs/settings.ts";
23
+ import type { SavedThread, ThreadEvent } from "./types.ts";
24
+ import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
10
25
  import { configureAgents } from "./ui/settings-ui.ts";
11
26
  import { AgentNavigationController, showAgentStatus } from "./ui/status-ui.ts";
12
- import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
13
- import { createDriverFactory } from "./orch/runtime.ts";
14
- import { createInheritedToolSource } from "./orch/inherited-tools.ts";
15
- import { subagentPrompt } from "./orch/prompt.ts";
16
- import { agentTools } from "./orch/tools.ts";
17
27
  import { editAgentTypes, updateWidget } from "./ui/ui.ts";
18
- import type { SavedThread, ThreadEvent } from "./types.ts";
19
28
 
20
29
  const REGISTRY_ENTRY = "pi-subagent:registry:v1";
21
30
  const ROOT_MAILBOX_ENTRY = "pi-subagent:root-mailbox:v1";
@@ -81,32 +90,55 @@ export default function piSubagent(pi: ExtensionAPI): void {
81
90
  if (existingFactory && existingFactory !== navigationEditorFactory) return;
82
91
  // The host only transfers raw text, not cursor/undo/expanded-paste state.
83
92
  if (ctx.ui.getEditorText?.()) return;
84
- const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap((message) => {
85
- if (message.role !== "user") return [];
86
- const content = message.content;
87
- return [typeof content === "string" ? content : content
88
- .filter((block) => block.type === "text").map((block) => block.text).join("")];
89
- });
93
+ const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap(
94
+ (message) => {
95
+ if (message.role !== "user") return [];
96
+ const content = message.content;
97
+ return [
98
+ typeof content === "string"
99
+ ? content
100
+ : content
101
+ .filter((block) => block.type === "text")
102
+ .map((block) => block.text)
103
+ .join(""),
104
+ ];
105
+ },
106
+ );
90
107
  navigationEditorFactory = (tui, theme, keys) => {
91
108
  const editor = new AgentNavigationEditor(tui, theme, keys, {
92
109
  // Initial startup hydrates after session_start; replacement editors do not.
93
- initialHistory: seedHistory ? history : undefined,
110
+ ...(seedHistory ? { initialHistory: history } : {}),
94
111
  generation: () => navigationGeneration,
95
- canOpen: () => manager !== undefined && context?.mode === "tui" &&
96
- limits.subagentMode !== "off" && !navigation.isOpen &&
97
- manager.list().some((thread) => thread.path !== "/root" &&
98
- (thread.state === "starting" || thread.state === "running")),
99
- openTree: () => manager && context
100
- ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons) : undefined,
101
- canCollapse: () => manager !== undefined && context?.mode === "tui" &&
102
- limits.subagentMode !== "off" && !navigation.isOpen && !widgetCollapsed &&
103
- limits.widgetMode === "full" && widgetThreads().some((thread) => thread.path !== "/root"),
112
+ canOpen: () =>
113
+ manager !== undefined &&
114
+ context?.mode === "tui" &&
115
+ limits.subagentMode !== "off" &&
116
+ !navigation.isOpen &&
117
+ manager
118
+ .list()
119
+ .some(
120
+ (thread) =>
121
+ thread.path !== "/root" &&
122
+ (thread.state === "starting" || thread.state === "running"),
123
+ ),
124
+ openTree: () =>
125
+ manager && context
126
+ ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons)
127
+ : undefined,
128
+ canCollapse: () =>
129
+ manager !== undefined &&
130
+ context?.mode === "tui" &&
131
+ limits.subagentMode !== "off" &&
132
+ !navigation.isOpen &&
133
+ !widgetCollapsed &&
134
+ limits.widgetMode === "full" &&
135
+ widgetThreads().some((thread) => thread.path !== "/root"),
104
136
  collapseWidget: () => {
105
137
  widgetCollapsed = true;
106
138
  if (context) refreshWidget(context);
107
139
  },
108
- onError: (error) => context?.ui.notify(
109
- error instanceof Error ? error.message : String(error), "error"),
140
+ onError: (error) =>
141
+ context?.ui.notify(error instanceof Error ? error.message : String(error), "error"),
110
142
  });
111
143
  navigationEditor = editor;
112
144
  return editor;
@@ -120,7 +152,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
120
152
  // Migration requires the root's file tools and must not delegate source prompts.
121
153
  if (limits.subagentMode === "orchestration") {
122
154
  if (!firstRun)
123
- ctx.ui.notify("Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.", "info");
155
+ ctx.ui.notify(
156
+ "Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.",
157
+ "info",
158
+ );
124
159
  return;
125
160
  }
126
161
  if (importInProgress) return;
@@ -140,12 +175,15 @@ export default function piSubagent(pi: ExtensionAPI): void {
140
175
  if (!manager) throw new Error("Subagent threads are not initialized; start a Pi session first");
141
176
  return manager;
142
177
  };
143
- const widgetThreads = () => requireManager().list().filter((thread) => {
144
- // Resuming a retained session makes it visible for this task, even after it settles again.
145
- if (thread.state === "starting" || thread.state === "running")
146
- hiddenWidgetThreads.delete(thread.path);
147
- return !hiddenWidgetThreads.has(thread.path);
148
- });
178
+ const widgetThreads = () =>
179
+ requireManager()
180
+ .list()
181
+ .filter((thread) => {
182
+ // Resuming a retained session makes it visible for this task, even after it settles again.
183
+ if (thread.state === "starting" || thread.state === "running")
184
+ hiddenWidgetThreads.delete(thread.path);
185
+ return !hiddenWidgetThreads.has(thread.path);
186
+ });
149
187
  const requireContext = () => {
150
188
  if (!context) throw new Error("No active Pi session");
151
189
  return context;
@@ -187,44 +225,72 @@ export default function piSubagent(pi: ExtensionAPI): void {
187
225
  };
188
226
  const pendingSummaries = (ctx: ExtensionContext) => {
189
227
  const entries = ctx.sessionManager.getBranch();
190
- const summarized = new Set(entries.flatMap((entry) => {
191
- if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
192
- const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
193
- return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
194
- }));
228
+ const summarized = new Set(
229
+ entries.flatMap((entry) => {
230
+ if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
231
+ const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
232
+ return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
233
+ }),
234
+ );
195
235
  return entries.flatMap((entry) => {
196
236
  if (entry.type !== "custom" || entry.customType !== ROOT_MAILBOX_ENTRY) return [];
197
237
  const notification = entry.data as RootNotification | undefined;
198
238
  return notification?.rootSessionId === ctx.sessionManager.getSessionId() &&
199
- notification.finalRecap === true && typeof notification.content === "string" &&
239
+ notification.finalRecap === true &&
240
+ typeof notification.content === "string" &&
200
241
  typeof notification.details?.mailboxId === "string" &&
201
- !summarized.has(notification.details.mailboxId) ? [notification] : [];
242
+ !summarized.has(notification.details.mailboxId)
243
+ ? [notification]
244
+ : [];
202
245
  });
203
246
  };
204
247
  const scheduleSummary = (ctx: ExtensionContext) => {
205
- if (!limits.finalRecap || limits.subagentMode === "off" || summaryRunning || suspendingSummaries ||
206
- summaryTimer !== undefined || ctx.isIdle() === false || !pendingSummaries(ctx).length) return;
248
+ if (
249
+ !limits.finalRecap ||
250
+ limits.subagentMode === "off" ||
251
+ summaryRunning ||
252
+ suspendingSummaries ||
253
+ summaryTimer !== undefined ||
254
+ ctx.isIdle() === false ||
255
+ pendingSummaries(ctx).length === 0
256
+ )
257
+ return;
207
258
  const currentGeneration = generation;
208
259
  // Coalesce siblings finishing together and leave the settlement boundary before
209
260
  // starting a new turn. Recheck settings/session/idle state after the deferral.
210
261
  summaryTimer = setTimeout(() => {
211
262
  summaryTimer = undefined;
212
- if (currentGeneration !== generation || context?.sessionManager !== ctx.sessionManager ||
213
- !limits.finalRecap || limits.subagentMode === "off" || suspendingSummaries || ctx.isIdle() === false) return;
263
+ if (
264
+ currentGeneration !== generation ||
265
+ context?.sessionManager !== ctx.sessionManager ||
266
+ !limits.finalRecap ||
267
+ limits.subagentMode === "off" ||
268
+ suspendingSummaries ||
269
+ ctx.isIdle() === false
270
+ )
271
+ return;
214
272
  const notifications = pendingSummaries(ctx);
215
- if (!notifications.length) return;
273
+ if (notifications.length === 0) return;
216
274
  summaryRunning = true;
217
275
  try {
218
- pi.sendMessage({
219
- customType: ROOT_SUMMARY_MESSAGE,
220
- content: "Summarize the newly finished asynchronous subagent results for the user. " +
221
- "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
222
- "Give a concise main-thread answer covering results and any failures. " +
223
- "Do not spawn or resume agents solely to summarize.\nAgents: " +
224
- [...new Set(notifications.map((notification) => notification.details.path))].join(", "),
225
- display: false,
226
- details: { mailboxIds: notifications.map((notification) => notification.details.mailboxId) },
227
- }, { triggerTurn: true });
276
+ pi.sendMessage(
277
+ {
278
+ customType: ROOT_SUMMARY_MESSAGE,
279
+ content:
280
+ "Summarize the newly finished asynchronous subagent results for the user. " +
281
+ "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
282
+ "Give a concise main-thread answer covering results and any failures. " +
283
+ "Do not spawn or resume agents solely to summarize.\nAgents: " +
284
+ [...new Set(notifications.map((notification) => notification.details.path))].join(
285
+ ", ",
286
+ ),
287
+ display: false,
288
+ details: {
289
+ mailboxIds: notifications.map((notification) => notification.details.mailboxId),
290
+ },
291
+ },
292
+ { triggerTurn: true },
293
+ );
228
294
  } catch (error) {
229
295
  summaryRunning = false;
230
296
  warnDelivery(error);
@@ -278,7 +344,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
278
344
  content: message,
279
345
  // Foreground results already handled by a busy main thread do not need
280
346
  // another turn. Completions during a summary do need a follow-up summary.
281
- finalRecap: limits.finalRecap && !suspendingSummaries && event.kind === "settled" &&
347
+ finalRecap:
348
+ limits.finalRecap &&
349
+ !suspendingSummaries &&
350
+ event.kind === "settled" &&
282
351
  (thread.state === "completed" || thread.state === "failed") &&
283
352
  (requireContext().isIdle() || summaryRunning),
284
353
  details: {
@@ -335,8 +404,14 @@ export default function piSubagent(pi: ExtensionAPI): void {
335
404
  const refreshWidget = (ctx: ExtensionContext) => {
336
405
  if (limits.subagentMode === "off") {
337
406
  if (ctx.hasUI) ctx.ui.setWidget("pi-subagent", undefined);
338
- } else updateWidget(ctx, widgetThreads(), widgetCollapsed ? "minimal" : limits.widgetMode,
339
- limits.nerdFontIcons, rootTurnEnded);
407
+ } else
408
+ updateWidget(
409
+ ctx,
410
+ widgetThreads(),
411
+ widgetCollapsed ? "minimal" : limits.widgetMode,
412
+ limits.nerdFontIcons,
413
+ rootTurnEnded,
414
+ );
340
415
  };
341
416
  pi.on("agent_start", async (_event, ctx) => {
342
417
  // A new task must not resurrect the previous task's settled agents. Automatic
@@ -356,7 +431,7 @@ export default function piSubagent(pi: ExtensionAPI): void {
356
431
  // A migration can span clarification turns. Reload after each root turn, without converting files.
357
432
  if (limits.subagentMode === "off" || !migrationRequested) return;
358
433
  store.reload();
359
- if (store.diagnostics.length) context?.ui.notify(store.diagnostics.join("\n"), "warning");
434
+ if (store.diagnostics.length > 0) context?.ui.notify(store.diagnostics.join("\n"), "warning");
360
435
  });
361
436
  syncTools();
362
437
  pi.on("before_agent_start", async (event, ctx) => {
@@ -369,12 +444,17 @@ export default function piSubagent(pi: ExtensionAPI): void {
369
444
  ctx.ui.notify(`Could not save import onboarding state: ${String(error)}`, "warning");
370
445
  }
371
446
  }
372
- return {
373
- systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
374
- };
447
+ // A section, not a returned `systemPrompt`: returning one makes pi force the whole prompt as
448
+ // the head of every request, so any change to our state section would miss the prompt cache.
449
+ event.systemPromptOptions.sections["pi-subagent"] = prompt;
450
+ return undefined;
375
451
  });
376
452
 
377
- const attachSession = async (ctx: ExtensionContext, installEditor = false, seedHistory = true) => {
453
+ const attachSession = async (
454
+ ctx: ExtensionContext,
455
+ installEditor = false,
456
+ seedHistory = true,
457
+ ) => {
378
458
  cancelSummary();
379
459
  const token = ++generation;
380
460
  closeNavigation(installEditor);
@@ -437,7 +517,7 @@ export default function piSubagent(pi: ExtensionAPI): void {
437
517
  restoreRootMailbox(ctx);
438
518
  refreshWidget(ctx);
439
519
  const diagnostics = [...store.diagnostics, ...settingsDiagnostics];
440
- if (limits.subagentMode !== "off" && diagnostics.length)
520
+ if (limits.subagentMode !== "off" && diagnostics.length > 0)
441
521
  ctx.ui.notify(diagnostics.join("\n"), "warning");
442
522
  };
443
523
  pi.on("session_start", async (event, ctx) => {
@@ -511,10 +591,11 @@ export default function piSubagent(pi: ExtensionAPI): void {
511
591
  refreshWidget(ctx);
512
592
  restoreRootMailbox(ctx);
513
593
  ctx.ui.notify(
514
- diagnostics.length
594
+ diagnostics.length > 0
515
595
  ? diagnostics.join("\n")
516
596
  : `Loaded ${store.list().length} agent types; maximum ${limits.maxLevels} levels`,
517
- diagnostics.length ? "warning" : "info",
597
+
598
+ diagnostics.length > 0 ? "warning" : "info",
518
599
  );
519
600
  } else if (!command || command === "settings") {
520
601
  await configureAgents(ctx, {
@@ -527,13 +608,23 @@ export default function piSubagent(pi: ExtensionAPI): void {
527
608
  syncTools();
528
609
  refreshWidget(ctx);
529
610
  restoreRootMailbox(ctx);
530
- if (diagnostics.length) ctx.ui.notify(diagnostics.join("\n"), "warning");
611
+ if (diagnostics.length > 0) ctx.ui.notify(diagnostics.join("\n"), "warning");
531
612
  },
532
613
  });
533
614
  } else if (command === "status") {
534
- await showAgentStatus(ctx, requireManager().scope("/root"), undefined, limits.nerdFontIcons);
615
+ await showAgentStatus(
616
+ ctx,
617
+ requireManager().scope("/root"),
618
+ undefined,
619
+ limits.nerdFontIcons,
620
+ );
535
621
  } else if (command === "tree") {
536
- await navigation.open(ctx, requireManager().scope("/root"), rest.join(" ") || undefined, limits.nerdFontIcons);
622
+ await navigation.open(
623
+ ctx,
624
+ requireManager().scope("/root"),
625
+ rest.join(" ") || undefined,
626
+ limits.nerdFontIcons,
627
+ );
537
628
  } else
538
629
  ctx.ui.notify(
539
630
  "Usage: /agents [tree [path] | status | settings | types | import | reload]",