pi-matt-pocock 0.2.1 → 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 +539 -363
  53. package/src/native-dialog.ts +149 -213
  54. package/src/resolver.ts +57 -11
  55. package/src/workflow.ts +76 -18
  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,16 +21,19 @@ 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";
32
- import { resolveAccessibleReference, resolveProcedureBundle, resolveWorkflowContext } from "./resolver.ts";
36
+ import { resolveAccessibleReference, resolveProcedureBundle, resolveProcedureDelta, resolveWorkflowContext } from "./resolver.ts";
33
37
  /** Geometry bound once: every Matt Pocock row shares hint and wrapping. */
34
38
  const mattPocockRows = bindLifecycleRenderers({
35
39
  fit: truncateToWidth,
@@ -49,24 +53,33 @@ import {
49
53
  transitionState,
50
54
  workflowGuidance,
51
55
  workflowRoutes,
56
+ withDeliveryDefaults,
52
57
  WORKFLOW_STATE_ENTRY,
53
58
  WORKFLOW_STATE_VERSION,
54
59
  type TerminalWorkflowState,
55
60
  type WorkflowState,
56
61
  } from "./workflow.ts";
57
62
 
63
+ /** Tools activated only while a workflow is active. */
58
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;
59
68
  const PROCEDURE_ENTRY = "matt-pocock-procedure";
60
69
 
61
70
  /** Session details for one delivered procedure: a workflow state, a standalone capability, and the user's own task. */
62
71
  type ProcedureDetails = Partial<WorkflowState> & { mode?: string; capability?: string; request?: string };
63
72
 
73
+ function readableProcedureTitle(id: string): string {
74
+ return findProcedure(id)?.label ?? id;
75
+ }
76
+
64
77
  /** The block body: the user's own task when given, otherwise what they started. */
65
78
  function procedureSubject(details: ProcedureDetails): string {
66
79
  const request = details.request?.trim();
67
80
  if (request) return request;
68
81
  if (details.phase) return readablePhaseTitle(details.phase);
69
- return details.capability ?? "";
82
+ return details.capability ? readableProcedureTitle(details.capability) : "";
70
83
  }
71
84
 
72
85
  function stringEnum(values: string[], options?: Record<string, unknown>) {
@@ -76,25 +89,46 @@ function stringEnum(values: string[], options?: Record<string, unknown>) {
76
89
  function workflowGatewayParameters() {
77
90
  const routes = workflowRoutes().map((route) => route.route);
78
91
  const capabilities = modelStandaloneCapabilities().map((capability) => capability.id);
79
- return Type.Union([
92
+ const userCapabilities = userStandaloneCapabilities().map((capability) => capability.id);
93
+ const branches = Type.Union([
80
94
  Type.Object({
81
95
  mode: Type.Literal("workflow", { description: "Start a persisted multi-step engineering workflow." }),
82
96
  route: stringEnum(routes, { description: "Workflow route to start." }),
83
- }),
97
+ }, { additionalProperties: false }),
84
98
  Type.Object({
85
99
  mode: Type.Literal("capability", { description: "Run a curated standalone capability without persistent workflow state." }),
86
100
  capability: stringEnum(capabilities, { description: "Standalone capability to run." }),
87
- }),
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 }),
88
106
  Type.Object({
89
107
  mode: Type.Literal("reference", { description: "Load a reference disclosed by a standalone capability." }),
90
108
  capability: Type.String({ description: "Standalone capability id from the injected capability context." }),
91
109
  reference: Type.String({ description: "Disclosed reference id returned by the capability." }),
92
- }),
110
+ }, { additionalProperties: false }),
93
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
+ });
94
128
  }
95
129
 
