intentdna 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/dist/cli/commands/setup.d.ts +16 -4
- package/dist/cli/commands/setup.js +179 -29
- package/dist/cli/commands/verify.d.ts +1 -0
- package/dist/cli/commands/verify.js +69 -0
- package/dist/cli/index.js +21 -2
- package/dist/compiler/compile.js +10 -0
- package/dist/compiler/workflow.js +1 -0
- package/dist/hooks/cli.js +46 -2
- package/dist/hooks/enforce.d.ts +18 -1
- package/dist/hooks/enforce.js +61 -0
- package/dist/hooks/state.d.ts +40 -1
- package/dist/hooks/state.js +137 -2
- package/dist/runtime/skill-adapter.js +21 -0
- package/dist/schema/types.d.ts +35 -0
- package/dist/templates/code-review-pipeline.dna.yaml +12 -0
- package/dist/templates/flutter-refactoring-rescue.dna.yaml +20 -0
- package/dist/templates/flutter-rewrite.dna.yaml +39 -0
- package/dist/templates/full-pipeline.dna.yaml +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "intentdna",
|
|
3
|
+
"description": "Declarative policy layer for AI agent governance — compile DNA templates to hooks, agents, and constraints",
|
|
4
|
+
"plugins": [
|
|
5
|
+
{
|
|
6
|
+
"name": "intentdna",
|
|
7
|
+
"description": "DNA template compilation + runtime enforcement",
|
|
8
|
+
"version": "1.2.3",
|
|
9
|
+
"source": "./"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dna setup
|
|
2
|
+
* dna setup [--scope user|project] [--yes]
|
|
3
3
|
*
|
|
4
|
-
* Registers intentdna as a Claude Code plugin
|
|
5
|
-
*
|
|
4
|
+
* Registers intentdna as a Claude Code plugin:
|
|
5
|
+
* 1. Detect claude CLI availability
|
|
6
|
+
* 2. Resolve package path (npm global root)
|
|
7
|
+
* 3. Confirm with user (unless --yes)
|
|
8
|
+
* 4. Run: claude plugin marketplace add <path>
|
|
9
|
+
* 5. Run: claude plugin install intentdna@intentdna --scope <scope>
|
|
10
|
+
* 6. Verify registration
|
|
11
|
+
*
|
|
12
|
+
* Falls back to bin mode guidance when claude CLI is unavailable.
|
|
13
|
+
*
|
|
14
|
+
* Reference: OMC plugin registration flow (marketplace add → plugin install)
|
|
6
15
|
*/
|
|
16
|
+
export type SetupScope = "user" | "project";
|
|
7
17
|
export interface SetupOptions {
|
|
18
|
+
scope: SetupScope;
|
|
19
|
+
yes: boolean;
|
|
8
20
|
}
|
|
9
|
-
export declare function runSetup(
|
|
21
|
+
export declare function runSetup(opts: SetupOptions): Promise<number>;
|
|
@@ -1,42 +1,192 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dna setup
|
|
2
|
+
* dna setup [--scope user|project] [--yes]
|
|
3
3
|
*
|
|
4
|
-
* Registers intentdna as a Claude Code plugin
|
|
5
|
-
*
|
|
4
|
+
* Registers intentdna as a Claude Code plugin:
|
|
5
|
+
* 1. Detect claude CLI availability
|
|
6
|
+
* 2. Resolve package path (npm global root)
|
|
7
|
+
* 3. Confirm with user (unless --yes)
|
|
8
|
+
* 4. Run: claude plugin marketplace add <path>
|
|
9
|
+
* 5. Run: claude plugin install intentdna@intentdna --scope <scope>
|
|
10
|
+
* 6. Verify registration
|
|
11
|
+
*
|
|
12
|
+
* Falls back to bin mode guidance when claude CLI is unavailable.
|
|
13
|
+
*
|
|
14
|
+
* Reference: OMC plugin registration flow (marketplace add → plugin install)
|
|
6
15
|
*/
|
|
7
16
|
import { execSync } from "node:child_process";
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
import { resolve, dirname } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { stat } from "node:fs/promises";
|
|
21
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
22
|
+
function log(msg) {
|
|
23
|
+
process.stderr.write(msg + "\n");
|
|
24
|
+
}
|
|
25
|
+
async function fileExists(path) {
|
|
26
|
+
try {
|
|
27
|
+
await stat(path);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function confirm(question) {
|
|
35
|
+
return new Promise((res) => {
|
|
36
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
37
|
+
rl.question(question + " [Y/n] ", (answer) => {
|
|
38
|
+
rl.close();
|
|
39
|
+
const a = answer.trim().toLowerCase();
|
|
40
|
+
res(a === "" || a === "y" || a === "yes");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Check if claude CLI is available and return its version string.
|
|
46
|
+
*/
|
|
47
|
+
function detectClaudeCLI() {
|
|
10
48
|
try {
|
|
11
|
-
execSync("claude --version", {
|
|
12
|
-
claudeAvailable = true;
|
|
49
|
+
return execSync("claude --version 2>/dev/null", { encoding: "utf-8", timeout: 5000 }).trim();
|
|
13
50
|
}
|
|
14
51
|
catch {
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the intentdna package root directory.
|
|
57
|
+
* Priority: npm global install → local package directory (development).
|
|
58
|
+
*/
|
|
59
|
+
async function resolvePackagePath() {
|
|
60
|
+
// 1. Try npm global root
|
|
61
|
+
try {
|
|
62
|
+
const npmRoot = execSync("npm root -g", {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
66
|
+
}).trim();
|
|
67
|
+
const globalPath = resolve(npmRoot, "intentdna");
|
|
68
|
+
if (await fileExists(resolve(globalPath, ".claude-plugin", "plugin.json"))) {
|
|
69
|
+
return globalPath;
|
|
27
70
|
}
|
|
28
|
-
|
|
29
|
-
|
|
71
|
+
}
|
|
72
|
+
catch { /* not installed globally */ }
|
|
73
|
+
// 2. Try local package (development mode — this file is inside the package)
|
|
74
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
75
|
+
// In dist: dist/cli/commands/setup.js → ../../.. = package root
|
|
76
|
+
const localRoot = resolve(thisDir, "..", "..", "..");
|
|
77
|
+
if (await fileExists(resolve(localRoot, ".claude-plugin", "plugin.json"))) {
|
|
78
|
+
return localRoot;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check if intentdna is already registered as a plugin.
|
|
84
|
+
*/
|
|
85
|
+
function isPluginRegistered() {
|
|
86
|
+
try {
|
|
87
|
+
const result = execSync("claude plugin list 2>/dev/null", {
|
|
88
|
+
encoding: "utf-8",
|
|
89
|
+
timeout: 5000,
|
|
90
|
+
});
|
|
91
|
+
return result.includes("intentdna");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ── Main ──────────────────────────────────────────────────
|
|
98
|
+
export async function runSetup(opts) {
|
|
99
|
+
const scope = opts.scope;
|
|
100
|
+
// Step 1: Detect claude CLI
|
|
101
|
+
const claudeVersion = detectClaudeCLI();
|
|
102
|
+
if (!claudeVersion) {
|
|
103
|
+
log("Claude Code CLI not detected.");
|
|
104
|
+
log("");
|
|
105
|
+
log("Intent DNA can run in bin mode (standalone dna-hook binary):");
|
|
106
|
+
log(" dna sync Register dna-hook events in settings.json");
|
|
107
|
+
log(" dna sync --plugin Force plugin-style IR generation");
|
|
108
|
+
log("");
|
|
109
|
+
log("To use plugin mode, install Claude Code first, then re-run 'dna setup'.");
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
log(`Claude Code detected: ${claudeVersion}`);
|
|
113
|
+
// Step 2: Check if already registered
|
|
114
|
+
if (isPluginRegistered()) {
|
|
115
|
+
log("Intent DNA plugin is already registered.");
|
|
116
|
+
log("Run 'dna sync' to compile and activate your DNA templates.");
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
// Step 3: Resolve package path
|
|
120
|
+
const pkgPath = await resolvePackagePath();
|
|
121
|
+
if (!pkgPath) {
|
|
122
|
+
log("Error: Could not find intentdna package with .claude-plugin/ directory.");
|
|
123
|
+
log("Ensure intentdna is installed: npm install -g intentdna");
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
log(`Package found: ${pkgPath}`);
|
|
127
|
+
log(`Scope: ${scope}`);
|
|
128
|
+
// Step 4: Confirmation prompt
|
|
129
|
+
if (!opts.yes) {
|
|
130
|
+
log("");
|
|
131
|
+
log("This will:");
|
|
132
|
+
log(` 1. Register '${pkgPath}' as a plugin marketplace`);
|
|
133
|
+
log(` 2. Install intentdna plugin (scope: ${scope})`);
|
|
134
|
+
log("");
|
|
135
|
+
if (process.stdin.isTTY) {
|
|
136
|
+
const ok = await confirm("Proceed?");
|
|
137
|
+
if (!ok) {
|
|
138
|
+
log("Aborted.");
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
30
141
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
142
|
+
}
|
|
143
|
+
// Step 5: Register marketplace
|
|
144
|
+
try {
|
|
145
|
+
log("");
|
|
146
|
+
log(`Running: claude plugin marketplace add ${pkgPath}`);
|
|
147
|
+
execSync(`claude plugin marketplace add "${pkgPath}"`, {
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
timeout: 30000,
|
|
150
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
151
|
+
});
|
|
152
|
+
log("Marketplace registered.");
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
156
|
+
log(`Warning: marketplace add failed: ${msg}`);
|
|
157
|
+
log("You may need to run manually:");
|
|
158
|
+
log(` claude plugin marketplace add "${pkgPath}"`);
|
|
159
|
+
// Continue — install may still work if marketplace was already added
|
|
160
|
+
}
|
|
161
|
+
// Step 6: Install plugin
|
|
162
|
+
try {
|
|
163
|
+
const installCmd = `claude plugin install intentdna@intentdna --scope ${scope}`;
|
|
164
|
+
log(`Running: ${installCmd}`);
|
|
165
|
+
execSync(installCmd, {
|
|
166
|
+
encoding: "utf-8",
|
|
167
|
+
timeout: 30000,
|
|
168
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
169
|
+
});
|
|
170
|
+
log("Plugin installed.");
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
174
|
+
log(`Warning: plugin install failed: ${msg}`);
|
|
175
|
+
log("You may need to run manually:");
|
|
176
|
+
log(` claude plugin install intentdna@intentdna --scope ${scope}`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
// Step 7: Verify
|
|
180
|
+
if (isPluginRegistered()) {
|
|
181
|
+
log("");
|
|
182
|
+
log("Intent DNA plugin registered successfully.");
|
|
183
|
+
log("Run 'dna sync' to compile and activate your DNA templates.");
|
|
184
|
+
return 0;
|
|
36
185
|
}
|
|
37
186
|
else {
|
|
38
|
-
|
|
39
|
-
|
|
187
|
+
log("");
|
|
188
|
+
log("Plugin install completed but verification failed.");
|
|
189
|
+
log("Try: claude plugin list");
|
|
190
|
+
return 1;
|
|
40
191
|
}
|
|
41
|
-
return 0;
|
|
42
192
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { readFile } from "node:fs/promises";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { readTraces } from "../../hooks/state.js";
|
|
14
15
|
// ── Helpers ────────────────────────────────────────────────
|
|
15
16
|
export function sha256(content) {
|
|
16
17
|
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
@@ -53,6 +54,10 @@ export async function verifyLock(lock) {
|
|
|
53
54
|
}
|
|
54
55
|
// ── CLI entry ──────────────────────────────────────────────
|
|
55
56
|
export async function runVerify(opts) {
|
|
57
|
+
// Handle --stats mode
|
|
58
|
+
if (opts.stats) {
|
|
59
|
+
return runStats();
|
|
60
|
+
}
|
|
56
61
|
// Read lock file
|
|
57
62
|
let raw;
|
|
58
63
|
try {
|
|
@@ -99,3 +104,67 @@ export async function runVerify(opts) {
|
|
|
99
104
|
return 0;
|
|
100
105
|
}
|
|
101
106
|
}
|
|
107
|
+
// ── Stats Mode ────────────────────────────────────────────
|
|
108
|
+
async function runStats() {
|
|
109
|
+
const cwd = process.cwd();
|
|
110
|
+
const entries = await readTraces(cwd, 1); // last 24h
|
|
111
|
+
if (entries.length === 0) {
|
|
112
|
+
process.stderr.write("No trace data found (last 24h).\n");
|
|
113
|
+
process.stderr.write("Traces are written to .dna/state/trace/ during hook execution.\n");
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
// Group by event
|
|
117
|
+
const byEvent = new Map();
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
const list = byEvent.get(e.event) ?? [];
|
|
120
|
+
list.push(e);
|
|
121
|
+
byEvent.set(e.event, list);
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write("Hook call statistics (last 24h):\n");
|
|
124
|
+
for (const [event, list] of byEvent) {
|
|
125
|
+
const blocks = list.filter(e => e.decision === "block").length;
|
|
126
|
+
const warns = list.filter(e => e.decision === "warn").length;
|
|
127
|
+
const avgMs = list.reduce((sum, e) => sum + e.duration_ms, 0) / list.length;
|
|
128
|
+
const parts = [`${list.length} calls`];
|
|
129
|
+
if (blocks > 0)
|
|
130
|
+
parts.push(`${blocks} blocks`);
|
|
131
|
+
if (warns > 0)
|
|
132
|
+
parts.push(`${warns} warns`);
|
|
133
|
+
parts.push(`avg ${avgMs.toFixed(1)}ms`);
|
|
134
|
+
process.stderr.write(` ${event.padEnd(18)} ${parts.join(", ")}\n`);
|
|
135
|
+
}
|
|
136
|
+
// Workflow stats
|
|
137
|
+
const withWorkflow = entries.filter(e => e.workflow);
|
|
138
|
+
if (withWorkflow.length > 0) {
|
|
139
|
+
const byWorkflow = new Map();
|
|
140
|
+
for (const e of withWorkflow) {
|
|
141
|
+
const key = e.workflow;
|
|
142
|
+
const list = byWorkflow.get(key) ?? [];
|
|
143
|
+
list.push(e);
|
|
144
|
+
byWorkflow.set(key, list);
|
|
145
|
+
}
|
|
146
|
+
process.stderr.write("\nWorkflow statistics:\n");
|
|
147
|
+
for (const [wf, list] of byWorkflow) {
|
|
148
|
+
const steps = new Set(list.map(e => e.step).filter(Boolean));
|
|
149
|
+
const blocks = list.filter(e => e.decision === "block").length;
|
|
150
|
+
process.stderr.write(` ${wf}: ${list.length} tool calls, ${steps.size} steps, ${blocks} blocks\n`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Block reasons TOP 3
|
|
154
|
+
const blockEntries = entries.filter(e => e.decision === "block");
|
|
155
|
+
if (blockEntries.length > 0) {
|
|
156
|
+
const reasons = new Map();
|
|
157
|
+
for (const e of blockEntries) {
|
|
158
|
+
const key = `${e.event}:${e.tool_name ?? "unknown"}`;
|
|
159
|
+
reasons.set(key, (reasons.get(key) ?? 0) + 1);
|
|
160
|
+
}
|
|
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
|
+
let rank = 0;
|
|
164
|
+
for (const [reason, count] of sorted) {
|
|
165
|
+
rank++;
|
|
166
|
+
process.stderr.write(` ${rank}. ${reason} x ${count}\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -17,7 +17,7 @@ const HELP = `Intent DNA CLI v0.3.0
|
|
|
17
17
|
Usage: dna <command> [options]
|
|
18
18
|
|
|
19
19
|
Commands:
|
|
20
|
-
setup Register intentdna as a Claude Code plugin (
|
|
20
|
+
setup Register intentdna as a Claude Code plugin (--scope user|project, --yes)
|
|
21
21
|
guard Zero-config guardrails — detect environment, apply safety rules
|
|
22
22
|
sync Compile + inject DNA into target file (CLAUDE.md, SOUL.md, etc.)
|
|
23
23
|
verify Verify synced files match .dna/lock checksums (drift detection)
|
|
@@ -53,6 +53,7 @@ Examples:
|
|
|
53
53
|
dna sync --remove --inject CLAUDE.md --hooks .claude/hooks --agents .claude/agents
|
|
54
54
|
dna verify Verify synced files against .dna/lock
|
|
55
55
|
dna verify --lock /path/to/.dna/lock
|
|
56
|
+
dna verify --stats Show hook call statistics (last 24h)
|
|
56
57
|
dna import . Import existing harness configs into DNA format
|
|
57
58
|
dna run --dna my.dna.json --workflow dev-pipeline --task P5.8
|
|
58
59
|
dna run --dna my.dna.json --workflow dev-pipeline --task P5.8 --dry-run
|
|
@@ -120,6 +121,7 @@ async function main() {
|
|
|
120
121
|
args: rest,
|
|
121
122
|
options: {
|
|
122
123
|
lock: { type: "string", default: ".dna/lock" },
|
|
124
|
+
stats: { type: "boolean", default: false },
|
|
123
125
|
},
|
|
124
126
|
allowPositionals: true,
|
|
125
127
|
strict: false,
|
|
@@ -127,6 +129,7 @@ async function main() {
|
|
|
127
129
|
const { runVerify } = await import("./commands/verify.js");
|
|
128
130
|
const code = await runVerify({
|
|
129
131
|
lockFile: verifyValues.lock,
|
|
132
|
+
stats: verifyValues.stats,
|
|
130
133
|
});
|
|
131
134
|
process.exit(code);
|
|
132
135
|
break;
|
|
@@ -357,8 +360,24 @@ async function main() {
|
|
|
357
360
|
break;
|
|
358
361
|
}
|
|
359
362
|
case "setup": {
|
|
363
|
+
const { values: setupValues } = parseArgs({
|
|
364
|
+
args: rest,
|
|
365
|
+
options: {
|
|
366
|
+
scope: { type: "string", short: "s", default: "user" },
|
|
367
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
368
|
+
},
|
|
369
|
+
strict: false,
|
|
370
|
+
});
|
|
371
|
+
const setupScope = setupValues.scope;
|
|
372
|
+
if (setupScope !== "user" && setupScope !== "project") {
|
|
373
|
+
process.stderr.write(`Error: --scope must be 'user' or 'project', got '${setupScope}'\n`);
|
|
374
|
+
process.exit(2);
|
|
375
|
+
}
|
|
360
376
|
const { runSetup } = await import("./commands/setup.js");
|
|
361
|
-
const code = await runSetup({
|
|
377
|
+
const code = await runSetup({
|
|
378
|
+
scope: setupScope,
|
|
379
|
+
yes: setupValues.yes,
|
|
380
|
+
});
|
|
362
381
|
process.exit(code);
|
|
363
382
|
break;
|
|
364
383
|
}
|
package/dist/compiler/compile.js
CHANGED
|
@@ -218,6 +218,7 @@ export function compileDNA(activated, cascaded) {
|
|
|
218
218
|
for (const [wfKey, wf] of Object.entries(cascaded.workflows)) {
|
|
219
219
|
const wfStepCheckpoints = [];
|
|
220
220
|
const activeRoles = [];
|
|
221
|
+
const handoffChain = [];
|
|
221
222
|
for (const step of wf.steps ?? []) {
|
|
222
223
|
if (!activeRoles.includes(step.role)) {
|
|
223
224
|
activeRoles.push(step.role);
|
|
@@ -229,6 +230,14 @@ export function compileDNA(activated, cascaded) {
|
|
|
229
230
|
checkpoints: [...step.checkpoints],
|
|
230
231
|
});
|
|
231
232
|
}
|
|
233
|
+
// Collect handoff chain entries
|
|
234
|
+
if (step.handoff) {
|
|
235
|
+
handoffChain.push({
|
|
236
|
+
step_id: step.id,
|
|
237
|
+
produces: step.handoff.produces,
|
|
238
|
+
consumes: step.handoff.consumes,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
232
241
|
}
|
|
233
242
|
// Derive namespace from workflow key: "ns_wfname" → "ns", "wfname" → ""
|
|
234
243
|
const underscoreIdx = wfKey.indexOf("_");
|
|
@@ -238,6 +247,7 @@ export function compileDNA(activated, cascaded) {
|
|
|
238
247
|
namespace,
|
|
239
248
|
step_checkpoints: wfStepCheckpoints,
|
|
240
249
|
active_roles: activeRoles,
|
|
250
|
+
handoff_chain: handoffChain,
|
|
241
251
|
});
|
|
242
252
|
}
|
|
243
253
|
}
|
|
@@ -173,6 +173,7 @@ function toWorkflowStep(def) {
|
|
|
173
173
|
prompt: def.prompt ?? null,
|
|
174
174
|
completion: def.completion && def.completion.length > 0 ? [...def.completion] : null,
|
|
175
175
|
checkpoints: def.checkpoints && def.checkpoints.length > 0 ? [...def.checkpoints] : null,
|
|
176
|
+
handoff: def.handoff ?? null,
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
/**
|
package/dist/hooks/cli.js
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { readFile } from "node:fs/promises";
|
|
18
18
|
import { resolve } from "node:path";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
import { readStdin, writeOutput, silentOutput } from "./protocol.js";
|
|
20
21
|
import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, } from "./enforce.js";
|
|
21
|
-
import { appendAudit, readWorkflowState } from "./state.js";
|
|
22
|
+
import { appendAudit, readWorkflowState, appendTrace, rotateTraces } from "./state.js";
|
|
22
23
|
// ── Constants ──────────────────────────────────────────────
|
|
23
24
|
const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
|
|
24
25
|
const VALID_EVENTS = new Set([
|
|
@@ -52,6 +53,17 @@ async function main() {
|
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
54
55
|
const state = {};
|
|
56
|
+
// Load workflow state for events that need handoff context
|
|
57
|
+
if (event === "PreToolUse") {
|
|
58
|
+
const wfState = await readWorkflowState(projectDir, sessionId);
|
|
59
|
+
if (wfState && wfState.active) {
|
|
60
|
+
state.workflowState = {
|
|
61
|
+
current_step: wfState.current_step,
|
|
62
|
+
workflow: wfState.workflow,
|
|
63
|
+
completed_artifacts: wfState.completed_artifacts,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
55
67
|
// Special handling for Stop — needs async workflow state read
|
|
56
68
|
if (event === "Stop") {
|
|
57
69
|
const wfState = await readWorkflowState(projectDir, sessionId);
|
|
@@ -61,6 +73,7 @@ async function main() {
|
|
|
61
73
|
current_step: wfState.current_step,
|
|
62
74
|
current_role: wfState.current_role,
|
|
63
75
|
started_at: wfState.started_at,
|
|
76
|
+
completed_artifacts: wfState.completed_artifacts,
|
|
64
77
|
} : null;
|
|
65
78
|
const stopOutput = enforceStop(ir, {
|
|
66
79
|
cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : undefined,
|
|
@@ -68,11 +81,42 @@ async function main() {
|
|
|
68
81
|
stop_reason: typeof rawInput.stop_reason === "string" ? rawInput.stop_reason : undefined,
|
|
69
82
|
}, stopContext);
|
|
70
83
|
writeOutput(stopOutput);
|
|
84
|
+
// Trace for Stop
|
|
85
|
+
appendTrace(projectDir, {
|
|
86
|
+
trace_id: randomUUID(),
|
|
87
|
+
event: "Stop",
|
|
88
|
+
workflow: wfState?.workflow,
|
|
89
|
+
step: wfState?.current_step,
|
|
90
|
+
decision: stopOutput.continue === false ? "block" : "allow",
|
|
91
|
+
duration_ms: Date.now() - Date.now(), // minimal
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
}).catch(() => { });
|
|
71
94
|
return;
|
|
72
95
|
}
|
|
73
|
-
// Dispatch to enforcement engine
|
|
96
|
+
// Dispatch to enforcement engine with timing
|
|
97
|
+
const start = Date.now();
|
|
74
98
|
const output = dispatch(event, ir, rawInput, state);
|
|
99
|
+
const durationMs = Date.now() - start;
|
|
75
100
|
writeOutput(output);
|
|
101
|
+
// Trace logging (async, fail-open)
|
|
102
|
+
const decision = output.continue === false ? "block"
|
|
103
|
+
: output.hookSpecificOutput?.additionalContext?.startsWith("WARN") ? "warn"
|
|
104
|
+
: "allow";
|
|
105
|
+
appendTrace(projectDir, {
|
|
106
|
+
trace_id: randomUUID(),
|
|
107
|
+
event,
|
|
108
|
+
tool_name: typeof rawInput.tool_name === "string" ? rawInput.tool_name : undefined,
|
|
109
|
+
agent_type: typeof rawInput.agent_type === "string" ? rawInput.agent_type : undefined,
|
|
110
|
+
workflow: state.workflowState?.workflow,
|
|
111
|
+
step: state.workflowState?.current_step,
|
|
112
|
+
decision: decision,
|
|
113
|
+
duration_ms: durationMs,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
}).catch(() => { }); // Fail-open
|
|
116
|
+
// Side effect: rotate traces on SessionStart
|
|
117
|
+
if (event === "SessionStart") {
|
|
118
|
+
rotateTraces(projectDir).catch(() => { });
|
|
119
|
+
}
|
|
76
120
|
// Side effect: audit log for notifications
|
|
77
121
|
if (event === "Notification" && output.hookSpecificOutput?.additionalContext?.includes("violation")) {
|
|
78
122
|
const message = typeof rawInput.message === "string" ? rawInput.message : "";
|
package/dist/hooks/enforce.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Step checkpoints are handled by the CLI, not here.
|
|
15
15
|
*/
|
|
16
|
-
import type { ConstraintIR, RoleDef } from "../schema/types.js";
|
|
16
|
+
import type { ConstraintIR, RoleDef, CompletedArtifactEntry } from "../schema/types.js";
|
|
17
17
|
import type { PreToolUseInput, PostToolUseInput, UserPromptSubmitInput, SubagentStopInput, NotificationInput, HookOutput } from "./protocol.js";
|
|
18
18
|
/** SessionStart input fields */
|
|
19
19
|
export interface SessionStartInput {
|
|
@@ -22,6 +22,12 @@ export interface SessionStartInput {
|
|
|
22
22
|
trigger?: string;
|
|
23
23
|
}
|
|
24
24
|
export interface EnforceState {
|
|
25
|
+
/** Workflow handoff context — passed from CLI after reading workflow state */
|
|
26
|
+
workflowState?: {
|
|
27
|
+
current_step: string;
|
|
28
|
+
workflow: string;
|
|
29
|
+
completed_artifacts?: CompletedArtifactEntry[];
|
|
30
|
+
};
|
|
25
31
|
}
|
|
26
32
|
/**
|
|
27
33
|
* Enforce PreToolUse constraints.
|
|
@@ -71,6 +77,7 @@ export interface StopWorkflowContext {
|
|
|
71
77
|
current_step: string;
|
|
72
78
|
current_role: string;
|
|
73
79
|
started_at: string;
|
|
80
|
+
completed_artifacts?: CompletedArtifactEntry[];
|
|
74
81
|
}
|
|
75
82
|
/**
|
|
76
83
|
* Enforce Stop hook — verify workflow checkpoint completion.
|
|
@@ -85,6 +92,16 @@ export interface StopWorkflowContext {
|
|
|
85
92
|
* - Workflow active + unmet checkpoints for current step
|
|
86
93
|
*/
|
|
87
94
|
export declare function enforceStop(ir: ConstraintIR, input: StopEnforceInput, workflowState: StopWorkflowContext | null): HookOutput;
|
|
95
|
+
/**
|
|
96
|
+
* Enforce handoff produces — verify that the current step has produced
|
|
97
|
+
* its declared artifacts. Called at stop/checkpoint time.
|
|
98
|
+
* Returns block output if a required artifact is missing, null if all produced.
|
|
99
|
+
*/
|
|
100
|
+
export declare function enforceHandoffProduces(ir: ConstraintIR, wfState: {
|
|
101
|
+
current_step: string;
|
|
102
|
+
workflow: string;
|
|
103
|
+
completed_artifacts?: CompletedArtifactEntry[];
|
|
104
|
+
}): HookOutput | null;
|
|
88
105
|
/** Check if a file path is allowed by a list of write globs (prefix matching). */
|
|
89
106
|
export declare function checkWriteAllowed(filePath: string, allowedGlobs: string[]): boolean;
|
|
90
107
|
/**
|
package/dist/hooks/enforce.js
CHANGED
|
@@ -46,6 +46,12 @@ export function enforcePreToolUse(ir, input, state, roles) {
|
|
|
46
46
|
if (result)
|
|
47
47
|
return result;
|
|
48
48
|
}
|
|
49
|
+
// Layer 6: Handoff — check consumed artifacts are available
|
|
50
|
+
if (state?.workflowState && ir.workflows_ir) {
|
|
51
|
+
const result = enforceHandoffConsumes(ir, state.workflowState);
|
|
52
|
+
if (result)
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
49
55
|
return silentOutput();
|
|
50
56
|
}
|
|
51
57
|
// ── PostToolUse Enforcement ────────────────────────────────
|
|
@@ -237,6 +243,16 @@ export function enforceStop(ir, input, workflowState) {
|
|
|
237
243
|
// Build list of blocking checkpoints
|
|
238
244
|
const blocking = workflowCheckpoints.flatMap(cp => cp.checkpoints.filter(c => (c.action ?? "block") === "block"));
|
|
239
245
|
if (blocking.length === 0) {
|
|
246
|
+
// No checkpoint blocking — check handoff produces
|
|
247
|
+
if (workflowState.workflow) {
|
|
248
|
+
const handoffResult = enforceHandoffProduces(ir, {
|
|
249
|
+
current_step: workflowState.current_step,
|
|
250
|
+
workflow: workflowState.workflow,
|
|
251
|
+
completed_artifacts: workflowState.completed_artifacts,
|
|
252
|
+
});
|
|
253
|
+
if (handoffResult && !handoffResult.continue)
|
|
254
|
+
return handoffResult;
|
|
255
|
+
}
|
|
240
256
|
return silentOutput();
|
|
241
257
|
}
|
|
242
258
|
// Block: workflow active with unmet checkpoints
|
|
@@ -245,6 +261,51 @@ export function enforceStop(ir, input, workflowState) {
|
|
|
245
261
|
messages.map(m => ` - ${m}`).join("\n"));
|
|
246
262
|
}
|
|
247
263
|
// ── Internal: Layer Enforcement ────────────────────────────
|
|
264
|
+
/**
|
|
265
|
+
* Enforce handoff consumes — verify that the current step's consumed
|
|
266
|
+
* artifacts have been produced by a preceding step.
|
|
267
|
+
* Returns block output if a required artifact is missing, null if all satisfied.
|
|
268
|
+
*/
|
|
269
|
+
function enforceHandoffConsumes(ir, wfState) {
|
|
270
|
+
const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
|
|
271
|
+
if (!activeWf || activeWf.handoff_chain.length === 0)
|
|
272
|
+
return null;
|
|
273
|
+
const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
|
|
274
|
+
if (!currentEntry?.consumes || currentEntry.consumes.length === 0)
|
|
275
|
+
return null;
|
|
276
|
+
const completedStepIds = new Set((wfState.completed_artifacts ?? []).map(a => a.step_id));
|
|
277
|
+
// For each consumed artifact, find the producing step and check it completed
|
|
278
|
+
for (const consumed of currentEntry.consumes) {
|
|
279
|
+
// Find which step produces this artifact
|
|
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
|
+
(consumed.from && h.step_id === consumed.from))));
|
|
282
|
+
if (producer && !completedStepIds.has(producer.step_id)) {
|
|
283
|
+
return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact from step '${producer.step_id}' (${consumed.description}). Run step '${producer.step_id}' first.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Enforce handoff produces — verify that the current step has produced
|
|
290
|
+
* its declared artifacts. Called at stop/checkpoint time.
|
|
291
|
+
* Returns block output if a required artifact is missing, null if all produced.
|
|
292
|
+
*/
|
|
293
|
+
export function enforceHandoffProduces(ir, wfState) {
|
|
294
|
+
const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
|
|
295
|
+
if (!activeWf || activeWf.handoff_chain.length === 0)
|
|
296
|
+
return null;
|
|
297
|
+
const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
|
|
298
|
+
if (!currentEntry?.produces || currentEntry.produces.length === 0)
|
|
299
|
+
return null;
|
|
300
|
+
const currentArtifacts = (wfState.completed_artifacts ?? []).find(a => a.step_id === wfState.current_step);
|
|
301
|
+
// Check that each produced artifact with a path has been recorded
|
|
302
|
+
for (const produced of currentEntry.produces) {
|
|
303
|
+
if (produced.path && (!currentArtifacts || !currentArtifacts.artifacts.some(a => a.path === produced.path))) {
|
|
304
|
+
return blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' (path: ${produced.path}) before proceeding.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
248
309
|
function enforceRoleScope(rolesScopeMap, input) {
|
|
249
310
|
if (!input.agent_type)
|
|
250
311
|
return null;
|
package/dist/hooks/state.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* All read operations are fail-safe (return null on error, never throw).
|
|
10
10
|
* All write operations use atomic temp+rename pattern.
|
|
11
11
|
*/
|
|
12
|
+
import type { CompletedArtifactEntry } from "../schema/types.js";
|
|
12
13
|
/** Workflow state written by runtime and read by hooks */
|
|
13
14
|
export interface DNAWorkflowState {
|
|
14
15
|
active: boolean;
|
|
@@ -18,6 +19,7 @@ export interface DNAWorkflowState {
|
|
|
18
19
|
iteration: number;
|
|
19
20
|
session_id: string;
|
|
20
21
|
started_at: string;
|
|
22
|
+
completed_artifacts?: CompletedArtifactEntry[];
|
|
21
23
|
}
|
|
22
24
|
/** Audit log entry (one per line in JSON Lines format) */
|
|
23
25
|
export interface AuditEntry {
|
|
@@ -47,7 +49,44 @@ export declare function writeWorkflowState(projectDir: string, state: DNAWorkflo
|
|
|
47
49
|
*/
|
|
48
50
|
export declare function clearWorkflowState(projectDir: string, sessionId?: string): Promise<void>;
|
|
49
51
|
/**
|
|
50
|
-
* Append an entry to the audit log.
|
|
52
|
+
* Append an entry to the audit log with dedup protection.
|
|
53
|
+
* Dedup key: event + tool_name + timestamp (second-level).
|
|
51
54
|
* Log file: `.dna/audit/violations-YYYY-MM-DD.log` (JSON Lines format)
|
|
52
55
|
*/
|
|
53
56
|
export declare function appendAudit(projectDir: string, entry: AuditEntry): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Record a completed artifact for a workflow step.
|
|
59
|
+
* Reads current workflow state, appends the artifact, writes back atomically.
|
|
60
|
+
*/
|
|
61
|
+
export declare function appendCompletedArtifact(projectDir: string, stepId: string, artifact: {
|
|
62
|
+
type: string;
|
|
63
|
+
path: string;
|
|
64
|
+
}, sessionId?: string): Promise<void>;
|
|
65
|
+
/** Trace entry for hook call observability */
|
|
66
|
+
export interface TraceEntry {
|
|
67
|
+
trace_id: string;
|
|
68
|
+
event: string;
|
|
69
|
+
tool_name?: string;
|
|
70
|
+
agent_type?: string;
|
|
71
|
+
workflow?: string;
|
|
72
|
+
step?: string;
|
|
73
|
+
decision: "allow" | "block" | "warn";
|
|
74
|
+
duration_ms: number;
|
|
75
|
+
timestamp: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Append a trace entry to the daily trace file.
|
|
79
|
+
* File: `.dna/state/trace/trace-YYYY-MM-DD.jsonl`
|
|
80
|
+
* Fail-open: never throws.
|
|
81
|
+
*/
|
|
82
|
+
export declare function appendTrace(projectDir: string, entry: TraceEntry): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Read trace entries from the last N days.
|
|
85
|
+
* Returns parsed entries sorted by timestamp.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readTraces(projectDir: string, days?: number): Promise<TraceEntry[]>;
|
|
88
|
+
/**
|
|
89
|
+
* Clean up trace files older than retention period.
|
|
90
|
+
* Removes `.dna/state/trace/trace-*.jsonl` files older than TRACE_RETENTION_DAYS.
|
|
91
|
+
*/
|
|
92
|
+
export declare function rotateTraces(projectDir: string): Promise<number>;
|
package/dist/hooks/state.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* All read operations are fail-safe (return null on error, never throw).
|
|
10
10
|
* All write operations use atomic temp+rename pattern.
|
|
11
11
|
*/
|
|
12
|
-
import { readFile, writeFile, rename, mkdir, appendFile, unlink } from "node:fs/promises";
|
|
12
|
+
import { readFile, writeFile, rename, mkdir, appendFile, unlink, readdir, stat } from "node:fs/promises";
|
|
13
13
|
import { join, dirname } from "node:path";
|
|
14
14
|
// ── Constants ──────────────────────────────────────────────
|
|
15
15
|
const WORKFLOW_FILE = "workflow.json";
|
|
@@ -73,7 +73,8 @@ export async function clearWorkflowState(projectDir, sessionId) {
|
|
|
73
73
|
}
|
|
74
74
|
// ── Audit Log ──────────────────────────────────────────────
|
|
75
75
|
/**
|
|
76
|
-
* Append an entry to the audit log.
|
|
76
|
+
* Append an entry to the audit log with dedup protection.
|
|
77
|
+
* Dedup key: event + tool_name + timestamp (second-level).
|
|
77
78
|
* Log file: `.dna/audit/violations-YYYY-MM-DD.log` (JSON Lines format)
|
|
78
79
|
*/
|
|
79
80
|
export async function appendAudit(projectDir, entry) {
|
|
@@ -81,8 +82,52 @@ export async function appendAudit(projectDir, entry) {
|
|
|
81
82
|
await mkdir(auditDir, { recursive: true });
|
|
82
83
|
const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD
|
|
83
84
|
const logPath = join(auditDir, `violations-${date}.log`);
|
|
85
|
+
// Dedup: check if identical entry (same event+tool+second) already exists
|
|
86
|
+
const dedupKey = `${entry.event}|${entry.tool_name ?? ""}|${entry.timestamp.slice(0, 19)}`;
|
|
87
|
+
try {
|
|
88
|
+
const existing = await readFile(logPath, "utf-8");
|
|
89
|
+
const lines = existing.trimEnd().split("\n");
|
|
90
|
+
// Check last 10 lines for dedup (avoid scanning entire file)
|
|
91
|
+
const recentLines = lines.slice(-10);
|
|
92
|
+
for (const line of recentLines) {
|
|
93
|
+
try {
|
|
94
|
+
const prev = JSON.parse(line);
|
|
95
|
+
const prevKey = `${prev.event}|${prev.tool_name ?? ""}|${prev.timestamp.slice(0, 19)}`;
|
|
96
|
+
if (prevKey === dedupKey)
|
|
97
|
+
return; // Already logged
|
|
98
|
+
}
|
|
99
|
+
catch { /* skip malformed lines */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch { /* file doesn't exist yet */ }
|
|
84
103
|
await appendFile(logPath, JSON.stringify(entry) + "\n", "utf-8");
|
|
85
104
|
}
|
|
105
|
+
// ── Completed Artifacts ───────────────────────────────────
|
|
106
|
+
/**
|
|
107
|
+
* Record a completed artifact for a workflow step.
|
|
108
|
+
* Reads current workflow state, appends the artifact, writes back atomically.
|
|
109
|
+
*/
|
|
110
|
+
export async function appendCompletedArtifact(projectDir, stepId, artifact, sessionId) {
|
|
111
|
+
const state = await readWorkflowState(projectDir, sessionId, 0);
|
|
112
|
+
if (!state)
|
|
113
|
+
return;
|
|
114
|
+
const artifacts = state.completed_artifacts ?? [];
|
|
115
|
+
let stepEntry = artifacts.find(a => a.step_id === stepId);
|
|
116
|
+
if (!stepEntry) {
|
|
117
|
+
stepEntry = { step_id: stepId, artifacts: [] };
|
|
118
|
+
artifacts.push(stepEntry);
|
|
119
|
+
}
|
|
120
|
+
// Dedup: don't add if same path already recorded
|
|
121
|
+
if (stepEntry.artifacts.some(a => a.path === artifact.path))
|
|
122
|
+
return;
|
|
123
|
+
stepEntry.artifacts.push({
|
|
124
|
+
type: artifact.type,
|
|
125
|
+
path: artifact.path,
|
|
126
|
+
verified_at: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
state.completed_artifacts = artifacts;
|
|
129
|
+
await writeWorkflowState(projectDir, state, sessionId);
|
|
130
|
+
}
|
|
86
131
|
// ── Internal ───────────────────────────────────────────────
|
|
87
132
|
/** Atomic write: write to temp file then rename. */
|
|
88
133
|
async function atomicWrite(filePath, data) {
|
|
@@ -91,3 +136,93 @@ async function atomicWrite(filePath, data) {
|
|
|
91
136
|
await writeFile(tmpPath, data, "utf-8");
|
|
92
137
|
await rename(tmpPath, filePath);
|
|
93
138
|
}
|
|
139
|
+
const TRACE_DIR = "trace";
|
|
140
|
+
const MAX_TRACE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
141
|
+
const TRACE_RETENTION_DAYS = 7;
|
|
142
|
+
/**
|
|
143
|
+
* Append a trace entry to the daily trace file.
|
|
144
|
+
* File: `.dna/state/trace/trace-YYYY-MM-DD.jsonl`
|
|
145
|
+
* Fail-open: never throws.
|
|
146
|
+
*/
|
|
147
|
+
export async function appendTrace(projectDir, entry) {
|
|
148
|
+
try {
|
|
149
|
+
const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
|
|
150
|
+
await mkdir(traceDir, { recursive: true });
|
|
151
|
+
const date = entry.timestamp.slice(0, 10);
|
|
152
|
+
const tracePath = join(traceDir, `trace-${date}.jsonl`);
|
|
153
|
+
// Check file size — rotate if over limit
|
|
154
|
+
try {
|
|
155
|
+
const stats = await stat(tracePath);
|
|
156
|
+
if (stats.size >= MAX_TRACE_SIZE)
|
|
157
|
+
return; // silently skip if file too large
|
|
158
|
+
}
|
|
159
|
+
catch { /* file doesn't exist yet */ }
|
|
160
|
+
await appendFile(tracePath, JSON.stringify(entry) + "\n", "utf-8");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Fail-open: trace write failure never affects hook execution
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Read trace entries from the last N days.
|
|
168
|
+
* Returns parsed entries sorted by timestamp.
|
|
169
|
+
*/
|
|
170
|
+
export async function readTraces(projectDir, days = 1) {
|
|
171
|
+
const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
|
|
172
|
+
const entries = [];
|
|
173
|
+
const cutoff = new Date();
|
|
174
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
175
|
+
const cutoffDate = cutoff.toISOString().slice(0, 10);
|
|
176
|
+
try {
|
|
177
|
+
const files = await readdir(traceDir);
|
|
178
|
+
const traceFiles = files
|
|
179
|
+
.filter(f => f.startsWith("trace-") && f.endsWith(".jsonl"))
|
|
180
|
+
.filter(f => {
|
|
181
|
+
const fileDate = f.slice(6, 16); // "trace-YYYY-MM-DD.jsonl" → "YYYY-MM-DD"
|
|
182
|
+
return fileDate >= cutoffDate;
|
|
183
|
+
})
|
|
184
|
+
.sort();
|
|
185
|
+
for (const file of traceFiles) {
|
|
186
|
+
const content = await readFile(join(traceDir, file), "utf-8");
|
|
187
|
+
for (const line of content.trim().split("\n")) {
|
|
188
|
+
if (!line)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
entries.push(JSON.parse(line));
|
|
192
|
+
}
|
|
193
|
+
catch { /* skip malformed */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// No trace dir yet
|
|
199
|
+
}
|
|
200
|
+
return entries;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Clean up trace files older than retention period.
|
|
204
|
+
* Removes `.dna/state/trace/trace-*.jsonl` files older than TRACE_RETENTION_DAYS.
|
|
205
|
+
*/
|
|
206
|
+
export async function rotateTraces(projectDir) {
|
|
207
|
+
const traceDir = join(projectDir, ".dna", "state", TRACE_DIR);
|
|
208
|
+
let removed = 0;
|
|
209
|
+
const cutoff = new Date();
|
|
210
|
+
cutoff.setDate(cutoff.getDate() - TRACE_RETENTION_DAYS);
|
|
211
|
+
const cutoffDate = cutoff.toISOString().slice(0, 10);
|
|
212
|
+
try {
|
|
213
|
+
const files = await readdir(traceDir);
|
|
214
|
+
for (const file of files) {
|
|
215
|
+
if (!file.startsWith("trace-") || !file.endsWith(".jsonl"))
|
|
216
|
+
continue;
|
|
217
|
+
const fileDate = file.slice(6, 16);
|
|
218
|
+
if (fileDate < cutoffDate) {
|
|
219
|
+
await unlink(join(traceDir, file)).catch(() => { });
|
|
220
|
+
removed++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// No trace dir
|
|
226
|
+
}
|
|
227
|
+
return removed;
|
|
228
|
+
}
|
|
@@ -124,6 +124,27 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
|
|
|
124
124
|
lines.push("</Checkpoints>");
|
|
125
125
|
lines.push("");
|
|
126
126
|
}
|
|
127
|
+
// Handoff — artifact flow between steps
|
|
128
|
+
const stepsWithHandoff = plan.steps.filter((s) => s.handoff && (s.handoff.consumes?.length || s.handoff.produces?.length));
|
|
129
|
+
if (stepsWithHandoff.length > 0) {
|
|
130
|
+
lines.push("<Handoff>");
|
|
131
|
+
for (const step of stepsWithHandoff) {
|
|
132
|
+
const h = step.handoff;
|
|
133
|
+
if (h.produces && h.produces.length > 0) {
|
|
134
|
+
for (const p of h.produces) {
|
|
135
|
+
lines.push(` Step ${step.id} produces: ${p.description} (${p.type}${p.path ? `, ${p.path}` : ""})`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (h.consumes && h.consumes.length > 0) {
|
|
139
|
+
for (const c of h.consumes) {
|
|
140
|
+
const fromNote = c.from ? ` from ${c.from}` : "";
|
|
141
|
+
lines.push(` Step ${step.id} consumes: ${c.description}${fromNote} (${c.type}${c.path ? `, ${c.path}` : ""})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
lines.push("</Handoff>");
|
|
146
|
+
lines.push("");
|
|
147
|
+
}
|
|
127
148
|
// Variables — collect {{var_name}} references from the whole plan
|
|
128
149
|
const varRefs = collectVariableRefs(plan);
|
|
129
150
|
if (varRefs.size > 0) {
|
package/dist/schema/types.d.ts
CHANGED
|
@@ -82,6 +82,20 @@ export interface RoleDef {
|
|
|
82
82
|
success_criteria?: string[];
|
|
83
83
|
failure_modes?: string[];
|
|
84
84
|
}
|
|
85
|
+
/** Handoff artifact type — what kind of artifact is passed between steps */
|
|
86
|
+
export type HandoffType = "file" | "directory" | "test_result" | "git_commit" | "summary" | "state";
|
|
87
|
+
/** A single artifact consumed or produced by a workflow step */
|
|
88
|
+
export interface HandoffArtifact {
|
|
89
|
+
type: HandoffType;
|
|
90
|
+
path?: string;
|
|
91
|
+
from?: string;
|
|
92
|
+
description: string;
|
|
93
|
+
}
|
|
94
|
+
/** Handoff declaration for a workflow step */
|
|
95
|
+
export interface StepHandoff {
|
|
96
|
+
consumes?: HandoffArtifact[];
|
|
97
|
+
produces?: HandoffArtifact[];
|
|
98
|
+
}
|
|
85
99
|
/** Transition condition between workflow steps */
|
|
86
100
|
export interface TransitionDef {
|
|
87
101
|
from: string;
|
|
@@ -124,6 +138,7 @@ export interface WorkflowStepDef {
|
|
|
124
138
|
prompt?: string;
|
|
125
139
|
completion?: CompletionCheck[];
|
|
126
140
|
checkpoints?: StepCheckpoint[];
|
|
141
|
+
handoff?: StepHandoff;
|
|
127
142
|
}
|
|
128
143
|
/** Top-level workflow definition */
|
|
129
144
|
export interface WorkflowDef {
|
|
@@ -133,6 +148,8 @@ export interface WorkflowDef {
|
|
|
133
148
|
transitions?: TransitionDef[];
|
|
134
149
|
retry_policy?: RetryPolicy;
|
|
135
150
|
max_rounds?: number;
|
|
151
|
+
produces?: HandoffArtifact[];
|
|
152
|
+
consumes?: HandoffArtifact[];
|
|
136
153
|
}
|
|
137
154
|
export interface EpigeneticEffect {
|
|
138
155
|
gene?: string;
|
|
@@ -236,12 +253,28 @@ export interface StepCheckpointIR {
|
|
|
236
253
|
step_id: string;
|
|
237
254
|
checkpoints: StepCheckpoint[];
|
|
238
255
|
}
|
|
256
|
+
/** Handoff chain entry — tracks what each step consumes/produces */
|
|
257
|
+
export interface HandoffChainEntry {
|
|
258
|
+
step_id: string;
|
|
259
|
+
produces?: HandoffArtifact[];
|
|
260
|
+
consumes?: HandoffArtifact[];
|
|
261
|
+
}
|
|
239
262
|
/** Workflow-partitioned IR — isolates constraints per workflow */
|
|
240
263
|
export interface WorkflowIR {
|
|
241
264
|
workflow_name: string;
|
|
242
265
|
namespace: string;
|
|
243
266
|
step_checkpoints: StepCheckpointIR[];
|
|
244
267
|
active_roles: string[];
|
|
268
|
+
handoff_chain: HandoffChainEntry[];
|
|
269
|
+
}
|
|
270
|
+
/** Record of artifacts completed by a step — used for cross-session resume */
|
|
271
|
+
export interface CompletedArtifactEntry {
|
|
272
|
+
step_id: string;
|
|
273
|
+
artifacts: {
|
|
274
|
+
type: string;
|
|
275
|
+
path: string;
|
|
276
|
+
verified_at: string;
|
|
277
|
+
}[];
|
|
245
278
|
}
|
|
246
279
|
/** Runtime workflow state written to .dna/state/workflow.json */
|
|
247
280
|
export interface WorkflowState {
|
|
@@ -252,6 +285,7 @@ export interface WorkflowState {
|
|
|
252
285
|
iteration: number;
|
|
253
286
|
session_id: string;
|
|
254
287
|
started_at: string;
|
|
288
|
+
completed_artifacts?: CompletedArtifactEntry[];
|
|
255
289
|
}
|
|
256
290
|
export interface ConstraintIR {
|
|
257
291
|
prompt_directives: PromptDirective[];
|
|
@@ -280,6 +314,7 @@ export interface WorkflowStep {
|
|
|
280
314
|
prompt: string | null;
|
|
281
315
|
completion: CompletionCheck[] | null;
|
|
282
316
|
checkpoints: StepCheckpoint[] | null;
|
|
317
|
+
handoff: StepHandoff | null;
|
|
283
318
|
}
|
|
284
319
|
/** A group of steps that can execute in parallel */
|
|
285
320
|
export interface ParallelGroup {
|
|
@@ -80,10 +80,22 @@ workflow:
|
|
|
80
80
|
role: implementer
|
|
81
81
|
description: Implement the feature
|
|
82
82
|
prompt: "Implement task {{task_id}} according to the design document."
|
|
83
|
+
handoff:
|
|
84
|
+
produces:
|
|
85
|
+
- type: git_commit
|
|
86
|
+
description: "Implementation commit"
|
|
83
87
|
- id: review
|
|
84
88
|
role: reviewer
|
|
85
89
|
description: Review the implementation
|
|
86
90
|
prompt: "Review all changes for task {{task_id}}. Output PASS or FAIL."
|
|
91
|
+
handoff:
|
|
92
|
+
consumes:
|
|
93
|
+
- type: git_commit
|
|
94
|
+
from: implement
|
|
95
|
+
description: "Implementation to review"
|
|
96
|
+
produces:
|
|
97
|
+
- type: summary
|
|
98
|
+
description: "Review verdict (PASS/FAIL)"
|
|
87
99
|
transitions:
|
|
88
100
|
- from: review
|
|
89
101
|
to: implement
|
|
@@ -130,13 +130,33 @@ workflows:
|
|
|
130
130
|
role: investigator
|
|
131
131
|
description: Trace the broken chain in v1 and v2, identify first breakpoint
|
|
132
132
|
prompt: "Trace the call chain for '{{feature}}' in v1 ({{v1_path}}) and v2 ({{v2_path}}). Find where v2 breaks."
|
|
133
|
+
handoff:
|
|
134
|
+
produces:
|
|
135
|
+
- type: summary
|
|
136
|
+
description: "Breakpoint analysis with file paths and line numbers"
|
|
133
137
|
- id: fix
|
|
134
138
|
role: surgeon
|
|
135
139
|
depends_on: [trace]
|
|
136
140
|
description: Fix the identified breakpoint
|
|
137
141
|
prompt: "Fix the breakpoint identified by investigator. Copy logic from v1, change one file only."
|
|
142
|
+
handoff:
|
|
143
|
+
consumes:
|
|
144
|
+
- type: summary
|
|
145
|
+
from: trace
|
|
146
|
+
description: "Breakpoint analysis from investigator"
|
|
147
|
+
produces:
|
|
148
|
+
- type: git_commit
|
|
149
|
+
description: "Single-file fix commit"
|
|
138
150
|
- id: verify
|
|
139
151
|
role: investigator
|
|
140
152
|
depends_on: [fix]
|
|
141
153
|
description: Verify the fix with adb
|
|
142
154
|
prompt: "Run the feature on device. adb screencap + logcat. Compare with v1 behavior."
|
|
155
|
+
handoff:
|
|
156
|
+
consumes:
|
|
157
|
+
- type: git_commit
|
|
158
|
+
from: fix
|
|
159
|
+
description: "Fix commit to verify"
|
|
160
|
+
produces:
|
|
161
|
+
- type: summary
|
|
162
|
+
description: "Verification result with screenshots"
|
|
@@ -190,11 +190,27 @@ workflows:
|
|
|
190
190
|
role: scanner
|
|
191
191
|
description: "Scan v1 module in {{v1_path}}/. Output behavior doc to {{behavior_docs}}/$ARGUMENTS.md. List all user actions with call chains."
|
|
192
192
|
prompt: "Scan module '$ARGUMENTS' in {{v1_path}}/. For each page, list: action → function() → return value. Output to {{behavior_docs}}/$ARGUMENTS.md. Then immediately proceed to write_tests."
|
|
193
|
+
handoff:
|
|
194
|
+
produces:
|
|
195
|
+
- type: file
|
|
196
|
+
path: "{{behavior_docs}}/$ARGUMENTS.md"
|
|
197
|
+
description: "Behavior document for module"
|
|
193
198
|
- id: write_tests
|
|
194
199
|
role: test_writer
|
|
195
200
|
depends_on: [scan]
|
|
196
201
|
description: "Read {{behavior_docs}}/$ARGUMENTS.md, write tests in {{test_path}}/$ARGUMENTS/, run baseline, commit."
|
|
197
202
|
prompt: "Read {{behavior_docs}}/$ARGUMENTS.md. Write tests in {{test_path}}/$ARGUMENTS/. Test ALL layers: logic, widget, navigation. Run tests, record red/green baseline. Append baseline to behavior doc. Git commit: behavior-lock($ARGUMENTS): X tests (Y red, Z skipped)"
|
|
203
|
+
handoff:
|
|
204
|
+
consumes:
|
|
205
|
+
- type: file
|
|
206
|
+
path: "{{behavior_docs}}/$ARGUMENTS.md"
|
|
207
|
+
description: "Behavior document from scan step"
|
|
208
|
+
produces:
|
|
209
|
+
- type: directory
|
|
210
|
+
path: "{{test_path}}/$ARGUMENTS/"
|
|
211
|
+
description: "Test files for module"
|
|
212
|
+
- type: git_commit
|
|
213
|
+
description: "Behavior lock commit"
|
|
198
214
|
|
|
199
215
|
rescue:
|
|
200
216
|
name: Rescue
|
|
@@ -204,6 +220,13 @@ workflows:
|
|
|
204
220
|
role: investigator
|
|
205
221
|
description: "Run tests, assess current state, pick next targets."
|
|
206
222
|
prompt: "Run tests in {{test_path}}/$ARGUMENTS/. Categorize all non-passing tests: 1) RED (failing) — highest priority, fix first. 2) SKIPPED-logic — state/notifier/service tests, fix second. 3) SKIPPED-widget — widget/UI/navigation tests, fix after logic is done. For the highest priority category, pick a batch of related tests. Trace: what does v1 do vs what does v2 do? Find the breakpoints. Report findings and the plan for this round."
|
|
223
|
+
handoff:
|
|
224
|
+
produces:
|
|
225
|
+
- type: summary
|
|
226
|
+
description: "Investigation findings and breakpoint analysis"
|
|
227
|
+
- type: test_result
|
|
228
|
+
path: "{{test_path}}/$ARGUMENTS/"
|
|
229
|
+
description: "Current test state assessment"
|
|
207
230
|
- id: fix
|
|
208
231
|
role: surgeon
|
|
209
232
|
depends_on: [investigate]
|
|
@@ -212,11 +235,27 @@ workflows:
|
|
|
212
235
|
- assert: clean_working_tree
|
|
213
236
|
message: "Commit all changes before proceeding to report step"
|
|
214
237
|
prompt: "Fix the identified breakpoints. Read v1 in {{v1_path}}/. Rewrite in v2 style in {{v2_path}}/. Logic tests: implement notifier/state/service code. Widget tests: copy widget from v1, change bindings (Obx→Consumer, Get.to→context.go), set up widget test infra (ProviderScope, mock providers, GoRouter) if needed. Run tests after each fix. Red→green or Skipped→green = done. Still failing = revert and re-analyze. Maximize test coverage per round. When done, commit all changes: rescue($ARGUMENTS): round N — X passed (+Y)"
|
|
238
|
+
handoff:
|
|
239
|
+
consumes:
|
|
240
|
+
- type: summary
|
|
241
|
+
from: investigate
|
|
242
|
+
description: "Investigation findings from investigate step"
|
|
243
|
+
produces:
|
|
244
|
+
- type: git_commit
|
|
245
|
+
description: "Rescue round commit"
|
|
215
246
|
- id: report
|
|
216
247
|
role: investigator
|
|
217
248
|
depends_on: [fix]
|
|
218
249
|
description: "Summarize round, guide next steps."
|
|
219
250
|
prompt: "Run full test suite in {{test_path}}/$ARGUMENTS/. Report: 1) passed/skipped/failed delta vs last round. 2) List remaining skipped tests by category (logic vs widget vs platform). 3) If skipped tests remain, end with: Run /rescue $ARGUMENTS to continue. 4) If all tests pass, end with: Module $ARGUMENTS rescue complete."
|
|
251
|
+
handoff:
|
|
252
|
+
consumes:
|
|
253
|
+
- type: git_commit
|
|
254
|
+
from: fix
|
|
255
|
+
description: "Committed fix from surgeon"
|
|
256
|
+
produces:
|
|
257
|
+
- type: summary
|
|
258
|
+
description: "Round summary with test delta"
|
|
220
259
|
|
|
221
260
|
core-align:
|
|
222
261
|
name: Core Align
|
|
@@ -110,18 +110,47 @@ workflow:
|
|
|
110
110
|
description: Read task and produce design document
|
|
111
111
|
run_if: "round == 1"
|
|
112
112
|
prompt: "Read task {{task_id}} from BOARD.md. Produce a design document."
|
|
113
|
+
handoff:
|
|
114
|
+
produces:
|
|
115
|
+
- type: file
|
|
116
|
+
path: "docs/tasks/{{task_id}}.md"
|
|
117
|
+
description: "Design document for task"
|
|
113
118
|
- id: implement
|
|
114
119
|
role: implementer
|
|
115
120
|
description: Implement according to design
|
|
116
121
|
prompt: "Implement task {{task_id}} following the design document."
|
|
122
|
+
handoff:
|
|
123
|
+
consumes:
|
|
124
|
+
- type: file
|
|
125
|
+
path: "docs/tasks/{{task_id}}.md"
|
|
126
|
+
description: "Design document from planner"
|
|
127
|
+
produces:
|
|
128
|
+
- type: git_commit
|
|
129
|
+
description: "Implementation commit"
|
|
117
130
|
- id: test
|
|
118
131
|
role: tester
|
|
119
132
|
description: Run tests and fix failures
|
|
120
133
|
prompt: "Run tests. Fix failures. Add missing tests for {{task_id}}."
|
|
134
|
+
handoff:
|
|
135
|
+
consumes:
|
|
136
|
+
- type: git_commit
|
|
137
|
+
from: implement
|
|
138
|
+
description: "Implementation to test"
|
|
139
|
+
produces:
|
|
140
|
+
- type: test_result
|
|
141
|
+
description: "Test results after fixes"
|
|
121
142
|
- id: review
|
|
122
143
|
role: reviewer
|
|
123
144
|
description: Review and output verdict
|
|
124
145
|
prompt: "Review changes for {{task_id}}. Output PASS or FAIL."
|
|
146
|
+
handoff:
|
|
147
|
+
consumes:
|
|
148
|
+
- type: test_result
|
|
149
|
+
from: test
|
|
150
|
+
description: "Test results to review"
|
|
151
|
+
produces:
|
|
152
|
+
- type: summary
|
|
153
|
+
description: "Review verdict (PASS/FAIL)"
|
|
125
154
|
transitions:
|
|
126
155
|
- from: review
|
|
127
156
|
to: implement
|