deepclause-pi 0.1.5 → 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 (49) hide show
  1. package/README.md +36 -0
  2. package/dist/diagram/extract.d.ts +5 -0
  3. package/dist/diagram/extract.js +701 -0
  4. package/dist/diagram/grade.d.ts +41 -0
  5. package/dist/diagram/grade.js +70 -0
  6. package/dist/diagram/validate.d.ts +36 -0
  7. package/dist/diagram/validate.js +148 -0
  8. package/dist/diagram/viewer.d.ts +36 -0
  9. package/dist/diagram/viewer.js +99 -0
  10. package/dist/diagram/workspace.d.ts +24 -0
  11. package/dist/diagram/workspace.js +106 -0
  12. package/dist/index.d.ts +8 -1
  13. package/dist/index.js +462 -17
  14. package/dist/model.d.ts +16 -0
  15. package/dist/model.js +28 -0
  16. package/dist/planner.d.ts +27 -2
  17. package/dist/planner.js +109 -4
  18. package/dist/runtime.d.ts +15 -1
  19. package/dist/runtime.js +116 -3
  20. package/dist/workspace.d.ts +3 -0
  21. package/dist/workspace.js +20 -0
  22. package/docs/DIAGRAM_INTEGRATION_PROPOSAL.md +154 -0
  23. package/docs/SPECKIT.md +222 -0
  24. package/docs/SPEC_LAYER_PROPOSAL.md +1893 -0
  25. package/package.json +1 -1
  26. package/src/assets/AGENTS.md +82 -0
  27. package/src/assets/apply.dml +188 -0
  28. package/src/assets/spec_apply.dml +20 -0
  29. package/src/assets/spec_archive.dml +11 -0
  30. package/src/assets/spec_coverage.dml +26 -0
  31. package/src/assets/spec_graph.dml +12 -0
  32. package/src/assets/spec_merge.dml +10 -0
  33. package/src/assets/spec_query.dml +10 -0
  34. package/src/assets/spec_scaffold.dml +10 -0
  35. package/src/assets/spec_status.dml +7 -0
  36. package/src/assets/spec_validate.dml +9 -0
  37. package/src/assets/specs.dml +991 -0
  38. package/src/assets/vendor/mermaid.min.js +3636 -0
  39. package/src/assets/viewer.template.html +319 -0
  40. package/src/diagram/extract.ts +721 -0
  41. package/src/diagram/grade.ts +104 -0
  42. package/src/diagram/validate.ts +188 -0
  43. package/src/diagram/viewer.ts +144 -0
  44. package/src/diagram/workspace.ts +109 -0
  45. package/src/index.ts +507 -16
  46. package/src/model.ts +46 -0
  47. package/src/planner.ts +123 -3
  48. package/src/runtime.ts +117 -2
  49. package/src/workspace.ts +24 -0
package/src/index.ts CHANGED
@@ -1,28 +1,53 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { access, mkdir, readFile, readdir, rename } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import { StringEnum } from "@earendil-works/pi-ai";
5
6
  import type { DMLEvent } from "deepclause-sdk";
6
7
  import { Type } from "typebox";
7
8
  import { buildInitialMessages } from "./context.js";
8
9
  import { loadConfig, setModelToolEnabled, type ContextMode } from "./config.js";
9
- import { executeDml } from "./runtime.js";
10
+ import { renderDml, renderSequence } from "./diagram/extract.js";
11
+ import { polishDiagram, resolveGrade, type DiagramGrade } from "./diagram/grade.js";
12
+ import { findChrome, validateMermaid, type MermaidView } from "./diagram/validate.js";
13
+ import {
14
+ buildEntriesViewer,
15
+ buildViewer,
16
+ openViewerInBrowser,
17
+ writeSidecar,
18
+ type DiagramEntry,
19
+ } from "./diagram/viewer.js";
20
+ import {
21
+ collectDiagramTargets,
22
+ diagramNameFor,
23
+ displayPath,
24
+ ensureDiagramDir,
25
+ resolveDiagramSource,
26
+ } from "./diagram/workspace.js";
27
+ import { completeTextWithPiModel } from "./model.js";
28
+ import { executeDml, gitRestore } from "./runtime.js";
10
29
  import { getPaths, initializeWorkspace, resolveDmlPath } from "./workspace.js";
11
30
  import {
12
31
  assemblePlanDml,
32
+ assembleTasksDml,
13
33
  buildPlanningPrompt,
14
34
  DC_PLAN_COMMIT_TOOL,
15
35
  isContextualPlan,
36
+ normalizePlanSlug,
16
37
  PI_AGENT_STEP_TOOL,
17
38
  readPlanRequiredTools,
18
39
  validateGeneratedPlan,
40
+ validateGeneratedTasks,
19
41
  validatePlanSpec,
42
+ writeChangeTasks,
20
43
  writePlanNonDestructively,
21
44
  type PlanningSnapshot,
22
45
  } from "./planner.js";
23
46
 
