pi-matt-pocock 0.2.2 → 0.3.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.
Files changed (70) hide show
  1. package/ARCHITECTURE.zh-CN.md +37 -19
  2. package/README.md +80 -13
  3. package/package.json +12 -7
  4. package/procedures/code-review.md +25 -17
  5. package/procedures/codebase-design.md +11 -10
  6. package/procedures/deslop.md +4 -2
  7. package/procedures/diagnosing-bugs.md +25 -25
  8. package/procedures/domain-modeling.md +18 -20
  9. package/procedures/grill-me.md +1 -1
  10. package/procedures/grill-with-docs.md +1 -1
  11. package/procedures/grilling.md +29 -5
  12. package/procedures/handoff.md +1 -1
  13. package/procedures/implement.md +7 -1
  14. package/procedures/improve-codebase-architecture.md +22 -22
  15. package/procedures/pr.md +169 -0
  16. package/procedures/prototype.md +7 -6
  17. package/procedures/research.md +2 -2
  18. package/procedures/retro.md +42 -0
  19. package/procedures/setup-matt-pocock-skills.md +26 -22
  20. package/procedures/tdd.md +17 -15
  21. package/procedures/teach.md +11 -7
  22. package/procedures/to-questionnaire.md +7 -5
  23. package/procedures/to-spec.md +2 -2
  24. package/procedures/to-tickets.md +12 -12
  25. package/procedures/triage.md +22 -22
  26. package/procedures/wait-what.md +1 -1
  27. package/procedures/wayfinder.md +24 -24
  28. package/procedures/wizard.md +7 -7
  29. package/procedures/writing-for-agents.md +14 -14
  30. package/{procedures → references}/ADR-FORMAT.md +11 -11
  31. package/{procedures → references}/AGENT-BRIEF.md +4 -4
  32. package/{procedures → references}/DEEPENING.md +3 -3
  33. package/{procedures → references}/DESIGN-IT-TWICE.md +7 -7
  34. package/{procedures → references}/GLOSSARY-FORMAT.md +9 -2
  35. package/{procedures → references}/HTML-REPORT.md +15 -15
  36. package/{procedures → references}/LEARNING-RECORD-FORMAT.md +10 -10
  37. package/{procedures → references}/LOGIC.md +5 -5
  38. package/{procedures → references}/MISSION-FORMAT.md +2 -2
  39. package/{procedures → references}/OUT-OF-SCOPE.md +11 -11
  40. package/{procedures → references}/RESOURCES-FORMAT.md +1 -1
  41. package/{procedures → references}/SKILL-MECHANICS.md +3 -3
  42. package/{procedures → references}/UI.md +14 -14
  43. package/{procedures → references}/bdd-best-practices.md +3 -3
  44. package/{procedures → references}/bdd.md +9 -9
  45. package/{procedures → references}/domain.md +12 -12
  46. package/references/issue-tracker-github.md +50 -0
  47. package/{procedures → references}/issue-tracker-gitlab.md +9 -9
  48. package/{procedures → references}/issue-tracker-local.md +3 -3
  49. package/{procedures → references}/phase-boundaries.md +8 -8
  50. package/src/catalog.json +49 -28
  51. package/src/catalog.ts +58 -2
  52. package/src/index.ts +521 -383
  53. package/src/native-dialog.ts +149 -213
  54. package/src/resolver.ts +30 -14
  55. package/src/workflow.ts +55 -16
  56. package/upstream/UPSTREAM.md +116 -0
  57. package/{scripts → upstream}/check-upstream-sync.mjs +12 -12
  58. package/{upstream-selection.json → upstream/upstream-selection.json} +9 -7
  59. package/UPSTREAM.md +0 -63
  60. package/procedures/CONTEXT-FORMAT.md +0 -60
  61. package/procedures/issue-tracker-github.md +0 -45
  62. /package/{procedures → assets}/hitl-loop.template.sh +0 -0
  63. /package/{procedures → assets}/template.sh +0 -0
  64. /package/{procedures → references}/GLOSSARY.md +0 -0
  65. /package/{procedures → references}/gherkin-guide.md +0 -0
  66. /package/{procedures → references}/mocking.md +0 -0
  67. /package/{procedures → references}/testing-anti-patterns.md +0 -0
  68. /package/{procedures → references}/tests.md +0 -0
  69. /package/{procedures → references}/triage-labels.md +0 -0
  70. /package/{procedures → references}/writing-great-skills.md +0 -0
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  fieldLine,
14
14
  notifyPi,
15
15
  safeDisplayText,
16
+ setOwnedTools,
16
17
  startedToolLifecycle,
17
18
  } from "@fradser/pi-kit";
18
19
  import {
@@ -20,12 +21,15 @@ import {
20
21
  loadConfig,
21
22
  macosChooseFromList,
22
23
  macosInputDialog,
24
+ type MacosChooseFromListResult,
23
25
  } from "./native-dialog.ts";
24
- import { Type } from "typebox";
26
+ import { Type, type Static } from "typebox";
25
27
  import {
26
28
  findProcedure,
27
29
  modelStandaloneCapabilities,
28
30
  standaloneCapabilities,
31
+ userStandaloneCapabilities,
32
+ validateProcedureCatalog,
29
33
  workflowDefinitions,
30
34
  workflowPlacement,
31
35
  } from "./catalog.ts";
@@ -56,18 +60,26 @@ import {
56
60
  type WorkflowState,
57
61
  } from "./workflow.ts";
58
62
 
63
+ /** Tools activated only while a workflow is active. */
59
64
  const ACTIVE_TOOLS = ["matt_pocock_active", "matt_pocock_ask"] as const;
65
+ /** Tool ids this extension registers. The gateway is unconditional: every routing
66
+ * prompt names it, so a session must never be left without it. */
67
+ const OWNED_TOOLS = ["matt_pocock_workflow", ...ACTIVE_TOOLS] as const;
60
68
  const PROCEDURE_ENTRY = "matt-pocock-procedure";
61
69
 
62
70
  /** Session details for one delivered procedure: a workflow state, a standalone capability, and the user's own task. */
63
71
  type ProcedureDetails = Partial<WorkflowState> & { mode?: string; capability?: string; request?: string };
64
72
 
73
+ function readableProcedureTitle(id: string): string {
74
+ return findProcedure(id)?.label ?? id;
75
+ }
76
+
65
77
  /** The block body: the user's own task when given, otherwise what they started. */
66
78
  function procedureSubject(details: ProcedureDetails): string {
67
79
  const request = details.request?.trim();
68
80
  if (request) return request;
69
81
  if (details.phase) return readablePhaseTitle(details.phase);
70
- return details.capability ?? "";
82
+ return details.capability ? readableProcedureTitle(details.capability) : "";
71
83
  }
72
84
 
73
85
  function stringEnum(values: string[], options?: Record<string, unknown>) {
@@ -77,25 +89,46 @@ function stringEnum(values: string[], options?: Record<string, unknown>) {
77
89
  function workflowGatewayParameters() {
78
90
  const routes = workflowRoutes().map((route) => route.route);
79
91
  const capabilities = modelStandaloneCapabilities().map((capability) => capability.id);
80
- return Type.Union([
92
+ const userCapabilities = userStandaloneCapabilities().map((capability) => capability.id);
93
+ const branches = Type.Union([
81
94
  Type.Object({
82
95
  mode: Type.Literal("workflow", { description: "Start a persisted multi-step engineering workflow." }),
83
96
  route: stringEnum(routes, { description: "Workflow route to start." }),
84
- }),
97
+ }, { additionalProperties: false }),
85
98
  Type.Object({
86
99
  mode: Type.Literal("capability", { description: "Run a curated standalone capability without persistent workflow state." }),
87
100
  capability: stringEnum(capabilities, { description: "Standalone capability to run." }),
88
- }),
101
+ }, { additionalProperties: false }),
102
+ Type.Object({
103
+ mode: Type.Literal("user-capability", { description: "Run a user-invoked standalone capability the user explicitly asked for." }),
104
+ capability: stringEnum(userCapabilities, { description: "User-invoked capability the user requested by name." }),
105
+ }, { additionalProperties: false }),
89
106
  Type.Object({
90
107
  mode: Type.Literal("reference", { description: "Load a reference disclosed by a standalone capability." }),
91
108
  capability: Type.String({ description: "Standalone capability id from the injected capability context." }),
92
109
  reference: Type.String({ description: "Disclosed reference id returned by the capability." }),
93
- }),
110
+ }, { additionalProperties: false }),
94
111
  ]);
112
+ // Keep exact branch validation and static narrowing while exposing every
113
+ // operation to providers that inspect root properties before union branches.
114
+ // Nullable hints prevent Pi v1 from dropping optional nulls before the original
115
+ // branches perform their existing coercion/validation. They add no valid calls.
116
+ return Type.Unsafe<Static<typeof branches>>({
117
+ type: "object",
118
+ properties: {
119
+ mode: stringEnum(["workflow", "capability", "user-capability", "reference"]),
120
+ route: Type.Union([stringEnum(routes), Type.Null()]),
121
+ capability: Type.Union([Type.String(), Type.Null()]),
122
+ reference: Type.Union([Type.String(), Type.Null()]),
123
+ },
124
+ required: ["mode"],
125
+ additionalProperties: false,
126
+ anyOf: branches.anyOf,
127
+ });
95
128
  }
