@widnyana/design-thinking 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,59 @@
1
1
  /**
2
2
  * Design Thinking — pi extension
3
3
  *
4
- * Adopts the three pillars:
5
- * - Design Thinking : the mindset (§1–§10 pipeline, problem-first)
6
- * - Design Graph : the artifact (annotated call graph of a concrete problem)
7
- * - Graph Protocol : the notation spec (fixed sections, fixed markers)
4
+ * Adopts r17x's three methods (one shape, three materials):
5
+ * - Design Thinking : code, X → Graph → Effect<A, E, R> (§1-§10 pipeline)
6
+ * - Design Graph : interface, Job → Flow → Surface<C, V, N>
7
+ * - Graph Protocol : delegation, AGENT SUBGRAPH in, IMPLEMENTED GRAPH out
8
8
  *
9
9
  * When /dt mode is active, every turn appends a compact distilled block to the
10
- * system prompt so design/plan/review answers render as Design Graphs in
11
- * Graph Protocol form. Full specs live in the package's references/ directory,
10
+ * system prompt so design/plan/review answers render as an annotated call
11
+ * graph in the Design Thinking output format. Full specs live in the package's references/ directory,
12
12
  * loaded on demand — not inlined here, to keep the injected prompt small.
13
+ * Separately, an ALWAYS-ON LSP reminder composes into every run's system
14
+ * prompt while the harness exposes an `lsp` tool (mode-independent); without
15
+ * one, the user is told once per session how to enable it.
13
16
  *
14
17
  * Commands:
18
+ * /research-prompt <msg> Meta Research Prompt Generator (references/meta-research.md)
15
19
  * /dt toggle Design Thinking mode
16
20
  * /dt on|off set explicitly
17
- * /dt status show current state
18
- * /dt approve|deny arm/revoke file edits for the next agent run
19
- * /dt <prompt> turn mode ON (never off), then run <prompt> as a user
20
- * message — graph + clarifying questions before implementation
21
+ * /dt approve|deny manual arm/revoke fallback (headless / no-UI sessions)
22
+ * After a gated run presents a design graph, a review dialog opens
23
+ * automatically: Approve (arm + implement), Refine (send feedback), or
24
+ * Deny (keep file edits blocked). /dt <prompt> turns the mode ON (never
25
+ * off) and runs the prompt — graph + clarifying questions before code.
26
+ *
27
+ * Approval gate design (one state machine, one owner):
28
+ * - The gate is `{ mode: "off" | "on", approval: "none" | "armed" }`. Every
29
+ * change goes through a transition function; handlers never touch the
30
+ * fields directly.
31
+ * - Approval is scoped to ONE human turn. It survives the many runs of
32
+ * implementing an approved design, and is re-locked (relock()) when a new
33
+ * user instruction arrives — a genuine `input` event, not the extension's
34
+ * own go-ahead / refine messages. The next instruction is a refinement:
35
+ * the agent updates and re-presents the graph, then the dialog re-opens.
36
+ * - At most one review dialog exists. Each offer carries an epoch; a dialog
37
+ * decision is applied only if its epoch is still current AND the gate is
38
+ * still on AND still unapproved. Stale decisions are discarded, not
39
+ * applied.
40
+ * - The dialog opens at run end when the agent presented a design graph
41
+ * (VERDICT + section headers) OR ended the turn asking for approval in
42
+ * prose — the human always gets a button, never a prose-only ask.
43
+ * - Dialogs are opened with an extension-owned AbortSignal: omp binds
44
+ * handler-opened dialogs to the handler's 30s abort scope, and a floating
45
+ * promise does not escape that binding. If the dialog is still dismissed
46
+ * (runtime kill, or the agent's own dialog displacing it), the offer is
47
+ * retracted and a fresh one opens at the next run end. No reopen loops,
48
+ * no timing heuristics.
49
+ * - An explicit Deny latches until a NEW graph is presented: deny means
50
+ * stop asking.
21
51
  *
22
52
  * Stack-agnostic: no language detection, no auto-enable. Adapt the vocabulary
23
53
  * to whatever stack the project uses; never the discipline.
24
54
  */
25
55
 
56
+ import fs from "node:fs";
26
57
  import path from "node:path";
27
58
  import { fileURLToPath } from "node:url";
28
59
 
