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/dist/index.js CHANGED
@@ -1,14 +1,23 @@
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 { StringEnum } from "@earendil-works/pi-ai";
4
5
  import { Type } from "typebox";
5
6
  import { buildInitialMessages } from "./context.js";
6
7
  import { loadConfig, setModelToolEnabled } from "./config.js";
7
- import { executeDml } from "./runtime.js";
8
+ import { renderDml, renderSequence } from "./diagram/extract.js";
9
+ import { polishDiagram, resolveGrade } from "./diagram/grade.js";
10
+ import { findChrome, validateMermaid } from "./diagram/validate.js";
11
+ import { buildEntriesViewer, buildViewer, openViewerInBrowser, writeSidecar, } from "./diagram/viewer.js";
12
+ import { collectDiagramTargets, diagramNameFor, displayPath, ensureDiagramDir, resolveDiagramSource, } from "./diagram/workspace.js";
13
+ import { completeTextWithPiModel } from "./model.js";
14
+ import { executeDml, gitRestore } from "./runtime.js";
8
15
  import { getPaths, initializeWorkspace, resolveDmlPath } from "./workspace.js";
9
- import { assemblePlanDml, buildPlanningPrompt, DC_PLAN_COMMIT_TOOL, isContextualPlan, PI_AGENT_STEP_TOOL, readPlanRequiredTools, validateGeneratedPlan, validatePlanSpec, writePlanNonDestructively, } from "./planner.js";
16
+ import { assemblePlanDml, assembleTasksDml, buildPlanningPrompt, DC_PLAN_COMMIT_TOOL, isContextualPlan, normalizePlanSlug, PI_AGENT_STEP_TOOL, readPlanRequiredTools, validateGeneratedPlan, validateGeneratedTasks, validatePlanSpec, writeChangeTasks, writePlanNonDestructively, } from "./planner.js";
10
17
  const DC_RUN_TOOL = "dc_run";
11
- 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/.`;
18
+ const DC_DIAGRAM_TOOL = "dc_diagram";
19
+ const DC_SPEC_GRAPH_TOOL = "dc_spec_graph";
20
+ 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.`;
12
21
  const STATUS_KEY = "deepclause";
13
22
  const WIDGET_KEY = "deepclause-stream";