24
47
  const DC_RUN_TOOL = "dc_run";
25
- const AUTHORING_INSTRUCTION = `DeepClause programs live in .pi/deepclause/skills/ and executable generated plans live in .pi/deepclause/plans/. You may create and edit DML skills directly after consulting .pi/deepclause/AGENTS.md and DML_REFERENCE.md. Use /dc-plan when the user asks pi to design a contextual executable plan; finish that planning turn with dc_plan_commit. DeepClause compilation is unavailable, so generated content must already be valid DML. Users execute programs through /dc-run. If the opt-in dc_run tool is active, you may execute an ordinary skill with it, but contextual plans requiring pi_agent_step must be started by the user. Never invoke a compiler or create .deepclause/.`;
48
+ const DC_DIAGRAM_TOOL = "dc_diagram";
49
+ const DC_SPEC_GRAPH_TOOL = "dc_spec_graph";
50
+ const AUTHORING_INSTRUCTION = `DeepClause programs live in .pi/deepclause/skills/ and executable generated plans live in .pi/deepclause/plans/. You may create and edit DML skills directly after consulting .pi/deepclause/AGENTS.md and DML_REFERENCE.md. Use /dc-plan when the user asks pi to design a contextual executable plan; finish that planning turn with dc_plan_commit. When the user asks for a diagram, flowchart, or visual of a .dml file, call the dc_diagram tool with the exact path and the requested grade (presentation or specification); it writes the viewer under .pi/deepclause/diagrams/ and opens it, so do not hand-write Mermaid. DeepClause compilation is unavailable, so generated content must already be valid DML. Users execute programs through /dc-run. If the opt-in dc_run tool is active, you may execute an ordinary skill with it, but contextual plans requiring pi_agent_step must be started by the user. Never invoke a compiler or create .deepclause/. Capability specs live in .pi/deepclause/specs/ and change deltas in .pi/deepclause/changes/<slug>/specs/; validate them deterministically with /dc-check, and call dc_spec_graph when the user wants a graph of capabilities, requirements, scenarios or changes.`;
26
51
  const STATUS_KEY = "deepclause";
27
52
  const WIDGET_KEY = "deepclause-stream";
28
53
 