@@ -48,7 +79,7 @@ const DISTILLED = `
48
79
  DESIGN THINKING MODE — active.
49
80
  Read the problem. Draw the data flow as a call graph. Write code that IS the graph.
50
81
 
51
- X → DesignGraph<A, E, R>
82
+ X → Graph → Effect<A, E, R>
52
83
  │ │ │ │ │
53
84
  │ │ │ │ └─ R: what each node needs (§5)
54
85
  │ │ │ └──── E: where the graph breaks (§4)
@@ -62,65 +93,287 @@ Pipeline: shapes → graph(A) → cardinality → E(⟳retry ↯escape ☠die)
62
93
  🔒 parse at boundaries → ⛈ orthogonal behavior → scope → test-R → verdict.
63
94
 
64
95
  Rules:
65
- - ANY turn that will end in code or file edits (feature, change, fix,
96
+ - ANY turn that will end in code or file changes (feature, change, fix,
66
97
  refactor — the model does NOT get to reclassify the request): render the
67
- Design Graph in Graph Protocol form and any clarifying questions FIRST;
68
- ZERO file edits until the user approves the design. Reading/exploring code
69
- first is fine; the FIRST edit is not allowed before the graph. Once the
70
- user runs /dt approve, edits are armed for the NEXT run: implement the
71
- approved design without re-presenting the graph.
72
- - Design, plan, and review answers must render a Design Graph — fixed
98
+ graph in the Design Thinking output format and any clarifying questions FIRST;
99
+ ZERO mutations until the user approves the design — no file edits, no
100
+ shell commands, no code execution. Reading/exploring code first is fine,
101
+ via read-only tools only (read/grep/glob; read-only MCP devices such as
102
+ qmd and codegraph exploration also pass). If any tool call is blocked by
103
+ the gate, STOP: do NOT route the change around it via bash, eval, scripts,
104
+ or subagents — present or update the graph and end the turn. End the turn
105
+ after the graph with NO prose approval request — no "approve and I'll…", no
106
+ "let me know", no go-ahead questions. A review dialog opens automatically
107
+ when the run ends — never mid-run: approve (implement the presented design
108
+ without re-presenting the graph), refine (present an updated graph), or
109
+ deny (stay blocked).
110
+ - Design, plan, and review answers must render the graph, fixed
73
111
  sections: PROBLEM, SHAPES, GRAPH, CARDINALITY, BOUNDARIES, BEHAVIOR, SCOPE,
74
112
  TEST LAYERS, VERDICT.
75
113
  - The happy path (A) stays readable; failure handling lives at defined join points.
76
114
  - Each layer scopes its own E; inner errors never leak through.
77
115
  - If the code doesn't match the call graph, the implementation is wrong.
78
116
  - Adapt vocabulary to the current language/stack; never the discipline.
117
+ - Interface work uses the Design Graph (Surface<C, V, N>) skeleton.
118
+ - Delegating to subagents or workers follows Graph Protocol: AGENT SUBGRAPH
119
+ in, IMPLEMENTED GRAPH out, compared before the work counts as done.
79
120
 
80
121
  Full specs — read these when a section needs detail:
81
- - ${REF_DIR}/protocol.md (Graph Protocol: notation spec + worked example)
82
- - ${REF_DIR}/method.md (the §1–§10 method, generalized)
83
- - ${REF_DIR}/design-graph.md (the artifact spec + completeness checklist)
84
- Skills load BY NAME into the agent (/skill:<name>); the files above are named
85
- by stem, NOT by skill name. Exact mapping:
86
- /skill:graph-protocol → protocol.md /skill:design-method → method.md
87
- /skill:design-graph → design-graph.md
88
- The extension blocks write/edit tools mechanically until the user runs
89
- /dt approve — it arms the NEXT run, cleared when that run ends. Re-approve
90
- per implementation round. Present the graph first.
122
+ - ${REF_DIR}/design-thinking.md (the code method, §1-§10)
123
+ - ${REF_DIR}/output-format.md (sections, markers, call graph block, checklist)
124
+ - ${REF_DIR}/design-graph.md (the interface method + skeleton)
125
+ - ${REF_DIR}/graph-protocol.md (delegation: execution graph, AGENT SUBGRAPH, RETURN)
126
+ The extension blocks file edits, shells (bash/powershell), and code
127
+ execution (eval) mechanically until the user approves — a review dialog
128
+ opens when a graph-presenting run ends. Approval covers
129
+ ONE human turn: it stays armed across the many runs it takes to implement
130
+ the approved design, and drops the moment the user sends a new instruction.
131
+ Treat that next instruction as a refinement of the current design: UPDATE
132
+ the existing design graph to fold it in and re-present it (do not start a
133
+ blank graph unless the goal itself changed), then end the turn and wait for
134
+ the dialog. /dt deny or mode off also re-lock.
135
+ NEVER ask the user to run /dt approve, /dt deny, or any approval command:
136
+ the dialog is automatic, and mentioning commands just confuses the flow.
91
137
  NEVER read references/<skill-name>.md — no such files exist.
92
138
  `.trim();
93
139
 