14
23
  export function splitArguments(input) {
@@ -92,25 +101,42 @@ export function parseRun(input) {
92
101
  export function parsePlan(input) {
93
102
  const tokens = splitArguments(input);
94
103
  let name;
104
+ let change;
105
+ let update = false;
95
106
  let debug = false;
96
107
  const requestParts = [];
97
108
  for (let index = 0; index < tokens.length; index++) {
98
109
  const token = tokens[index];
99
110
  if (token === "--debug" || token === "-d")
100
111
  debug = true;
112
+ else if (token === "--update")
113
+ update = true;
101
114
  else if (token.startsWith("--name="))
102
115
  name = token.slice("--name=".length);
103
116
  else if (token === "--name")
104
117
  name = tokens[++index];
118
+ else if (token.startsWith("--change="))
119
+ change = token.slice("--change=".length);
120
+ else if (token === "--change")
121
+ change = tokens[++index];
105
122
  else
106
123
  requestParts.push(token);
107
124
  }
125
+ // allow the leading "update" keyword form: /dc-plan update --change=<slug> <request>
126
+ if (change && requestParts[0] === "update") {
127
+ update = true;
128
+ requestParts.shift();
129
+ }
108
130
  const request = requestParts.join(" ").trim();
109
131
  if (!request)
110
- throw new Error("Usage: /dc-plan <request> [--name=slug] [--debug]");
132
+ throw new Error("Usage: /dc-plan <request> [--name=slug] [--change=slug] [--update] [--debug]");
111
133
  if (name !== undefined && !name.trim())
112
134
  throw new Error("--name requires a non-empty slug");
113
- return { request, name: name?.trim(), debug };
135
+ if (change !== undefined && !change.trim())
136
+ throw new Error("--change requires a non-empty slug");
137
+ if (update && !change)
138
+ throw new Error("--update requires --change=<slug>");
139
+ return { request, name: name?.trim(), change: change?.trim(), update, debug };
114
140
  }
115
141
  function messageText(message) {
116
142
  if (!message || typeof message !== "object")
@@ -147,6 +173,52 @@ function eventSummary(event, debug) {
147
173
  case "memory_compaction": return `compaction ${event.compactionAction ?? "event"}`;
148
174
  }
149
175
  }
176
+ async function isMutatingSpecSkill(filePath) {
177
+ try {
178
+ return /^%\s*Mutating:\s*true\s*$/m.test(await readFile(filePath, "utf8"));
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /**
185
+ * After a step that leaves the tree dirty, offer to commit it (or remind the user).
186
+ * A clean tree is what lets the next /dc-apply take a rollback snapshot.
187
+ */
188
+ export async function offerCommit(pi, ctx, action, change) {
189
+ let status;
190
+ try {
191
+ status = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
192
+ }
193
+ catch {
194
+ return;
195
+ }
196
+ if (status.code !== 0)
197
+ return; // not a repository: nothing to say
198
+ const files = status.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
199
+ if (files.length === 0)
200
+ return; // clean
201
+ const message = `${action}: ${change}`;
202
+ const listed = files.slice(0, 12).join("\n");
203
+ const more = files.length > 12 ? `\n… and ${files.length - 12} more` : "";
204
+ const reminder = `${files.length} changed file(s):\n${listed}${more}\n\ngit add -A && git commit -m "${message}"`;
205
+ if (!ctx.hasUI) {
206
+ ctx.ui.notify(`Uncommitted changes. ${reminder}`, "warning");
207
+ return;
208
+ }
209
+ if (!await ctx.ui.confirm("Commit these changes?", `${reminder}\n\nCommit now?`)) {
210
+ ctx.ui.notify(`Remember to commit before continuing. ${reminder}`, "warning");
211
+ return;
212
+ }
213
+ await pi.exec("git", ["add", "-A"], { cwd: ctx.cwd });
214
+ const commit = await pi.exec("git", ["commit", "-m", message], { cwd: ctx.cwd });
215
+ if (commit.code === 0) {
216
+ ctx.ui.notify(`Committed: ${message}`, "info");
217
+ }
218
+ else {
219
+ ctx.ui.notify(`Commit failed: ${commit.stderr.trim() || "see git output"}`, "error");
220
+ }
221
+ }
150
222
  async function listDmlFiles(directory, prefix = "") {
151
223
  let entries;
152
224
  try {
@@ -170,6 +242,12 @@ async function listDmlFiles(directory, prefix = "") {
170
242
  function modelLabel(ctx) {
171
243
  return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none selected";
172
244
  }
245
+ async function bundledViewerTemplate() {
246
+ return readFile(fileURLToPath(new URL("./assets/viewer.template.html", import.meta.url)), "utf8");
247
+ }
248
+ function viewerVendorAssetPath() {
249
+ return fileURLToPath(new URL("./assets/vendor/mermaid.min.js", import.meta.url));
250
+ }
173
251
  function publishResult(pi, content, details) {
174
252
  pi.sendMessage({ customType: "deepclause-result", content, display: true, details });
175
253
  }
@@ -177,6 +255,8 @@ export default function deepClauseExtension(pi) {
177
255
  let activeController;
178
256
  let activeDescription;
179
257
  let modelToolRegistered = false;
258
+ let diagramToolRegistered = false;
259
+ let specGraphToolRegistered = false;
180
260
  let planCommitRegistered = false;
181
261
  let planningTransaction;
182
262
  let pendingAgentStep;
@@ -204,6 +284,8 @@ export default function deepClauseExtension(pi) {
204
284
  requiredTools: Type.Array(Type.String()),
205
285
  relevantSkills: Type.Array(Type.String()),
206
286
  expectedResult: Type.String(),
287
+ satisfies: Type.Optional(Type.Array(Type.String())),
288
+ checks: Type.Optional(Type.Array(Type.String())),
207
289
  }), { minItems: 1, maxItems: 12 }),
208
290
  finalSynthesis: Type.Optional(Type.String()),
209
291
  failureMessage: Type.String(),
@@ -223,14 +305,18 @@ export default function deepClauseExtension(pi) {
223
305
  };
224
306
  }
225
307
  try {
226
- const plan = validatePlanSpec(params, transaction.snapshot, transaction.nameOverride);
308
+ const plan = validatePlanSpec(params, transaction.snapshot, transaction.nameOverride, {
309
+ requireChecks: Boolean(transaction.change),
310
+ change: transaction.change,
311
+ });
227
312
  const preview = [
228
313
  plan.spec.title,
229
314
  `Objective: ${plan.spec.objective}`,
315
+ transaction.change ? `Change: ${transaction.change}` : "",
230
316
  `Steps: ${plan.spec.steps.length}`,
231
317
  `Pi tools: ${plan.requiredTools.join(", ") || "none"}`,
232
- ...plan.spec.steps.map((step, index) => `${index + 1}. [${step.executor}] ${step.title}`),
233
- ].join("\n");
318
+ ...plan.spec.steps.map((step, index) => `${index + 1}. [${step.executor}] ${step.title}${step.checks.length ? ` (${step.checks.length} checks)` : ""}`),
319
+ ].filter(Boolean).join("\n");
234
320
  if (!ctx.hasUI || !await ctx.ui.confirm("Create executable DeepClause plan?", preview)) {
235
321
  return {
236
322
  content: [{ type: "text", text: "Plan creation was not approved." }],
@@ -238,15 +324,27 @@ export default function deepClauseExtension(pi) {
238
324
  };
239
325
  }
240
326
  const paths = await initializeWorkspace(ctx.cwd);
241
- const dml = assemblePlanDml(plan, transaction.snapshot);
242
- await validateGeneratedPlan(dml);
243
- const filePath = await writePlanNonDestructively(paths, plan.spec.slug, dml);
327
+ const content = transaction.change
328
+ ? assembleTasksDml(plan, transaction.snapshot)
329
+ : assemblePlanDml(plan, transaction.snapshot);
330
+ if (transaction.change)
331
+ await validateGeneratedTasks(content);
332
+ else
333
+ await validateGeneratedPlan(content);
334
+ const filePath = transaction.change
335
+ ? await writeChangeTasks(paths, normalizePlanSlug(transaction.change), content, Boolean(transaction.update))
336
+ : await writePlanNonDestructively(paths, plan.spec.slug, content);
244
337
  transaction.committed = true;
245
338
  setPlanCommitActive(false);
339
+ await offerCommit(pi, ctx, "plan", transaction.change ? normalizePlanSlug(transaction.change) : plan.spec.slug);
246
340
  const relativePath = path.relative(paths.root, filePath).split(path.sep).join("/");
247
341
  const text = [
248
- `Created executable DML plan: .pi/deepclause/${relativePath}`,
249
- `Run it with: /dc-run ${relativePath}`,
342
+ transaction.change
343
+ ? `Created change plan: .pi/deepclause/${relativePath}`
344
+ : `Created executable DML plan: .pi/deepclause/${relativePath}`,
345
+ transaction.change
346
+ ? `Next: /dc-check ${normalizePlanSlug(transaction.change)}`
347
+ : `Run it with: /dc-run ${relativePath}`,
250
348
  plan.warnings.length ? `Warnings:\n${plan.warnings.join("\n")}` : "",
251
349
  ].filter(Boolean).join("\n\n");
252
350
  return {
@@ -254,6 +352,7 @@ export default function deepClauseExtension(pi) {
254
352
  details: {
255
353
  success: true,
256
354
  path: relativePath,
355
+ change: transaction.change,
257
356
  contextual: plan.spec.steps.some((step) => step.executor === "pi"),
258
357
  requiredTools: plan.requiredTools,
259
358
  warnings: plan.warnings,
@@ -468,9 +567,208 @@ export default function deepClauseExtension(pi) {
468
567
  : activeTools.filter((name) => name !== DC_RUN_TOOL));
469
568
  }
470
569
  };
570
+ const setDiagramToolActive = () => {
571
+ if (!diagramToolRegistered) {
572
+ pi.registerTool({
573
+ name: DC_DIAGRAM_TOOL,
574
+ label: "Create DeepClause Diagram",
575
+ 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.",
576
+ promptSnippet: "Create a presentation- or specification-grade diagram from a DML file",
577
+ promptGuidelines: [
578
+ "Use dc_diagram whenever the user asks for a diagram, flowchart, or visual of a .dml file; pass the exact path the user named.",
579
+ "Choose grade=presentation for slides and overviews and grade=specification for engineering detail; use grade=both only when the user asks for both.",
580
+ "Do not hand-write Mermaid or run diagram tools yourself; call dc_diagram and report the viewer result.",
581
+ ],
582
+ parameters: Type.Object({
583
+ dml: Type.String({ description: "Path to a .dml file, relative to the workspace or absolute. A leading @ is ignored." }),
584
+ grade: Type.Optional(Type.String({ description: "presentation (default), specification, or both. Synonyms such as detailed or technical map to specification." })),
585
+ view: Type.Optional(StringEnum(["flow", "sequence"], { description: "Base layout used to seed the grade; default flow." })),
586
+ }),
587
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
588
+ if (activeController) {
589
+ return {
590
+ content: [{ type: "text", text: "Another DeepClause operation is already active; wait for it to finish." }],
591
+ details: { success: false, error: "execution_already_active" },
592
+ };
593
+ }
594
+ const requested = resolveGrade(String(params.grade ?? "")) ?? "presentation";
595
+ const grades = requested === "both" ? ["presentation", "specification"] : [requested];
596
+ const view = params.view === "sequence" ? "sequence" : "flow";
597
+ let sourcePath;
598
+ try {
599
+ sourcePath = await resolveDiagramSource(ctx.cwd, params.dml);
600
+ }
601
+ catch (error) {
602
+ const message = error instanceof Error ? error.message : String(error);
603
+ return { content: [{ type: "text", text: `dc_diagram failed: ${message}` }], details: { success: false, error: message } };
604
+ }
605
+ if (!ctx.model) {
606
+ return {
607
+ content: [{ type: "text", text: "dc_diagram requires an active pi model. Select one and try again." }],
608
+ details: { success: false, error: "no_model" },
609
+ };
610
+ }
611
+ const config = await loadConfig(getPaths(ctx.cwd).config);
612
+ const source = await readFile(sourcePath, "utf8");
613
+ const display = displayPath(ctx.cwd, sourcePath);
614
+ const seed = view === "sequence"
615
+ ? renderSequence(display, source)
616
+ : renderDml(display, source, { hideOutput: true });
617
+ const targets = await collectDiagramTargets(ctx.cwd, [sourcePath]);
618
+ const name = diagramNameFor(sourcePath, targets, ctx.cwd);
619
+ const { diagrams, vendor } = await ensureDiagramDir(ctx.cwd, viewerVendorAssetPath());
620
+ const templateText = await bundledViewerTemplate();
621
+ const controller = new AbortController();
622
+ const cancel = () => controller.abort(signal?.reason ?? new Error("dc_diagram cancelled"));
623
+ if (signal?.aborted)
624
+ cancel();
625
+ else
626
+ signal?.addEventListener("abort", cancel, { once: true });
627
+ activeController = controller;
628
+ activeDescription = `diagram ${name} (${grades.join("+")})`;
629
+ const run = (command, args, options) => pi.exec(command, args, options);
630
+ let chrome;
631
+ let chromeResolved = false;
632
+ try {
633
+ for (const grade of grades) {
634
+ const result = await polishDiagram({
635
+ grade,
636
+ view,
637
+ source,
638
+ seed,
639
+ maxTokens: config.maxTokens,
640
+ signal: controller.signal,
641
+ complete: (options) => completeTextWithPiModel(ctx, options),
642
+ validate: async (code) => {
643
+ if (!chromeResolved) {
644
+ chrome = await findChrome(run);
645
+ chromeResolved = true;
646
+ }
647
+ const outcome = await validateMermaid(code, view, { run, vendorDir: vendor, chrome: chrome ?? null });
648
+ return outcome.result;
649
+ },
650
+ onProgress: (message) => onUpdate?.({
651
+ content: [{ type: "text", text: message }],
652
+ details: { dml: display, grades, name },
653
+ }),
654
+ });
655
+ await writeSidecar(diagrams, name, grade, result.code);
656
+ }
657
+ const build = await buildViewer({
658
+ cwd: ctx.cwd,
659
+ templateText,
660
+ vendorAssetPath: viewerVendorAssetPath(),
661
+ extraPaths: [sourcePath],
662
+ });
663
+ const opened = ctx.hasUI
664
+ ? await openViewerInBrowser(pi, build.viewerPath, name, grades[0] ?? "presentation")
665
+ : false;
666
+ const viewer = displayPath(ctx.cwd, build.viewerPath);
667
+ const text = `Created ${grades.join(" + ")}-grade diagram for ${display}. Viewer: ${viewer}${opened ? " (opened in your browser)" : ""}`;
668
+ return {
669
+ content: [{ type: "text", text }],
670
+ details: { success: true, dml: display, grades, name, viewer, opened, chrome: Boolean(chrome) },
671
+ };
672
+ }
673
+ catch (error) {
674
+ const message = error instanceof Error ? error.message : String(error);
675
+ return { content: [{ type: "text", text: `dc_diagram failed: ${message}` }], details: { success: false, error: message } };
676
+ }
677
+ finally {
678
+ signal?.removeEventListener("abort", cancel);
679
+ activeController = undefined;
680
+ activeDescription = undefined;
681
+ }
682
+ },
683
+ });
684
+ diagramToolRegistered = true;
685
+ }
686
+ const activeTools = pi.getActiveTools();
687
+ if (!activeTools.includes(DC_DIAGRAM_TOOL)) {
688
+ pi.setActiveTools([...activeTools, DC_DIAGRAM_TOOL]);
689
+ }
690
+ };
691
+ const setSpecGraphActive = () => {
692
+ if (!specGraphToolRegistered) {
693
+ pi.registerTool({
694
+ name: DC_SPEC_GRAPH_TOOL,
695
+ label: "Spec Graph",
696
+ description: "Create a Mermaid graph of DeepClause capabilities, requirements, scenarios and changes, write a viewer under .pi/deepclause/diagrams/, and open it.",
697
+ promptSnippet: "Create a capability/change graph from DeepClause spec facts",
698
+ promptGuidelines: [
699
+ "Call dc_spec_graph when the user asks for a graph or visual of capabilities, requirements, changes, or spec coverage.",
700
+ ],
701
+ parameters: Type.Object({
702
+ view: Type.Optional(StringEnum(["capabilities", "changes"])),
703
+ }),
704
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
705
+ try {
706
+ const view = params.view ?? "capabilities";
707
+ const mermaid = await runSpecSkill(ctx, "spec_graph", [view]);
708
+ const name = `spec-${view}`;
709
+ const entry = {
710
+ name,
711
+ path: `specs (${view})`,
712
+ flow: mermaid,
713
+ seq: "",
714
+ dml: "",
715
+ presentation: mermaid,
716
+ specification: null,
717
+ };
718
+ const build = await buildEntriesViewer({
719
+ cwd: ctx.cwd,
720
+ templateText: await bundledViewerTemplate(),
721
+ vendorAssetPath: viewerVendorAssetPath(),
722
+ entries: [entry],
723
+ });
724
+ const opened = ctx.hasUI
725
+ ? await openViewerInBrowser(pi, build.viewerPath, name, "presentation")
726
+ : false;
727
+ const viewer = displayPath(ctx.cwd, build.viewerPath);
728
+ return {
729
+ content: [{ type: "text", text: `Created spec graph (${view}). Viewer: ${viewer}${opened ? " (opened in your browser)" : ""}` }],
730
+ details: { success: true, view, viewer, opened },
731
+ };
732
+ }
733
+ catch (error) {
734
+ const message = error instanceof Error ? error.message : String(error);
735
+ return { content: [{ type: "text", text: `dc_spec_graph failed: ${message}` }], details: { success: false, error: message } };
736
+ }
737
+ },
738
+ });
739
+ specGraphToolRegistered = true;
740
+ }
741
+ const activeTools = pi.getActiveTools();
742
+ if (!activeTools.includes(DC_SPEC_GRAPH_TOOL)) {
743
+ pi.setActiveTools([...activeTools, DC_SPEC_GRAPH_TOOL]);
744
+ }
745
+ };
746
+ const runSpecSkill = async (ctx, skill, args = [], options = {}) => {
747
+ const paths = await initializeWorkspace(ctx.cwd);
748
+ const config = await loadConfig(paths.config);
749
+ const filePath = await resolveDmlPath(paths, skill);
750
+ const controller = new AbortController();
751
+ activeController = controller;
752
+ activeDescription = `running ${skill}`;
753
+ try {
754
+ const result = await executeDml(filePath, args, [], config, pi, ctx, controller, {
755
+ onEvent() { },
756
+ onInput: async () => { throw new Error("spec skills do not request input"); },
757
+ }, options.piAgentStep ? (request, signal) => runPiAgentStep(request, signal, ctx) : undefined, options.verifyCommands ?? [], options.changeJsonPath);
758
+ if (result.errors.length)
759
+ throw new Error(result.errors.join("\n"));
760
+ return result.answer ?? "(no answer)";
761
+ }
762
+ finally {
763
+ activeController = undefined;
764
+ activeDescription = undefined;
765
+ }
766
+ };
471
767
  pi.on("session_start", async (_event, ctx) => {
472
768
  const config = await loadConfig(getPaths(ctx.cwd).config);
473
769
  setModelToolActive(config.modelToolEnabled);
770
+ setDiagramToolActive();
771
+ setSpecGraphActive();
474
772
  });
475
773
  pi.on("tool_execution_start", (event) => {
476
774
  if (pendingAgentStep && !pendingAgentStep.toolsUsed.includes(event.toolName)) {
@@ -547,9 +845,15 @@ export default function deepClauseExtension(pi) {
547
845
  `Plans: ${path.relative(ctx.cwd, paths.plans)}`,
548
846
  `Context: ${config.contextMode} (verbose default: ${config.verbose})`,
549
847
  `Model tool (${DC_RUN_TOOL}): ${config.modelToolEnabled && pi.getActiveTools().includes(DC_RUN_TOOL) ? "enabled" : "disabled"}`,
848
+ `Model tool (${DC_DIAGRAM_TOOL}): ${pi.getActiveTools().includes(DC_DIAGRAM_TOOL) ? "enabled" : "disabled"}`,
849
+ "Ask pi for a presentation-grade or specification-grade diagram of any .dml file;",
850
+ "it writes the viewer under .pi/deepclause/diagrams/ and opens it.",
550
851
  "Commands:",
551
852
  " /dc-list",
552
- " /dc-plan <request> [--name=slug] create an executable contextual DML plan",
853
+ " /dc-plan <request> [--change=slug] [--update] [--name=slug] create or regenerate a plan",
854
+ " /dc-check <change|spec> validate specs and deltas deterministically",
855
+ " /dc-archive <change> merge a change delta into specs/ and archive it",
856
+ " /dc-apply <change> [--abort] execute tasks.dml; --abort discards an interrupted apply",
553
857
  " /dc-run <skill|path> [args] [--context=turn|branch|isolated]",
554
858
  " /dc-run <skill|path> --verbose show lifecycle events",
555
859
  " /dc-run <skill|path> --debug show full event payloads and SDK diagnostics",
@@ -571,6 +875,17 @@ export default function deepClauseExtension(pi) {
571
875
  if (!ctx.model)
572
876
  throw new Error("Select a pi model before creating a plan");
573
877
  const paths = await initializeWorkspace(ctx.cwd);
878
+ if (parsed.change && !parsed.update) {
879
+ const changeSlug = normalizePlanSlug(parsed.change);
880
+ try {
881
+ await access(path.join(paths.changes, changeSlug, "tasks.dml"));
882
+ ctx.ui.notify(`changes/${changeSlug}/tasks.dml already exists. Re-run with --update to regenerate it, or edit tasks.dml directly.`, "error");
883
+ return;
884
+ }
885
+ catch {
886
+ // no existing plan: proceed
887
+ }
888
+ }
574
889
  const promptOptions = ctx.getSystemPromptOptions();
575
890
  const snapshot = {
576
891
  model: `${ctx.model.provider}/${ctx.model.id}`,
@@ -585,12 +900,18 @@ export default function deepClauseExtension(pi) {
585
900
  planningTransaction = {
586
901
  snapshot,
587
902
  nameOverride: parsed.name,
903
+ change: parsed.change,
904
+ update: parsed.update,
588
905
  committed: false,
589
906
  startedAt: Date.now(),
590
907
  };
591
908
  setPlanCommitActive(true);
592
- ctx.ui.notify("Starting a contextual pi planning turn. Review the generated plan before it is written.", "info");
593
- pi.sendUserMessage(buildPlanningPrompt(parsed.request, snapshot, parsed.name));
909
+ ctx.ui.notify(parsed.change
910
+ ? parsed.update
911
+ ? `Regenerating the change plan for '${parsed.change}'. Existing artifacts are read first and tasks.dml statuses reset to pending.`
912
+ : `Starting a change planning turn for '${parsed.change}'. Review the delta and plan before it is written.`
913
+ : "Starting a contextual pi planning turn. Review the generated plan before it is written.", "info");
914
+ pi.sendUserMessage(buildPlanningPrompt(parsed.request, snapshot, parsed.name, parsed.change, parsed.update));
594
915
  }
595
916
  catch (error) {
596
917
  planningTransaction = undefined;
@@ -654,6 +975,126 @@ export default function deepClauseExtension(pi) {
654
975
  ctx.ui.notify("Cancelling DeepClause execution", "warning");
655
976
  },
656
977
  });
978
+ pi.registerCommand("dc-check", {
979
+ description: "Validate DeepClause specs and change deltas deterministically (no model calls)",
980
+ handler: async (_rawArgs, ctx) => {
981
+ if (activeController || !ctx.isIdle()) {
982
+ ctx.ui.notify("DeepClause or pi is already active; wait before running /dc-check", "warning");
983
+ return;
984
+ }
985
+ try {
986
+ const answer = await runSpecSkill(ctx, "spec_validate");
987
+ publishResult(pi, answer, { skill: "spec_validate" });
988
+ ctx.ui.notify(answer.startsWith("spec check: OK") ? "Spec check passed" : "Spec check reported errors", answer.startsWith("spec check: OK") ? "info" : "warning");
989
+ }
990
+ catch (error) {
991
+ const message = error instanceof Error ? error.message : String(error);
992
+ publishResult(pi, `Spec check failed: ${message}`, { error: message });
993
+ ctx.ui.notify(message, "error");
994
+ }
995
+ },
996
+ });
997
+ pi.registerCommand("dc-archive", {
998
+ description: "Merge a change delta into specs/ after review, then move the change into changes/archive/",
999
+ handler: async (rawArgs, ctx) => {
1000
+ if (activeController || !ctx.isIdle()) {
1001
+ ctx.ui.notify("DeepClause or pi is already active; wait before running /dc-archive", "warning");
1002
+ return;
1003
+ }
1004
+ const change = rawArgs.trim();
1005
+ if (!change) {
1006
+ ctx.ui.notify("Usage: /dc-archive <change>", "warning");
1007
+ return;
1008
+ }
1009
+ try {
1010
+ const plan = await runSpecSkill(ctx, "spec_merge", [change]);
1011
+ if (!ctx.hasUI || !await ctx.ui.confirm("Archive change into specs?", plan)) {
1012
+ ctx.ui.notify("Archive cancelled", "warning");
1013
+ return;
1014
+ }
1015
+ const applied = await runSpecSkill(ctx, "spec_archive", [change]);
1016
+ const paths = await initializeWorkspace(ctx.cwd);
1017
+ const from = path.join(paths.changes, change);
1018
+ const stamp = new Date().toISOString().slice(0, 10);
1019
+ await mkdir(path.join(paths.changes, "archive"), { recursive: true });
1020
+ let target = path.join(paths.changes, "archive", `${stamp}-${change}`);
1021
+ try {
1022
+ await access(target);
1023
+ target = `${target}-2`;
1024
+ }
1025
+ catch {
1026
+ // target is free
1027
+ }
1028
+ await rename(from, target);
1029
+ const archivedTo = path.relative(ctx.cwd, target).split(path.sep).join("/");
1030
+ publishResult(pi, `${applied}\n\n moved to ${archivedTo}`, { skill: "spec_archive", change, archivedTo });
1031
+ ctx.ui.notify(`Archived ${change}`, "info");
1032
+ await offerCommit(pi, ctx, "archive", change);
1033
+ }
1034
+ catch (error) {
1035
+ const message = error instanceof Error ? error.message : String(error);
1036
+ publishResult(pi, `Archive failed: ${message}`, { error: message });
1037
+ ctx.ui.notify(message, "error");
1038
+ }
1039
+ },
1040
+ });
1041
+ pi.registerCommand("dc-apply", {
1042
+ description: "Execute a change's tasks.dml with per-task verification and bounded retries",
1043
+ handler: async (rawArgs, ctx) => {
1044
+ if (activeController || !ctx.isIdle()) {
1045
+ ctx.ui.notify("DeepClause or pi is already active; wait before running /dc-apply", "warning");
1046
+ return;
1047
+ }
1048
+ const tokens = splitArguments(rawArgs);
1049
+ const abort = tokens.includes("--abort");
1050
+ const change = tokens.filter((token) => token !== "--abort").join(" ").trim();
1051
+ if (!change) {
1052
+ ctx.ui.notify("Usage: /dc-apply <change> [--abort]", "warning");
1053
+ return;
1054
+ }
1055
+ try {
1056
+ const paths = await initializeWorkspace(ctx.cwd);
1057
+ const changeJson = path.join(paths.changes, change, "change.json");
1058
+ if (abort) {
1059
+ const restored = await gitRestore(pi, ctx.cwd, changeJson).catch(() => null);
1060
+ const message = restored
1061
+ ? `Discarded the apply and restored the working tree to ${restored}.`
1062
+ : "No recorded apply snapshot to discard.";
1063
+ publishResult(pi, message, { skill: "spec_apply", change, aborted: Boolean(restored) });
1064
+ ctx.ui.notify(message, restored ? "warning" : "info");
1065
+ return;
1066
+ }
1067
+ let started = false;
1068
+ let succeeded = false;
1069
+ try {
1070
+ const plan = await runSpecSkill(ctx, "spec_apply", [change, "plan"]);
1071
+ const commands = [...new Set([...plan.matchAll(/^command:\s*(.+)$/gm)].map((match) => match[1].trim()))];
1072
+ const preview = [plan, "", `Approved verification commands: ${commands.join(", ") || "none"}`].join("\n");
1073
+ if (!ctx.hasUI || !await ctx.ui.confirm("Apply change tasks?", preview)) {
1074
+ ctx.ui.notify("Apply cancelled", "warning");
1075
+ return;
1076
+ }
1077
+ started = true;
1078
+ const answer = await runSpecSkill(ctx, "spec_apply", [change, "apply"], { verifyCommands: commands, piAgentStep: true, changeJsonPath: changeJson });
1079
+ succeeded = answer.includes("status: OK");
1080
+ publishResult(pi, answer, { skill: "spec_apply", change });
1081
+ ctx.ui.notify(succeeded ? `Applied ${change}` : `Apply incomplete for ${change}`, succeeded ? "info" : "warning");
1082
+ }
1083
+ finally {
1084
+ if (started && !succeeded) {
1085
+ ctx.ui.notify(`Apply interrupted; the working tree and task statuses were preserved. Resume with /dc-apply ${change}, or discard with /dc-apply ${change} --abort.`, "warning");
1086
+ }
1087
+ }
1088
+ if (succeeded)
1089
+ await offerCommit(pi, ctx, "apply", change);
1090
+ }
1091
+ catch (error) {
1092
+ const message = error instanceof Error ? error.message : String(error);
1093
+ publishResult(pi, `Apply failed: ${message}`, { error: message });
1094
+ ctx.ui.notify(message, "error");
1095
+ }
1096
+ },
1097
+ });
657
1098
  pi.registerCommand("dc-run", {
658
1099
  description: "Run a DML skill with pi's active model",
659
1100
  handler: async (rawArgs, ctx) => {
@@ -666,6 +1107,10 @@ export default function deepClauseExtension(pi) {
666
1107
  const paths = await initializeWorkspace(ctx.cwd);
667
1108
  const config = await loadConfig(paths.config);
668
1109
  const filePath = await resolveDmlPath(paths, parsed.target);
1110
+ if (await isMutatingSpecSkill(filePath)) {
1111
+ ctx.ui.notify(`${parsed.target} modifies specs/. Use /dc-archive <change> so you can review the merge first.`, "warning");
1112
+ return;
1113
+ }
669
1114
  const contextualPlan = await isContextualPlan(filePath);
670
1115
  if (contextualPlan) {
671
1116
  const requiredTools = await readPlanRequiredTools(filePath);
@@ -0,0 +1,16 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Usage } from "@earendil-works/pi-ai";
3
+ export interface TextCompletion {
4
+ text: string;
5
+ usage?: Usage;
6
+ }
7
+ /**
8
+ * One-shot text completion through pi's active model and credentials. Used by
9
+ * diagram grading; it never requests API keys or mutates provider state.
10
+ */
11
+ export declare function completeTextWithPiModel(ctx: ExtensionContext, options: {
12
+ systemPrompt?: string;
13
+ prompt: string;
14
+ maxTokens: number;
15
+ signal?: AbortSignal;
16
+ }): Promise<TextCompletion>;