96
130
  function activeWorkflowParameters() {
97
- return Type.Union([
131
+ const branches = Type.Union([
98
132
  Type.Object({
99
133
  action: Type.Literal("transition"),
100
134
  target: Type.String({ description: "One procedure id from the current state's allowedNext list." }),
@@ -109,6 +143,18 @@ function activeWorkflowParameters() {
109
143
  reason: Type.String({ description: "Why this workflow is being cancelled." }),
110
144
  }, { additionalProperties: false }),
111
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
+ });
112
158
  }
113
159
 
114
160
  function workflowEventSubject(action: "transition" | "complete" | "cancel", phase: string): string {
@@ -116,221 +162,357 @@ function workflowEventSubject(action: "transition" | "complete" | "cancel", phas
116
162
  return action === "transition" ? title : `${title} ${action === "complete" ? "completed" : "cancelled"}`;
117
163
  }
118
164
 
119
- let pi: ExtensionAPI;
120
- let activeWorkflow: WorkflowState | undefined;
121
-
122
- function refreshActiveTools(active: boolean): void {
123
- if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
124
- const tools = pi.getActiveTools();
125
- const withoutActive = tools.filter((tool) => !ACTIVE_TOOLS.includes(tool as typeof ACTIVE_TOOLS[number]));
126
- 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}`;
172
+ }
173
+
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
+ }
127
198
  }
128
199
 
129
- /** Deliver a procedure as one lifecycle row: users see their own task, the model sees the whole body. */
130
- function deliverProcedure(content: string, details: ProcedureDetails): void {
131
- pi.sendMessage({ customType: PROCEDURE_ENTRY, content, display: true, details }, { deliverAs: "followUp", triggerTurn: true });
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" };
132
208
  }
133
209
 
134
- function persistWorkflow(state: WorkflowState | TerminalWorkflowState): void {
135
- pi.appendEntry(WORKFLOW_STATE_ENTRY, state);
136
- activeWorkflow = state.status === "active" ? state : undefined;
137
- refreshActiveTools(state.status === "active");
138
- }
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
+ }
139
223
 
140
- function terminalState(status: "completed" | "cancelled", reason?: string): TerminalWorkflowState {
141
- if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
142
- return {
143
- version: WORKFLOW_STATE_VERSION,
144
- workItemId: activeWorkflow.workItemId,
145
- route: activeWorkflow.route,
146
- procedure: activeWorkflow.procedure,
147
- phase: activeWorkflow.phase,
148
- status,
149
- reason,
150
- };
151
- }
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
+ }
152
237
 
153
- function availableReferences(state: WorkflowState): string[] {
154
- return resolveWorkflowContext(state.procedure, state.loadedReferences).availableReferences;
155
- }
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
+ }
156
253
 
157
- function stateContract(state: WorkflowState, action: string): string {
158
- const placement = workflowPlacement(state.route, state.procedure);
159
- 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`;
160
- }
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
+ }
161
261
 
162
- /** Delivered to the model, never rendered: the user's own words belong in the prompt. */
163
- function userRequestSection(request: string): string {
164
- return request ? `\n\nUser target/request:\n${request}` : "";
165
- }
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
+ }
166
266
 
167
- function workflowPrompt(state: WorkflowState, action: string, request = ""): string {
168
- const bundle = resolveWorkflowContext(state.procedure, state.loadedReferences);
169
- return `# Matt Pocock workflow procedure\n\nRoute: ${state.route}\nPhase: ${state.phase}\nWork item: ${state.workItemId}\n\n${bundle.content}${userRequestSection(request)}${stateContract(state, action)}`;
170
- }
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
+ }
171
273
 
172
- function standalonePrompt(capability: string, request = ""): string {
173
- const definition = findProcedure(capability);
174
- if (!definition?.standalone) throw new Error(`Unknown standalone Matt Pocock capability: ${capability}`);
175
- const bundle = resolveProcedureBundle(definition.id);
176
- 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)}`;
177
- }
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
+ }
178
286
 
179
- function standaloneReferencePrompt(capability: string, reference: string): string {
180
- const definition = findProcedure(capability);
181
- if (!definition?.standalone) {
182
- 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;
183
289
  }
184
- const bundle = resolveAccessibleReference(definition.id, [], reference);
185
- 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}`;
186
- }
187
290
 
188
- function startWorkflow(route: string): WorkflowState {
189
- if (!findWorkflowRoute(route)) throw new Error(`Unknown Matt Pocock route: ${route}`);
190
- const state = routeEntryState(route, randomUUID());
191
- if (!state) throw new Error(`Workflow ${route} has no catalog entry procedure.`);
192
- resolveProcedureBundle(state.procedure);
193
- persistWorkflow(state);
194
- return state;
195
- }
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
+ }
196
301
 