94
- // pi's file-edit tools — the mechanical gate's scope ("file edits"). Bash
95
- // stays open for read-only exploration. apply_patch/multiedit are Claude Code
96
- // tool names that never fire in pi; keep this list to real pi mutators.
140
+ // pi + omp mutation tools — the mechanical gate's scope. While unapproved,
141
+ // these are blocked outright: file edits (write, edit), shells (bash, and
142
+ // powershell on Windows), and omp's code-execution kernel (eval). Names a
143
+ // harness lacks never fire — a no-op there (powershell on unix, eval on pi).
144
+ // Bash is NOT pattern-gated: a deny-list of shell mutators is porous by
145
+ // construction (redirects, package managers, interpreters — `go mod init` is
146
+ // the proof), and the design turn should execute nothing anyway. Read-only
147
+ // exploration keeps its dedicated tools (read/grep/find/ls/glob); read-only
148
+ // MCP devices routed through write on omp pass too (see READONLY_MCP_PATH).
97
149
  const MUTATING_TOOLS: Record<string, true> = {
98
150
  write: true,
99
151
  edit: true,
152
+ bash: true,
153
+ powershell: true,
154
+ eval: true,
155
+ };
156
+ // omp executes MCP tools through the `write` tool as `xd://mcp__<tool>`
157
+ // device paths; the blanket write block would eat them. Pure knowledge
158
+ // lookups are carved out in the tool_call handler: qmd (retrieve/search)
159
+ // and codegraph's read-only actions. Everything else stays blocked — file
160
+ // writes and execution devices (ast_edit, lsp, debug, browser,
161
+ // chrome_devtools, codegraph init/index/sync/unlock); unknown codegraph
162
+ // actions and unparsable device args default to blocked.
163
+ const READONLY_MCP_PATH =
164
+ /^xd:\/\/mcp__(?:[\w-]+__)?(?:qmd_(?:get|multi_get|query|status|search)|codegraph_explore)$/;
165
+
166
+ const READONLY_CODEGRAPH_ACTIONS: Record<string, true> = {
167
+ explore: true,
168
+ node: true,
169
+ search: true,
170
+ files: true,
171
+ status: true,
172
+ callers: true,
173
+ callees: true,
174
+ impact: true,
175
+ affected: true,
176
+ help: true,
100
177
  };
101
178
 
102
179
  interface DtState {
103
180
  enabled: boolean;
104
181
  }
105
182
 
183
+
184
+
106
185
  const GATE_REASON =
