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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/commands/generate.d.ts +16 -0
- package/dist/cli/commands/generate.js +151 -0
- package/dist/cli/commands/verify.js +51 -5
- package/dist/cli/index.js +22 -0
- package/dist/compiler/workflow.d.ts +25 -1
- package/dist/compiler/workflow.js +170 -6
- package/dist/hooks/cli.js +34 -1
- package/dist/hooks/enforce.d.ts +3 -0
- package/dist/hooks/enforce.js +7 -2
- package/dist/runtime/workflow-runner.js +95 -3
- package/dist/schema/types.d.ts +16 -0
- package/package.json +2 -1
- package/spec/parallel-isolation.md +205 -0
- package/spec/schema-spec.md +676 -0
|
@@ -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 =
|
|
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,
|
|
162
|
-
process.stderr.write("\nBlock reasons TOP
|
|
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}. ${
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
417
|
+
// 7. Resolve retry config
|
|
254
418
|
const retry = resolveRetryConfig(workflow);
|
|
255
|
-
//
|
|
419
|
+
// 8. Build WorkflowStep[] from sorted steps
|
|
256
420
|
const compiledSteps = sorted.map(toWorkflowStep);
|
|
257
|
-
//
|
|
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
|
-
//
|
|
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
|
package/dist/hooks/enforce.d.ts
CHANGED
|
@@ -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.
|
package/dist/hooks/enforce.js
CHANGED
|
@@ -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
|
}
|