197
- function loadActiveReference(reference: string): { state: WorkflowState; content: string } {
198
- if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
199
- const bundle = resolveAccessibleReference(activeWorkflow.procedure, activeWorkflow.loadedReferences, reference);
200
- const loadedReferences = [...new Set([...activeWorkflow.loadedReferences, ...bundle.loaded])];
201
- const state = { ...activeWorkflow, loadedReferences };
202
- persistWorkflow(state);
203
- return {
204
- state,
205
- content: `# Matt Pocock workflow reference\n\nWork item: ${state.workItemId}\nReference: ${bundle.root}\n\n${bundle.content}${stateContract(state, `loaded reference ${bundle.root}`)}`,
206
- };
207
- }
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
+ }
208
306
 
209
- function routeChoices(): string[] {
210
- return workflowRoutes().map((route) => `${route.label} — ${route.description}`);
211
- }
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
+ }
212
331
 
213
- function routeFromChoice(choice: string): string | undefined {
214
- return workflowRoutes().find((route) => choice.startsWith(route.label))?.route;
215
- }
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
+ }
216
339
 
217
- function capabilityChoices(): string[] {
218
- return standaloneCapabilities().map((capability) => `${capability.label} — ${capability.description ?? capability.id}`);
219
- }
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
+ }
220
352
 
221
- function capabilityFromChoice(choice: string): string | undefined {
222
- return standaloneCapabilities().find((capability) => choice.startsWith(`${capability.label} —`))?.id;
223
- }
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
+ }
224
369
 
225
- async function chooseRoute(ctx: ExtensionCommandContext): Promise<void> {
226
- const choice = await ctx.ui.select("Start Matt Pocock workflow", routeChoices());
227
- if (!choice) return;
228
- const route = routeFromChoice(choice);
229
- if (!route) return;
230
- const state = startWorkflow(route);
231
- clearPiStatus(ctx.ui, "matt-pocock");
232
- deliverProcedure(workflowPrompt(state, "started workflow"), { ...state, request: "" });
233
- }
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
+ }
234
384
 
235
- async function chooseCapability(ctx: ExtensionCommandContext): Promise<void> {
236
- const choice = await ctx.ui.select("Run Matt Pocock capability", capabilityChoices());
237
- if (!choice) return;
238
- const capability = capabilityFromChoice(choice);
239
- if (capability) deliverProcedure(standalonePrompt(capability), { mode: "capability", capability, request: "" });
240
- }
385
+ function routeChoices(): string[] {
386
+ return workflowRoutes().map((route) => `${route.label} — ${route.description}`);
387
+ }
241
388
 