@@ -37,12 +62,16 @@ export interface ParsedRun {
37
62
  interface ParsedPlan {
38
63
  request: string;
39
64
  name?: string;
65
+ change?: string;
66
+ update?: boolean;
40
67
  debug: boolean;
41
68
  }
42
69
 
43
70
  interface PlanningTransaction {
44
71
  snapshot: PlanningSnapshot;
45
72
  nameOverride?: string;
73
+ change?: string;
74
+ update?: boolean;
46
75
  committed: boolean;
47
76
  startedAt: number;
48
77
  }
@@ -127,19 +156,31 @@ export function parseRun(input: string): ParsedRun {
127
156
  export function parsePlan(input: string): ParsedPlan {
128
157
  const tokens = splitArguments(input);
129
158
  let name: string | undefined;
159
+ let change: string | undefined;
160
+ let update = false;
130
161
  let debug = false;
131
162
  const requestParts: string[] = [];
132
163
  for (let index = 0; index < tokens.length; index++) {
133
164
  const token = tokens[index]!;
134
165
  if (token === "--debug" || token === "-d") debug = true;
166
+ else if (token === "--update") update = true;
135
167
  else if (token.startsWith("--name=")) name = token.slice("--name=".length);
136
168
  else if (token === "--name") name = tokens[++index];
169
+ else if (token.startsWith("--change=")) change = token.slice("--change=".length);
170
+ else if (token === "--change") change = tokens[++index];
137
171
  else requestParts.push(token);
138
172
  }
173
+ // allow the leading "update" keyword form: /dc-plan update --change=<slug> <request>
174
+ if (change && requestParts[0] === "update") {
175
+ update = true;
176
+ requestParts.shift();
177
+ }
139
178
  const request = requestParts.join(" ").trim();
140
- if (!request) throw new Error("Usage: /dc-plan <request> [--name=slug] [--debug]");
179
+ if (!request) throw new Error("Usage: /dc-plan <request> [--name=slug] [--change=slug] [--update] [--debug]");
141
180
  if (name !== undefined && !name.trim()) throw new Error("--name requires a non-empty slug");
142
- return { request, name: name?.trim(), debug };
181
+ if (change !== undefined && !change.trim()) throw new Error("--change requires a non-empty slug");
182
+ if (update && !change) throw new Error("--update requires --change=<slug>");
183
+ return { request, name: name?.trim(), change: change?.trim(), update, debug };
143
184
  }
144
185
 
145
186
  function messageText(message: unknown): string {
@@ -176,6 +217,56 @@ function eventSummary(event: DMLEvent, debug: boolean): string {
176
217
  }
177
218
  }
178
219
 
220
+ async function isMutatingSpecSkill(filePath: string): Promise<boolean> {
221
+ try {
222
+ return /^%\s*Mutating:\s*true\s*$/m.test(await readFile(filePath, "utf8"));
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * After a step that leaves the tree dirty, offer to commit it (or remind the user).
230
+ * A clean tree is what lets the next /dc-apply take a rollback snapshot.
231
+ */
232
+ export async function offerCommit(
233
+ pi: ExtensionAPI,
234
+ ctx: ExtensionContext,
235
+ action: string,
236
+ change: string,
237
+ ): Promise<void> {
238
+ let status;
239
+ try {
240
+ status = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
241
+ } catch {
242
+ return;
243
+ }
244
+ if (status.code !== 0) return; // not a repository: nothing to say
245
+ const files = status.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
246
+ if (files.length === 0) return; // clean
247
+
248
+ const message = `${action}: ${change}`;
249
+ const listed = files.slice(0, 12).join("\n");
250
+ const more = files.length > 12 ? `\n… and ${files.length - 12} more` : "";
251
+ const reminder = `${files.length} changed file(s):\n${listed}${more}\n\ngit add -A && git commit -m "${message}"`;
252
+
253
+ if (!ctx.hasUI) {
254
+ ctx.ui.notify(`Uncommitted changes. ${reminder}`, "warning");
255
+ return;
256
+ }
257
+ if (!await ctx.ui.confirm("Commit these changes?", `${reminder}\n\nCommit now?`)) {
258
+ ctx.ui.notify(`Remember to commit before continuing. ${reminder}`, "warning");
259
+ return;
260
+ }
261
+ await pi.exec("git", ["add", "-A"], { cwd: ctx.cwd });
262
+ const commit = await pi.exec("git", ["commit", "-m", message], { cwd: ctx.cwd });
263
+ if (commit.code === 0) {
264
+ ctx.ui.notify(`Committed: ${message}`, "info");
265
+ } else {
266
+ ctx.ui.notify(`Commit failed: ${commit.stderr.trim() || "see git output"}`, "error");
267
+ }
268
+ }
269
+
179
270
  async function listDmlFiles(directory: string, prefix = ""): Promise<string[]> {
180
271
  let entries;
181
272
  try {
@@ -197,6 +288,14 @@ function modelLabel(ctx: ExtensionCommandContext): string {
197
288
  return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none selected";
198
289
  }
199
290
 
291
+ async function bundledViewerTemplate(): Promise<string> {
292
+ return readFile(fileURLToPath(new URL("./assets/viewer.template.html", import.meta.url)), "utf8");
293
+ }
294
+
295
+ function viewerVendorAssetPath(): string {
296
+ return fileURLToPath(new URL("./assets/vendor/mermaid.min.js", import.meta.url));
297
+ }
298
+
200
299
  function publishResult(pi: ExtensionAPI, content: string, details: Record<string, unknown>): void {
201
300
  pi.sendMessage({ customType: "deepclause-result", content, display: true, details });
202
301
  }
@@ -205,6 +304,8 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
205
304
  let activeController: AbortController | undefined;
206
305
  let activeDescription: string | undefined;
207
306
  let modelToolRegistered = false;
307
+ let diagramToolRegistered = false;
308
+ let specGraphToolRegistered = false;
208
309
  let planCommitRegistered = false;
209
310
  let planningTransaction: PlanningTransaction | undefined;
210
311
  let pendingAgentStep: PendingAgentStep | undefined;
@@ -233,6 +334,8 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
233
334
  requiredTools: Type.Array(Type.String()),
234
335
  relevantSkills: Type.Array(Type.String()),
235
336
  expectedResult: Type.String(),
337
+ satisfies: Type.Optional(Type.Array(Type.String())),
338
+ checks: Type.Optional(Type.Array(Type.String())),
236
339
  }), { minItems: 1, maxItems: 12 }),
237
340
  finalSynthesis: Type.Optional(Type.String()),
238
341
  failureMessage: Type.String(),
@@ -253,14 +356,18 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
253
356
  }
254
357
 
255
358
  try {
256
- const plan = validatePlanSpec(params, transaction.snapshot, transaction.nameOverride);
359
+ const plan = validatePlanSpec(params, transaction.snapshot, transaction.nameOverride, {
360
+ requireChecks: Boolean(transaction.change),
361
+ change: transaction.change,
362
+ });
257
363
  const preview = [
258
364
  plan.spec.title,
259
365
  `Objective: ${plan.spec.objective}`,
366
+ transaction.change ? `Change: ${transaction.change}` : "",
260
367
  `Steps: ${plan.spec.steps.length}`,
261
368
  `Pi tools: ${plan.requiredTools.join(", ") || "none"}`,
262
- ...plan.spec.steps.map((step, index) => `${index + 1}. [${step.executor}] ${step.title}`),
263
- ].join("\n");
369
+ ...plan.spec.steps.map((step, index) => `${index + 1}. [${step.executor}] ${step.title}${step.checks.length ? ` (${step.checks.length} checks)` : ""}`),
370
+ ].filter(Boolean).join("\n");
264
371
  if (!ctx.hasUI || !await ctx.ui.confirm("Create executable DeepClause plan?", preview)) {
265
372
  return {
266
373
  content: [{ type: "text", text: "Plan creation was not approved." }],
@@ -269,15 +376,25 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
269
376
  }
270
377
 
271
378
  const paths = await initializeWorkspace(ctx.cwd);
272
- const dml = assemblePlanDml(plan, transaction.snapshot);
273
- await validateGeneratedPlan(dml);
274
- const filePath = await writePlanNonDestructively(paths, plan.spec.slug, dml);
379
+ const content = transaction.change
380
+ ? assembleTasksDml(plan, transaction.snapshot)
381
+ : assemblePlanDml(plan, transaction.snapshot);
382
+ if (transaction.change) await validateGeneratedTasks(content);
383
+ else await validateGeneratedPlan(content);
384
+ const filePath = transaction.change
385
+ ? await writeChangeTasks(paths, normalizePlanSlug(transaction.change), content, Boolean(transaction.update))
386
+ : await writePlanNonDestructively(paths, plan.spec.slug, content);
275
387
  transaction.committed = true;
276
388
  setPlanCommitActive(false);
389
+ await offerCommit(pi, ctx, "plan", transaction.change ? normalizePlanSlug(transaction.change) : plan.spec.slug);
277
390
  const relativePath = path.relative(paths.root, filePath).split(path.sep).join("/");
278
391
  const text = [
279
- `Created executable DML plan: .pi/deepclause/${relativePath}`,
280
- `Run it with: /dc-run ${relativePath}`,
392
+ transaction.change
393
+ ? `Created change plan: .pi/deepclause/${relativePath}`
394
+ : `Created executable DML plan: .pi/deepclause/${relativePath}`,
395
+ transaction.change
396
+ ? `Next: /dc-check ${normalizePlanSlug(transaction.change)}`
397
+ : `Run it with: /dc-run ${relativePath}`,
281
398
  plan.warnings.length ? `Warnings:\n${plan.warnings.join("\n")}` : "",
282
399
  ].filter(Boolean).join("\n\n");
283
400
  return {
@@ -285,6 +402,7 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
285
402
  details: {
286
403
  success: true,
287
404
  path: relativePath,
405
+ change: transaction.change,
288
406
  contextual: plan.spec.steps.some((step) => step.executor === "pi"),
289
407
  requiredTools: plan.requiredTools,
290
408
  warnings: plan.warnings,
@@ -507,9 +625,228 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
507
625
  }
508
626
  };
509
627
 
628
+ const setDiagramToolActive = () => {
629
+ if (!diagramToolRegistered) {
630
+ pi.registerTool({
631
+ name: DC_DIAGRAM_TOOL,
632
+ label: "Create DeepClause Diagram",
633
+ description: "Create a presentation-grade or specification-grade Mermaid diagram from any .dml file, write a self-contained offline viewer under .pi/deepclause/diagrams/, and open it.",
634
+ promptSnippet: "Create a presentation- or specification-grade diagram from a DML file",
635
+ promptGuidelines: [
636
+ "Use dc_diagram whenever the user asks for a diagram, flowchart, or visual of a .dml file; pass the exact path the user named.",
637
+ "Choose grade=presentation for slides and overviews and grade=specification for engineering detail; use grade=both only when the user asks for both.",
638
+ "Do not hand-write Mermaid or run diagram tools yourself; call dc_diagram and report the viewer result.",
639
+ ],
640
+ parameters: Type.Object({
641
+ dml: Type.String({ description: "Path to a .dml file, relative to the workspace or absolute. A leading @ is ignored." }),
642
+ grade: Type.Optional(Type.String({ description: "presentation (default), specification, or both. Synonyms such as detailed or technical map to specification." })),
643
+ view: Type.Optional(StringEnum(["flow", "sequence"] as const, { description: "Base layout used to seed the grade; default flow." })),
644
+ }),
645
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
646
+ if (activeController) {
647
+ return {
648
+ content: [{ type: "text", text: "Another DeepClause operation is already active; wait for it to finish." }],
649
+ details: { success: false, error: "execution_already_active" },
650
+ };
651
+ }
652
+
653
+ const requested = resolveGrade(String(params.grade ?? "")) ?? "presentation";
654
+ const grades: DiagramGrade[] = requested === "both" ? ["presentation", "specification"] : [requested];
655
+ const view: MermaidView = params.view === "sequence" ? "sequence" : "flow";
656
+
657
+ let sourcePath: string;
658
+ try {
659
+ sourcePath = await resolveDiagramSource(ctx.cwd, params.dml);
660
+ } catch (error) {
661
+ const message = error instanceof Error ? error.message : String(error);
662
+ return { content: [{ type: "text", text: `dc_diagram failed: ${message}` }], details: { success: false, error: message } };
663
+ }
664
+ if (!ctx.model) {
665
+ return {
666
+ content: [{ type: "text", text: "dc_diagram requires an active pi model. Select one and try again." }],
667
+ details: { success: false, error: "no_model" },
668
+ };
669
+ }
670
+
671
+ const config = await loadConfig(getPaths(ctx.cwd).config);
672
+ const source = await readFile(sourcePath, "utf8");
673
+ const display = displayPath(ctx.cwd, sourcePath);
674
+ const seed = view === "sequence"
675
+ ? renderSequence(display, source)
676
+ : renderDml(display, source, { hideOutput: true });
677
+ const targets = await collectDiagramTargets(ctx.cwd, [sourcePath]);
678
+ const name = diagramNameFor(sourcePath, targets, ctx.cwd);
679
+ const { diagrams, vendor } = await ensureDiagramDir(ctx.cwd, viewerVendorAssetPath());
680
+ const templateText = await bundledViewerTemplate();
681
+
682
+ const controller = new AbortController();
683
+ const cancel = () => controller.abort(signal?.reason ?? new Error("dc_diagram cancelled"));
684
+ if (signal?.aborted) cancel();
685
+ else signal?.addEventListener("abort", cancel, { once: true });
686
+ activeController = controller;
687
+ activeDescription = `diagram ${name} (${grades.join("+")})`;
688
+
689
+ const run = (command: string, args: string[], options?: { timeout?: number }) => pi.exec(command, args, options);
690
+ let chrome: string | undefined;
691
+ let chromeResolved = false;
692
+
693
+ try {
694
+ for (const grade of grades) {
695
+ const result = await polishDiagram({
696
+ grade,
697
+ view,
698
+ source,
699
+ seed,
700
+ maxTokens: config.maxTokens,
701
+ signal: controller.signal,
702
+ complete: (options) => completeTextWithPiModel(ctx, options),
703
+ validate: async (code) => {
704
+ if (!chromeResolved) {
705
+ chrome = await findChrome(run);
706
+ chromeResolved = true;
707
+ }
708
+ const outcome = await validateMermaid(code, view, { run, vendorDir: vendor, chrome: chrome ?? null });
709
+ return outcome.result;
710
+ },
711
+ onProgress: (message) => onUpdate?.({
712
+ content: [{ type: "text", text: message }],
713
+ details: { dml: display, grades, name },
714
+ }),
715
+ });
716
+ await writeSidecar(diagrams, name, grade, result.code);
717
+ }
718
+
719
+ const build = await buildViewer({
720
+ cwd: ctx.cwd,
721
+ templateText,
722
+ vendorAssetPath: viewerVendorAssetPath(),
723
+ extraPaths: [sourcePath],
724
+ });
725
+ const opened = ctx.hasUI
726
+ ? await openViewerInBrowser(pi, build.viewerPath, name, grades[0] ?? "presentation")
727
+ : false;
728
+ const viewer = displayPath(ctx.cwd, build.viewerPath);
729
+ const text = `Created ${grades.join(" + ")}-grade diagram for ${display}. Viewer: ${viewer}${opened ? " (opened in your browser)" : ""}`;
730
+ return {
731
+ content: [{ type: "text", text }],
732
+ details: { success: true, dml: display, grades, name, viewer, opened, chrome: Boolean(chrome) },
733
+ };
734
+ } catch (error) {
735
+ const message = error instanceof Error ? error.message : String(error);
736
+ return { content: [{ type: "text", text: `dc_diagram failed: ${message}` }], details: { success: false, error: message } };
737
+ } finally {
738
+ signal?.removeEventListener("abort", cancel);
739
+ activeController = undefined;
740
+ activeDescription = undefined;
741
+ }
742
+ },
743
+ });
744
+ diagramToolRegistered = true;
745
+ }
746
+
747
+ const activeTools = pi.getActiveTools();
748
+ if (!activeTools.includes(DC_DIAGRAM_TOOL)) {
749
+ pi.setActiveTools([...activeTools, DC_DIAGRAM_TOOL]);
750
+ }
751
+ };
752
+
753
+ const setSpecGraphActive = () => {
754
+ if (!specGraphToolRegistered) {
755
+ pi.registerTool({
756
+ name: DC_SPEC_GRAPH_TOOL,
757
+ label: "Spec Graph",
758
+ description: "Create a Mermaid graph of DeepClause capabilities, requirements, scenarios and changes, write a viewer under .pi/deepclause/diagrams/, and open it.",
759
+ promptSnippet: "Create a capability/change graph from DeepClause spec facts",
760
+ promptGuidelines: [
761
+ "Call dc_spec_graph when the user asks for a graph or visual of capabilities, requirements, changes, or spec coverage.",
762
+ ],
763
+ parameters: Type.Object({
764
+ view: Type.Optional(StringEnum(["capabilities", "changes"] as const)),
765
+ }),
766
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
767
+ try {
768
+ const view = params.view ?? "capabilities";
769
+ const mermaid = await runSpecSkill(ctx, "spec_graph", [view]);
770
+ const name = `spec-${view}`;
771
+ const entry: DiagramEntry = {
772
+ name,
773
+ path: `specs (${view})`,
774
+ flow: mermaid,
775
+ seq: "",
776
+ dml: "",
777
+ presentation: mermaid,
778
+ specification: null,
779
+ };
780
+ const build = await buildEntriesViewer({
781
+ cwd: ctx.cwd,
782
+ templateText: await bundledViewerTemplate(),
783
+ vendorAssetPath: viewerVendorAssetPath(),
784
+ entries: [entry],
785
+ });
786
+ const opened = ctx.hasUI
787
+ ? await openViewerInBrowser(pi, build.viewerPath, name, "presentation")
788
+ : false;
789
+ const viewer = displayPath(ctx.cwd, build.viewerPath);
790
+ return {
791
+ content: [{ type: "text", text: `Created spec graph (${view}). Viewer: ${viewer}${opened ? " (opened in your browser)" : ""}` }],
792
+ details: { success: true, view, viewer, opened },
793
+ };
794
+ } catch (error) {
795
+ const message = error instanceof Error ? error.message : String(error);
796
+ return { content: [{ type: "text", text: `dc_spec_graph failed: ${message}` }], details: { success: false, error: message } };
797
+ }
798
+ },
799
+ });
800
+ specGraphToolRegistered = true;
801
+ }
802
+ const activeTools = pi.getActiveTools();
803
+ if (!activeTools.includes(DC_SPEC_GRAPH_TOOL)) {
804
+ pi.setActiveTools([...activeTools, DC_SPEC_GRAPH_TOOL]);
805
+ }
806
+ };
807
+
808
+ const runSpecSkill = async (
809
+ ctx: ExtensionContext,
810
+ skill: string,
811
+ args: string[] = [],
812
+ options: { verifyCommands?: string[]; piAgentStep?: boolean; changeJsonPath?: string } = {},
813
+ ): Promise<string> => {
814
+ const paths = await initializeWorkspace(ctx.cwd);
815
+ const config = await loadConfig(paths.config);
816
+ const filePath = await resolveDmlPath(paths, skill);
817
+ const controller = new AbortController();
818
+ activeController = controller;
819
+ activeDescription = `running ${skill}`;
820
+ try {
821
+ const result = await executeDml(
822
+ filePath,
823
+ args,
824
+ [],
825
+ config,
826
+ pi,
827
+ ctx,
828
+ controller,
829
+ {
830
+ onEvent() {},
831
+ onInput: async () => { throw new Error("spec skills do not request input"); },
832
+ },
833
+ options.piAgentStep ? (request, signal) => runPiAgentStep(request, signal, ctx) : undefined,
834
+ options.verifyCommands ?? [],
835
+ options.changeJsonPath,
836
+ );
837
+ if (result.errors.length) throw new Error(result.errors.join("\n"));
838
+ return result.answer ?? "(no answer)";
839
+ } finally {
840
+ activeController = undefined;
841
+ activeDescription = undefined;
842
+ }
843
+ };
844
+
510
845
  pi.on("session_start", async (_event, ctx) => {
511
846
  const config = await loadConfig(getPaths(ctx.cwd).config);
512
847
  setModelToolActive(config.modelToolEnabled);
848
+ setDiagramToolActive();
849
+ setSpecGraphActive();
513
850
  });
514
851
 
515
852
  pi.on("tool_execution_start", (event) => {
@@ -590,9 +927,15 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
590
927
  `Plans: ${path.relative(ctx.cwd, paths.plans)}`,
591
928
  `Context: ${config.contextMode} (verbose default: ${config.verbose})`,
592
929
  `Model tool (${DC_RUN_TOOL}): ${config.modelToolEnabled && pi.getActiveTools().includes(DC_RUN_TOOL) ? "enabled" : "disabled"}`,
930
+ `Model tool (${DC_DIAGRAM_TOOL}): ${pi.getActiveTools().includes(DC_DIAGRAM_TOOL) ? "enabled" : "disabled"}`,
931
+ "Ask pi for a presentation-grade or specification-grade diagram of any .dml file;",
932
+ "it writes the viewer under .pi/deepclause/diagrams/ and opens it.",
593
933
  "Commands:",
594
934
  " /dc-list",
595
- " /dc-plan <request> [--name=slug] create an executable contextual DML plan",
935
+ " /dc-plan <request> [--change=slug] [--update] [--name=slug] create or regenerate a plan",
936
+ " /dc-check <change|spec> validate specs and deltas deterministically",
937
+ " /dc-archive <change> merge a change delta into specs/ and archive it",
938
+ " /dc-apply <change> [--abort] execute tasks.dml; --abort discards an interrupted apply",
596
939
  " /dc-run <skill|path> [args] [--context=turn|branch|isolated]",
597
940
  " /dc-run <skill|path> --verbose show lifecycle events",
598
941
  " /dc-run <skill|path> --debug show full event payloads and SDK diagnostics",
@@ -614,6 +957,16 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
614
957
  const parsed = parsePlan(rawArgs);
615
958
  if (!ctx.model) throw new Error("Select a pi model before creating a plan");
616
959
  const paths = await initializeWorkspace(ctx.cwd);
960
+ if (parsed.change && !parsed.update) {
961
+ const changeSlug = normalizePlanSlug(parsed.change);
962
+ try {
963
+ await access(path.join(paths.changes, changeSlug, "tasks.dml"));
964
+ ctx.ui.notify(`changes/${changeSlug}/tasks.dml already exists. Re-run with --update to regenerate it, or edit tasks.dml directly.`, "error");
965
+ return;
966
+ } catch {
967
+ // no existing plan: proceed
968
+ }
969
+ }
617
970
  const promptOptions = ctx.getSystemPromptOptions();
618
971
  const snapshot: PlanningSnapshot = {
619
972
  model: `${ctx.model.provider}/${ctx.model.id}`,
@@ -628,12 +981,21 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
628
981
  planningTransaction = {
629
982
  snapshot,
630
983
  nameOverride: parsed.name,
984
+ change: parsed.change,
985
+ update: parsed.update,
631
986
  committed: false,
632
987
  startedAt: Date.now(),
633
988
  };
634
989
  setPlanCommitActive(true);
635
- ctx.ui.notify("Starting a contextual pi planning turn. Review the generated plan before it is written.", "info");
636
- pi.sendUserMessage(buildPlanningPrompt(parsed.request, snapshot, parsed.name));
990
+ ctx.ui.notify(
991
+ parsed.change
992
+ ? parsed.update
993
+ ? `Regenerating the change plan for '${parsed.change}'. Existing artifacts are read first and tasks.dml statuses reset to pending.`
994
+ : `Starting a change planning turn for '${parsed.change}'. Review the delta and plan before it is written.`
995
+ : "Starting a contextual pi planning turn. Review the generated plan before it is written.",
996
+ "info",
997
+ );
998
+ pi.sendUserMessage(buildPlanningPrompt(parsed.request, snapshot, parsed.name, parsed.change, parsed.update));
637
999
  } catch (error) {
638
1000
  planningTransaction = undefined;
639
1001
  setPlanCommitActive(false);
@@ -698,6 +1060,131 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
698
1060
  },
699
1061
  });
700
1062
 
1063
+ pi.registerCommand("dc-check", {
1064
+ description: "Validate DeepClause specs and change deltas deterministically (no model calls)",
1065
+ handler: async (_rawArgs, ctx) => {
1066
+ if (activeController || !ctx.isIdle()) {
1067
+ ctx.ui.notify("DeepClause or pi is already active; wait before running /dc-check", "warning");
1068
+ return;
1069
+ }
1070
+ try {
1071
+ const answer = await runSpecSkill(ctx, "spec_validate");
1072
+ publishResult(pi, answer, { skill: "spec_validate" });
1073
+ ctx.ui.notify(answer.startsWith("spec check: OK") ? "Spec check passed" : "Spec check reported errors", answer.startsWith("spec check: OK") ? "info" : "warning");
1074
+ } catch (error) {
1075
+ const message = error instanceof Error ? error.message : String(error);
1076
+ publishResult(pi, `Spec check failed: ${message}`, { error: message });
1077
+ ctx.ui.notify(message, "error");
1078
+ }
1079
+ },
1080
+ });
1081
+
1082
+ pi.registerCommand("dc-archive", {
1083
+ description: "Merge a change delta into specs/ after review, then move the change into changes/archive/",
1084
+ handler: async (rawArgs, ctx) => {
1085
+ if (activeController || !ctx.isIdle()) {
1086
+ ctx.ui.notify("DeepClause or pi is already active; wait before running /dc-archive", "warning");
1087
+ return;
1088
+ }
1089
+ const change = rawArgs.trim();
1090
+ if (!change) {
1091
+ ctx.ui.notify("Usage: /dc-archive <change>", "warning");
1092
+ return;
1093
+ }
1094
+ try {
1095
+ const plan = await runSpecSkill(ctx, "spec_merge", [change]);
1096
+ if (!ctx.hasUI || !await ctx.ui.confirm("Archive change into specs?", plan)) {
1097
+ ctx.ui.notify("Archive cancelled", "warning");
1098
+ return;
1099
+ }
1100
+ const applied = await runSpecSkill(ctx, "spec_archive", [change]);
1101
+ const paths = await initializeWorkspace(ctx.cwd);
1102
+ const from = path.join(paths.changes, change);
1103
+ const stamp = new Date().toISOString().slice(0, 10);
1104
+ await mkdir(path.join(paths.changes, "archive"), { recursive: true });
1105
+ let target = path.join(paths.changes, "archive", `${stamp}-${change}`);
1106
+ try {
1107
+ await access(target);
1108
+ target = `${target}-2`;
1109
+ } catch {
1110
+ // target is free
1111
+ }
1112
+ await rename(from, target);
1113
+ const archivedTo = path.relative(ctx.cwd, target).split(path.sep).join("/");
1114
+ publishResult(pi, `${applied}\n\n moved to ${archivedTo}`, { skill: "spec_archive", change, archivedTo });
1115
+ ctx.ui.notify(`Archived ${change}`, "info");
1116
+ await offerCommit(pi, ctx, "archive", change);
1117
+ } catch (error) {
1118
+ const message = error instanceof Error ? error.message : String(error);
1119
+ publishResult(pi, `Archive failed: ${message}`, { error: message });
1120
+ ctx.ui.notify(message, "error");
1121
+ }
1122
+ },
1123
+ });
1124
+
1125
+ pi.registerCommand("dc-apply", {
1126
+ description: "Execute a change's tasks.dml with per-task verification and bounded retries",
1127
+ handler: async (rawArgs, ctx) => {
1128
+ if (activeController || !ctx.isIdle()) {
1129
+ ctx.ui.notify("DeepClause or pi is already active; wait before running /dc-apply", "warning");
1130
+ return;
1131
+ }
1132
+ const tokens = splitArguments(rawArgs);
1133
+ const abort = tokens.includes("--abort");
1134
+ const change = tokens.filter((token) => token !== "--abort").join(" ").trim();
1135
+ if (!change) {
1136
+ ctx.ui.notify("Usage: /dc-apply <change> [--abort]", "warning");
1137
+ return;
1138
+ }
1139
+ try {
1140
+ const paths = await initializeWorkspace(ctx.cwd);
1141
+ const changeJson = path.join(paths.changes, change, "change.json");
1142
+
1143
+ if (abort) {
1144
+ const restored = await gitRestore(pi, ctx.cwd, changeJson).catch(() => null);
1145
+ const message = restored
1146
+ ? `Discarded the apply and restored the working tree to ${restored}.`
1147
+ : "No recorded apply snapshot to discard.";
1148
+ publishResult(pi, message, { skill: "spec_apply", change, aborted: Boolean(restored) });
1149
+ ctx.ui.notify(message, restored ? "warning" : "info");
1150
+ return;
1151
+ }
1152
+
1153
+ let started = false;
1154
+ let succeeded = false;
1155
+ try {
1156
+ const plan = await runSpecSkill(ctx, "spec_apply", [change, "plan"]);
1157
+ const commands = [...new Set([...plan.matchAll(/^command:\s*(.+)$/gm)].map((match) => match[1]!.trim()))];
1158
+ const preview = [plan, "", `Approved verification commands: ${commands.join(", ") || "none"}`].join("\n");
1159
+ if (!ctx.hasUI || !await ctx.ui.confirm("Apply change tasks?", preview)) {
1160
+ ctx.ui.notify("Apply cancelled", "warning");
1161
+ return;
1162
+ }
1163
+ started = true;
1164
+ const answer = await runSpecSkill(ctx, "spec_apply", [change, "apply"], { verifyCommands: commands, piAgentStep: true, changeJsonPath: changeJson });
1165
+ succeeded = answer.includes("status: OK");
1166
+ publishResult(pi, answer, { skill: "spec_apply", change });
1167
+ ctx.ui.notify(
1168
+ succeeded ? `Applied ${change}` : `Apply incomplete for ${change}`,
1169
+ succeeded ? "info" : "warning",
1170
+ );
1171
+ } finally {
1172
+ if (started && !succeeded) {
1173
+ ctx.ui.notify(
1174
+ `Apply interrupted; the working tree and task statuses were preserved. Resume with /dc-apply ${change}, or discard with /dc-apply ${change} --abort.`,
1175
+ "warning",
1176
+ );
1177
+ }
1178
+ }
1179
+ if (succeeded) await offerCommit(pi, ctx, "apply", change);
1180
+ } catch (error) {
1181
+ const message = error instanceof Error ? error.message : String(error);
1182
+ publishResult(pi, `Apply failed: ${message}`, { error: message });
1183
+ ctx.ui.notify(message, "error");
1184
+ }
1185
+ },
1186
+ });
1187
+
701
1188
  pi.registerCommand("dc-run", {
702
1189
  description: "Run a DML skill with pi's active model",
703
1190
  handler: async (rawArgs, ctx) => {
@@ -712,6 +1199,10 @@ export default function deepClauseExtension(pi: ExtensionAPI) {
712
1199
  const paths = await initializeWorkspace(ctx.cwd);
713
1200
  const config = await loadConfig(paths.config);
714
1201
  const filePath = await resolveDmlPath(paths, parsed.target);
1202
+ if (await isMutatingSpecSkill(filePath)) {
1203
+ ctx.ui.notify(`${parsed.target} modifies specs/. Use /dc-archive <change> so you can review the merge first.`, "warning");
1204
+ return;
1205
+ }
715
1206
  const contextualPlan = await isContextualPlan(filePath);
716
1207
  if (contextualPlan) {
717
1208
  const requiredTools = await readPlanRequiredTools(filePath);