107
- "Design Thinking mode is ON: present the Design Graph (Graph Protocol) and wait for user approval before file edits. The user arms the NEXT run with /dt approve; the approval clears when that run ends.";
186
+ "Design Thinking mode is ON: file edits, shell commands, and code execution are blocked until the user approves the presented design. Do NOT attempt this change through any other channel — no bash or shell commands, no eval/scripts, no subagent delegation. Explore with read-only tools (read/grep/glob), present (or update and re-present) the design graph, then END YOUR TURN with NO prose approval request — an approval dialog opens for the user automatically when the run ends. NEVER ask the user to run /dt approve, /dt deny, or any command; NEVER re-attempt the blocked action this turn.";
187
+
188
+ const REVIEW_OPTIONS = [
189
+ "Approve — implement now",
190
+ "Refine — ask the agent for changes",
191
+ "Deny — keep file edits blocked",
192
+ ] as const;
193
+
194
+ const GO_AHEAD_MESSAGE =
195
+ "Approved — implement the presented design now. (Design Thinking gate is armed; go straight to implementation, no graph re-presentation.)";
196
+
197
+ /** Always-on LSP discipline block — mode-INDEPENDENT: it composes into every
198
+ * run's system prompt while a language server exists, whether or not /dt is
199
+ * on. */
200
+ const LSP_REMINDER = `
201
+ LSP FIRST — a language server is available in this session.
202
+ - definition, type_definition, implementation, references, hover, symbols,
203
+ rename, rename_file, code_actions, diagnostics: use the \`lsp\` tool.
204
+ - NEVER hand-grep for definitions/references or hand-edit cross-file renames
205
+ when \`lsp\` answers the same question — it follows shadowing and re-exports
206
+ that text search misses.
207
+ - Imports and quick-fixes: list \`code_actions\`, apply one.
208
+ - If \`lsp\` reports no configured servers for this project, tell the user ONCE
209
+ this session how to enable one (project lsp.json or server install), then
210
+ continue without repeating it.
211
+ `.trim();
212
+
213
+ const LSP_SUGGESTION_OMP =
214
+ "LSP Reminder: no `lsp` tool in this session — LSP is disabled. Enable it in settings, or add a server for this project (lsp.json; auto-detect needs a root marker plus the server binary on PATH). Until then, code intelligence falls back to text search.";
215
+
216
+ const LSP_SUGGESTION_PI =
217
+ "LSP Reminder: pi has no LSP integration — code intelligence falls back to text search. omp provides a built-in `lsp` tool.";
218
+
219
+ // Output-format section headers (code and interface skeletons). A real graph renders the fixed set;
220
+ // VERDICT is mandatory. Requiring VERDICT + ≥3 others avoids tripping on a bare
221
+ // mention of the word "verdict" in prose, and still fires on a graph rendered
222
+ // without that exact literal elsewhere.
223
+ const SECTION_HEADER =
224
+ /^[ \t]*#{0,4}[ \t>*-]*(PROBLEM|SHAPES|GRAPH|CARDINALITY|BOUNDARIES|BEHAVIOR|SCOPE|TEST LAYERS|JOB|SURFACES|FLOW|VOID|NEEDS|BOUNDARY|ATTENTION|PROOF|VERDICT)\b/gm;
225
+
226
+ /** The agent is asking the human for a go-ahead in prose — narrow on purpose so
227
+ * an ordinary closing sentence does not trip it. */
228
+ const APPROVAL_ASK =
229
+ /\b(approve|go[- ]?ahead|await(?:ing)? (?:your )?(?:approval|go)|shall i (?:implement|proceed|write|build|start)|should i (?:implement|proceed|write|build|start)|want me to (?:implement|write|build|start|proceed)|ready to implement)\b/i;
230
+
231
+ /** Flatten an AgentMessage's content to plain text (string or content parts). */
232
+ function messageText(content: unknown): string {
233
+ if (typeof content === "string") return content;
234
+ if (!Array.isArray(content)) return "";
235
+ return content
236
+ .map((part) =>
237
+ part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
238
+ ? (part as { text: string }).text
239
+ : "",
240
+ )
241
+ .join("\n");
242
+ }
243
+
244
+ /** Does an assistant message render a design graph (code or interface skeleton)? */
245
+ function presentsGraph(message: { role: string; content?: unknown }): boolean {
246
+ if (message.role !== "assistant") return false;
247
+ const seen = new Set<string>();
248
+ for (const m of messageText(message.content).matchAll(SECTION_HEADER)) seen.add(m[1]);
249
+ return seen.has("VERDICT") && seen.size >= 4;
250
+ }
251
+
252
+ /** Did the agent end its turn asking the human to approve, in prose? */
253
+ function asksApproval(message: { role: string; content?: unknown }): boolean {
254
+ return message.role === "assistant" && APPROVAL_ASK.test(messageText(message.content));
255
+ }
256
+
257
+ interface Offer {
258
+ epoch: number;
259
+ abort: AbortController;
260
+ }
108
261
 
