intentdna 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "hooks": {
3
- "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PreToolUse", "timeout": 5000 }] }],
4
- "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PostToolUse", "timeout": 3000 }] }],
5
- "UserPromptSubmit": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook UserPromptSubmit", "timeout": 5000 }] }],
6
- "SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook SubagentStop", "timeout": 3000 }] }],
7
- "PreCompact": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PreCompact", "timeout": 3000 }] }],
8
- "Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook Notification", "timeout": 3000 }] }],
9
- "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook Stop", "timeout": 3000 }] }],
10
- "SessionStart": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook SessionStart", "timeout": 5000 }] }]
3
+ "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PreToolUse", "timeout": 5 }] }],
4
+ "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PostToolUse", "timeout": 3 }] }],
5
+ "UserPromptSubmit": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook UserPromptSubmit", "timeout": 5 }] }],
6
+ "SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook SubagentStop", "timeout": 3 }] }],
7
+ "PreCompact": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PreCompact", "timeout": 3 }] }],
8
+ "Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook Notification", "timeout": 3 }] }],
9
+ "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook Stop", "timeout": 3 }] }],
10
+ "SessionStart": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook SessionStart", "timeout": 5 }] }]
11
11
  }
12
12
  }
@@ -9,8 +9,8 @@
9
9
  {
10
10
  "name": "intentdna",
11
11
  "description": "DNA template compilation + runtime enforcement",
12
- "version": "1.4.1",
13
- "source": "./.claude-plugin"
12
+ "version": "1.4.4",
13
+ "source": "./"
14
14
  }
15
15
  ]
16
16
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "0.6.0",
3
+ "version": "1.4.4",
4
4
  "description": "Declarative policy layer for AI agent governance"