96
129
 
97
130
  function activeWorkflowParameters() {
98
- return Type.Union([
131
+ const branches = Type.Union([
99
132
  Type.Object({
100
133
  action: Type.Literal("transition"),
101
134
  target: Type.String({ description: "One procedure id from the current state's allowedNext list." }),
@@ -110,6 +143,18 @@ function activeWorkflowParameters() {
110
143
  reason: Type.String({ description: "Why this workflow is being cancelled." }),
111
144
  }, { additionalProperties: false }),
112
145
  ]);
146
+ return Type.Unsafe<Static<typeof branches>>({
147
+ type: "object",
148
+ properties: {
149
+ action: stringEnum(["transition", "load", "complete", "cancel"]),
150
+ target: Type.Union([Type.String(), Type.Null()]),
151
+ reference: Type.Union([Type.String(), Type.Null()]),
152
+ reason: Type.Union([Type.String(), Type.Null()]),
153
+ },
154
+ required: ["action"],
155
+ additionalProperties: false,
156
+ anyOf: branches.anyOf,
157
+ });
113
158
  }
114
159
 
115
160
  function workflowEventSubject(action: "transition" | "complete" | "cancel", phase: string): string {
@@ -117,251 +162,357 @@ function workflowEventSubject(action: "transition" | "complete" | "cancel", phas
117
162
  return action === "transition" ? title : `${title} ${action === "complete" ? "completed" : "cancelled"}`;
118
163
  }
119
164
 
120
- let pi: ExtensionAPI;
121
- let activeWorkflow: WorkflowState | undefined;
122
-
123
- function refreshActiveTools(active: boolean): void {
124
- if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
125
- const tools = pi.getActiveTools();
126
- const withoutActive = tools.filter((tool) => !ACTIVE_TOOLS.includes(tool as typeof ACTIVE_TOOLS[number]));
127
- pi.setActiveTools(active ? [...withoutActive, ...ACTIVE_TOOLS] : withoutActive);
165
+ /** Keep the selected decision explicit in model context without echoing the offered choices. */
166
+ function formatDecisionAnswer(question: string, answer: string, recommendation?: string): string {
167
+ const selected = answer === recommendation
168
+ ? answer.replace(/^\s*\(Recommended\)\s*|\s*\(Recommended\)\s*$/i, "")
169
+ : answer;
170
+ const quote = selected.split(/\r?\n/).map((line) => `> ${line}`).join("\n");
171
+ return `${question}\n\n${quote}`;
128
172
  }
129
173
 
130
- /** Deliver a procedure as one lifecycle row: users see their own task, the model sees the whole body. */
131
- function deliverProcedure(content: string, details: ProcedureDetails): void {
132
- pi.sendMessage({ customType: PROCEDURE_ENTRY, content, display: true, details }, { deliverAs: "followUp", triggerTurn: true });
174
+ /**
175
+ * A deadline dismisses one dialog. Its own timer sets the expiry flag, never a clock
176
+ * reading, so a cancel that arrives before the deadline stays a cancel.
177
+ */
178
+ async function askUntilDeadline<T>(
179
+ deadline: number, signal: AbortSignal | undefined, open: (dialogSignal: AbortSignal) => Promise<T>,
180
+ ): Promise<{ value: T; expired: boolean }> {
181
+ const controller = new AbortController();
182
+ const forward = () => controller.abort();
183
+ signal?.addEventListener("abort", forward, { once: true });
184
+ if (signal?.aborted) forward();
185
+ let expired = false;
186
+ const timer = setTimeout(() => {
187
+ if (!controller.signal.aborted) {
188
+ expired = true;
189
+ controller.abort();
190
+ }
191
+ }, Math.max(0, deadline - Date.now()));
192
+ try {
193
+ return { value: await open(controller.signal), expired };
194
+ } finally {
195
+ clearTimeout(timer);
196
+ signal?.removeEventListener("abort", forward);
197
+ }
133
198
  }
134
199
 
135
- function persistWorkflow(state: WorkflowState | TerminalWorkflowState): void {
136
- pi.appendEntry(WORKFLOW_STATE_ENTRY, state);
137
- activeWorkflow = state.status === "active" ? state : undefined;
138
- refreshActiveTools(state.status === "active");
200
+ /** The deadline timer, not a clock reading, dismisses the selector and reports the expiry. */
201
+ async function selectDecision(
202
+ ctx: ExtensionContext, question: string, choices: string[], deadline: number, budget: number, signal?: AbortSignal,
203
+ ): Promise<MacosChooseFromListResult> {
204
+ const { value, expired } = await askUntilDeadline(deadline, signal, (dialogSignal) =>
205
+ ctx.ui.select(question, choices, { signal: dialogSignal, timeout: budget }));
206
+ if (value !== undefined) return { action: "selected", item: value };
207
+ return { action: expired ? "timed_out" : "cancelled" };
139
208
  }
140
209
 
141
- function terminalState(status: "completed" | "cancelled", reason?: string): TerminalWorkflowState {
142
- if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
143
- return {
144
- version: WORKFLOW_STATE_VERSION,
145
- workItemId: activeWorkflow.workItemId,
146
- route: activeWorkflow.route,
147
- procedure: activeWorkflow.procedure,
148
- phase: activeWorkflow.phase,
149
- status,
150
- reason,
151
- };
152
- }
210
+ export default function mattPocock(extensionApi: ExtensionAPI): void {
211
+ const errors = validateProcedureCatalog();
212
+ if (errors.length > 0) throw new Error(`Invalid Matt Pocock catalog: ${errors.join("; ")}`);
213
+ const pi = extensionApi;
214
+ const standaloneReferences = new Map<string, string[]>();
215
+ const pendingUI = new Set<AbortController>();
216
+ let activeWorkflow: WorkflowState | undefined;
217
+
218
+ function interruptPendingUI(): void {
219
+ const controllers = [...pendingUI];
220
+ pendingUI.clear();
221
+ for (const controller of controllers) controller.abort();
222
+ }
153
223
 
154
- function availableReferences(state: WorkflowState): string[] {
155
- return resolveWorkflowContext(state.procedure, state.loadedReferences).availableReferences;
156
- }
224
+ async function withWorkflowSignal<T>(signal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
225
+ const controller = new AbortController();
226
+ const abort = () => controller.abort();
227
+ signal?.addEventListener("abort", abort, { once: true });
228
+ if (signal?.aborted) abort();
229
+ pendingUI.add(controller);
230
+ try {
231
+ return await run(controller.signal);
232
+ } finally {
233
+ pendingUI.delete(controller);
234
+ signal?.removeEventListener("abort", abort);
235
+ }
236
+ }
157
237
 
158
- function stateContract(state: WorkflowState, action: string): string {
159
- const placement = workflowPlacement(state.route, state.procedure);
160
- return `\n\n## Workflow state contract\n- Addressed: ${state.workItemId} · ${state.route} · ${state.procedure}\n- Synchronous action: ${action}\n- Allowed next: ${placement?.allowedNext.join(", ") || "none"}\n- Available references: ${availableReferences(state).join(", ") || "none"}\n- Pending: workflow remains active\n- Next actor: agent`;
161
- }
238
+ async function selectWorkflowOption(
239
+ ctx: ExtensionCommandContext,
240
+ title: string,
241
+ choices: string[],
242
+ apply: (choice: string) => void | Promise<void>,
243
+ ): Promise<void> {
244
+ const workflow = activeWorkflow;
245
+ await withWorkflowSignal(undefined, async (signal) => {
246
+ const choice = await ctx.ui.select(title, choices, { signal });
247
+ if (!choice || signal.aborted || activeWorkflow !== workflow) return;
248
+ // Invoke the action before yielding: another await would let a replacement
249
+ // workflow start between this identity check and the selected action.
250
+ await apply(choice);
251
+ });
252
+ }
162
253
 
163
- /** Delivered to the model, never rendered: the user's own words belong in the prompt. */
164
- function userRequestSection(request: string): string {
165
- return request ? `\n\nUser target/request:\n${request}` : "";
166
- }
254
+ function refreshActiveTools(active: boolean): void {
255
+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
256
+ // The gateway is re-asserted on every write, never read back from the snapshot:
257
+ // /matt-pocock delivers a routing prompt that names it, so a session that lost
258
+ // it would instruct the model to call a tool it cannot see. See setOwnedTools.
259
+ setOwnedTools(pi, { owned: OWNED_TOOLS, always: ["matt_pocock_workflow"], toggled: ACTIVE_TOOLS, enabled: active });
260
+ }
167
261
 
168
- /** Delivered when a procedure's body is already in the session, so the prompt is never empty. */
169
- const ALREADY_DELIVERED = "Its instructions were delivered earlier in this session and are unchanged. Continue against them; do not re-request them.";
262
+ /** Deliver a procedure as one lifecycle row: users see their own task, the model sees the whole body. */
263
+ function deliverProcedure(content: string, details: ProcedureDetails): void {
264
+ pi.sendMessage({ customType: PROCEDURE_ENTRY, content, display: true, details }, { deliverAs: "followUp", triggerTurn: true });
265
+ }
170
266
 
171
- /**
172
- * The prompt for a workflow step, plus the state that records what it delivered.
173
- *
174
- * Only procedures this session has not been shown are embedded. A transition re-delivers
175
- * the active context every time, and a bundle carries its whole dependency closure, so
176
- * re-sending that closure would spend the context the work needs and end a long task early
177
- * for want of room rather than for want of work.
178
- */
179
- function deliveryStep(state: WorkflowState, action: string, request = "", full = false): { text: string; state: WorkflowState } {
180
- const delta = resolveProcedureDelta(
181
- state.procedure,
182
- state.loadedReferences,
183
- full ? [] : state.deliveredProcedures,
184
- );
185
- const next: WorkflowState = { ...state, deliveredProcedures: delta.delivered };
186
- const body = delta.content ? delta.content : ALREADY_DELIVERED;
187
- return {
188
- text: `# Matt Pocock workflow procedure\n\nRoute: ${next.route}\nPhase: ${next.phase}\nWork item: ${next.workItemId}\n\n${body}${userRequestSection(request)}${stateContract(next, action)}`,
189
- state: next,
190
- };
191
- }
267
+ function persistWorkflow(state: WorkflowState | TerminalWorkflowState): void {
268
+ pi.appendEntry(WORKFLOW_STATE_ENTRY, state);
269
+ activeWorkflow = state.status === "active" ? state : undefined;
270
+ interruptPendingUI();
271
+ refreshActiveTools(state.status === "active");
272
+ }
192
273
 
193
- function standalonePrompt(capability: string, request = ""): string {
194
- const definition = findProcedure(capability);
195
- if (!definition?.standalone) throw new Error(`Unknown standalone Matt Pocock capability: ${capability}`);
196
- const bundle = resolveProcedureBundle(definition.id);
197
- return `# Matt Pocock standalone capability\n\nCapability: ${definition.id}\nPersistent workflow state: none\nAvailable references: ${bundle.availableReferences.join(", ") || "none"}\n\n${bundle.content}${userRequestSection(request)}`;
198
- }
274
+ function terminalState(status: "completed" | "cancelled", reason?: string): TerminalWorkflowState {
275
+ if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
276
+ return {
277
+ version: WORKFLOW_STATE_VERSION,
278
+ workItemId: activeWorkflow.workItemId,
279
+ route: activeWorkflow.route,
280
+ procedure: activeWorkflow.procedure,
281
+ phase: activeWorkflow.phase,
282
+ status,
283
+ reason,
284
+ };
285
+ }
199
286
 
200
- function standaloneReferencePrompt(capability: string, reference: string): string {
201
- const definition = findProcedure(capability);
202
- if (!definition?.standalone) {
203
- throw new Error(`Capability ${capability} is not a standalone Matt Pocock capability.`);
287
+ function availableReferences(state: WorkflowState): string[] {
288
+ return resolveWorkflowContext(state.procedure, state.loadedReferences).availableReferences;
204
289
  }
205
- const bundle = resolveAccessibleReference(definition.id, [], reference);
206
- return `# Matt Pocock standalone reference\n\nCapability: ${definition.id}\nReference: ${bundle.root}\nPersistent workflow state: none\nAvailable references: ${bundle.availableReferences.join(", ") || "none"}\n\n${bundle.content}`;
207
- }
208
290
 
209
- /**
210
- * Begin a workflow and persist its state exactly once, carrying the delivery set
211
- * this first step shows. A second entry would read as two workflows begun, and a
212
- * persisted state without the set would re-deliver the whole closure on restore.
213
- */
214
- function startWorkflow(route: string, request = ""): { state: WorkflowState; text: string } {
215
- if (!findWorkflowRoute(route)) throw new Error(`Unknown Matt Pocock route: ${route}`);
216
- const created = routeEntryState(route, randomUUID());
217
- if (!created) throw new Error(`Workflow ${route} has no catalog entry procedure.`);
218
- resolveProcedureBundle(created.procedure);
219
- const step = deliveryStep(created, "started workflow", request);
220
- persistWorkflow(step.state);
221
- return step;
222
- }
291
+ function stateContract(state: WorkflowState, action: string, references: string[]): string {
292
+ const placement = workflowPlacement(state.route, state.procedure);
293
+ const calls = [
294
+ ...(placement?.allowedNext ?? []).map((target) => JSON.stringify({ action: "transition", target })),
295
+ ...references.map((reference) => JSON.stringify({ action: "load", reference })),
296
+ JSON.stringify({ action: "complete" }),
297
+ JSON.stringify({ action: "cancel", reason: "<why>" }),
298
+ ];
299
+ return `\n\n## Workflow state contract\n- Addressed: ${state.workItemId} · ${state.route} · ${state.procedure}\n- Synchronous action: ${action}\n- Allowed next: ${placement?.allowedNext.join(", ") || "none"}\n- Available references: ${references.join(", ") || "none"}\n- Pending: workflow remains active\n- Next actor: agent\n- Exact matt_pocock_active calls: ${calls.join("; ") || "none"}\n- Failed controls do not change the current procedure. Follow its returned state; do not act as if a failed transition succeeded.`;
300
+ }
223
301
 
224
- function loadActiveReference(reference: string): { state: WorkflowState; content: string } {
225
- if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
226
- const bundle = resolveAccessibleReference(activeWorkflow.procedure, activeWorkflow.loadedReferences, reference);
227
- const loadedReferences = [...new Set([...activeWorkflow.loadedReferences, ...bundle.loaded])];
228
- // The reference's own closure is deduplicated too: a reference whose body is already in
229
- // the session does not need to arrive twice.
230
- const delta = resolveProcedureDelta(reference, [], activeWorkflow.deliveredProcedures);
231
- const state: WorkflowState = { ...activeWorkflow, loadedReferences, deliveredProcedures: delta.delivered };
232
- persistWorkflow(state);
233
- return {
234
- state,
235
- content: `# Matt Pocock workflow reference\n\nWork item: ${state.workItemId}\nReference: ${bundle.root}\n\n${delta.content || ALREADY_DELIVERED}${stateContract(state, `loaded reference ${bundle.root}`)}`,
236
- };
237
- }
302
+ /** Delivered to the model, never rendered: the user's own words belong in the prompt. */
303
+ function userRequestSection(request: string): string {
304
+ return request ? `\n\nUser target/request:\n${request}` : "";
305
+ }
238
306
 
239
- function routeChoices(): string[] {
240
- return workflowRoutes().map((route) => `${route.label} — ${route.description}`);
241
- }
307
+ /** Delivered when a procedure's body is already in the session, so the prompt is never empty. */
308
+ const ALREADY_DELIVERED = "Its instructions were delivered earlier in this session and are unchanged. Continue against them; do not re-request them.";
309
+
310
+ /**
311
+ * The prompt for a workflow step, plus the state that records what it delivered.
312
+ *
313
+ * Only procedures this session has not been shown are embedded. A transition re-delivers
314
+ * the active context every time, and a bundle carries its whole dependency closure, so
315
+ * re-sending that closure would spend the context the work needs and end a long task early
316
+ * for want of room rather than for want of work.
317
+ */
318
+ function deliveryStep(state: WorkflowState, action: string, request = "", full = false): { text: string; state: WorkflowState } {
319
+ const delta = resolveProcedureDelta(
320
+ state.procedure,
321
+ state.loadedReferences,
322
+ full ? [] : state.deliveredProcedures,
323
+ );
324
+ const next: WorkflowState = { ...state, deliveredProcedures: delta.delivered };
325
+ const body = delta.content ? delta.content : ALREADY_DELIVERED;
326
+ return {
327
+ text: `# Matt Pocock workflow procedure\n\nRoute: ${next.route}\nPhase: ${next.phase}\nWork item: ${next.workItemId}\n\n${body}${userRequestSection(request)}${stateContract(next, action, delta.availableReferences)}`,
328
+ state: next,
329
+ };
330
+ }
242
331
 
243
- function routeFromChoice(choice: string): string | undefined {
244
- return workflowRoutes().find((route) => choice.startsWith(route.label))?.route;
245
- }
332
+ function standalonePrompt(capability: string, request = ""): string {
333
+ const definition = findProcedure(capability);
334
+ if (!definition?.standalone) throw new Error(`Unknown standalone Matt Pocock capability: ${capability}`);
335
+ const bundle = resolveProcedureBundle(definition.id);
336
+ standaloneReferences.set(definition.id, []);
337
+ return `# Matt Pocock standalone capability\n\nCapability: ${definition.id}\nPersistent workflow state: none\nAvailable references: ${bundle.availableReferences.join(", ") || "none"}\nStandalone decisions: ask the user in conversation; workflow-only tools do not belong to this capability.\n\n${bundle.content}${userRequestSection(request)}`;
338
+ }
246
339
 
247
- function capabilityChoices(): string[] {
248
- return standaloneCapabilities().map((capability) => `${capability.label} — ${capability.description ?? capability.id}`);
249
- }
340
+ function standaloneReferencePrompt(capability: string, reference: string): string {
341
+ const definition = findProcedure(capability);
342
+ if (!definition?.standalone) {
343
+ throw new Error(`Capability ${capability} is not a standalone Matt Pocock capability.`);
344
+ }
345
+ const previous = standaloneReferences.get(definition.id) ?? [];
346
+ const bundle = resolveAccessibleReference(definition.id, previous, reference);
347
+ const loaded = [...new Set([...previous, ...bundle.loaded])];
348
+ const context = resolveWorkflowContext(definition.id, loaded);
349
+ standaloneReferences.set(definition.id, loaded);
350
+ return `# Matt Pocock standalone reference\n\nCapability: ${definition.id}\nReference: ${bundle.root}\nPersistent workflow state: none\nAvailable references: ${context.availableReferences.join(", ") || "none"}\n\n${bundle.content}`;
351
+ }
250
352
 
251
- function capabilityFromChoice(choice: string): string | undefined {
252
- return standaloneCapabilities().find((capability) => choice.startsWith(`${capability.label} —`))?.id;
253
- }
353
+ /**
354
+ * Begin a workflow and persist its state exactly once, carrying the delivery set
355
+ * this first step shows. A second entry would read as two workflows begun, and a
356
+ * persisted state without the set would re-deliver the whole closure on restore.
357
+ */
358
+ function startWorkflow(route: string, request = ""): { state: WorkflowState; text: string } {
359
+ if (activeWorkflow) {
360
+ throw new Error(`Workflow ${activeWorkflow.workItemId} is already active. Complete or cancel it before starting another.`);
361
+ }
362
+ if (!findWorkflowRoute(route)) throw new Error(`Unknown Matt Pocock route: ${route}`);
363
+ const created = routeEntryState(route, randomUUID());
364
+ if (!created) throw new Error(`Workflow ${route} has no catalog entry procedure.`);
365
+ const step = deliveryStep(created, "started workflow", request);
366
+ persistWorkflow(step.state);
367
+ return step;
368
+ }
254
369
 
255
- async function chooseRoute(ctx: ExtensionCommandContext): Promise<void> {
256
- const choice = await ctx.ui.select("Start Matt Pocock workflow", routeChoices());
257
- if (!choice) return;
258
- const route = routeFromChoice(choice);
259
- if (!route) return;
260
- const step = startWorkflow(route);
261
- clearPiStatus(ctx.ui, "matt-pocock");
262
- deliverProcedure(step.text, { ...step.state, request: "" });
263
- }
370
+ function loadActiveReference(reference: string): { state: WorkflowState; content: string } {
371
+ if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
372
+ const bundle = resolveAccessibleReference(activeWorkflow.procedure, activeWorkflow.loadedReferences, reference);
373
+ const loadedReferences = [...new Set([...activeWorkflow.loadedReferences, ...bundle.loaded])];
374
+ // The reference's own closure is deduplicated too: a reference whose body is already in
375
+ // the session does not need to arrive twice.
376
+ const delta = resolveProcedureDelta(activeWorkflow.procedure, loadedReferences, activeWorkflow.deliveredProcedures);
377
+ const state: WorkflowState = { ...activeWorkflow, loadedReferences, deliveredProcedures: delta.delivered };
378
+ persistWorkflow(state);
379
+ return {
380
+ state,
381
+ content: `# Matt Pocock workflow reference\n\nWork item: ${state.workItemId}\nReference: ${bundle.root}\n\n${delta.content || ALREADY_DELIVERED}${stateContract(state, `loaded reference ${bundle.root}`, delta.availableReferences)}`,
382
+ };
383
+ }
264
384
 
265
- async function chooseCapability(ctx: ExtensionCommandContext): Promise<void> {
266
- const choice = await ctx.ui.select("Run Matt Pocock capability", capabilityChoices());
267
- if (!choice) return;
268
- const capability = capabilityFromChoice(choice);
269
- if (capability) deliverProcedure(standalonePrompt(capability), { mode: "capability", capability, request: "" });
270
- }
385
+ function routeChoices(): string[] {
386
+ return workflowRoutes().map((route) => `${route.label} — ${route.description}`);
387
+ }
271
388
 
272
- /** Apply one catalog-legal transition, whether chosen from the menu or named on the command. */
273
- function applyTransition(ctx: ExtensionCommandContext, target: string): void {
274
- if (!activeWorkflow) {
275
- notifyPi(ctx.ui, "No active Matt Pocock workflow.", "warning");
276
- return;
389
+ function routeFromChoice(choice: string): string | undefined {
390
+ return workflowRoutes().find((route) => choice.startsWith(route.label))?.route;
277
391
  }
278
- const state = transitionState(activeWorkflow, target);
279
- resolveProcedureBundle(state.procedure);
280
- const step = deliveryStep(state, `transitioned to ${state.procedure}`);
281
- persistWorkflow(step.state);
282
- deliverProcedure(step.text, { ...step.state, request: "" });
283
- }
284
392
 
285
- async function chooseTransition(ctx: ExtensionCommandContext): Promise<void> {
286
- if (!activeWorkflow) {
287
- notifyPi(ctx.ui, "No active Matt Pocock workflow.", "warning");
288
- return;
393
+ function capabilityChoices(): string[] {
394
+ return standaloneCapabilities().map((capability) => `${capability.label} — ${capability.description ?? capability.id}`);
289
395
  }
290
- const next = workflowPlacement(activeWorkflow.route, activeWorkflow.procedure)?.allowedNext ?? [];
291
- if (next.length === 0) {
292
- notifyPi(ctx.ui, "This workflow has no next procedure. Complete or cancel it.", "info");
293
- return;
396
+
397
+ function capabilityFromChoice(choice: string): string | undefined {
398
+ return standaloneCapabilities().find((capability) => choice.startsWith(`${capability.label} —`))?.id;
294
399
  }
295
- const choice = await ctx.ui.select(`Transition ${activeWorkflow.route} from ${activeWorkflow.procedure}`, next);
296
- if (!choice) return;
297
- applyTransition(ctx, choice);
298
- }
299
400
 
300
- function completeWorkflow(ctx: ExtensionContext): void {
301
- const terminal = terminalState("completed");
302
- persistWorkflow(terminal);
303
- clearPiStatus(ctx.ui, "matt-pocock");
304
- notifyPi(ctx.ui, `Matt Pocock workflow completed: ${terminal.workItemId}`, "info");
305
- }
401
+ async function chooseRoute(ctx: ExtensionCommandContext): Promise<void> {
402
+ await selectWorkflowOption(ctx, "Start Matt Pocock workflow", routeChoices(), (choice) => {
403
+ const route = routeFromChoice(choice);
404
+ if (!route) return;
405
+ const step = startWorkflow(route);
406
+ clearPiStatus(ctx.ui, "matt-pocock");
407
+ deliverProcedure(step.text, { ...step.state, request: "" });
408
+ });
409
+ }
306
410
 
307
- function cancelWorkflow(ctx: ExtensionContext, reason: string): void {
308
- const terminal = terminalState("cancelled", reason);
309
- persistWorkflow(terminal);
310
- clearPiStatus(ctx.ui, "matt-pocock");
311
- notifyPi(ctx.ui, `Matt Pocock workflow cancelled: ${terminal.workItemId}`, "info");
312
- }
411
+ async function chooseCapability(ctx: ExtensionCommandContext): Promise<void> {
412
+ await selectWorkflowOption(ctx, "Run Matt Pocock capability", capabilityChoices(), (choice) => {
413
+ const capability = capabilityFromChoice(choice);
414
+ if (capability) deliverProcedure(standalonePrompt(capability), { mode: "capability", capability, request: "" });
415
+ });
416
+ }
313
417
 
314
- function showStatus(ctx: ExtensionContext): void {
315
- if (!activeWorkflow) {
316
- notifyPi(ctx.ui, "Matt Pocock workflow: inactive", "info");
317
- return;
418
+ function transitionWorkflow(target: string): { state: WorkflowState; text: string } {
419
+ if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
420
+ const state = transitionState(activeWorkflow, target);
421
+ const step = deliveryStep(state, `transitioned to ${state.procedure}`);
422
+ persistWorkflow(step.state);
423
+ return step;
318
424
  }
319
- notifyPi(
320
- ctx.ui,
321
- `Matt Pocock workflow: ${formatReadableWorkflowSubject(activeWorkflow.route, activeWorkflow.phase)} · ${activeWorkflow.workItemId}`,
322
- "info",
323
- );
324
- }
325
425
 
326
- async function showMenu(ctx: ExtensionCommandContext): Promise<void> {
327
- const choices = activeWorkflow
328
- ? [START_TASK_CHOICE, "View current workflow", "Transition current workflow", "Complete current workflow", "Cancel current workflow", "Run a standalone capability"]
329
- : [START_TASK_CHOICE, "Start a workflow", "Run a standalone capability", "View current workflow"];
330
- const choice = await ctx.ui.select("Matt Pocock", choices);
331
- if (!choice) return;
332
- if (choice === START_TASK_CHOICE) return startFromContext(ctx);
333
- if (choice === "Start a workflow") return chooseRoute(ctx);
334
- if (choice === "Run a standalone capability") return chooseCapability(ctx);
335
- if (choice === "View current workflow") return showStatus(ctx);
336
- if (choice === "Transition current workflow") return chooseTransition(ctx);
337
- if (choice === "Complete current workflow") return completeWorkflow(ctx);
338
- cancelWorkflow(ctx, "Cancelled by user from the Matt Pocock menu.");
339
- }
426
+ /** Both command and tool transitions use the same validated state change. */
427
+ function applyTransition(ctx: ExtensionCommandContext, target: string): void {
428
+ if (!activeWorkflow) {
429
+ notifyPi(ctx.ui, "No active Matt Pocock workflow.", "warning");
430
+ return;
431
+ }
432
+ const step = transitionWorkflow(target);
433
+ deliverProcedure(step.text, { ...step.state, request: "" });
434
+ }
340
435
 
341
- const START_TASK_CHOICE = "Start a task";
436
+ async function chooseTransition(ctx: ExtensionCommandContext): Promise<void> {
437
+ if (!activeWorkflow) {
438
+ notifyPi(ctx.ui, "No active Matt Pocock workflow.", "warning");
439
+ return;
440
+ }
441
+ const { route, procedure } = activeWorkflow;
442
+ const next = workflowPlacement(route, procedure)?.allowedNext ?? [];
443
+ if (next.length === 0) {
444
+ notifyPi(ctx.ui, "This workflow has no next procedure. Complete or cancel it.", "info");
445
+ return;
446
+ }
447
+ const choices = next.map((id) => ({ id, label: readablePhaseTitle(workflowPlacement(route, id)!.phase) }));
448
+ await selectWorkflowOption(ctx, `Transition ${readableRouteTitle(activeWorkflow.route)} from ${readablePhaseTitle(activeWorkflow.phase)}`, choices.map((choice) => choice.label),
449
+ (label) => {
450
+ const target = choices.find((choice) => choice.label === label)?.id;
451
+ if (target) applyTransition(ctx, target);
452
+ });
453
+ }
342
454
 
343
- function contextRoutingPrompt(cancelledWorkflow: boolean): string {
344
- return `Infer the user's current task from recent conversation context and route and execute it through the relevant Matt Pocock workflow or standalone capability.\n\n${cancelledWorkflow ? "The previous active workflow has been cancelled because this is a new request. " : ""}Use matt_pocock_workflow with mode workflow for a structured multi-step engineering task, or mode capability for a matching standalone capability. If neither applies, handle the request normally. Begin immediately once the route is clear.`;
345
- }
455
+ function endWorkflow(ctx: ExtensionContext, status: "completed" | "cancelled", reason?: string): void {
456
+ const terminal = terminalState(status, reason);
457
+ persistWorkflow(terminal);
458
+ clearPiStatus(ctx.ui, "matt-pocock");
459
+ notifyPi(ctx.ui, `Matt Pocock workflow ${status}: ${terminal.workItemId}`, "info");
460
+ }
346
461
 
347
- function startFromContext(ctx: ExtensionCommandContext): void {
348
- const cancelledWorkflow = Boolean(activeWorkflow);
349
- if (activeWorkflow) {
350
- persistWorkflow(terminalState("cancelled", "Superseded by a new /matt-pocock context routing request."));
462
+ function showStatus(ctx: ExtensionContext): void {
463
+ if (!activeWorkflow) {
464
+ notifyPi(ctx.ui, "Matt Pocock workflow: inactive", "info");
465
+ return;
466
+ }
467
+ notifyPi(
468
+ ctx.ui,
469
+ `Matt Pocock workflow: ${formatReadableWorkflowSubject(activeWorkflow.route, activeWorkflow.phase)} · ${activeWorkflow.workItemId}`,
470
+ "info",
471
+ );
351
472
  }
352
- clearPiStatus(ctx.ui, "matt-pocock");
353
- deliverProcedure(contextRoutingPrompt(cancelledWorkflow), { request: "" });
354
- }
355
473
 
356
- function workflowRoutingPrompt(prompt: string, cancelledWorkflow: boolean): string {
357
- return `Route and execute this request through the relevant Matt Pocock workflow or standalone capability: ${prompt}\n\n${cancelledWorkflow ? "The previous active workflow has been cancelled because this is a new request. " : ""}Use matt_pocock_workflow with mode workflow for a structured multi-step engineering task, or mode capability for a matching standalone capability. If neither applies, handle the request normally. Begin immediately once the route is clear.`;
358
- }
474
+ async function showMenu(ctx: ExtensionCommandContext): Promise<void> {
475
+ const choices = activeWorkflow
476
+ ? [START_TASK_CHOICE, "View current workflow", "Transition current workflow", "Complete current workflow", "Cancel current workflow", "Run a standalone capability"]
477
+ : [START_TASK_CHOICE, "Start a workflow", "Run a standalone capability", "View current workflow"];
478
+ await selectWorkflowOption(ctx, "Matt Pocock", choices, (choice) => {
479
+ if (choice === START_TASK_CHOICE) return startFromContext(ctx);
480
+ if (choice === "Start a workflow") return chooseRoute(ctx);
481
+ if (choice === "Run a standalone capability") return chooseCapability(ctx);
482
+ if (choice === "View current workflow") return showStatus(ctx);
483
+ if (choice === "Transition current workflow") return chooseTransition(ctx);
484
+ if (choice === "Complete current workflow") return endWorkflow(ctx, "completed");
485
+ endWorkflow(ctx, "cancelled", "Cancelled by user from the Matt Pocock menu.");
486
+ });
487
+ }
359
488
 
360
- export default function mattPocock(extensionApi: ExtensionAPI): void {
361
- pi = extensionApi;
489
+ const START_TASK_CHOICE = "Start a task";
490
+
491
+ function startFromContext(ctx: ExtensionCommandContext): void {
492
+ const cancelledWorkflow = Boolean(activeWorkflow);
493
+ if (activeWorkflow) {
494
+ persistWorkflow(terminalState("cancelled", "Superseded by a new /matt-pocock context routing request."));
495
+ }
496
+ clearPiStatus(ctx.ui, "matt-pocock");
497
+ deliverProcedure(workflowRoutingPrompt("", cancelledWorkflow), { request: "" });
498
+ }
499
+
500
+ function workflowRoutingPrompt(prompt: string, cancelledWorkflow: boolean): string {
501
+ const task = prompt
502
+ ? `Route and execute this request through the relevant Matt Pocock workflow or standalone capability: ${prompt}`
503
+ : "Infer the user's current task from recent conversation context and route and execute it through the relevant Matt Pocock workflow or standalone capability.";
504
+ return `${task}\n\n${cancelledWorkflow ? "The previous active workflow has been cancelled because this is a new request. " : ""}Use matt_pocock_workflow with mode workflow for a structured multi-step engineering task, or mode capability for a matching standalone capability. If neither applies, handle the request normally. Begin immediately once the route is clear.`;
505
+ }
362
506
 
363
- pi.on("session_start", async (_event, ctx) => {
364
- const record = latestWorkflowRecord(ctx.sessionManager.getBranch());
507
+ const restoreWorkflow = async (ctx: ExtensionContext): Promise<void> => {
508
+ interruptPendingUI();
509
+ standaloneReferences.clear();
510
+ const branch = ctx.sessionManager.getBranch();
511
+ const record = latestWorkflowRecord(branch);
512
+ const newest = [...branch].reverse().find((entry) => entry.type === "custom" && entry.customType === WORKFLOW_STATE_ENTRY);
513
+ if (newest && !record) {
514
+ notifyPi(ctx.ui, "Could not restore Matt Pocock workflow: latest record is malformed or incompatible.", "warning");
515
+ }
365
516
  if (!record || record.status !== "active") {
366
517
  activeWorkflow = undefined;
367
518
  refreshActiveTools(false);
@@ -370,7 +521,8 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
370
521
  }
371
522
 
372
523
  try {
373
- if (!workflowPlacement(record.route, record.procedure)) {
524
+ const placement = workflowPlacement(record.route, record.procedure);
525
+ if (!placement || placement.phase !== record.phase) {
374
526
  throw new Error(`Procedure ${record.procedure} is not part of workflow ${record.route}.`);
375
527
  }
376
528
  // A restore is a context boundary: this session may have been compacted or restarted,
@@ -378,8 +530,8 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
378
530
  // tracking only suppresses repeats inside one continuous session.
379
531
  const restored = withDeliveryDefaults(record);
380
532
  const step = deliveryStep(restored, "restored workflow", "", true);
381
- // Not persisted here: session_start must not write to the branch. The next transition
382
- // records the delivery set it has actually shown.
533
+ // Restore the in-memory delivery set without adding workflow state entries.
534
+ // The silent custom message must enter context before automatic compaction retries.
383
535
  activeWorkflow = step.state;
384
536
  refreshActiveTools(true);
385
537
  clearPiStatus(ctx.ui, "matt-pocock");
@@ -388,7 +540,7 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
388
540
  content: step.text,
389
541
  display: false,
390
542
  details: step.state,
391
- }, { deliverAs: "nextTurn" });
543
+ }, { triggerTurn: false });
392
544
  } catch (error) {
393
545
  activeWorkflow = record;
394
546
  const cancelled = terminalState("cancelled", `Restore validation failed: ${String(error)}`);
@@ -401,7 +553,10 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
401
553
  "warning",
402
554
  );
403
555
  }
404
- });
556
+ };
557
+ pi.on("session_start", async (_event, ctx) => restoreWorkflow(ctx));
558
+ pi.on("session_tree", async (_event, ctx) => restoreWorkflow(ctx));
559
+ pi.on("session_compact", async (_event, ctx) => restoreWorkflow(ctx));
405
560
 
406
561
  if (typeof pi.registerMessageRenderer === "function") {
407
562
  pi.registerMessageRenderer(PROCEDURE_ENTRY, (message, options, theme) => {
@@ -416,21 +571,28 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
416
571
  });
417
572
  }
418
573
 
419
- pi.on("before_agent_start", async (event) => ({
420
- systemPrompt: `${event.systemPrompt}\n\n${activeWorkflow
574
+ pi.on("before_agent_start", async (event) => {
575
+ const options = event.systemPromptOptions;
576
+ if (!("sections" in options) || !options.sections || typeof options.sections !== "object" || Array.isArray(options.sections)) {
577
+ throw new Error("This extension requires Pi structured prompt sections.");
578
+ }
579
+ const sections = options.sections;
580
+ Reflect.deleteProperty(sections, "matt_pocock_workflow");
581
+ Object.assign(sections, { matt_pocock_workflow: activeWorkflow
421
582
  ? workflowGuidance(activeWorkflow, availableReferences(activeWorkflow))
422
- : availableWorkflowsGuidance()}`,
423
- }));
583
+ : availableWorkflowsGuidance() });
584
+ });
424
585
 
425
586
  pi.registerTool({
426
587
  name: "matt_pocock_workflow",
427
588
  label: "Matt Pocock Gateway",
428
- description: "Start a Matt Pocock engineering workflow, run a curated standalone capability, or load a reference disclosed by that capability. Workflows cover structured multi-step engineering; standalone capabilities cover focused methods and are listed in the injected capability catalog.",
589
+ description: "Start a Matt Pocock engineering workflow, run a curated standalone capability, run a user-invoked capability the user explicitly asked for, or load a reference disclosed by that capability. Workflows cover structured multi-step engineering; standalone capabilities cover focused methods and are listed in the injected capability catalog.",
429
590
  promptSnippet: "Start a Matt Pocock workflow or run a standalone capability",
430
591
  promptGuidelines: [
431
- "mode workflow: structured multi-step engineering task (idea-to-ship for features, hard-bug for non-trivial bugs, architecture for deepening/refactoring, wayfinding for ambiguous maps, triage for issues).",
432
- "mode capability: a curated capability directly matches the request.",
433
- "mode reference: a reference named in a capability result.",
592
+ 'mode workflow: {"mode": "workflow", "route": "<route id>"} for structured multi-step engineering (idea-to-ship for features, hard-bug for non-trivial bugs, architecture for deepening/refactoring, wayfinding for ambiguous maps, triage for issues).',
593
+ 'mode capability: {"mode": "capability", "capability": "<capability id>"} for a focused method. A capability id is not a workflow route; use mode capability even when its subject is engineering.',
594
+ 'mode user-capability: {"mode": "user-capability", "capability": "<capability id>"} only when the user explicitly asked for that named capability, such as "grill me". Never select one on your own initiative.',
595
+ 'mode reference: {"mode": "reference", "capability": "<capability id>", "reference": "<disclosed reference id>"}.',
434
596
  "Start with matt_pocock_workflow; the started workflow carries its procedure and next-step guidance.",
435
597
  ],
436
598
  parameters: workflowGatewayParameters(),
@@ -441,18 +603,17 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
441
603
  const subject = details.mode === "workflow"
442
604
  ? readablePhaseTitle(details.phase ?? "active")
443
605
  : details.mode === "reference"
444
- ? `${details.capability ?? "capability"} · ${details.reference ?? "reference"}`
445
- : details.capability ?? "standalone capability";
446
- const routeTitle = details.mode === "workflow" ? readableRouteTitle(details.route ?? "workflow") : undefined;
447
- const fields = routeTitle && routeTitle !== subject ? [fieldLine("route", routeTitle)] : [];
448
- return mattPocockRows.result(() => eventToolLifecycle("matt pocock", subject, { label: "started", details: fields }))(result, options, theme, context);
606
+ ? `${readableProcedureTitle(details.capability ?? "capability")} · ${readableProcedureTitle(details.reference ?? "reference")}`
607
+ : readableProcedureTitle(details.capability ?? "standalone capability");
608
+ // A start row is the whole message: the phase title alone, with nothing held
609
+ // back for Ctrl+O. Entry phase titles are unique per route, so the row needs no
610
+ // route field. The route id stays in the result details for model context.
611
+ return mattPocockRows.result(() => eventToolLifecycle("matt pocock", subject, { label: "started" }))(result, options, theme, context);
449
612
  },
450
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
613
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
614
+ signal?.throwIfAborted();
451
615
  clearPiStatus(ctx.ui, "matt-pocock");
452
616
  if (params.mode === "workflow") {
453
- if (activeWorkflow) {
454
- throw new Error(`Workflow ${activeWorkflow.workItemId} is already active. Complete or cancel it before starting another.`);
455
- }
456
617
  const step = startWorkflow(params.route);
457
618
  return {
458
619
  content: [{ type: "text", text: step.text }],
@@ -466,6 +627,16 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
466
627
  details: { mode: "reference", capability: params.capability, reference: params.reference },
467
628
  };
468
629
  }
630
+ if (params.mode === "user-capability") {
631
+ const definition = findProcedure(params.capability);
632
+ if (!definition?.standalone || definition.invocation !== "user") {
633
+ throw new Error(`Capability ${params.capability} is not a user-invocation capability.`);
634
+ }
635
+ return {
636
+ content: [{ type: "text", text: standalonePrompt(definition.id) }],
637
+ details: { mode: "user-capability", capability: definition.id },
638
+ };
639
+ }
469
640
  const definition = findProcedure(params.capability);
470
641
  if (!definition?.standalone || definition.invocation !== "model") {
471
642
  throw new Error(`Capability ${params.capability} is not available to the model gateway.`);
@@ -480,7 +651,7 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
480
651
  pi.registerTool({
481
652
  name: "matt_pocock_active",
482
653
  label: "Matt Pocock Active Workflow",
483
- description: "Operate on the active Matt Pocock workflow: transition (target), load a reference (reference), complete, or cancel (reason).",
654
+ description: 'Operate on the active workflow using exactly one shape: {"action":"transition","target":"<allowedNext id>"}, {"action":"load","reference":"<availableReferences id>"}, {"action":"complete"}, or {"action":"cancel","reason":"<why>"}. Transition requires target; a reference uses action load. Copy a call from the latest workflow state contract.',
484
655
  promptSnippet: "Transition, load a reference, complete, or cancel the active Matt Pocock workflow",
485
656
  promptGuidelines: [
486
657
  'action transition: {"action": "transition", "target": "<one allowedNext id>"} — target is required.',
@@ -492,35 +663,37 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
492
663
  renderShell: "self",
493
664
  renderCall: () => mattPocockRows.emptyCall(),
494
665
  renderResult(result, options, theme, context) {
495
- const details = (result.details ?? {}) as { action?: string; subject?: string; state?: { phase?: string; reason?: string } };
666
+ const details = (result.details ?? {}) as { action?: string; subject?: string; reference?: string; state?: { phase?: string; reason?: string } };
496
667
  // Saved results may still have route-based subjects; render from that event's phase, not active state.
497
668
  const subject = details.state?.phase && (details.action === "transition" || details.action === "complete" || details.action === "cancel")
498
669
  ? workflowEventSubject(details.action, details.state.phase)
499
- : details.subject ?? details.action ?? "workflow updated";
670
+ : details.action === "load"
671
+ ? `loaded ${readableProcedureTitle(details.reference ?? details.subject?.replace(/^loaded /, "") ?? "reference")}`
672
+ : details.subject ?? details.action ?? "workflow updated";
500
673
  const reason = details.action === "cancel" ? safeDisplayText(details.state?.reason ?? "").trim() : undefined;
674
+ // Every Matt Pocock row shows its facts by default, so a cancellation reason is
675
+ // an inline body line, never a body that waits behind Ctrl+O.
501
676
  return mattPocockRows.result(() => eventToolLifecycle("matt pocock", subject, {
502
677
  label: "event",
503
- details: reason ? [fieldLine("reason", reason)] : [],
678
+ summary: reason ? [fieldLine("reason", reason)] : [],
504
679
  }))(result, options, theme, context);
505
680
  },
506
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
681
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
682
+ signal?.throwIfAborted();
507
683
  if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
508
684
  if (params.action === "transition") {
509
- const state = transitionState(activeWorkflow, params.target);
510
- resolveProcedureBundle(state.procedure);
511
- const step = deliveryStep(state, `transitioned to ${state.procedure}`);
512
- persistWorkflow(step.state);
685
+ const step = transitionWorkflow(params.target);
513
686
  clearPiStatus(ctx.ui, "matt-pocock");
514
687
  return {
515
688
  content: [{ type: "text", text: step.text }],
516
- details: { action: "transition", subject: workflowEventSubject("transition", state.phase), state: step.state },
689
+ details: { action: "transition", subject: workflowEventSubject("transition", step.state.phase), state: step.state },
517
690
  };
518
691
  }
519
692
  if (params.action === "load") {
520
693
  const loaded = loadActiveReference(params.reference);
521
694
  return {
522
695
  content: [{ type: "text", text: loaded.content }],
523
- details: { action: "load", subject: `loaded ${params.reference}`, state: loaded.state },
696
+ details: { action: "load", reference: params.reference, subject: `loaded ${readableProcedureTitle(params.reference)}`, state: loaded.state },
524
697
  };
525
698
  }
526
699
  const terminal = params.action === "complete"
@@ -539,39 +712,23 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
539
712
  },
540
713
  });
541
714
 
542
- function isChineseText(text: string): boolean {
543
- return /[\u4e00-\u9fa5]/.test(text);
544
- }
545
-
546
- function deriveDialogTitle(explicitTitle?: string): string {
547
- if (explicitTitle && explicitTitle.trim()) return explicitTitle.trim();
548
- if (activeWorkflow) {
549
- return formatReadableWorkflowSubject(activeWorkflow.route, activeWorkflow.phase);
550
- }
551
- return "Decision";
552
- }
553
-
554
- function deriveDialogButtonNames(question: string): { ok: string; cancel: string } {
555
- const isZh = isChineseText(question);
556
- return {
557
- ok: isZh ? "确认" : "Select",
558
- cancel: isZh ? "取消" : "Cancel",
559
- };
560
- }
561
-
562
715
  pi.registerTool({
563
716
  name: "matt_pocock_ask",
564
717
  label: "Matt Pocock Ask",
565
718
  description: "Ask a structured workflow decision question via Pi UI selection.",
566
719
  promptSnippet: "Ask a structured question during the active Matt Pocock workflow",
720
+ promptGuidelines: [
721
+ "Every call passes recommended as an exact copy of one offered option; the deadline adopts it and no question waits indefinitely. For an approval-required external or destructive action, recommend the option that does not act.",
722
+ "Escape, cancellation, abort, and no UI leave a decision pending; do not interpret them as consent.",
723
+ ],
567
724
  parameters: Type.Object({
568
725
  title: Type.Optional(Type.String({ description: "Short title summarizing the decision context; defaults to workflow route and phase." })),
569
726
  question: Type.String({ description: "The interview or decision question to ask the user." }),
570
- options: Type.Array(Type.String(), { description: "2 to 4 suggested options, recommended option first." }),
571
- recommended: Type.Optional(Type.String({ description: "The recommended option. When provided, timeout automatically adopts this option." })),
572
- timeout_seconds: Type.Optional(Type.Number({ description: "Seconds to wait; default 60; needs a recommended option." })),
727
+ options: Type.Array(Type.String({ minLength: 1 }), { minItems: 2, maxItems: 4, uniqueItems: true, description: "2 to 4 suggested options, recommended option first." }),
728
+ recommended: Type.String({ description: "Required. An exact copy of one options entry, including annotations such as (Recommended) and whitespace. The selection deadline automatically adopts this option." }),
729
+ timeout_seconds: Type.Optional(Type.Number({ exclusiveMinimum: 0, maximum: 3600, description: "Seconds to wait before adopting the recommended option; default 60." })),
573
730
  allow_custom: Type.Optional(Type.Boolean({ description: "Allow a custom typed answer; default true." })),
574
- }),
731
+ }, { additionalProperties: false }),
575
732
  renderShell: "self",
576
733
  renderCall: () => mattPocockRows.emptyCall(),
577
734
  renderResult(result, options, theme, context) {
@@ -585,140 +742,118 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
585
742
  : answerLines.map((line, index) => index === 0 ? `Answer: ${line}` : ` ${line}`);
586
743
  const metadata = [
587
744
  details.timed_out && details.source === "timeout_recommended" ? fieldLine("reason", "selection timed out (used recommendation)") : undefined,
745
+ // A deadline always adopts a recommendation now. This line only renders a
746
+ // pending timeout persisted by an older session, so history keeps its reason.
588
747
  details.timed_out && details.pending ? fieldLine("reason", "selection timed out") : undefined,
589
748
  details.source === "no_ui" ? fieldLine("reason", "no UI available") : undefined,
590
749
  details.is_custom ? fieldLine("source", "custom input") : undefined,
591
750
  ].filter((line): line is string => Boolean(line));
592
751
  return mattPocockRows.result(() => eventToolLifecycle("matt pocock", safeDisplayText(params.question ?? "question"), {
593
752
  label: "ask",
594
- summary: summary.length > 0 ? summary : ["Answer: (none)"],
595
- details: metadata,
753
+ // Decision metadata joins the answer body: one collapsed row carries the
754
+ // question, the answer, and how that answer was obtained.
755
+ summary: [...(summary.length > 0 ? summary : ["Answer: (none)"]), ...metadata],
596
756
  }))(result, options, theme, context);
597
757
  },
598
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
758
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
599
759
  if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
600
- if (!ctx.hasUI) {
601
- return {
602
- content: [{ type: "text", text: "[Pending user decision] No UI is available. Do not proceed until the user responds." }],
603
- details: { pending: true, source: "no_ui" },
604
- };
605
- }
606
- const allowCustom = params.allow_custom ?? true;
607
- const hasRecommended = typeof params.recommended === "string" && params.recommended.trim().length > 0;
608
- const timeoutMs = hasRecommended
609
- ? ((params.timeout_seconds !== undefined && params.timeout_seconds > 0) ? params.timeout_seconds * 1000 : 60 * 1000)
610
- : undefined;
611
-
612
- const customOption = "Type custom answer...";
613
- const choices = [...params.options];
614
- if (allowCustom && !choices.includes(customOption)) choices.push(customOption);
615
-
616
- const config = loadConfig();
617
- if (config.useNativeDialog && isNativeDialogSupported()) {
618
- try {
619
- const defaultItem = (hasRecommended && choices.includes(params.recommended!.trim()))
620
- ? params.recommended!.trim()
621
- : choices[0];
622
- const timeoutSeconds = hasRecommended
623
- ? ((params.timeout_seconds !== undefined && params.timeout_seconds > 0) ? params.timeout_seconds : 60)
624
- : undefined;
625
-
626
- const dialogTitle = deriveDialogTitle(params.title);
627
- const buttonNames = deriveDialogButtonNames(params.question);
628
-
629
- const nativeChoice = await macosChooseFromList({
630
- title: dialogTitle,
631
- prompt: params.question,
632
- items: choices,
633
- defaultItem,
634
- okButtonName: buttonNames.ok,
635
- cancelButtonName: buttonNames.cancel,
636
- timeoutSeconds,
637
- });
638
-
639
- if (nativeChoice.action === "timed_out") {
640
- if (hasRecommended) {
641
- const answer = params.recommended!.trim();
642
- return {
643
- content: [{ type: "text", text: `User selected (timeout default): ${answer}` }],
644
- details: { answer, is_custom: false, timed_out: true, pending: false, source: "timeout_recommended" },
645
- };
646
- }
647
- return {
648
- content: [{ type: "text", text: "[Pending user decision] Selection timed out. Do not proceed until the user responds." }],
649
- details: { pending: true, timed_out: true, source: "cancelled" },
650
- };
651
- }
652
-
653
- if (nativeChoice.action === "cancelled" || !nativeChoice.item) {
760
+ const askedWorkflow = activeWorkflow;
761
+ return withWorkflowSignal(signal, async (decisionSignal) => {
762
+ const interrupted = () => decisionSignal.aborted || activeWorkflow !== askedWorkflow;
763
+ const pendingAbort = () => ({
764
+ content: [{ type: "text" as const, text: "[Pending user decision] Question interrupted. Do not proceed until the user responds." }],
765
+ details: { pending: true, source: "aborted" },
766
+ });
767
+ if (interrupted()) return pendingAbort();
768
+ const offered = typeof params.recommended === "string" ? params.recommended : "";
769
+ const recommended = params.options.includes(offered) ? offered : offered.trim();
770
+ if (!recommended || !params.options.includes(recommended)) {
771
+ throw new Error("A recommended answer is required and must match an offered option.");
772
+ }
773
+ if (!ctx.hasUI) {
774
+ return {
775
+ content: [{ type: "text", text: "[Pending user decision] No UI is available. Do not proceed until the user responds." }],
776
+ details: { pending: true, source: "no_ui" },
777
+ };
778
+ }
779
+ // One deadline governs the whole ask: it dismisses the selector and the custom
780
+ // answer input, then adopts the recommendation so no question waits indefinitely.
781
+ // The first dialog receives the requested budget, and later phases the remainder.
782
+ const timeoutSeconds = params.timeout_seconds ?? 60;
783
+ const timeoutMs = timeoutSeconds * 1000;
784
+ const deadline = Date.now() + timeoutMs;
785
+ const remainingMs = () => Math.max(1, deadline - Date.now());
786
+ const adoptRecommendation = () => ({
787
+ content: [{ type: "text" as const, text: formatDecisionAnswer(params.question, recommended, recommended) }],
788
+ details: { answer: recommended, is_custom: false, timed_out: true, pending: false, source: "timeout_recommended" },
789
+ });
790
+ let customOption = "Type custom answer...";
791
+ while (params.options.includes(customOption)) customOption += " (custom)";
792
+ const choices = [...params.options];
793
+ const allowCustom = params.allow_custom ?? true;
794
+ if (allowCustom) choices.push(customOption);
795
+ const title = params.title?.trim() || formatReadableWorkflowSubject(askedWorkflow.route, askedWorkflow.phase);
796
+ const isChinese = /[\u4e00-\u9fa5]/.test(params.question);
797
+ const buttons = { ok: isChinese ? "确认" : "Select", cancel: isChinese ? "取消" : "Cancel" };
798
+
799
+ // Each UI adapter returns the same selection outcome; decision handling is shared.
800
+ async function ask(native: boolean) {
801
+ if (interrupted()) return pendingAbort();
802
+ const choice = native
803
+ ? await macosChooseFromList({
804
+ title, prompt: params.question, items: choices, defaultItem: recommended,
805
+ okButtonName: buttons.ok, cancelButtonName: buttons.cancel,
806
+ timeoutSeconds, signal: decisionSignal,
807
+ })
808
+ : await selectDecision(ctx, params.question, choices, deadline, timeoutMs, decisionSignal);
809
+ if (interrupted()) return pendingAbort();
810
+ if (choice.action === "timed_out") return adoptRecommendation();
811
+ if (choice.action !== "selected" || !choice.item) {
654
812
  return {
655
- content: [{ type: "text", text: "[Pending user decision] No answer was selected. Do not proceed until the user responds." }],
813
+ content: [{ type: "text" as const, text: "[Pending user decision] No answer was selected. Do not proceed until the user responds." }],
656
814
  details: { pending: true, timed_out: false, source: "cancelled" },
657
815
  };
658
816
  }
659
-
660
- if (nativeChoice.item === customOption) {
661
- const inputRes = await macosInputDialog({
662
- title: dialogTitle,
663
- prompt: params.question,
664
- defaultAnswer: "",
665
- buttons: [buttonNames.cancel, buttonNames.ok],
666
- defaultButton: buttonNames.ok,
667
- cancelButton: buttonNames.cancel,
668
- });
669
- const answer = inputRes.action === "confirmed" ? inputRes.text?.trim() : undefined;
817
+ const isCustom = allowCustom && choice.item === customOption;
818
+ let answer = choice.item;
819
+ if (isCustom) {
820
+ if (native) {
821
+ const input = await macosInputDialog({
822
+ title, prompt: params.question, defaultAnswer: "", buttons: [buttons.cancel, buttons.ok],
823
+ defaultButton: buttons.ok, cancelButton: buttons.cancel,
824
+ timeoutSeconds: remainingMs() / 1000, signal: decisionSignal,
825
+ });
826
+ if (interrupted()) return pendingAbort();
827
+ if (input.action === "timed_out") return adoptRecommendation();
828
+ answer = input.action === "confirmed" ? input.text?.trim() ?? "" : "";
829
+ } else {
830
+ const remaining = remainingMs();
831
+ const { value, expired } = await askUntilDeadline(deadline, decisionSignal, (dialogSignal) =>
832
+ ctx.ui.input(params.question, "Enter your answer...", { signal: dialogSignal, timeout: remaining }));
833
+ if (interrupted()) return pendingAbort();
834
+ if (expired) return adoptRecommendation();
835
+ answer = value?.trim() ?? "";
836
+ }
670
837
  if (!answer) {
671
838
  return {
672
- content: [{ type: "text", text: "[Pending user decision] No custom answer was provided. Do not proceed until the user responds." }],
839
+ content: [{ type: "text" as const, text: "[Pending user decision] No custom answer was provided. Do not proceed until the user responds." }],
673
840
  details: { pending: true, source: "custom_input_cancelled" },
674
841
  };
675
842
  }
676
- return {
677
- content: [{ type: "text", text: `User answered (custom): ${answer}` }],
678
- details: { answer, is_custom: true, source: "custom_input" },
679
- };
680
843
  }
681
-
682
844
  return {
683
- content: [{ type: "text", text: `User selected: ${nativeChoice.item}` }],
684
- details: { answer: nativeChoice.item, is_custom: false, source: "choice_selected" },
845
+ content: [{ type: "text" as const, text: formatDecisionAnswer(params.question, answer, isCustom ? undefined : recommended) }],
846
+ details: { answer, is_custom: isCustom, source: isCustom ? "custom_input" : "choice_selected" },
685
847
  };
686
- } catch {
687
- // Graceful fallback to TUI on any native dialog error
688
848
  }
689
- }
690
849
 
691
- const selected = await ctx.ui.select(params.question, choices, timeoutMs !== undefined ? { timeout: timeoutMs } : undefined);
692
- if (selected === undefined) {
693
- if (hasRecommended) {
694
- const answer = params.recommended!.trim();
695
- return {
696
- content: [{ type: "text", text: `User selected (timeout default): ${answer}` }],
697
- details: { answer, is_custom: false, timed_out: true, pending: false, source: "timeout_recommended" },
698
- };
699
- }
700
- return {
701
- content: [{ type: "text", text: "[Pending user decision] No answer was selected. Do not proceed until the user responds." }],
702
- details: { pending: true, timed_out: false, source: "cancelled" },
703
- };
704
- }
705
- if (selected === customOption) {
706
- const answer = (await ctx.ui.input(params.question, "Enter your answer..."))?.trim();
707
- if (!answer) {
708
- return {
709
- content: [{ type: "text", text: "[Pending user decision] No custom answer was provided. Do not proceed until the user responds." }],
710
- details: { pending: true, source: "custom_input_cancelled" },
711
- };
850
+ if (loadConfig().useNativeDialog && isNativeDialogSupported()) {
851
+ try { return await ask(true); } catch {
852
+ // Process and parsing failures fall back to Pi UI; user cancellation does not.
853
+ }
712
854
  }
713
- return {
714
- content: [{ type: "text", text: `User answered (custom): ${answer}` }],
715
- details: { answer, is_custom: true, source: "custom_input" },
716
- };
717
- }
718
- return {
719
- content: [{ type: "text", text: `User selected: ${selected}` }],
720
- details: { answer: selected, is_custom: false, source: "choice_selected" },
721
- };
855
+ return await ask(false);
856
+ });
722
857
  },
723
858
  });
724
859
 
@@ -738,6 +873,10 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
738
873
  const token = command.match(/^\S+/)?.[0] ?? command;
739
874
  const task = command.slice(token.length).trim();
740
875
  if (token === "status" && !task) return showStatus(ctx);
876
+ if ((token === "status" || token === "complete") && task) {
877
+ notifyPi(ctx.ui, `Usage: /matt-pocock ${token}`, "error");
878
+ return;
879
+ }
741
880
  if (token === "transition" && !task) {
742
881
  if (!ctx.hasUI) {
743
882
  notifyPi(ctx.ui, "Name a target: /matt-pocock transition <target>, or use the interactive menu.", "error");
@@ -746,13 +885,12 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
746
885
  return chooseTransition(ctx);
747
886
  }
748
887
  if (token === "transition") return applyTransition(ctx, task);
749
- if (token === "complete" && !task) return completeWorkflow(ctx);
888
+ if (token === "complete" && !task) return endWorkflow(ctx, "completed");
750
889
  if (token === "cancel" || token === "end") {
751
- return cancelWorkflow(ctx, task || `Cancelled by user with /matt-pocock ${token}.`);
890
+ return endWorkflow(ctx, "cancelled", task || `Cancelled by user with /matt-pocock ${token}.`);
752
891
  }
753
892
 
754
893
  if (findWorkflowRoute(token)) {
755
- if (activeWorkflow) throw new Error(`Workflow ${activeWorkflow.workItemId} is already active.`);
756
894
  const step = startWorkflow(token, task);
757
895
  clearPiStatus(ctx.ui, "matt-pocock");
758
896
  deliverProcedure(step.text, { ...step.state, request: task });