deepclause-pi 0.2.0 → 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.
package/dist/planner.js CHANGED
@@ -31,7 +31,7 @@ export function normalizePlanSlug(value) {
31
31
  throw new Error("Plan slug must contain a letter or digit");
32
32
  return slug;
33
33
  }
34
- export function validatePlanSpec(value, snapshot, nameOverride) {
34
+ export function validatePlanSpec(value, snapshot, nameOverride, options = {}) {
35
35
  if (!value || typeof value !== "object" || Array.isArray(value))
36
36
  throw new Error("Plan specification must be an object");
37
37
  const raw = value;
@@ -48,8 +48,8 @@ export function validatePlanSpec(value, snapshot, nameOverride) {
48
48
  throw new Error(`steps[${index}] must be an object`);
49
49
  const step = entry;
50
50
  const id = requireText(step.id ?? `step_${index + 1}`, `steps[${index}].id`, 80);
51
- if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(id))
52
- throw new Error(`steps[${index}].id must be a simple identifier`);
51
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(id))
52
+ throw new Error(`steps[${index}].id must be a simple identifier (letters, digits, dot, dash, underscore)`);
53
53
  if (ids.has(id))
54
54
  throw new Error(`Duplicate plan step id: ${id}`);
55
55
  ids.add(id);
@@ -58,6 +58,13 @@ export function validatePlanSpec(value, snapshot, nameOverride) {
58
58
  throw new Error(`steps[${index}].executor must be pi or dml`);
59
59
  const requiredTools = stringArray(step.requiredTools, `steps[${index}].requiredTools`, 16);
60
60
  const relevantSkills = stringArray(step.relevantSkills, `steps[${index}].relevantSkills`, 16);
61
+ const satisfies = stringArray(step.satisfies, `steps[${index}].satisfies`, 32);
62
+ const checks = stringArray(step.checks, `steps[${index}].checks`, 16);
63
+ for (const check of checks)
64
+ parseCheck(check);
65
+ if (options.requireChecks && checks.length === 0) {
66
+ throw new Error(`Plan step ${id} must declare at least one verification check (cmd:..., exists:... or model:...)`);
67
+ }
61
68
  if (executor === "dml" && requiredTools.length > 0) {
62
69
  throw new Error(`DML step ${id} cannot request pi tools; use executor=pi`);
63
70
  }
@@ -81,6 +88,8 @@ export function validatePlanSpec(value, snapshot, nameOverride) {
81
88
  requiredTools: [...new Set(requiredTools)],
82
89
  relevantSkills: [...new Set(relevantSkills)],
83
90
  expectedResult: requireText(step.expectedResult, `steps[${index}].expectedResult`, 1_000),
91
+ satisfies: [...new Set(satisfies)],
92
+ checks: [...new Set(checks)],
84
93
  };
85
94
  });