5
5
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * dna generate <description> [--output <path>] [--match]
3
+ *
4
+ * Generate a DNA config from a natural language description.
5
+ *
6
+ * Modes:
7
+ * Default: Output a generation prompt (spec + description) for LLM use
8
+ * --match: Score templates against description, recommend best match
9
+ * --output: Write generated prompt to file (default: stdout)
10
+ */
11
+ export interface GenerateOptions {
12
+ description: string;
13
+ output?: string;
14
+ match?: boolean;
15
+ }
16
+ export declare function runGenerate(opts: GenerateOptions): Promise<number>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * dna generate <description> [--output <path>] [--match]
3
+ *
4
+ * Generate a DNA config from a natural language description.
5
+ *
6
+ * Modes:
7
+ * Default: Output a generation prompt (spec + description) for LLM use
8
+ * --match: Score templates against description, recommend best match
9
+ * --output: Write generated prompt to file (default: stdout)
10
+ */
11
+ import { readFile, writeFile } from "node:fs/promises";
12
+ import { resolve, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ const SPEC_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "spec", "schema-spec.md");
15
+ const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
16
+ /** Keyword → template name mapping for scoring */
17
+ const TEMPLATE_KEYWORDS = {
18
+ "code-review-pipeline": ["review", "code review", "pr review", "pull request", "审查"],
19
+ "safe-refactoring": ["refactor", "refactoring", "restructure", "rewrite safely", "重构"],
20
+ "tdd-strict": ["tdd", "test driven", "test first", "red green", "测试驱动"],
21
+ "full-pipeline": ["pipeline", "plan implement test", "full workflow", "完整流水线"],
22
+ "systematic-debugging": ["debug", "debugging", "bug fix", "troubleshoot", "调试"],
23
+ "verification-loop": ["verify", "verification", "validate", "check", "验证"],
24
+ "secure-dev": ["security", "secure", "owasp", "vulnerability", "安全"],
25
+ "enterprise-baseline": ["enterprise", "organization", "company", "corporate", "企业"],
26
+ "frontend-quality": ["frontend", "react", "vue", "css", "ui", "前端"],
27
+ "api-backend": ["api", "backend", "rest", "graphql", "server", "后端"],
28
+ "mobile-dev": ["mobile", "ios", "android", "react native", "移动"],
29
+ "documentation-writer": ["doc", "documentation", "readme", "wiki", "文档"],
30
+ "devops-cicd": ["devops", "ci", "cd", "deploy", "pipeline", "运维"],
31
+ "monorepo-governance": ["monorepo", "workspace", "packages", "governance"],
32
+ "flutter-behavior-lock": ["flutter", "behavior lock", "behavior spec", "行为锁定"],
33
+ "flutter-rewrite": ["flutter", "rewrite", "migration", "flutter rewrite"],
34
+ "flutter-refactoring-rescue": ["flutter", "rescue", "refactoring rescue"],
35
+ "multi-perspective-review": ["multi perspective", "multiple reviewer", "多角度"],
36
+ "subagent-parallel": ["parallel", "subagent", "concurrent", "并行"],
37
+ "yolo-with-guardrails": ["yolo", "fast", "guardrails", "move fast"],
38
+ "brainstorming-first": ["brainstorm", "explore", "ideation", "探索"],
39
+ "branch-finishing": ["branch", "finish", "cleanup", "pr prep"],
40
+ "pr-submitter": ["pr", "pull request", "submit"],
41
+ };
42
+ function scoreTemplates(description) {
43
+ const lower = description.toLowerCase();
44
+ const scores = [];
45
+ for (const [template, keywords] of Object.entries(TEMPLATE_KEYWORDS)) {
46
+ const matched = [];
47
+ for (const kw of keywords) {
48
+ if (lower.includes(kw)) {
49
+ matched.push(kw);
50
+ }
51
+ }
52
+ if (matched.length > 0) {
53
+ scores.push({
54
+ name: template,
55
+ displayName: template.replace(/-/g, " "),
56
+ score: matched.length,
57
+ matchedKeywords: matched,
58
+ });
59
+ }
60
+ }
61
+ return scores.sort((a, b) => b.score - a.score);
62
+ }
63
+ // ── Prompt Generation ─────────────────────────────────────
64
+ function buildPrompt(description, spec) {
65
+ return `# DNA Config Generation Request
66
+
67
+ ## Your Scenario
68
+ ${description}
69
+
70
+ ## Instructions
71
+ Generate a valid Intent DNA config in YAML format based on the scenario above.
72
+ Follow the schema specification below exactly. Output ONLY the YAML config, no explanation.
73
+
74
+ Requirements:
75
+ 1. Include all required fields (version, id, name, type, cascade, genes, contexts)
76
+ 2. Use \`cascade.inherits: ["species:default"]\` unless standalone
77
+ 3. Add a \`namespace\` if defining roles or workflows (2-8 lowercase chars)
78
+ 4. Define genes with descriptive codons (attract/repel/threshold/weight/sense)
79
+ 5. Define roles with appropriate tool_permissions and scope if the scenario needs agents
80
+ 6. Define a workflow if the scenario describes a multi-step process
81
+ 7. Use handoff artifacts with paths for artifact flow between steps
82
+ 8. Add variables for any project-specific values (use \`{{var}}\` syntax)
83
+
84
+ ## Schema Specification
85
+
86
+ ${spec}
87
+ `;
88
+ }
89
+ // ── Main ──────────────────────────────────────────────────
90
+ export async function runGenerate(opts) {
91
+ const { description } = opts;
92
+ if (!description || description.trim().length === 0) {
93
+ process.stderr.write("Usage: dna generate <description> [--output <path>] [--match]\n");
94
+ process.stderr.write("\nExamples:\n");
95
+ process.stderr.write(' dna generate "Flutter app with behavior locking and code review"\n');
96
+ process.stderr.write(' dna generate "Secure API backend with TDD" --match\n');
97
+ process.stderr.write(' dna generate "Enterprise security baseline" --output .dna/prompt.md\n');
98
+ return 2;
99
+ }
100
+ // Match mode: score templates and recommend
101
+ if (opts.match) {
102
+ const scores = scoreTemplates(description);
103
+ if (scores.length === 0) {
104
+ process.stderr.write("No matching templates found.\n");
105
+ process.stderr.write("Use without --match to generate a custom config prompt.\n");
106
+ process.stderr.write("Or list templates: dna init --template list\n");
107
+ return 0;
108
+ }
109
+ process.stderr.write("Template matches (ranked):\n\n");
110
+ for (const s of scores.slice(0, 5)) {
111
+ const stars = "*".repeat(Math.min(s.score, 5));
112
+ process.stderr.write(` ${stars.padEnd(5)} ${s.name}\n`);
113
+ process.stderr.write(` matched: ${s.matchedKeywords.join(", ")}\n`);
114
+ }
115
+ const best = scores[0];
116
+ process.stderr.write(`\nRecommended: dna init --template ${best.name}\n`);
117
+ return 0;
118
+ }
119
+ // Default mode: generate prompt with spec
120
+ let spec;
121
+ try {
122
+ spec = await readFile(SPEC_PATH, "utf-8");
123
+ }
124
+ catch {
125
+ // Fallback: try from installed package location
126
+ const altPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "spec", "schema-spec.md");
127
+ try {
128
+ spec = await readFile(altPath, "utf-8");
129
+ }
130
+ catch {
131
+ process.stderr.write("Schema spec not found. Run from project root or install intentdna globally.\n");
132
+ return 1;
133
+ }
134
+ }
135
+ const prompt = buildPrompt(description, spec);
136
+ if (opts.output) {
137
+ try {
138
+ await writeFile(opts.output, prompt, "utf-8");
139
+ }
140
+ catch (err) {
141
+ process.stderr.write(`Error writing to ${opts.output}: ${err instanceof Error ? err.message : String(err)}\n`);
142
+ return 1;
143
+ }
144
+ process.stderr.write(`Generation prompt written to ${opts.output}\n`);
145
+ process.stderr.write(`Use with: cat ${opts.output} | claude --print\n`);
146
+ }
147
+ else {
148
+ process.stdout.write(prompt);
149
+ }
150
+ return 0;
151
+ }
@@ -150,21 +150,67 @@ async function runStats() {
150
150
  process.stderr.write(` ${wf}: ${list.length} tool calls, ${steps.size} steps, ${blocks} blocks\n`);
151
151
  }