242
- /** Apply one catalog-legal transition, whether chosen from the menu or named on the command. */
243
- function applyTransition(ctx: ExtensionCommandContext, target: string): void {
244
- if (!activeWorkflow) {
245
- notifyPi(ctx.ui, "No active Matt Pocock workflow.", "warning");
246
- return;
389
+ function routeFromChoice(choice: string): string | undefined {
390
+ return workflowRoutes().find((route) => choice.startsWith(route.label))?.route;
247
391
  }
248
- const state = transitionState(activeWorkflow, target);
249
- resolveProcedureBundle(state.procedure);
250
- persistWorkflow(state);
251
- deliverProcedure(workflowPrompt(state, `transitioned to ${state.procedure}`), { ...state, request: "" });
252
- }
253
392
 
254
- async function chooseTransition(ctx: ExtensionCommandContext): Promise<void> {
255
- if (!activeWorkflow) {
256
- notifyPi(ctx.ui, "No active Matt Pocock workflow.", "warning");
257
- return;
393
+ function capabilityChoices(): string[] {
394
+ return standaloneCapabilities().map((capability) => `${capability.label} — ${capability.description ?? capability.id}`);
258
395
  }
259
- const next = workflowPlacement(activeWorkflow.route, activeWorkflow.procedure)?.allowedNext ?? [];
260
- if (next.length === 0) {
261
- notifyPi(ctx.ui, "This workflow has no next procedure. Complete or cancel it.", "info");
262
- return;
396
+
397
+ function capabilityFromChoice(choice: string): string | undefined {
398
+ return standaloneCapabilities().find((capability) => choice.startsWith(`${capability.label} —`))?.id;
263
399
  }
264
- const choice = await ctx.ui.select(`Transition ${activeWorkflow.route} from ${activeWorkflow.procedure}`, next);
265
- if (!choice) return;
266
- applyTransition(ctx, choice);
267
- }
268
400
 
269
- function completeWorkflow(ctx: ExtensionContext): void {
270
- const terminal = terminalState("completed");
271
- persistWorkflow(terminal);
272
- clearPiStatus(ctx.ui, "matt-pocock");
273
- notifyPi(ctx.ui, `Matt Pocock workflow completed: ${terminal.workItemId}`, "info");
274
- }
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
+ }
275
410
 
276
- function cancelWorkflow(ctx: ExtensionContext, reason: string): void {
277
- const terminal = terminalState("cancelled", reason);
278
- persistWorkflow(terminal);
279
- clearPiStatus(ctx.ui, "matt-pocock");
280
- notifyPi(ctx.ui, `Matt Pocock workflow cancelled: ${terminal.workItemId}`, "info");
281
- }
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
+ }
282
417
 
283
- function showStatus(ctx: ExtensionContext): void {
284
- if (!activeWorkflow) {
285
- notifyPi(ctx.ui, "Matt Pocock workflow: inactive", "info");
286
- 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;
287
424
  }
288
- notifyPi(
289
- ctx.ui,
290
- `Matt Pocock workflow: ${formatReadableWorkflowSubject(activeWorkflow.route, activeWorkflow.phase)} · ${activeWorkflow.workItemId}`,
291
- "info",
292
- );
293
- }
294
425
 
295
- async function showMenu(ctx: ExtensionCommandContext): Promise<void> {
296
- const choices = activeWorkflow
297
- ? [START_TASK_CHOICE, "View current workflow", "Transition current workflow", "Complete current workflow", "Cancel current workflow", "Run a standalone capability"]
298
- : [START_TASK_CHOICE, "Start a workflow", "Run a standalone capability", "View current workflow"];
299
- const choice = await ctx.ui.select("Matt Pocock", choices);
300
- if (!choice) return;
301
- if (choice === START_TASK_CHOICE) return startFromContext(ctx);
302
- if (choice === "Start a workflow") return chooseRoute(ctx);
303
- if (choice === "Run a standalone capability") return chooseCapability(ctx);
304
- if (choice === "View current workflow") return showStatus(ctx);
305
- if (choice === "Transition current workflow") return chooseTransition(ctx);
306
- if (choice === "Complete current workflow") return completeWorkflow(ctx);
307
- cancelWorkflow(ctx, "Cancelled by user from the Matt Pocock menu.");
308
- }
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
+ }
435
+
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
+ }
309
454
 
310
- const START_TASK_CHOICE = "Start a task";
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
+ }
311
461
 
312
- function contextRoutingPrompt(cancelledWorkflow: boolean): string {
313
- 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.`;
314
- }
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
+ );
472
+ }
315
473
 
316
- function startFromContext(ctx: ExtensionCommandContext): void {
317
- const cancelledWorkflow = Boolean(activeWorkflow);
318
- if (activeWorkflow) {
319
- persistWorkflow(terminalState("cancelled", "Superseded by a new /matt-pocock context routing request."));
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
+ });
320
487
  }
321
- clearPiStatus(ctx.ui, "matt-pocock");
322
- deliverProcedure(contextRoutingPrompt(cancelledWorkflow), { request: "" });
323
- }
324
488
 
325
- function workflowRoutingPrompt(prompt: string, cancelledWorkflow: boolean): string {
326
- 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.`;
327
- }
489
+ const START_TASK_CHOICE = "Start a task";
328
490
 
329
- export default function mattPocock(extensionApi: ExtensionAPI): void {
330
- pi = extensionApi;
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
+ }
331
499
 
332
- pi.on("session_start", async (_event, ctx) => {
333
- const record = latestWorkflowRecord(ctx.sessionManager.getBranch());
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
+ }
506
+
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
+ }
334
516
  if (!record || record.status !== "active") {
335
517
  activeWorkflow = undefined;
336
518
  refreshActiveTools(false);
@@ -339,19 +521,26 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
339
521
  }