86
95
  const spec = {
@@ -93,6 +102,7 @@ export function validatePlanSpec(value, snapshot, nameOverride) {
93
102
  ? requireText(raw.finalSynthesis, "finalSynthesis", 2_000)
94
103
  : undefined,
95
104
  failureMessage: requireText(raw.failureMessage, "failureMessage", 1_000),
105
+ change: options.change ? normalizePlanSlug(options.change) : undefined,
96
106
  };
97
107
  return {
98
108
  spec,
@@ -109,6 +119,71 @@ function dmlStringList(values) {
109
119
  function commentText(value) {
110
120
  return value.replace(/[\r\n]+/g, " ").replace(/%/g, "percent").trim();
111
121
  }
122
+ /** Parse an encoded verification check: cmd:<command>, exists:<path>, model:<question>. */
123
+ export function parseCheck(encoded) {
124
+ const separator = encoded.indexOf(":");
125
+ if (separator <= 0)
126
+ throw new Error(`Check must be cmd:..., exists:... or model:...; got '${encoded}'`);
127
+ const kind = encoded.slice(0, separator).trim();
128
+ const value = encoded.slice(separator + 1).trim();
129
+ if (kind !== "cmd" && kind !== "exists" && kind !== "model")
130
+ throw new Error(`Unknown check kind '${kind}' in '${encoded}'`);
131
+ if (!value)
132
+ throw new Error(`Check '${encoded}' has an empty value`);
133
+ return { kind, value };
134
+ }
135
+ /**
136
+ * Assemble the `tasks.dml` data artifact for a change: plan_task/2 definitions plus
137
+ * the managed plan_task_status/2 block. Verified by /dc-check and executed by /dc-apply.
138
+ */
139
+ export function assembleTasksDml(plan, snapshot) {
140
+ const { spec } = plan;
141
+ const taskBlocks = spec.steps.map((step) => {
142
+ const checks = step.checks.map((encoded) => {
143
+ const { kind, value } = parseCheck(encoded);
144
+ return `${kind}(${dmlString(value)})`;
145
+ });
146
+ return [
147
+ `plan_task(${dmlString(step.id)}, task{`,
148
+ ` executor: ${step.executor},`,
149
+ ` do: ${dmlString(step.instruction)},`,
150
+ ` tools: ${dmlStringList(step.requiredTools)},`,
151
+ ` expected: ${dmlString(step.expectedResult)},`,
152
+ ` satisfies: ${dmlStringList(step.satisfies)},`,
153
+ ` checks: [${checks.join(", ")}]`,
154
+ `}).`,
155
+ ].join("\n");
156
+ });
157
+ const statusLines = spec.steps.map((step) => `plan_task_status(${dmlString(step.id)}, pending).`);
158
+ const metadata = [
159
+ "% tasks.dml — generated by /dc-plan. Edit conservatively.",
160
+ `% Change: ${commentText(spec.change ?? spec.slug)}`,
161
+ `% Title: ${commentText(spec.title)}`,
162
+ `% Planning model: ${commentText(snapshot.model)}`,
163
+ `% Required pi tools: ${plan.requiredTools.join(", ") || "none"}`,
164
+ ].join("\n");
165
+ return `${metadata}\n\n${taskBlocks.join("\n\n")}\n\n% --- execution state (managed by apply.dml; do not edit by hand) ---\n${statusLines.join("\n")}\n`;
166
+ }
167
+ /** Write changes/<slug>/tasks.dml, refusing to clobber an existing plan unless overwrite=true. */
168
+ export async function writeChangeTasks(paths, slug, text, overwrite = false) {
169
+ const dir = path.join(paths.changes, slug);
170
+ await mkdir(dir, { recursive: true });
171
+ const filePath = path.join(dir, "tasks.dml");
172
+ if (overwrite) {
173
+ await writeFile(filePath, text, { encoding: "utf8" });
174
+ return filePath;
175
+ }
176
+ try {
177
+ await writeFile(filePath, text, { encoding: "utf8", flag: "wx" });
178
+ }
179
+ catch (error) {
180
+ if (error.code === "EEXIST") {
181
+ throw new Error(`changes/${slug}/tasks.dml already exists; re-run with --update to regenerate it`);
182
+ }
183
+ throw error;
184
+ }
185
+ return filePath;
186
+ }
112
187
  export function assemblePlanDml(plan, snapshot) {
113
188
  const { spec } = plan;
114
189
  const resultVariables = [];
@@ -167,6 +242,17 @@ export async function validateGeneratedPlan(dml) {
167
242
  if (!validation.valid)
168
243
  throw new Error(`Generated DML failed validation: ${validation.errors.join("; ")}`);
169
244
  }
245
+ /**
246
+ * Data-only artifact (tasks.dml): no agent_main of its own, so validation appends a
247
+ * trivial entry point to parse the facts without changing what is written.
248
+ */
249
+ export async function validateGeneratedTasks(dml) {
250
+ if (dml.includes(".deepclause/"))
251
+ throw new Error("Generated tasks may not reference .deepclause/");
252
+ const validation = await validateWithProlog(`${dml}\nagent_main :- true.\n`);
253
+ if (!validation.valid)
254
+ throw new Error(`Generated tasks.dml failed validation: ${validation.errors.join("; ")}`);
255
+ }
170
256
  export async function writePlanNonDestructively(paths, slug, dml) {
171
257
  await mkdir(paths.plans, { recursive: true });
172
258
  for (let suffix = 1; suffix <= 100; suffix++) {
@@ -193,7 +279,7 @@ export async function readPlanRequiredTools(filePath) {
193
279
  return [];
194
280
  return [...new Set(match[1].split(",").map((name) => name.trim()).filter(Boolean))];
195
281
  }
196
- export function buildPlanningPrompt(request, snapshot, nameOverride) {
282
+ export function buildPlanningPrompt(request, snapshot, nameOverride, change, update = false) {
197
283
  const tools = snapshot.allTools.map((tool) => ({
198
284
  name: tool.name,
199
285
  active: snapshot.activeTools.includes(tool.name),
@@ -202,6 +288,24 @@ export function buildPlanningPrompt(request, snapshot, nameOverride) {
202
288
  guidelines: tool.promptGuidelines ?? [],
203
289
  source: tool.sourceInfo,
204
290
  }));
291
+ const changeInstructions = change
292
+ ? [
293
+ update
294
+ ? `This regenerates the change '${change}'. Read the existing .pi/deepclause/changes/${change}/proposal.md, specs/**, design.md and tasks.dml first, then revise them as needed.`
295
+ : `This plan is for the change '${change}'. Before committing, create .pi/deepclause/changes/${change}/ with normal file tools:`,
296
+ ...(update
297
+ ? []
298
+ : [
299
+ "- proposal.md — why / what / capabilities / impact.",
300
+ "- specs/<capability>.spec.md — delta(s) using ## ADDED|MODIFIED|REMOVED Requirements.",
301
+ "- design.md — optional approach and trade-offs.",
302
+ ]),
303
+ "Specs describe behaviour only: no commands, file paths, library choices or implementation steps.",
304
+ "Use exactly three hashes for ### Requirement and four for #### Scenario; every requirement needs at least one scenario.",
305
+ "Each committed step must list the scenario ids it satisfies (capability#scenario-slug) and at least one check encoded as cmd:<command>, exists:<path> or model:<question>.",
306
+ "The committed tasks.dml replaces any previous one; every plan_task_status entry resets to pending.",
307
+ ]
308
+ : [];
205
309
  return [
206
310
  "Create an executable DeepClause plan for the request below.",
207
311
  "You are in a normal pi turn: inspect the workspace and use currently active tools when that materially improves the plan.",
@@ -211,6 +315,7 @@ export function buildPlanningPrompt(request, snapshot, nameOverride) {
211
315
  "Choose executor='dml' for contained reasoning that needs no pi tool; requiredTools must then be empty.",
212
316
  "Use only exact active tool names. Never request dc_run, dc_plan_commit, or pi_agent_step.",
213
317
  "Keep steps bounded, concrete, ordered, and independently observable. Prefer 3-8 steps.",
318
+ ...changeInstructions,
214
319
  nameOverride ? `The user requested the plan filename slug: ${nameOverride}` : "Choose a concise lowercase slug.",
215
320
  `User request:\n${request}`,
216
321
  `Current model: ${snapshot.model}; thinking level: ${snapshot.thinkingLevel}`,
package/dist/runtime.d.ts CHANGED
@@ -3,6 +3,20 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import type { DeepClauseConfig } from "./config.js";
4
4
  export declare const PI_WORKSPACE_LIST_TOOL = "pi_workspace_list";
5
5
  export declare const PI_BASH_TOOL = "pi_bash";
6
+ export declare const DC_VERIFY_RUN_TOOL = "dc_verify_run";
7
+ export declare const DC_APPLY_SNAPSHOT_TOOL = "dc_apply_snapshot";
8
+ export declare const DC_APPLY_ACCEPT_TOOL = "dc_apply_accept";
9
+ export declare const DC_APPLY_RESTORE_TOOL = "dc_apply_restore";
10
+ /**
11
+ * Record a git snapshot ref, refusing a dirty working tree. If an apply is already
12
+ * in progress for this change, the existing ref is returned so the run resumes
13
+ * instead of starting over or rejecting the (intentionally) dirty tree.
14
+ */
15
+ export declare function gitSnapshot(pi: Pick<ExtensionAPI, "exec">, cwd: string, changeJsonPath: string): Promise<string>;
16
+ /** Restore the recorded snapshot (hard reset plus clean of untracked files). */
17
+ export declare function gitRestore(pi: Pick<ExtensionAPI, "exec">, cwd: string, changeJsonPath: string): Promise<string | null>;
18
+ /** Mark the apply accepted and clear the snapshot. */
19
+ export declare function gitAccept(changeJsonPath: string): Promise<void>;
6
20
  export type BashApproval = (command: string, signal: AbortSignal) => Promise<boolean>;
7
21
  export declare function registerPiRuntimeTools(sdk: DeepClauseSDK, pi: Pick<ExtensionAPI, "exec">, cwd: string, signal: AbortSignal, approveBash?: BashApproval): void;
8
22
  export interface ExecutionCallbacks {
@@ -27,4 +41,4 @@ export interface ExecutionResult {
27
41
  errors: string[];
28
42
  usage: LLMUsage;
29
43
  }
30
- export declare function executeDml(filePath: string, args: string[], initialMessages: MemoryMessage[], config: DeepClauseConfig, pi: ExtensionAPI, ctx: ExtensionContext, controller: AbortController, callbacks: ExecutionCallbacks, runPiAgentStep?: (request: PiAgentStepRequest, signal: AbortSignal) => Promise<PiAgentStepResult>): Promise<ExecutionResult>;
44
+ export declare function executeDml(filePath: string, args: string[], initialMessages: MemoryMessage[], config: DeepClauseConfig, pi: ExtensionAPI, ctx: ExtensionContext, controller: AbortController, callbacks: ExecutionCallbacks, runPiAgentStep?: (request: PiAgentStepRequest, signal: AbortSignal) => Promise<PiAgentStepResult>, verifyCommands?: string[], applyChangeJsonPath?: string): Promise<ExecutionResult>;
package/dist/runtime.js CHANGED
@@ -1,9 +1,71 @@
1
- import { realpath, readFile } from "node:fs/promises";
1
+ import { realpath, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createDeepClause } from "deepclause-sdk";
4
4
  import { PI_AGENT_STEP_TOOL } from "./planner.js";
5
5
  export const PI_WORKSPACE_LIST_TOOL = "pi_workspace_list";
6
6
  export const PI_BASH_TOOL = "pi_bash";
7
+ export const DC_VERIFY_RUN_TOOL = "dc_verify_run";
8
+ export const DC_APPLY_SNAPSHOT_TOOL = "dc_apply_snapshot";
9
+ export const DC_APPLY_ACCEPT_TOOL = "dc_apply_accept";
10
+ export const DC_APPLY_RESTORE_TOOL = "dc_apply_restore";
11
+ async function readChangeJson(changeJsonPath) {
12
+ try {
13
+ const parsed = JSON.parse(await readFile(changeJsonPath, "utf8"));
14
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ async function patchChangeJson(changeJsonPath, patch) {
21
+ let existing = {};
22
+ try {
23
+ const parsed = JSON.parse(await readFile(changeJsonPath, "utf8"));
24
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
25
+ existing = parsed;
26
+ }
27
+ catch {
28
+ // start a fresh manifest
29
+ }
30
+ await writeFile(changeJsonPath, `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`, "utf8");
31
+ }
32
+ /**
33
+ * Record a git snapshot ref, refusing a dirty working tree. If an apply is already
34
+ * in progress for this change, the existing ref is returned so the run resumes
35
+ * instead of starting over or rejecting the (intentionally) dirty tree.
36
+ */
37
+ export async function gitSnapshot(pi, cwd, changeJsonPath) {
38
+ const existing = await readChangeJson(changeJsonPath);
39
+ const resumable = typeof existing.snapshot === "string" && existing.snapshot && existing.applyState === "in_progress";
40
+ if (resumable)
41
+ return existing.snapshot;
42
+ const status = await pi.exec("git", ["status", "--porcelain"], { cwd });
43
+ if (status.code !== 0)
44
+ throw new Error("git is unavailable or this is not a repository");
45
+ if (status.stdout.trim())
46
+ throw new Error("working tree is dirty; commit or stash before applying, or resume with /dc-apply (which preserves an interrupted apply)");
47
+ const head = await pi.exec("git", ["rev-parse", "HEAD"], { cwd });
48
+ if (head.code !== 0)
49
+ throw new Error("could not read git HEAD");
50
+ const ref = head.stdout.trim();
51
+ await patchChangeJson(changeJsonPath, { snapshot: ref, applyState: "in_progress" });
52
+ return ref;
53
+ }
54
+ /** Restore the recorded snapshot (hard reset plus clean of untracked files). */
55
+ export async function gitRestore(pi, cwd, changeJsonPath) {
56
+ const existing = await readChangeJson(changeJsonPath);
57
+ const ref = typeof existing.snapshot === "string" && existing.snapshot ? existing.snapshot : null;
58
+ if (!ref)
59
+ return null;
60
+ await pi.exec("git", ["reset", "--hard", ref], { cwd });
61
+ await pi.exec("git", ["clean", "-fd"], { cwd });
62
+ await patchChangeJson(changeJsonPath, { snapshot: null, applyState: "aborted" });
63
+ return ref;
64
+ }
65
+ /** Mark the apply accepted and clear the snapshot. */
66
+ export async function gitAccept(changeJsonPath) {
67
+ await patchChangeJson(changeJsonPath, { snapshot: null, applyState: "done" });
68
+ }
7
69
  function isInside(parent, child) {
8
70
  const relative = path.relative(parent, child);
9
71
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -198,7 +260,7 @@ function createPiBackend(ctx, maxTokens, onDiagnostic) {
198
260
  },
199
261
  };
200
262
  }
201
- export async function executeDml(filePath, args, initialMessages, config, pi, ctx, controller, callbacks, runPiAgentStep) {
263
+ export async function executeDml(filePath, args, initialMessages, config, pi, ctx, controller, callbacks, runPiAgentStep, verifyCommands = [], applyChangeJsonPath) {
202
264
  const model = ctx.model;
203
265
  if (!model)
204
266
  throw new Error("Select a pi model before running DeepClause");
@@ -242,9 +304,60 @@ export async function executeDml(filePath, args, initialMessages, config, pi, ct
242
304
  },
243
305
  });
244
306
  }
307
+ if (verifyCommands.length > 0) {
308
+ sdk.registerTool(DC_VERIFY_RUN_TOOL, {
309
+ description: "Run one of the change's pre-approved verification commands in the workspace and return its exit code, stdout and stderr.",
310
+ parameters: {
311
+ type: "object",
312
+ properties: {
313
+ command: { type: "string", description: "Exact approved command string" },
314
+ },
315
+ required: ["command"],
316
+ },
317
+ execute: async (args) => {
318
+ const command = typeof args.command === "string" ? args.command.trim() : "";
319
+ if (!verifyCommands.includes(command)) {
320
+ throw new Error(`verification command was not approved for this run: ${command}`);
321
+ }
322
+ const result = await pi.exec("bash", ["-lc", command], {
323
+ cwd: ctx.cwd,
324
+ signal: controller.signal,
325
+ timeout: 120_000,
326
+ });
327
+ return { command, stdout: result.stdout, stderr: result.stderr, exitCode: result.code, killed: result.killed };
328
+ },
329
+ });
330
+ }
331
+ if (applyChangeJsonPath) {
332
+ const changeJsonPath = applyChangeJsonPath;
333
+ sdk.registerTool(DC_APPLY_SNAPSHOT_TOOL, {
334
+ description: "Record a git snapshot of the workspace before applying tasks. Refuses a dirty working tree.",
335
+ parameters: { type: "object", properties: {}, required: [] },
336
+ execute: async () => gitSnapshot(pi, ctx.cwd, changeJsonPath),
337
+ });
338
+ sdk.registerTool(DC_APPLY_ACCEPT_TOOL, {
339
+ description: "Accept the apply so the working tree is not restored.",
340
+ parameters: { type: "object", properties: {}, required: [] },
341
+ execute: async () => {
342
+ await gitAccept(changeJsonPath);
343
+ return "accepted";
344
+ },
345
+ });
346
+ sdk.registerTool(DC_APPLY_RESTORE_TOOL, {
347
+ description: "Restore the recorded snapshot, discarding task changes.",
348
+ parameters: { type: "object", properties: {}, required: [] },
349
+ execute: async () => (await gitRestore(pi, ctx.cwd, changeJsonPath)) ?? "none",
350
+ });
351
+ }
245
352
  sdk.setToolPolicy({
246
353
  mode: "whitelist",
247
- tools: [PI_WORKSPACE_LIST_TOOL, PI_BASH_TOOL, ...(runPiAgentStep ? [PI_AGENT_STEP_TOOL] : [])],
354
+ tools: [
355
+ PI_WORKSPACE_LIST_TOOL,
356
+ PI_BASH_TOOL,
357
+ ...(runPiAgentStep ? [PI_AGENT_STEP_TOOL] : []),
358
+ ...(verifyCommands.length > 0 ? [DC_VERIFY_RUN_TOOL] : []),
359
+ ...(applyChangeJsonPath ? [DC_APPLY_SNAPSHOT_TOOL, DC_APPLY_ACCEPT_TOOL, DC_APPLY_RESTORE_TOOL] : []),
360
+ ],
248
361
  });
249
362
  const result = {
250
363
  errors: [],
@@ -2,6 +2,9 @@ export interface DeepClausePaths {
2
2
  root: string;
3
3
  skills: string;
4
4
  plans: string;
5
+ specs: string;
6
+ changes: string;
7
+ lib: string;
5
8
  config: string;
6
9
  agents: string;
7
10
  reference: string;
package/dist/workspace.js CHANGED
@@ -46,6 +46,9 @@ export function getPaths(cwd) {
46
46
  root,
47
47
  skills: path.join(root, "skills"),
48
48
  plans: path.join(root, "plans"),
49
+ specs: path.join(root, "specs"),
50
+ changes: path.join(root, "changes"),
51
+ lib: path.join(root, "lib"),
49
52
  config: path.join(root, "config.json"),
50
53
  agents: path.join(root, "AGENTS.md"),
51
54
  reference: path.join(root, "DML_REFERENCE.md"),
@@ -68,6 +71,9 @@ async function bundledReference() {
68
71
  async function bundledAuthoringGuide() {
69
72
  return readFile(fileURLToPath(new URL("./assets/AGENTS.md", import.meta.url)), "utf8");
70
73
  }
74
+ async function bundledAsset(name) {
75
+ return readFile(fileURLToPath(new URL(`./assets/${name}`, import.meta.url)), "utf8");
76
+ }
71
77
  async function bundledDeepResearch() {
72
78
  return readFile(fileURLToPath(new URL("./assets/deep_research.dml", import.meta.url)), "utf8");
73
79
  }
@@ -76,6 +82,9 @@ export async function initializeWorkspace(cwd) {
76
82
  await Promise.all([
77
83
  mkdir(paths.skills, { recursive: true }),
78
84
  mkdir(paths.plans, { recursive: true }),
85
+ mkdir(paths.specs, { recursive: true }),
86
+ mkdir(paths.changes, { recursive: true }),
87
+ mkdir(paths.lib, { recursive: true }),
79
88
  ]);
80
89
  await Promise.all([
81
90
  writeIfMissing(paths.config, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`),
@@ -83,6 +92,17 @@ export async function initializeWorkspace(cwd) {
83
92
  writeIfMissing(paths.reference, await bundledReference()),
84
93
  writeIfMissing(path.join(paths.skills, "example.dml"), EXAMPLE_DML),
85
94
  writeIfMissing(path.join(paths.skills, "deep_research.dml"), await bundledDeepResearch()),
95
+ writeIfMissing(path.join(paths.lib, "specs.dml"), await bundledAsset("specs.dml")),
96
+ writeIfMissing(path.join(paths.lib, "apply.dml"), await bundledAsset("apply.dml")),
97
+ writeIfMissing(path.join(paths.skills, "spec_validate.dml"), await bundledAsset("spec_validate.dml")),
98
+ writeIfMissing(path.join(paths.skills, "spec_status.dml"), await bundledAsset("spec_status.dml")),
99
+ writeIfMissing(path.join(paths.skills, "spec_query.dml"), await bundledAsset("spec_query.dml")),
100
+ writeIfMissing(path.join(paths.skills, "spec_graph.dml"), await bundledAsset("spec_graph.dml")),
101
+ writeIfMissing(path.join(paths.skills, "spec_merge.dml"), await bundledAsset("spec_merge.dml")),
102
+ writeIfMissing(path.join(paths.skills, "spec_archive.dml"), await bundledAsset("spec_archive.dml")),
103
+ writeIfMissing(path.join(paths.skills, "spec_coverage.dml"), await bundledAsset("spec_coverage.dml")),
104
+ writeIfMissing(path.join(paths.skills, "spec_scaffold.dml"), await bundledAsset("spec_scaffold.dml")),
105
+ writeIfMissing(path.join(paths.skills, "spec_apply.dml"), await bundledAsset("spec_apply.dml")),
86
106
  ]);
87
107
  return paths;
88
108
  }
@@ -0,0 +1,222 @@
1
+ # deepclause-pi speckit
2
+
3
+ Spec-driven changes for pi, without leaving the session.
4
+
5
+ `deepclause-pi speckit` adds a lightweight spec layer on top of the [DeepClause](https://github.com/deepclause/deepclause-sdk) runtime in pi:
6
+
7
+ - **Specs describe behaviour.** Plain Markdown under `.pi/deepclause/specs/` is the source of truth.
8
+ - **A change is a reviewed proposal.** `.pi/deepclause/changes/<slug>/` holds a proposal, delta specs, optional design notes, and an executable task plan.
9
+ - **Only the model touches prose.** Parsing, validation, merging, coverage, verification and rollback are deterministic DML — no model calls.
10
+ - **Nothing lands silently.** Every step that writes or executes is a gated, reviewable command.
11
+
12
+ ```text
13
+ /dc-plan <request> --change=<slug> propose
14
+ /dc-check <slug> validate (0 tokens)
15
+ /dc-apply <slug> execute, verify, retry, resume
16
+ /dc-archive <slug> merge into specs/
17
+ ```
18
+
19
+ ## Requirements
20
+
21
+ - pi with this extension installed (see the [README](../README.md#install)).
22
+ - Node.js 22+.
23
+ - Git, for apply snapshots and resumable/rollback behaviour. Without it everything still works, but the apply report says `rollback: unavailable`.
24
+
25
+ ## Getting started
26
+
27
+ ### A new project
28
+
29
+ ```sh
30
+ git init
31
+ printf 'node_modules/\ndist/\n' > .gitignore
32
+ git add -A && git commit -m "init"
33
+ ```
34
+
35
+ Then in pi, once, to seed the workspace:
36
+
37
+ ```text
38
+ /dc
39
+ ```
40
+
41
+ Describe the first feature as a change:
42
+
43
+ ```text
44
+ /dc-plan build a URL shortener with slugs and click counts --change=url_shortener
45
+ ```
46
+
47
+ Pi creates:
48
+
49
+ ```text
50
+ .pi/deepclause/changes/url_shortener/
51
+ ├── proposal.md # why / what / capabilities / impact
52
+ ├── specs/shortener/links.spec.md # ## Purpose + ## ADDED Requirements + scenarios
53
+ ├── design.md # approach (optional)
54
+ └── tasks.dml # one plan_task per step, with checks
55
+ ```
56
+
57
+ Commit the plan, then run it:
58
+
59
+ ```sh
60
+ git add -A && git commit -m "plan: url_shortener"
61
+ ```
62
+
63
+ ```text
64
+ /dc-check url_shortener # grammar, delta, scenario coverage
65
+ /dc-apply url_shortener # shows the tasks and the exact command set → confirm
66
+ /dc-archive url_shortener # shows the merge diff → confirm
67
+ ```
68
+
69
+ Archiving creates `specs/shortener/links.spec.md` and moves the change to `changes/archive/`. That is the bootstrap: **conversation → change → applied → spec.**
70
+
71
+ Every later feature is the same loop, but pi now reads the existing specs first and writes `## MODIFIED Requirements` when it changes behaviour that already exists.
72
+
73
+ ### An existing project
74
+
75
+ There is no code-scanning onboarding yet. Two practical routes:
76
+
77
+ 1. **Hand-write the first specs.** Create `specs/<capability>.spec.md` for behaviour you care about, then run `/dc-check` to validate it. This is often the fastest way to capture a system you already understand.
78
+ 2. **Document as you go.** Use `/dc-plan ... --change=<slug>` for the next real change. Describe the current behaviour you are building on as `## ADDED Requirements` if it is not yet captured, or trust the code and only specify the new behaviour.
79
+
80
+ Either way, `specs/` grows one reviewed change at a time.
81
+
82
+ ## Command reference
83
+
84
+ | Command | Effect |
85
+ |---|---|
86
+ | `/dc` | Status: model, paths, context mode, runtime state |
87
+ | `/dc-list` | Skills, plans, specs and changes |
88
+ | `/dc-plan <request> [--name=slug]` | Standalone executable plan under `plans/` |
89
+ | `/dc-plan <request> --change=<slug>` | Change plan: proposal + delta specs + `tasks.dml` |
90
+ | `/dc-plan update --change=<slug> <request>` | Regenerate a change plan (`tasks.dml` statuses reset to pending) |
91
+ | `/dc-check` | Validate every spec, delta and coverage hole (0 tokens) |
92
+ | `/dc-apply <change>` | Execute tasks; verify, retry, resume |
93
+ | `/dc-apply <change> --abort` | Discard an interrupted apply and restore the snapshot |
94
+ | `/dc-archive <change>` | Merge the delta into `specs/` and move the change to `archive/` |
95
+ | `/dc-run <skill> [args]` | Run any DML skill, e.g. `spec_status`, `spec_query ui/theme` |
96
+ | `/dc-cancel` | Cancel the active execution |
97
+ | `/dc-tool enable\|disable\|status` | Control the model-callable `dc_run` tool |
98
+
99
+ `/dc-run` refuses skills marked `% Mutating: true` (`spec_apply`, `spec_archive`) so that mutating steps always go through the reviewed `/dc-apply` and `/dc-archive` paths.
100
+
101
+ ### Read-only helpers
102
+
103
+ ```text
104
+ /dc-run spec_status # capability + delta inventory
105
+ /dc-run spec_query ui/theme # one capability's requirements and scenarios
106
+ /dc-run spec_coverage url_shortener # uncovered scenarios, tasks without checks
107
+ /dc-run spec_merge url_shortener # merge preview
108
+ /dc-run spec_scaffold url_shortener # draft tasks.dml text
109
+ /dc-run spec_graph capabilities # Mermaid graph
110
+ /dc-run spec_graph changes
111
+ ```
112
+
113
+ Ask pi for "the graph of capabilities and changes" and it calls `dc_spec_graph`, which renders the graph in the same offline Mermaid viewer as `dc_diagram`.
114
+
115
+ ## Files
116
+
117
+ ```text
118
+ .pi/deepclause/
119
+ ├── specs/<capability>.spec.md behaviour, source of truth
120
+ ├── changes/<slug>/
121
+ │ ├── proposal.md why / what / impact
122
+ │ ├── specs/<capability>.spec.md delta (ADDED / MODIFIED / REMOVED)
123
+ │ ├── design.md approach (optional)
124
+ │ ├── tasks.dml plan_task/2 + plan_task_status/2
125
+ │ └── change.json apply snapshot + applyState
126
+ ├── changes/archive/<date>-<slug>/
127
+ ├── lib/specs.dml parser, validator, merger, coverage
128
+ ├── lib/apply.dml task driver
129
+ └── skills/spec_*.dml
130
+ ```
131
+
132
+ ## Writing specs
133
+
134
+ A capability spec:
135
+
136
+ ```markdown
137
+ # Theme Specification
138
+
139
+ ## Purpose
140
+ Lets users choose between light and dark themes, defaulting to the OS preference.
141
+
142
+ ## Requirements
143
+
144
+ ### Requirement: Theme selection
145
+ The app SHALL let users switch between light and dark themes at runtime.
146
+
147
+ #### Scenario: User toggles dark mode
148
+ - **WHEN** the user clicks the theme toggle
149
+ - **THEN** the app switches to dark mode and persists the choice
150
+ ```
151
+
152
+ Rules the validator enforces:
153
+
154
+ - exactly **three** hashes for `### Requirement:` and **four** for `#### Scenario:` (the classic silent failure);
155
+ - every requirement has at least one scenario;
156
+ - no duplicate requirement names;
157
+ - specs are behaviour only — no commands, file paths, library choices, or task lists.
158
+
159
+ A delta wraps requirements in `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements` or `## RENAMED Requirements`:
160
+
161
+ - `MODIFIED` carries the **full** replacement requirement;
162
+ - `MODIFIED`/`REMOVED` must name a requirement that exists in the target spec (otherwise `/dc-archive` refuses rather than silently dropping it);
163
+ - a brand-new capability may only `ADD`;
164
+ - `RENAMED` is not supported by merge yet — `/dc-archive` refuses it.
165
+
166
+ Scenario ids are the join key between specs and tasks. They are derived from the scenario name: `Theme selection` in capability `ui/theme` → `ui/theme#theme-selection`. Renaming a scenario changes its id, so `/dc-check` will report the new scenario as uncovered until a task references it.
167
+
168
+ ## The task plan
169
+
170
+ `tasks.dml` is data, not a program:
171
+
172
+ ```prolog
173
+ plan_task("1.1", task{
174
+ executor: pi,
175
+ do: "Add a ThemeProvider context exposing theme and setTheme.",
176
+ tools: ["read", "edit"],
177
+ expected: "src/theme/ThemeProvider.tsx exports ThemeProvider and typechecks.",
178
+ satisfies: ["ui/theme#theme-selection"],
179
+ checks: [ exists("src/theme/ThemeProvider.tsx"),
180
+ cmd("npm run typecheck") ]
181
+ }).
182
+
183
+ % --- execution state (managed by apply.dml; do not edit by hand) ---
184
+ plan_task_status("1.1", pending).
185
+ ```
186
+
187
+ - `executor: pi` delegates a bounded step to pi with exactly the listed tools; `executor: dml` uses contained model reasoning with no pi tools.
188
+ - `satisfies` links the step to delta scenarios; `/dc-check` fails if any delta scenario is uncovered.
189
+ - Checks are declarative: `exists("path")`, `cmd("command")`, `model("question")`.
190
+ - Use `plan_task/2` and `plan_task_status/2` — **not** `task/2`, which collides with DML's built-in `task/N` predicate.
191
+
192
+ ## Verification, resume and rollback
193
+
194
+ - **Checks** run after each step. A failed check retries the step (up to 3 attempts) with the failure evidence — including command stderr — threaded into the repair instruction.
195
+ - **Verification commands are approved once per run** and then executed through the allowlisted `dc_verify_run` tool. Ordinary `pi_bash` approvals are unaffected.
196
+ - **Snapshots.** `/dc-apply` records a git ref before running and refuses to start on a dirty tree. On success it clears the ref.
197
+ - **Resume.** If a run stops — `/dc-cancel`, a crash, or exhausted retries — the working tree and the `done`/`failed` statuses are **preserved**. Re-run `/dc-apply <change>` and it resumes from the remaining tasks, reusing the original snapshot.
198
+ - **Abort.** `/dc-apply <change> --abort` discards the apply and restores the snapshot (`git reset --hard` + `git clean -fd`), which also resets `tasks.dml` statuses.
199
+
200
+ A step interrupted halfway is simply re-executed, because its task is not `done` yet and its checks re-verify.
201
+
202
+ ## Committing
203
+
204
+ After `/dc-plan`, `/dc-apply` and `/dc-archive` leave uncommitted changes, the extension lists the changed files and offers to commit them (`git add -A` with an `<action>: <change>` message), or reminds you when you decline. A clean tree is what lets the next apply take a snapshot.
205
+
206
+ Suggested `.gitignore`:
207
+
208
+ ```
209
+ .pi/deepclause/diagrams/
210
+ .pi/deepclause/changes/*/change.json
211
+ ```
212
+
213
+ Track `specs/` and `changes/` (including `tasks.dml`); ignore the generated viewer and the transient apply state.
214
+
215
+ ## Limits
216
+
217
+ - `RENAMED` deltas are validated but not merged yet.
218
+ - There is no digest/drift check between a delta and `specs/` beyond the merge guard; run `/dc-check` after editing specs by hand.
219
+ - `/dc-archive` does not run `/dc-check` for you, and it writes per capability, so run the check first.
220
+ - The `deltas.dml` / `index.dml` derived-fact files from the design are not implemented; queries derive on demand.
221
+
222
+ See [SPEC_LAYER_PROPOSAL.md](SPEC_LAYER_PROPOSAL.md) for the full design and rationale.