109
262
  export default function designThinkingExtension(pi: ExtensionAPI) {
110
- let enabled = false;
111
- // Armed by /dt approve for the NEXT agent run. Cleared when that run ends
112
- // (agent_end), by /dt deny, or when the mode turns off. Not persisted.
113
- let editsApproved = false;
263
+ // ── Gate state — the ONLY mutable state; change it via the transitions
264
+ // below, never inline in a handler. ─────────────────────────────────────
265
+ let mode: "off" | "on" = "off";
266
+ // "armed" lets mutating tools through. Approval is NOT consumed by runs:
267
+ // implementing an approved design spans many runs, and re-arming per run
268
+ // only produced approval loops. It is re-locked by relock() when a new user
269
+ // instruction arrives, when a NEW design graph is presented, by /dt deny, or
270
+ // when the mode turns off.
271
+ let approval: "none" | "armed" = "none";
272
+ // A design graph was presented in the CURRENT design cycle. Reset by
273
+ // relock() — i.e. when a new user instruction arrives. Approval is scoped to
274
+ // one human turn: it survives the many runs of implementing an approved
275
+ // design, and drops the moment the user speaks again (a new goal, or a
276
+ // refinement of the current one → re-graph, re-approve).
277
+ let cycleGraphSeen = false;
278
+ // Set by an explicit deny (dialog or /dt deny). Latches until a NEW graph
279
+ // is presented — deny means stop asking.
280
+ let graphDenied = false;
281
+ // Dialog offers. Monotonic epoch; a decision applies only when its epoch
282
+ // matches the live offer and the gate still allows arming.
283
+ let nextEpoch = 1;
284
+ let offer: Offer | undefined;
285
+ // How many messages of the session have already been scanned for VERDICT.
286
+ let scannedMessages = 0;
287
+
288
+ // ── LSP reminder state — always-on, independent of dt mode ───────────────
289
+ // Probed once per session; the suggestion fires at most once per session
290
+ // (reset on session_start). Probing uses pi.getAllTools(), present on both
291
+ // pi 0.84.3 and omp; omp is detected via its pi.logger surface (absent on
292
+ // pi) only to pick the suggestion text.
293
+ let lspProbed = false;
294
+ let lspCapable = false;
295
+ let lspSuggested = false;
296
+
297
+ function isOmp(): boolean {
298
+ return typeof (pi as ExtensionAPI & { logger?: unknown }).logger === "object";
299
+ }
300
+
301
+ function probeLsp(): void {
302
+ lspProbed = true;
303
+ try {
304
+ const tools: Array<{ name?: string }> =
305
+ (pi as ExtensionAPI & { getAllTools?: () => unknown[] }).getAllTools?.() ?? [];
306
+ lspCapable = tools.some((t) => t?.name === "lsp");
307
+ } catch {
308
+ lspCapable = false;
309
+ }
310
+ }
311
+
312
+ function maybeSuggestLsp(ctx: ExtensionContext): void {
313
+ if (lspSuggested || lspCapable || !ctx.hasUI) return;
314
+ lspSuggested = true;
315
+ ctx.ui.notify(isOmp() ? LSP_SUGGESTION_OMP : LSP_SUGGESTION_PI, "info");
316
+ }
317
+
318
+ // omp-only pi.logger surface; absent on pi 0.84.3.
319
+ const logger = (pi as ExtensionAPI & { logger?: { warn?: (msg: string) => void } }).logger;
320
+
321
+ // ── Transitions ─────────────────────────────────────────────────────────
322
+ function setMode(on: boolean): void {
323
+ mode = on ? "on" : "off";
324
+ if (!on) {
325
+ approval = "none";
326
+ cycleGraphSeen = false;
327
+ graphDenied = false;
328
+ retractOffer();
329
+ }
330
+ }
331
+
332
+ /** Arm the gate. Fails when the mode is off or an approval is already in
333
+ * effect — the caller decides how to surface that. */
334
+ function arm(): boolean {
335
+ if (mode !== "on" || approval !== "none") return false;
336
+ approval = "armed";
337
+ retractOffer();
338
+ return true;
339
+ }
340
+
341
+ /** Explicit revoke (/dt deny). Latches graph review until a new graph. */
342
+ function revoke(): void {
343
+ approval = "none";
344
+ graphDenied = true;
345
+ retractOffer();
346
+ }
347
+
348
+ /** A new user instruction opens a fresh design decision: re-lock the gate so
349
+ * the agent must present (or update and re-present) the graph and get a new
350
+ * approval before editing. Not called for the extension's own go-ahead /
351
+ * refine messages. */
352
+ function relock(): void {
353
+ if (mode !== "on") return;
354
+ approval = "none";
355
+ cycleGraphSeen = false;
356
+ graphDenied = false;
357
+ retractOffer();
358
+ }
359
+
360
+ function retractOffer(): void {
361
+ offer?.abort.abort();
362
+ offer = undefined;
363
+ }
364
+
365
+ /** May a dialog decision from `epoch` still be applied? */
366
+ function decisionApplies(epoch: number): boolean {
367
+ return offer?.epoch === epoch && mode === "on" && approval === "none";
368
+ }
114
369
 
370
+ // ── Session plumbing ────────────────────────────────────────────────────
115
371
  function applyStatus(ctx: ExtensionContext) {
116
- ctx.ui.setStatus(
117
- "design-thinking",
118
- enabled ? "design-thinking: on" : undefined,
119
- );
372
+ ctx.ui.setStatus("design-thinking", mode === "on" ? "design-thinking: on" : undefined);
120
373
  }
121
374
 
122
375
  function persist() {
123
- pi.appendEntry<DtState>(STATE_TYPE, { enabled });
376
+ pi.appendEntry<DtState>(STATE_TYPE, { enabled: mode === "on" });
124
377
  }
125
378
 
126
379
  function restore(ctx: ExtensionContext) {
@@ -134,13 +387,15 @@ export default function designThinkingExtension(pi: ExtensionAPI) {
134
387
  }
135
388
  }
136
389
  }
137
- if (restored !== undefined) {
138
- enabled = restored;
139
- }
390
+ if (restored !== undefined) setMode(restored);
140
391
  }
141
392
 
142
- // Restore toggle state when a session starts, loads, or reloads.
143
393
  pi.on("session_start", async (_event, ctx) => {
394
+ // A new session gets one fresh LSP probe and suggestion.
395
+ lspProbed = false;
396
+ lspSuggested = false;
397
+ probeLsp();
398
+ maybeSuggestLsp(ctx);
144
399
  restore(ctx);
145
400
  applyStatus(ctx);
146
401
  });
@@ -149,82 +404,242 @@ export default function designThinkingExtension(pi: ExtensionAPI) {
149
404
  ctx.ui.setStatus("design-thinking", undefined);
150
405
  });