340
522
 
341
523
  try {
342
- if (!workflowPlacement(record.route, record.procedure)) {
524
+ const placement = workflowPlacement(record.route, record.procedure);
525
+ if (!placement || placement.phase !== record.phase) {
343
526
  throw new Error(`Procedure ${record.procedure} is not part of workflow ${record.route}.`);
344
527
  }
345
- const content = workflowPrompt(record, "restored workflow");
346
- activeWorkflow = record;
528
+ // A restore is a context boundary: this session may have been compacted or restarted,
529
+ // so the whole active context is re-delivered rather than deduplicated. Delivery
530
+ // tracking only suppresses repeats inside one continuous session.
531
+ const restored = withDeliveryDefaults(record);
532
+ const step = deliveryStep(restored, "restored workflow", "", true);
533
+ // Restore the in-memory delivery set without adding workflow state entries.
534
+ // The silent custom message must enter context before automatic compaction retries.
535
+ activeWorkflow = step.state;
347
536
  refreshActiveTools(true);
348
537
  clearPiStatus(ctx.ui, "matt-pocock");
349
538
  pi.sendMessage({
350
539
  customType: PROCEDURE_ENTRY,
351
- content,
540
+ content: step.text,
352
541
  display: false,
353
- details: record,
354
- }, { deliverAs: "nextTurn" });
542
+ details: step.state,
543
+ }, { triggerTurn: false });
355
544
  } catch (error) {
356
545
  activeWorkflow = record;
357
546
  const cancelled = terminalState("cancelled", `Restore validation failed: ${String(error)}`);
@@ -364,7 +553,10 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
364
553
  "warning",
365
554
  );
366
555
  }
367
- });
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));
368
560
 
369
561
  if (typeof pi.registerMessageRenderer === "function") {
370
562
  pi.registerMessageRenderer(PROCEDURE_ENTRY, (message, options, theme) => {
@@ -379,21 +571,28 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
379
571
  });
380
572
  }
381
573
 
