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.
- package/.claude-plugin/hooks/hooks.json +8 -8
- package/.claude-plugin/marketplace.json +2 -2
- 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 +9 -1
- package/dist/compiler/workflow.js +107 -5
- package/dist/hooks/cli.js +40 -1
- package/dist/hooks/enforce.d.ts +3 -0
- package/dist/hooks/enforce.js +7 -2
- package/dist/hooks/state.d.ts +2 -0
- package/dist/schema/types.d.ts +6 -0
- package/package.json +2 -1
- package/spec/parallel-isolation.md +205 -0
- package/spec/schema-spec.md +676 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PreToolUse", "timeout":
|
|
4
|
-
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PostToolUse", "timeout":
|
|
5
|
-
"UserPromptSubmit": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook UserPromptSubmit", "timeout":
|
|
6
|
-
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook SubagentStop", "timeout":
|
|
7
|
-
"PreCompact": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook PreCompact", "timeout":
|
|
8
|
-
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook Notification", "timeout":
|
|
9
|
-
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook Stop", "timeout":
|
|
10
|
-
"SessionStart": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "dna-hook SessionStart", "timeout":
|
|
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
|
}
|
|
@@ -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, 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.
|
|
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
|
-
//
|
|
355
|
+
// 7. Resolve retry config
|
|
254
356
|
const retry = resolveRetryConfig(workflow);
|
|
255
|
-
//
|
|
357
|
+
// 8. Build WorkflowStep[] from sorted steps
|
|
256
358
|
const compiledSteps = sorted.map(toWorkflowStep);
|
|
257
|
-
//
|
|
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
|
-
//
|
|
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
|
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
|
}
|
package/dist/hooks/state.d.ts
CHANGED
package/dist/schema/types.d.ts
CHANGED
|
@@ -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 {
|