intentdna 1.4.3 → 1.4.5

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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "intentdna",
11
11
  "description": "DNA template compilation + runtime enforcement",
12
- "version": "1.4.3",
12
+ "version": "1.4.5",
13
13
  "source": "./"
14
14
  }
15
15
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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, WorkflowStepDef, WorkflowPlan, RoleDef, RawWorkflowStepEntry } 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,28 @@ export interface CompileWorkflowResult {
19
21
  plan?: WorkflowPlan;
20
22
  errors: CompileWorkflowError[];
21
23
  }
24
+ /**
25
+ * Expand parallel blocks into regular steps with depends_on.
26
+ *
27
+ * A parallel block:
28
+ * - parallel:
29
+ * isolation: worktree
30
+ * steps: [{id: "a", role: "x"}, {id: "b", role: "y"}]
31
+ *
32
+ * Becomes:
33
+ * - {id: "a", role: "x", depends_on: [<prev_step>], isolation: worktree}
34
+ * - {id: "b", role: "y", depends_on: [<prev_step>], isolation: worktree}
35
+ *
36
+ * The step immediately before the parallel block is the dependency.
37
+ * If no previous step, the parallel steps have no depends_on.
38
+ */
39
+ export declare function expandParallelBlocks(steps: RawWorkflowStepEntry[]): WorkflowStepDef[];
40
+ /**
41
+ * Check if two sets of write glob patterns have overlapping scope.
42
+ * Uses prefix-based matching: "src/xx" and "src/lib/xx" overlap.
43
+ * Wildcard-only patterns are treated as overlapping with everything.
44
+ */
45
+ export declare function hasWriteScopeOverlap(scopeA: string[], scopeB: string[]): boolean;
22
46
  /**
23
47
  * Compile a WorkflowDef into an executable WorkflowPlan.
24
48
  *
@@ -6,6 +6,66 @@
6
6
  * retry config resolution, cycle detection, and optional Mermaid diagram generation.
7
7
  */
8
8
  // ── Internal helpers ─────────────────────────────────────────