382
- pi.on("before_agent_start", async (event) => ({
383
- 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
384
582
  ? workflowGuidance(activeWorkflow, availableReferences(activeWorkflow))
385
- : availableWorkflowsGuidance()}`,
386
- }));
583
+ : availableWorkflowsGuidance() });
584
+ });
387
585
 
388
586
  pi.registerTool({
389
587
  name: "matt_pocock_workflow",
390
588
  label: "Matt Pocock Gateway",
391
- 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.",
392
590
  promptSnippet: "Start a Matt Pocock workflow or run a standalone capability",
393
591
  promptGuidelines: [
394
- "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).",
395
- "mode capability: a curated capability directly matches the request.",
396
- "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>"}.',
397
596
  "Start with matt_pocock_workflow; the started workflow carries its procedure and next-step guidance.",
398
597
  ],
399
598
  parameters: workflowGatewayParameters(),
@@ -404,22 +603,21 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
404
603
  const subject = details.mode === "workflow"
405
604
  ? readablePhaseTitle(details.phase ?? "active")
406
605
  : details.mode === "reference"
407
- ? `${details.capability ?? "capability"} · ${details.reference ?? "reference"}`
408
- : details.capability ?? "standalone capability";
409
- const routeTitle = details.mode === "workflow" ? readableRouteTitle(details.route ?? "workflow") : undefined;
410
- const fields = routeTitle && routeTitle !== subject ? [fieldLine("route", routeTitle)] : [];
411
- 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);
412
612
  },
413
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
613
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
614
+ signal?.throwIfAborted();
414
615
  clearPiStatus(ctx.ui, "matt-pocock");
415
616
  if (params.mode === "workflow") {
416
- if (activeWorkflow) {
417
- throw new Error(`Workflow ${activeWorkflow.workItemId} is already active. Complete or cancel it before starting another.`);
418
- }
419
- const state = startWorkflow(params.route);
617
+ const step = startWorkflow(params.route);
420
618
  return {
421
- content: [{ type: "text", text: workflowPrompt(state, "started workflow") }],
422
- details: { mode: "workflow", ...state },
619
+ content: [{ type: "text", text: step.text }],
620
+ details: { mode: "workflow", ...step.state },
423
621
  };
424
622
  }
425
623
  if (params.mode === "reference") {
@@ -429,6 +627,16 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
429
627
  details: { mode: "reference", capability: params.capability, reference: params.reference },
430
628
  };
431
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
+ }
432
640
  const definition = findProcedure(params.capability);
433
641
  if (!definition?.standalone || definition.invocation !== "model") {
434
642
  throw new Error(`Capability ${params.capability} is not available to the model gateway.`);
@@ -443,7 +651,7 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
443
651
  pi.registerTool({
444
652
  name: "matt_pocock_active",
445
653
  label: "Matt Pocock Active Workflow",
446
- 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.',
447
655
  promptSnippet: "Transition, load a reference, complete, or cancel the active Matt Pocock workflow",
448
656
  promptGuidelines: [
449
657
  'action transition: {"action": "transition", "target": "<one allowedNext id>"} — target is required.',
@@ -455,34 +663,37 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
455
663
  renderShell: "self",
456
664
  renderCall: () => mattPocockRows.emptyCall(),
457
665
  renderResult(result, options, theme, context) {
458
- 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 } };
459
667
  // Saved results may still have route-based subjects; render from that event's phase, not active state.
460
668
  const subject = details.state?.phase && (details.action === "transition" || details.action === "complete" || details.action === "cancel")
461
669
  ? workflowEventSubject(details.action, details.state.phase)
462
- : 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";
463
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.
464
676
  return mattPocockRows.result(() => eventToolLifecycle("matt pocock", subject, {
465
677
  label: "event",
466
- details: reason ? [fieldLine("reason", reason)] : [],
678
+ summary: reason ? [fieldLine("reason", reason)] : [],
467
679
  }))(result, options, theme, context);
468
680
  },
469
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
681
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
682
+ signal?.throwIfAborted();
470
683
  if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
471
684
  if (params.action === "transition") {
472
- const state = transitionState(activeWorkflow, params.target);
473
- resolveProcedureBundle(state.procedure);
474
- persistWorkflow(state);
685
+ const step = transitionWorkflow(params.target);
475
686
  clearPiStatus(ctx.ui, "matt-pocock");
476
687
  return {
477
- content: [{ type: "text", text: workflowPrompt(state, `transitioned to ${state.procedure}`) }],
478
- details: { action: "transition", subject: workflowEventSubject("transition", state.phase), state },
688
+ content: [{ type: "text", text: step.text }],
689
+ details: { action: "transition", subject: workflowEventSubject("transition", step.state.phase), state: step.state },
479
690
  };
480
691
  }
481
692
  if (params.action === "load") {
482
693
  const loaded = loadActiveReference(params.reference);
483
694
  return {
484
695
  content: [{ type: "text", text: loaded.content }],
485
- 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 },
486
697
  };
487
698
  }
488
699
  const terminal = params.action === "complete"
@@ -501,39 +712,23 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
501
712
  },
502
713
  });
503
714
 
504
- function isChineseText(text: string): boolean {
505
- return /[\u4e00-\u9fa5]/.test(text);
506
- }
507
-
508
- function deriveDialogTitle(explicitTitle?: string): string {
509
- if (explicitTitle && explicitTitle.trim()) return explicitTitle.trim();
510
- if (activeWorkflow) {
511
- return formatReadableWorkflowSubject(activeWorkflow.route, activeWorkflow.phase);
512
- }
513
- return "Decision";
514
- }
515
-
516
- function deriveDialogButtonNames(question: string): { ok: string; cancel: string } {
517
- const isZh = isChineseText(question);
518
- return {
519
- ok: isZh ? "确认" : "Select",
520
- cancel: isZh ? "取消" : "Cancel",
521
- };
522
- }
523
-
524
715
  pi.registerTool({
525
716
  name: "matt_pocock_ask",
526
717
  label: "Matt Pocock Ask",
527
718
  description: "Ask a structured workflow decision question via Pi UI selection.",
528
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
+ ],
529
724
  parameters: Type.Object({
530
725
  title: Type.Optional(Type.String({ description: "Short title summarizing the decision context; defaults to workflow route and phase." })),
531
726
  question: Type.String({ description: "The interview or decision question to ask the user." }),
532
- options: Type.Array(Type.String(), { description: "2 to 4 suggested options, recommended option first." }),
533
- recommended: Type.Optional(Type.String({ description: "The recommended option. When provided, timeout automatically adopts this option." })),
534
- 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." })),
535
730
  allow_custom: Type.Optional(Type.Boolean({ description: "Allow a custom typed answer; default true." })),
536
- }),
731
+ }, { additionalProperties: false }),
537
732
  renderShell: "self",
538
733
  renderCall: () => mattPocockRows.emptyCall(),
539
734
  renderResult(result, options, theme, context) {
@@ -547,140 +742,118 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
547
742
  : answerLines.map((line, index) => index === 0 ? `Answer: ${line}` : ` ${line}`);
548
743
  const metadata = [
549
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.
550
747
  details.timed_out && details.pending ? fieldLine("reason", "selection timed out") : undefined,
551
748
  details.source === "no_ui" ? fieldLine("reason", "no UI available") : undefined,
552
749
  details.is_custom ? fieldLine("source", "custom input") : undefined,
553
750
  ].filter((line): line is string => Boolean(line));
554
751
  return mattPocockRows.result(() => eventToolLifecycle("matt pocock", safeDisplayText(params.question ?? "question"), {
555
752
  label: "ask",
556
- summary: summary.length > 0 ? summary : ["Answer: (none)"],
557
- 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],
558
756
  }))(result, options, theme, context);
559
757
  },
560
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
758
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
561
759
  if (!activeWorkflow) throw new Error("No active Matt Pocock workflow.");
562
- if (!ctx.hasUI) {
563
- return {
564
- content: [{ type: "text", text: "[Pending user decision] No UI is available. Do not proceed until the user responds." }],
565
- details: { pending: true, source: "no_ui" },
566
- };
567
- }
568
- const allowCustom = params.allow_custom ?? true;
569
- const hasRecommended = typeof params.recommended === "string" && params.recommended.trim().length > 0;
570
- const timeoutMs = hasRecommended
571
- ? ((params.timeout_seconds !== undefined && params.timeout_seconds > 0) ? params.timeout_seconds * 1000 : 60 * 1000)
572
- : undefined;
573
-
574
- const customOption = "Type custom answer...";
575
- const choices = [...params.options];
576
- if (allowCustom && !choices.includes(customOption)) choices.push(customOption);
577
-
578
- const config = loadConfig();
579
- if (config.useNativeDialog && isNativeDialogSupported()) {
580
- try {
581
- const defaultItem = (hasRecommended && choices.includes(params.recommended!.trim()))
582
- ? params.recommended!.trim()
583
- : choices[0];
584
- const timeoutSeconds = hasRecommended
585
- ? ((params.timeout_seconds !== undefined && params.timeout_seconds > 0) ? params.timeout_seconds : 60)
586
- : undefined;
587
-
588
- const dialogTitle = deriveDialogTitle(params.title);
589
- const buttonNames = deriveDialogButtonNames(params.question);
590
-
591
- const nativeChoice = await macosChooseFromList({
592
- title: dialogTitle,
593
- prompt: params.question,
594
- items: choices,
595
- defaultItem,
596
- okButtonName: buttonNames.ok,
597
- cancelButtonName: buttonNames.cancel,
598
- timeoutSeconds,
599
- });
600
-
601
- if (nativeChoice.action === "timed_out") {
602
- if (hasRecommended) {
603
- const answer = params.recommended!.trim();
604
- return {
605
- content: [{ type: "text", text: `User selected (timeout default): ${answer}` }],
606
- details: { answer, is_custom: false, timed_out: true, pending: false, source: "timeout_recommended" },
607
- };
608
- }
609
- return {
610
- content: [{ type: "text", text: "[Pending user decision] Selection timed out. Do not proceed until the user responds." }],
611
- details: { pending: true, timed_out: true, source: "cancelled" },
612
- };
613
- }
614
-
615
- 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) {
616
812
  return {
617
- 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." }],
618
814
  details: { pending: true, timed_out: false, source: "cancelled" },
619
815
  };
620
816
  }
621
-
622
- if (nativeChoice.item === customOption) {
623
- const inputRes = await macosInputDialog({
624
- title: dialogTitle,
625
- prompt: params.question,
626
- defaultAnswer: "",
627
- buttons: [buttonNames.cancel, buttonNames.ok],
628
- defaultButton: buttonNames.ok,
629
- cancelButton: buttonNames.cancel,
630
- });
631
- 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
+ }
632
837
  if (!answer) {
633
838
  return {
634
- 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." }],
635
840
  details: { pending: true, source: "custom_input_cancelled" },
636
841
  };
637
842
  }
638
- return {
639
- content: [{ type: "text", text: `User answered (custom): ${answer}` }],
640
- details: { answer, is_custom: true, source: "custom_input" },
641
- };
642
843
  }
643
-
644
844
  return {
645
- content: [{ type: "text", text: `User selected: ${nativeChoice.item}` }],
646
- 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" },
647
847
  };
648
- } catch {
649
- // Graceful fallback to TUI on any native dialog error
650
848
  }
651
- }
652
849
 
653
- const selected = await ctx.ui.select(params.question, choices, timeoutMs !== undefined ? { timeout: timeoutMs } : undefined);
654
- if (selected === undefined) {
655
- if (hasRecommended) {
656
- const answer = params.recommended!.trim();
657
- return {
658
- content: [{ type: "text", text: `User selected (timeout default): ${answer}` }],
659
- details: { answer, is_custom: false, timed_out: true, pending: false, source: "timeout_recommended" },
660
- };
661
- }
662
- return {
663
- content: [{ type: "text", text: "[Pending user decision] No answer was selected. Do not proceed until the user responds." }],
664
- details: { pending: true, timed_out: false, source: "cancelled" },
665
- };
666
- }
667
- if (selected === customOption) {
668
- const answer = (await ctx.ui.input(params.question, "Enter your answer..."))?.trim();
669
- if (!answer) {
670
- return {
671
- content: [{ type: "text", text: "[Pending user decision] No custom answer was provided. Do not proceed until the user responds." }],
672
- details: { pending: true, source: "custom_input_cancelled" },
673
- };
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
+ }
674
854
  }
675
- return {
676
- content: [{ type: "text", text: `User answered (custom): ${answer}` }],
677
- details: { answer, is_custom: true, source: "custom_input" },
678
- };
679
- }
680
- return {
681
- content: [{ type: "text", text: `User selected: ${selected}` }],
682
- details: { answer: selected, is_custom: false, source: "choice_selected" },
683
- };
855
+ return await ask(false);
856
+ });
684
857
  },
685
858
  });
686
859
 
@@ -700,6 +873,10 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
700
873
  const token = command.match(/^\S+/)?.[0] ?? command;
701
874
  const task = command.slice(token.length).trim();
702
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
+ }
703
880
  if (token === "transition" && !task) {
704
881
  if (!ctx.hasUI) {
705
882
  notifyPi(ctx.ui, "Name a target: /matt-pocock transition <target>, or use the interactive menu.", "error");
@@ -708,16 +885,15 @@ export default function mattPocock(extensionApi: ExtensionAPI): void {
708
885
  return chooseTransition(ctx);
709
886
  }
710
887
  if (token === "transition") return applyTransition(ctx, task);
711
- if (token === "complete" && !task) return completeWorkflow(ctx);
888
+ if (token === "complete" && !task) return endWorkflow(ctx, "completed");
712
889
  if (token === "cancel" || token === "end") {
713
- return cancelWorkflow(ctx, task || `Cancelled by user with /matt-pocock ${token}.`);
890
+ return endWorkflow(ctx, "cancelled", task || `Cancelled by user with /matt-pocock ${token}.`);
714
891
  }
715
892
 
716
893
  if (findWorkflowRoute(token)) {
717
- if (activeWorkflow) throw new Error(`Workflow ${activeWorkflow.workItemId} is already active.`);
718
- const state = startWorkflow(token);
894
+ const step = startWorkflow(token, task);
719
895
  clearPiStatus(ctx.ui, "matt-pocock");
720
- deliverProcedure(workflowPrompt(state, "started workflow", task), { ...state, request: task });
896
+ deliverProcedure(step.text, { ...step.state, request: task });
721
897
  return;
722
898
  }
723
899
  const capability = findProcedure(token);