151
406
 
152
- // /dt — the Design Thinking tool. Toggles mode; on|off|status set/query;
153
- // approve|deny arm/revoke file edits; anything else runs as a prompt with mode on.
407
+ // ── /dt command ─────────────────────────────────────────────────────────
154
408
  pi.registerCommand("dt", {
155
- description: "Design Thinking mode: /dt toggles, on|off|status set/query, approve arms the next run for file edits, deny revokes; anything else runs as a prompt with mode on",
409
+ description: "Design Thinking mode: /dt toggles, on|off|status set/query, approve asks then implements, deny re-locks; anything else runs as a prompt with mode on",
156
410
  handler: async (args, ctx) => {
157
411
  const arg = (args ?? "").trim();
158
412
  const lower = arg.toLowerCase();
159
413
 
160
414
  if (lower === "status") {
161
- ctx.ui.notify(`Design Thinking mode: ${enabled ? "on" : "off"}${editsApproved ? " (file edits armed for the next run)" : ""}`, "info");
415
+ ctx.ui.notify(`Design Thinking mode: ${mode === "on" ? "on" : "off"}${approval === "armed" ? " (gate armed)" : ""}`, "info");
162
416
  return;
163
417
  }
164
418
 
165
419
  if (lower === "approve") {
166
- if (!enabled) {
420
+ if (mode !== "on") {
167
421
  ctx.ui.notify("Design Thinking is OFF — nothing to approve. Run /dt on first.", "info");
168
422
  return;
169
423
  }
170
- editsApproved = true;
171
- ctx.ui.notify("Design Thinking: file edits armed (current or next run) — it may implement the approved design without re-presenting the graph", "info");
424
+ // Direct arm: the interactive approve/refine/deny dialog opens
425
+ // automatically after a gated run ends; this command is the
426
+ // headless/scripting path, so no nested dialog here.
427
+ if (!arm()) {
428
+ ctx.ui.notify("Design Thinking: gate is already armed", "info");
429
+ return;
430
+ }
431
+ ctx.ui.notify("Design Thinking: gate armed — implementing the approved design without re-presenting the graph", "info");
432
+ // Arming alone leaves the session idle: the agent never learns approval
433
+ // happened and the user sees "nothing happened". Send the go-ahead so
434
+ // the armed run starts now (steer if mid-run).
435
+ await pi.sendUserMessage(GO_AHEAD_MESSAGE, { deliverAs: "steer" });
172
436
  return;
173
437
  }
174
438
  if (lower === "deny") {
175
- if (!editsApproved) {
176
- ctx.ui.notify("Design Thinking: no approval in effect — file edits are already blocked", "info");
439
+ if (approval !== "armed") {
440
+ ctx.ui.notify("Design Thinking: no approval in effect — mutations are already blocked", "info");
177
441
  return;
178
442
  }
179
- editsApproved = false;
180
- ctx.ui.notify("Design Thinking: file edits blocked again", "info");
443
+ revoke();
444
+ ctx.ui.notify("Design Thinking: mutations blocked again", "info");
181
445
  return;
182
446
  }
183
-
184
447
  // "on"/"off" set explicitly; anything else is a prompt to run in this
185
448
  // mode and always turns the mode ON (never toggles it off); bare /dt toggles.
186
449
  const prompt = /^(on|off|status)$/.test(lower) ? "" : arg;
187
- enabled = lower === "on" || prompt ? true : lower === "off" ? false : !enabled;
188
- if (!enabled) editsApproved = false;
450
+ const on = lower === "on" || prompt ? true : lower === "off" ? false : mode === "off";
451
+ setMode(on);
189
452
  persist();
190
453
  applyStatus(ctx);
191
454
  ctx.ui.notify(
192
- enabled
193
- ? "Design Thinking ON — plans and reviews will render as Design Graphs (Graph Protocol)"
455
+ on
456
+ ? "Design Thinking ON — plans and reviews will render as annotated call graphs"
194
457
  : "Design Thinking OFF",
195
458
  "info",
196
459
  );
197
460
 
198
461
  if (prompt) {
462
+ // A prompt via /dt is a new goal: re-lock so the graph comes first
463
+ // (the input handler skips this — sendUserMessage is source "extension").
464
+ relock();
199
465
  // deliverAs "steer": sends immediately when idle, queues mid-stream.
200
466
  await pi.sendUserMessage(
201
- `${prompt}\n\n(Design Thinking is ON: before implementing, present the Design Graph (Graph Protocol) and any clarifying questions, then wait for my go-ahead.)`,
467
+ `${prompt}\n\n(Design Thinking is ON: before implementing, present the design graph and any clarifying questions, then wait for my go-ahead.)`,
202
468
  { deliverAs: "steer" },
203
469
  );
204
470
  }
205
471
  },
206
472
  });
207
473
 
208
- // While active, append the distilled block to the system prompt every run.
209
- pi.on("before_agent_start", async (event) => {
210
- if (!enabled) return undefined;
211
- return {
212
- systemPrompt: event.systemPrompt + "\n\n" + DISTILLED + "\n",
213
- };
474
+ // ── /research-prompt command ────────────────────────────────────────────
475
+ // r17x's Meta Research Prompt Generator. The template ships in references/;
476
+ // pi prompts cannot read package files, so the command reads it and sends
477
+ // it with the user's message (§6 boundary: file → text, read once here).
478
+ pi.registerCommand("research-prompt", {
479
+ description: "Meta Research Prompt Generator: writes ./research/projects/<NAME>/.<NAME>.research.prompt and prints the run command",
480
+ handler: async (args, ctx) => {
481
+ let generator: string;
482
+ try {
483
+ generator = fs.readFileSync(path.join(REF_DIR, "meta-research.md"), "utf8");
484
+ } catch (err) {
485
+ ctx.ui.notify(`research-prompt: cannot read ${REF_DIR}/meta-research.md (${(err as Error).message})`, "error");
486
+ return;
487
+ }
488
+ const harness = isOmp() ? "omp" : "pi";
489
+ await pi.sendUserMessage(
490
+ `${generator}\n\n---\n\nYou run inside ${harness}: in step 8 print only the ${harness} command.\n\nUser message:\n${(args ?? "").trim()}`,
491
+ { deliverAs: "steer" },
492
+ );
493
+ },
494
+ });
495
+
496
+ // ── Run hooks ───────────────────────────────────────────────────────────
497
+ // A genuine user instruction re-locks the gate: the previous approval covered
498
+ // the previous turn's work only. Our own go-ahead / refine messages arrive as
499
+ // source "extension" and must NOT re-lock.
500
+ pi.on("input", async (event) => {
501
+ if (mode !== "on") return undefined;
502
+ if (event.source === "extension") return undefined;
503
+ relock();
504
+ return undefined;
214
505
  });
215
506
 
216
- // An approved run consumes the approval: clear it when the run ends so
217
- // later runs are gated again until the user re-approves with /dt approve.
218
- pi.on("agent_end", async () => {
219
- editsApproved = false;
507
+ // Compose the system prompt: the distilled block is dt-mode-scoped, the
508
+ // LSP reminder is always-on. Either may be absent.
509
+ pi.on("before_agent_start", async (event) => {
510
+ if (!lspProbed) probeLsp();
511
+ const parts = [event.systemPrompt];
512
+ if (mode === "on") parts.push(DISTILLED + "\n");
513
+ if (lspCapable) parts.push(LSP_REMINDER + "\n");
514
+ if (parts.length === 1) return undefined;
515
+ return { systemPrompt: parts.join("\n\n") };
220
516
  });
221
517
 
222
- // Hard gate: while enabled, file-edit tools are blocked until the user
223
- // arms the next run with /dt approve. Prompt-level rules alone proved
224
- // insufficient — this is the mechanical backstop.
225
518
  pi.on("tool_call", async (event) => {
226
- if (!enabled || editsApproved) return undefined;
519
+ if (mode !== "on") return undefined;
227
520
  if (!MUTATING_TOOLS[event.toolName]) return undefined;
521
+ if (approval === "armed") return undefined;
522
+ // omp routes MCP calls through `write` (xd://mcp__… device paths); let
523
+ // read-only devices through. qmd devices are pure reads; codegraph
524
+ // only for its read-only actions — init/index/sync/unlock and
525
+ // unparsable args stay blocked.
526
+ if (event.toolName === "write") {
527
+ const input = event.input as { path?: unknown; content?: unknown } | undefined;
528
+ const path = input?.path;
529
+ if (typeof path === "string" && READONLY_MCP_PATH.test(path)) {
530
+ const isCodegraph = path.includes("codegraph_explore");
531
+ let action = "explore"; // codegraph's default action is a read
532
+ if (isCodegraph) {
533
+ try {
534
+ action = (JSON.parse(String(input?.content ?? "{}")) as { action?: string })
535
+ .action ?? "explore";
536
+ } catch {
537
+ action = "init"; // unparsable device args → treat as mutating
538
+ }
539
+ }
540
+ if (!isCodegraph || READONLY_CODEGRAPH_ACTIONS[action]) return undefined;
541
+ }
542
+ }
543
+ // Block only — NEVER open the review dialog here. Mid-run, the agent is
544
+ // still streaming, and a UI-up-while-the-agent-acts is the failure this
545
+ // mode exists to prevent. A blocked mutation returns the stop-and-do-not-
546
+ // route-around reason; agent_end offers the dialog (a graph seen earlier
547
+ // this cycle re-offers there too), so the UI appears only after the run
548
+ // ends and the agent has stopped.
228
549
  return { block: true, reason: GATE_REASON };
229
550
  });
551
+
552
+ // Run-end housekeeping: offer the review dialog when the agent presented a
553
+ // graph this cycle, or ended the turn asking for approval in prose (so the
554
+ // human always gets a button, never a prose-only ask). While an approval
555
+ // holds, edits stay open and no dialog is offered.
556
+ pi.on("agent_end", async (event, ctx) => {
557
+ // Keep the scan cursor in sync even while off, so re-enabling does not
558
+ // rescan or mis-slice history.
559
+ const messages = event.messages;
560
+ if (mode !== "on") {
561
+ scannedMessages = messages.length;
562
+ return;
563
+ }
564
+
565
+ // Incremental scan — only messages since the last run end. A long
566
+ // session never re-scans old history.
567
+ const fresh = messages.slice(Math.min(scannedMessages, messages.length));
568
+ scannedMessages = messages.length;
569
+ if (fresh.some(presentsGraph)) {
570
+ cycleGraphSeen = true;
571
+ graphDenied = false;
572
+ approval = "none"; // a presented graph always means: awaiting approval
573
+ }
574
+
575
+ if (approval === "armed") return;
576
+ if (graphDenied) return;
577
+ if (offer) return; // a dialog is already live
578
+ // Open the dialog on a presented graph, or a prose approval ask.
579
+ if (!cycleGraphSeen && !fresh.some(asksApproval)) return;
580
+ if (!ctx.hasUI) {
581
+ ctx.ui.notify("Design Thinking: review the presented design, then run /dt approve or /dt deny", "info");
582
+ return;
583
+ }
584
+ offerReview(ctx);
585
+ });
586
+
587
+ // ── Review dialog ───────────────────────────────────────────────────────
588
+ // One dialog per offer. Extension-owned AbortSignal: omp dismisses
589
+ // handler-opened dialogs when the handler's 30s budget expires, and a
590
+ // detached promise does not escape that binding — the signal replaces it.
591
+ // If the dialog is dismissed anyway (runtime kill, or the agent's own
592
+ // dialog displacing it), the offer is retracted; the next run end makes a
593
+ // fresh offer. A dismissed dialog is NOT a deny.
594
+ function offerReview(ctx: ExtensionContext): void {
595
+ const epoch = nextEpoch++;
596
+ const abort = new AbortController();
597
+ offer = { epoch, abort };
598
+
599
+ void (async () => {
600
+ const choice = await ctx.ui.select("Review the presented design", [...REVIEW_OPTIONS], {
601
+ signal: abort.signal,
602
+ });
603
+ if (!decisionApplies(epoch)) return; // superseded; discard silently
604
+
605
+ if (choice === undefined) {
606
+ // Dismissed, not decided. File edits stay blocked; a fresh offer
607
+ // opens at the next run end.
608
+ retractOffer();
609
+ return;
610
+ }
611
+ if (choice.startsWith("Deny")) {
612
+ graphDenied = true;
613
+ retractOffer();
614
+ ctx.ui.notify("Design Thinking: mutations blocked — nothing approved", "info");
615
+ return;
616
+ }
617
+ if (choice.startsWith("Approve")) {
618
+ if (!arm()) return; // gate changed while the human was deciding
619
+ ctx.ui.notify("Design Thinking: gate armed — implementing the approved design", "info");
620
+ await pi.sendUserMessage(GO_AHEAD_MESSAGE, { deliverAs: "steer" });
621
+ return;
622
+ }
623
+ // Refine: collect feedback and send it back; the dialog re-opens
624
+ // after the refined plan is presented.
625
+ const feedback = await ctx.ui.input(
626
+ "What should change in the design?",
627
+ "e.g. too complex — drop the cache layer",
628
+ { signal: abort.signal },
629
+ );
630
+ if (!decisionApplies(epoch)) return;
631
+ if (feedback === undefined || !feedback.trim()) {
632
+ retractOffer();
633
+ return;
634
+ }
635
+ retractOffer();
636
+ await pi.sendUserMessage(
637
+ `Refine the presented design (do not implement yet): ${feedback.trim()}\n\n(Design Thinking: update the design graph per this feedback and present it again. Mutations stay blocked.)`,
638
+ { deliverAs: "steer" },
639
+ );
640
+ })().catch((err: unknown) => {
641
+ if (offer?.epoch === epoch) retractOffer();
642
+ logger?.warn?.(`design-thinking: review dialog failed: ${String(err)}`);
643
+ });
644
+ }
230
645
  }