9
+ /**
10
+ * Type guard: check if a step entry is a parallel block (syntax sugar).
11
+ */
12
+ function isParallelBlock(entry) {
13
+ return "parallel" in entry && typeof entry.parallel === "object";
14
+ }
15
+ /**
16
+ * Expand parallel blocks into regular steps with depends_on.
17
+ *
18
+ * A parallel block:
19
+ * - parallel:
20
+ * isolation: worktree
21
+ * steps: [{id: "a", role: "x"}, {id: "b", role: "y"}]
22
+ *
23
+ * Becomes:
24
+ * - {id: "a", role: "x", depends_on: [<prev_step>], isolation: worktree}
25
+ * - {id: "b", role: "y", depends_on: [<prev_step>], isolation: worktree}
26
+ *
27
+ * The step immediately before the parallel block is the dependency.
28
+ * If no previous step, the parallel steps have no depends_on.
29
+ */
30
+ export function expandParallelBlocks(steps) {
31
+ const result = [];
32
+ let lastStepId = null;
33
+ for (const entry of steps) {
34
+ if (isParallelBlock(entry)) {
35
+ const block = entry.parallel;
36
+ const expandedIds = [];
37
+ for (const inner of block.steps) {
38
+ const expanded = {
39
+ ...inner,
40
+ // Add depends_on from last step before this block
41
+ depends_on: lastStepId
42
+ ? [...(inner.depends_on ?? []), lastStepId]
43
+ : inner.depends_on,
44
+ // Inherit isolation from parallel block if step doesn't override
45
+ isolation: inner.isolation ?? block.isolation,
46
+ };
47
+ result.push(expanded);
48
+ expandedIds.push(inner.id);
49
+ }
50
+ // After expansion, "lastStepId" stays as the set of expanded IDs.
51
+ // The next step should depend on ALL parallel steps.
52
+ // We can't set a single lastStepId, so we track it differently:
53
+ // We set lastStepId to null and let the NEXT regular step
54
+ // depend on all expanded IDs via explicit depends_on in the input.
55
+ // Actually, the simplest approach: peek at the next step.
56
+ // If the next step already has depends_on referencing these IDs, good.
57
+ // Otherwise, we need a way to inject deps.
58
+ // Since this is sugar, we rely on the user specifying depends_on
59
+ // on the join step (like in the spec example).
60
+ lastStepId = null; // Reset — next step must use explicit depends_on
61
+ }
62
+ else {
63
+ result.push(entry);
64
+ lastStepId = entry.id;
65
+ }
66
+ }
67
+ return result;
68
+ }
9
69
  /**
10
70
  * If no step in the workflow has depends_on, inject implicit sequential
11
71
  * dependencies based on array order (step[i] depends_on step[i-1]).
@@ -78,6 +138,9 @@ function topologicalSort(steps, graph) {
78
138
  groups.push({
79
139
  group_index: groupIndex,
80
140
  step_ids: [...queue],
141
+ isolation: "none",
142
+ merge_strategy: "escalate",
143
+ scope_overlap: false,
81
144
  });
82
145
  groupIndex++;
83
146
  const nextQueue = [];
@@ -211,6 +274,100 @@ function generateMermaid(plan) {
211
274
  return lines.join("\n");
212
275
  }
213
276
  // ── Public API ───────────────────────────────────────────────
277
+ /**
278
+ * Check if two sets of write glob patterns have overlapping scope.
279
+ * Uses prefix-based matching: "src/xx" and "src/lib/xx" overlap.
280
+ * Wildcard-only patterns are treated as overlapping with everything.
281
+ */
282
+ export function hasWriteScopeOverlap(scopeA, scopeB) {
283
+ if (scopeA.length === 0 || scopeB.length === 0)
284
+ return false;
285
+ const prefixesA = scopeA.map(globToPrefix);
286
+ const prefixesB = scopeB.map(globToPrefix);
287
+ for (const a of prefixesA) {
288
+ for (const b of prefixesB) {
289
+ // Empty prefix = root glob ("**/*") — overlaps with everything
290
+ if (a.length === 0 || b.length === 0)
291
+ return true;
292
+ // Check mutual prefix containment
293
+ if (a.startsWith(b) || b.startsWith(a))
294
+ return true;
295
+ }
296
+ }
297
+ return false;
298
+ }
299
+ /** Extract directory prefix from a glob (same logic as enforce.ts) */
300
+ function globToPrefix(glob) {
301
+ const starIdx = glob.indexOf("*");
302
+ if (starIdx === -1)
303
+ return glob;
304
+ return glob.substring(0, starIdx);
305
+ }
306
+ /**
307
+ * Resolve isolation mode for each parallel group.
308
+ *
309
+ * Resolution order: step.isolation > workflow.default_isolation > "none"
310
+ * For "auto" mode: check write scope overlap between roles in the group.
311
+ */
312
+ function resolveGroupIsolation(groups, workflow, stepMap, roles) {
313
+ const defaultIsolation = workflow.default_isolation ?? "none";
314
+ const mergeStrategy = workflow.merge_strategy ?? "escalate";
315
+ for (const group of groups) {
316
+ group.merge_strategy = mergeStrategy;
317
+ // Single-step groups always use "none" (no parallelism to isolate)
318
+ if (group.step_ids.length <= 1) {
319
+ group.isolation = "none";
320
+ group.scope_overlap = false;
321
+ continue;
322
+ }
323
+ // Collect per-step isolation settings
324
+ const stepIsolations = group.step_ids.map(id => {
325
+ const step = stepMap.get(id);
326
+ return step?.isolation ?? defaultIsolation;
327
+ });
328
+ // If any step explicitly requires worktree, use worktree
329
+ if (stepIsolations.some(i => i === "worktree")) {
330
+ group.isolation = "worktree";
331
+ group.scope_overlap = true; // conservative
332
+ continue;
333
+ }
334
+ // If all steps are explicitly "none", use none
335
+ if (stepIsolations.every(i => i === "none")) {
336
+ group.isolation = "none";
337
+ group.scope_overlap = false;
338
+ continue;
339
+ }
340
+ // Auto mode: check scope overlap
341
+ const hasAuto = stepIsolations.some(i => i === "auto");
342
+ if (hasAuto && roles) {
343
+ const writeScopes = group.step_ids.map(id => {
344
+ const step = stepMap.get(id);
345
+ const role = step ? roles[step.role] : undefined;
346
+ return role?.scope?.write ?? [];
347
+ });
348
+ // Check pairwise overlap
349
+ let overlap = false;
350
+ for (let i = 0; i < writeScopes.length && !overlap; i++) {
351
+ for (let j = i + 1; j < writeScopes.length && !overlap; j++) {
352
+ if (hasWriteScopeOverlap(writeScopes[i], writeScopes[j])) {
353
+ overlap = true;
354
+ }
355
+ }
356
+ }
357
+ group.scope_overlap = overlap;
358
+ group.isolation = overlap ? "worktree" : "none";
359
+ }
360
+ else if (hasAuto) {
361
+ // Auto without roles — conservative default to worktree
362
+ group.isolation = "worktree";
363
+ group.scope_overlap = true;
364
+ }
365
+ else {
366
+ group.isolation = "none";
367
+ group.scope_overlap = false;
368
+ }
369
+ }
370
+ }
214
371
  /**
215
372
  * Compile a WorkflowDef into an executable WorkflowPlan.
216
373
  *
@@ -235,8 +392,10 @@ export function compileWorkflow(workflow, options) {
235
392
  });
236
393
  return { ok: false, errors };
237
394
  }
395
+ // 1.5. Expand parallel blocks (syntax sugar) into regular steps
396
+ const expandedSteps = expandParallelBlocks(workflow.steps);
238
397
  // 2. Infer implicit deps if no step has depends_on
239
- const resolvedSteps = inferImplicitDeps(workflow.steps);
398
+ const resolvedSteps = inferImplicitDeps(expandedSteps);
240
399
  // 3. Build dependency graph
241
400
  const graph = buildDependencyGraph(resolvedSteps);
242
401
  // 4. Topological sort + cycle detection
@@ -248,13 +407,18 @@ export function compileWorkflow(workflow, options) {
248
407
  });
249
408
  return { ok: false, errors };
250
409
  }
251
- // 5. Compile transitions
410
+ // 5. Resolve isolation for parallel groups
411
+ const stepMap = new Map();
412
+ for (const s of resolvedSteps)
413
+ stepMap.set(s.id, s);
414
+ resolveGroupIsolation(groups, workflow, stepMap, options?.roles);
415
+ // 6. Compile transitions
252
416
  const { defaults, explicit } = compileTransitions(workflow, groups);
253
- // 6. Resolve retry config
417
+ // 7. Resolve retry config
254
418
  const retry = resolveRetryConfig(workflow);
255
- // 7. Build WorkflowStep[] from sorted steps
419
+ // 8. Build WorkflowStep[] from sorted steps
256
420
  const compiledSteps = sorted.map(toWorkflowStep);
257
- // 8. Construct the plan
421
+ // 9. Construct the plan
258
422
  const plan = {
259
423
  name: workflow.name,
260
424
  description: workflow.description ?? "",
@@ -267,7 +431,7 @@ export function compileWorkflow(workflow, options) {
267
431
  compiled_at: new Date().toISOString(),
268
432
  source_workflow: workflow.name,
269
433
  };
270
- // 9. Optional Mermaid diagram
434
+ // 10. Optional Mermaid diagram
271
435
  if (options?.mermaid) {
272
436
  plan.mermaid = generateMermaid(plan);
273
437
  }
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
@@ -199,6 +202,36 @@ async function loadIR(irPath) {
199
202
  return null;
200
203
  }
201
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
+ }
202
235
  // ── Entry Point ────────────────────────────────────────────
203
236
  main().catch(() => {
204
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
  }