152
152
  }
153
- // Block reasons TOP 3
153
+ // Block reasons TOP 3 — use actual reason text when available
154
154
  const blockEntries = entries.filter(e => e.decision === "block");
155
155
  if (blockEntries.length > 0) {
156
156
  const reasons = new Map();
157
157
  for (const e of blockEntries) {
158
- const key = `${e.event}:${e.tool_name ?? "unknown"}`;
158
+ const key = e.reason
159
+ ? `${e.event}: ${truncate(e.reason, 70)}`
160
+ : `${e.event}:${e.tool_name ?? "unknown"}`;
159
161
  reasons.set(key, (reasons.get(key) ?? 0) + 1);
160
162
  }
161
- const sorted = [...reasons.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
162
- process.stderr.write("\nBlock reasons TOP 3:\n");
163
+ const sorted = [...reasons.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
164
+ process.stderr.write("\nBlock reasons TOP 5:\n");
163
165
  let rank = 0;
164
166
  for (const [reason, count] of sorted) {
165
167
  rank++;
166
- process.stderr.write(` ${rank}. ${reason} x ${count}\n`);
168
+ process.stderr.write(` ${rank}. [x${count}] ${reason}\n`);
169
+ }
170
+ }
171
+ // Blocked paths — aggregate target_path for blocks
172
+ const blockedPaths = blockEntries.filter(e => e.target_path);
173
+ if (blockedPaths.length > 0) {
174
+ const pathCounts = new Map();
175
+ for (const e of blockedPaths) {
176
+ pathCounts.set(e.target_path, (pathCounts.get(e.target_path) ?? 0) + 1);
177
+ }
178
+ const sorted = [...pathCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
179
+ process.stderr.write("\nBlocked paths TOP 5:\n");
180
+ for (const [path, count] of sorted) {
181
+ process.stderr.write(` [x${count}] ${path}\n`);
182
+ }
183
+ }
184
+ // Role activity — agent_type distribution
185
+ const withAgent = entries.filter(e => e.agent_type);
186
+ if (withAgent.length > 0) {
187
+ const roleCounts = new Map();
188
+ for (const e of withAgent) {
189
+ const role = e.agent_type;
190
+ const stats = roleCounts.get(role) ?? { total: 0, blocks: 0, warns: 0 };
191
+ stats.total++;
192
+ if (e.decision === "block")
193
+ stats.blocks++;
194
+ if (e.decision === "warn")
195
+ stats.warns++;
196
+ roleCounts.set(role, stats);
197
+ }
198
+ process.stderr.write("\nRole activity:\n");
199
+ const sorted = [...roleCounts.entries()].sort((a, b) => b[1].total - a[1].total);
200
+ for (const [role, stats] of sorted) {
201
+ const parts = [`${stats.total} calls`];
202
+ if (stats.blocks > 0)
203
+ parts.push(`${stats.blocks} blocks`);
204
+ if (stats.warns > 0)
205
+ parts.push(`${stats.warns} warns`);
206
+ process.stderr.write(` ${role.padEnd(30)} ${parts.join(", ")}\n`);
167
207
  }
168
208
  }
169
209
  return 0;
170
210
  }
211
+ /** Truncate string to max length with ellipsis */
212
+ function truncate(s, max) {
213
+ if (s.length <= max)
214
+ return s;
215
+ return s.slice(0, max - 3) + "...";
216
+ }
package/dist/cli/index.js CHANGED
@@ -23,6 +23,7 @@ Commands:
23
23
  verify Verify synced files match .dna/lock checksums (drift detection)
24
24
  run Execute a workflow directly (TypeScript runtime)
25
25
  init Create a new DNA file interactively
26
+ generate Generate DNA config from natural language description
26
27
  compile Compile DNA files to framework configuration
27
28
  validate Validate DNA files for correctness
28
29
  show Show gene expression state
@@ -41,6 +42,8 @@ Examples:
41
42
  dna init --template flutter-rewrite Create DNA config from template
42
43
  dna init --template list List all available templates with namespaces
43
44
  dna init --register my.dna.yaml Register a custom template (checks namespace uniqueness)
45
+ dna generate "Flutter app with behavior locking" Generate config prompt with schema spec
46
+ dna generate "Secure API backend" --match Find matching templates
44
47
  dna sync Auto-detect .dna/config.yaml + environment, sync all
45
48
  dna sync .dna/config.yaml --target claude-md --inject CLAUDE.md --context work
46
49
  dna sync my.dna.json --target claude-md --inject CLAUDE.md --hooks .claude/hooks
@@ -240,6 +243,25 @@ async function main() {
240
243
  process.exit(code);
241
244
  break;
242
245
  }
246
+ case "generate": {
247
+ const { values: genValues, positionals: genPositionals } = parseArgs({
248
+ args: rest,
249
+ options: {
250
+ output: { type: "string", short: "o" },
251
+ match: { type: "boolean", default: false },
252
+ },
253
+ allowPositionals: true,
254
+ strict: false,
255
+ });
256
+ const { runGenerate } = await import("./commands/generate.js");
257
+ const code = await runGenerate({
258
+ description: genPositionals.join(" "),
259
+ output: genValues.output,
260
+ match: genValues.match,
261
+ });
262
+ process.exit(code);
263
+ break;
264
+ }
243
265
  case "show": {
244
266
  const { values, positionals } = parseArgs({
245
267
  args: rest,
@@ -5,10 +5,12 @@
5
5
  * Performs topological sort, parallel group detection, transition compilation,
6
6
  * retry config resolution, cycle detection, and optional Mermaid diagram generation.
7
7
  */
8
- import type { WorkflowDef, WorkflowPlan } from "../schema/types.js";
8
+ import type { WorkflowDef, WorkflowPlan, RoleDef } from "../schema/types.js";
9
9
  export interface CompileWorkflowOptions {
10
10
  /** Generate Mermaid diagram (default: false) */
11
11
  mermaid?: boolean;
12
+ /** Roles for scope overlap analysis (needed for isolation: auto) */
13
+ roles?: Record<string, RoleDef>;
12
14
  }
13
15
  export interface CompileWorkflowError {
14
16
  path: string;
@@ -19,6 +21,12 @@ export interface CompileWorkflowResult {
19
21
  plan?: WorkflowPlan;
20
22
  errors: CompileWorkflowError[];
21
23
  }
24
+ /**
25
+ * Check if two sets of write glob patterns have overlapping scope.
26
+ * Uses prefix-based matching: "src/xx" and "src/lib/xx" overlap.
27
+ * Wildcard-only patterns are treated as overlapping with everything.
28
+ */
29
+ export declare function hasWriteScopeOverlap(scopeA: string[], scopeB: string[]): boolean;
22
30
  /**
23
31
  * Compile a WorkflowDef into an executable WorkflowPlan.
24
32
  *
@@ -78,6 +78,9 @@ function topologicalSort(steps, graph) {
78
78
  groups.push({
79
79
  group_index: groupIndex,
80
80
  step_ids: [...queue],
81
+ isolation: "none",
82
+ merge_strategy: "escalate",
83
+ scope_overlap: false,
81
84
  });
82
85
  groupIndex++;
83
86
  const nextQueue = [];
@@ -211,6 +214,100 @@ function generateMermaid(plan) {
211
214
  return lines.join("\n");
212
215
  }
213
216
  // ── Public API ───────────────────────────────────────────────
217
+ /**
218
+ * Check if two sets of write glob patterns have overlapping scope.
219
+ * Uses prefix-based matching: "src/xx" and "src/lib/xx" overlap.
220
+ * Wildcard-only patterns are treated as overlapping with everything.
221
+ */
222
+ export function hasWriteScopeOverlap(scopeA, scopeB) {
223
+ if (scopeA.length === 0 || scopeB.length === 0)
224
+ return false;
225
+ const prefixesA = scopeA.map(globToPrefix);
226
+ const prefixesB = scopeB.map(globToPrefix);
227
+ for (const a of prefixesA) {
228
+ for (const b of prefixesB) {
229
+ // Empty prefix = root glob ("**/*") — overlaps with everything
230
+ if (a.length === 0 || b.length === 0)
231
+ return true;
232
+ // Check mutual prefix containment
233
+ if (a.startsWith(b) || b.startsWith(a))
234
+ return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+ /** Extract directory prefix from a glob (same logic as enforce.ts) */
240
+ function globToPrefix(glob) {
241
+ const starIdx = glob.indexOf("*");
242
+ if (starIdx === -1)
243
+ return glob;
244
+ return glob.substring(0, starIdx);
245
+ }
246
+ /**
247
+ * Resolve isolation mode for each parallel group.
248
+ *
249
+ * Resolution order: step.isolation > workflow.default_isolation > "none"
250
+ * For "auto" mode: check write scope overlap between roles in the group.
251
+ */
252
+ function resolveGroupIsolation(groups, workflow, stepMap, roles) {
253
+ const defaultIsolation = workflow.default_isolation ?? "none";
254
+ const mergeStrategy = workflow.merge_strategy ?? "escalate";
255
+ for (const group of groups) {
256
+ group.merge_strategy = mergeStrategy;
257
+ // Single-step groups always use "none" (no parallelism to isolate)
258
+ if (group.step_ids.length <= 1) {
259
+ group.isolation = "none";
260
+ group.scope_overlap = false;
261
+ continue;
262
+ }
263
+ // Collect per-step isolation settings
264
+ const stepIsolations = group.step_ids.map(id => {
265
+ const step = stepMap.get(id);
266
+ return step?.isolation ?? defaultIsolation;
267
+ });
268
+ // If any step explicitly requires worktree, use worktree
269
+ if (stepIsolations.some(i => i === "worktree")) {
270
+ group.isolation = "worktree";
271
+ group.scope_overlap = true; // conservative
272
+ continue;
273
+ }
274
+ // If all steps are explicitly "none", use none
275
+ if (stepIsolations.every(i => i === "none")) {
276
+ group.isolation = "none";
277
+ group.scope_overlap = false;
278
+ continue;
279
+ }
280
+ // Auto mode: check scope overlap
281
+ const hasAuto = stepIsolations.some(i => i === "auto");
282
+ if (hasAuto && roles) {
283
+ const writeScopes = group.step_ids.map(id => {
284
+ const step = stepMap.get(id);
285
+ const role = step ? roles[step.role] : undefined;
286
+ return role?.scope?.write ?? [];
287
+ });
288
+ // Check pairwise overlap
289
+ let overlap = false;
290
+ for (let i = 0; i < writeScopes.length && !overlap; i++) {
291
+ for (let j = i + 1; j < writeScopes.length && !overlap; j++) {
292
+ if (hasWriteScopeOverlap(writeScopes[i], writeScopes[j])) {
293
+ overlap = true;
294
+ }
295
+ }
296
+ }
297
+ group.scope_overlap = overlap;
298
+ group.isolation = overlap ? "worktree" : "none";
299
+ }
300
+ else if (hasAuto) {
301
+ // Auto without roles — conservative default to worktree
302
+ group.isolation = "worktree";
303
+ group.scope_overlap = true;
304
+ }
305
+ else {
306
+ group.isolation = "none";
307
+ group.scope_overlap = false;
308
+ }
309
+ }
310
+ }
214
311
  /**
215
312
  * Compile a WorkflowDef into an executable WorkflowPlan.
216
313
  *
@@ -248,13 +345,18 @@ export function compileWorkflow(workflow, options) {
248
345
  });
249
346
  return { ok: false, errors };
250
347
  }
251
- // 5. Compile transitions
348
+ // 5. Resolve isolation for parallel groups
349
+ const stepMap = new Map();
350
+ for (const s of resolvedSteps)
351
+ stepMap.set(s.id, s);
352
+ resolveGroupIsolation(groups, workflow, stepMap, options?.roles);
353
+ // 6. Compile transitions
252
354
  const { defaults, explicit } = compileTransitions(workflow, groups);
253
- // 6. Resolve retry config
355
+ // 7. Resolve retry config
254
356
  const retry = resolveRetryConfig(workflow);
255
- // 7. Build WorkflowStep[] from sorted steps
357
+ // 8. Build WorkflowStep[] from sorted steps
256
358
  const compiledSteps = sorted.map(toWorkflowStep);
257
- // 8. Construct the plan
359
+ // 9. Construct the plan
258
360
  const plan = {
259
361
  name: workflow.name,
260
362
  description: workflow.description ?? "",
@@ -267,7 +369,7 @@ export function compileWorkflow(workflow, options) {
267
369
  compiled_at: new Date().toISOString(),
268
370
  source_workflow: workflow.name,
269
371
  };
270
- // 9. Optional Mermaid diagram
372
+ // 10. Optional Mermaid diagram
271
373
  if (options?.mermaid) {
272
374
  plan.mermaid = generateMermaid(plan);
273
375
  }
package/dist/hooks/cli.js CHANGED
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Fail-open: all errors → { continue: true, suppressOutput: true }
16
16
  */
17
- import { readFile } from "node:fs/promises";
17
+ import { readFile, stat } from "node:fs/promises";
18
18
  import { resolve } from "node:path";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { readStdin, writeOutput, silentOutput } from "./protocol.js";
@@ -62,6 +62,9 @@ async function main() {
62
62
  workflow: wfState.workflow,
63
63
  completed_artifacts: wfState.completed_artifacts,
64
64
  };
65
+ // Re-run fallback: scan consumed artifact paths on disk so
66
+ // enforceHandoffConsumes can skip blocks for files that already exist.
67
+ state.existingArtifactPaths = await scanConsumedArtifactPaths(projectDir, ir, wfState.workflow, wfState.current_step);
65
68
  }
66
69
  }
67
70
  // Special handling for Stop — needs async workflow state read
@@ -102,6 +105,10 @@ async function main() {
102
105
  const decision = output.continue === false ? "block"
103
106
  : output.hookSpecificOutput?.additionalContext?.startsWith("WARN") ? "warn"
104
107
  : "allow";
108
+ // Extract target file path from tool_input (Write/Edit/Read)
109
+ const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
110
+ ? rawInput.tool_input : undefined;
111
+ const targetPath = typeof toolInput?.file_path === "string" ? toolInput.file_path : undefined;
105
112
  appendTrace(projectDir, {
106
113
  trace_id: randomUUID(),
107
114
  event,
@@ -110,6 +117,8 @@ async function main() {
110
117
  workflow: state.workflowState?.workflow,
111
118
  step: state.workflowState?.current_step,
112
119
  decision: decision,
120
+ reason: decision !== "allow" ? output.reason : undefined,
121
+ target_path: decision !== "allow" ? targetPath : undefined,
113
122
  duration_ms: durationMs,
114
123
  timestamp: new Date().toISOString(),
115
124
  }).catch(() => { }); // Fail-open
@@ -193,6 +202,36 @@ async function loadIR(irPath) {
193
202
  return null;
194
203
  }
195
204
  }
205
+ // ── Artifact Scanning ─────────────────────────────────────
206
+ /**
207
+ * Check consumed artifact paths on disk for the current workflow step.
208
+ * Returns a Set of artifact paths that exist, enabling re-run idempotency
209
+ * (prior run's output files satisfy the current step's consumes).
210
+ */
211
+ async function scanConsumedArtifactPaths(projectDir, ir, workflowName, currentStep) {
212
+ const paths = new Set();
213
+ if (!ir.workflows_ir)
214
+ return paths;
215
+ const activeWf = ir.workflows_ir.find(w => w.workflow_name === workflowName);
216
+ if (!activeWf)
217
+ return paths;
218
+ const entry = activeWf.handoff_chain.find(h => h.step_id === currentStep);
219
+ if (!entry?.consumes)
220
+ return paths;
221
+ for (const consumed of entry.consumes) {
222
+ if (!consumed.path)
223
+ continue;
224
+ const fullPath = resolve(projectDir, consumed.path);
225
+ try {
226
+ await stat(fullPath);
227
+ paths.add(consumed.path);
228
+ }
229
+ catch {
230
+ // File doesn't exist — not an error, just means artifact not available
231
+ }
232
+ }
233
+ return paths;
234
+ }
196
235
  // ── Entry Point ────────────────────────────────────────────
197
236
  main().catch(() => {
198
237
  // Fail-open: never block Claude Code on unexpected errors
@@ -28,6 +28,9 @@ export interface EnforceState {
28
28
  workflow: string;
29
29
  completed_artifacts?: CompletedArtifactEntry[];
30
30
  };
31
+ /** Artifact paths that exist on disk — fallback for re-run idempotency.
32
+ * CLI checks disk, passes paths here so enforce stays pure (no I/O). */
33
+ existingArtifactPaths?: Set<string>;
31
34
  }
32
35
  /**
33
36
  * Enforce PreToolUse constraints.
@@ -48,7 +48,7 @@ export function enforcePreToolUse(ir, input, state, roles) {
48
48
  }
49
49
  // Layer 6: Handoff — check consumed artifacts are available
50
50
  if (state?.workflowState && ir.workflows_ir) {
51
- const result = enforceHandoffConsumes(ir, state.workflowState);
51
+ const result = enforceHandoffConsumes(ir, state.workflowState, state.existingArtifactPaths);
52
52
  if (result)
53
53
  return result;
54
54
  }
@@ -266,7 +266,7 @@ export function enforceStop(ir, input, workflowState) {
266
266
  * artifacts have been produced by a preceding step.
267
267
  * Returns block output if a required artifact is missing, null if all satisfied.
268
268
  */
269
- function enforceHandoffConsumes(ir, wfState) {
269
+ function enforceHandoffConsumes(ir, wfState, existingPaths) {
270
270
  const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
271
271
  if (!activeWf || activeWf.handoff_chain.length === 0)
272
272
  return null;
@@ -280,6 +280,11 @@ function enforceHandoffConsumes(ir, wfState) {
280
280
  const producer = activeWf.handoff_chain.find(h => h.produces?.some(p => p.type === consumed.type && ((p.path && consumed.path && p.path === consumed.path) ||
281
281
  (consumed.from && h.step_id === consumed.from))));
282
282
  if (producer && !completedStepIds.has(producer.step_id)) {
283
+ // Re-run fallback: if the consumed artifact file exists on disk
284
+ // (from a prior run), treat the dependency as satisfied.
285
+ if (consumed.path && existingPaths?.has(consumed.path)) {
286
+ continue;
287
+ }
283
288
  return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact from step '${producer.step_id}' (${consumed.description}). Run step '${producer.step_id}' first.`);
284
289
  }
285
290
  }
@@ -71,6 +71,8 @@ export interface TraceEntry {
71
71
  workflow?: string;
72
72
  step?: string;
73
73
  decision: "allow" | "block" | "warn";
74
+ reason?: string;
75
+ target_path?: string;
74
76
  duration_ms: number;
75
77
  timestamp: string;
76
78
  }
@@ -139,6 +139,7 @@ export interface WorkflowStepDef {
139
139
  completion?: CompletionCheck[];
140
140
  checkpoints?: StepCheckpoint[];
141
141
  handoff?: StepHandoff;
142
+ isolation?: "none" | "worktree" | "auto";
142
143
  }
143
144
  /** Top-level workflow definition */
144
145
  export interface WorkflowDef {
@@ -150,6 +151,8 @@ export interface WorkflowDef {
150
151
  max_rounds?: number;
151
152
  produces?: HandoffArtifact[];
152
153
  consumes?: HandoffArtifact[];
154
+ default_isolation?: "none" | "worktree" | "auto";
155
+ merge_strategy?: "escalate";
153
156
  }
154
157
  export interface EpigeneticEffect {
155
158
  gene?: string;
@@ -320,6 +323,9 @@ export interface WorkflowStep {
320
323
  export interface ParallelGroup {
321
324
  group_index: number;
322
325
  step_ids: string[];
326
+ isolation: "none" | "worktree";
327
+ merge_strategy: "escalate";
328
+ scope_overlap: boolean;
323
329
  }
324
330
  /** Compiled transition rule */
325
331
  export interface TransitionRule {