intentdna 1.4.7 → 1.4.9
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/feedback.d.ts +3 -1
- package/dist/cli/commands/feedback.js +37 -3
- package/dist/cli/index.js +3 -1
- package/dist/evolution/trace-bridge.d.ts +31 -0
- package/dist/evolution/trace-bridge.js +99 -0
- package/dist/hooks/cli.js +37 -10
- package/dist/hooks/enforce.d.ts +15 -2
- package/dist/hooks/enforce.js +150 -20
- package/dist/runtime/workflow-runner.js +76 -2
- package/package.json +1 -1
- package/spec/parallel-isolation.md +64 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dna feedback [--days <N>] [--json]
|
|
2
|
+
* dna feedback [--days <N>] [--json] [--evolve]
|
|
3
3
|
*
|
|
4
4
|
* Analyze trace/audit data and produce actionable feedback for template optimization.
|
|
5
|
+
* With --evolve: convert trace data into epigenetic markers (preview by default).
|
|
5
6
|
*
|
|
6
7
|
* Reads: .dna/state/trace/trace-*.jsonl
|
|
7
8
|
* Outputs: human-readable report (or JSON with --json)
|
|
@@ -9,6 +10,7 @@
|
|
|
9
10
|
export interface FeedbackOptions {
|
|
10
11
|
days: number;
|
|
11
12
|
json: boolean;
|
|
13
|
+
evolve: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface FeedbackReport {
|
|
14
16
|
period_days: number;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dna feedback [--days <N>] [--json]
|
|
2
|
+
* dna feedback [--days <N>] [--json] [--evolve]
|
|
3
3
|
*
|
|
4
4
|
* Analyze trace/audit data and produce actionable feedback for template optimization.
|
|
5
|
+
* With --evolve: convert trace data into epigenetic markers (preview by default).
|
|
5
6
|
*
|
|
6
7
|
* Reads: .dna/state/trace/trace-*.jsonl
|
|
7
8
|
* Outputs: human-readable report (or JSON with --json)
|
|
8
9
|
*/
|
|
9
10
|
import { readTraces } from "../../hooks/state.js";
|
|
11
|
+
import { traceToOutcomes, summarizeConversion } from "../../evolution/trace-bridge.js";
|
|
12
|
+
import { describeMarkerChanges } from "../../evolution/marker-gen.js";
|
|
13
|
+
import { EvolutionStore } from "../../evolution/store.js";
|
|
10
14
|
function aggregate(traces) {
|
|
11
15
|
let blocks = 0, warns = 0, allows = 0;
|
|
12
16
|
const toolCounts = new Map();
|
|
@@ -130,11 +134,41 @@ export async function runFeedback(opts) {
|
|
|
130
134
|
const stats = aggregate(traces);
|
|
131
135
|
const suggestions = generateSuggestions(stats);
|
|
132
136
|
const report = { ...stats, period_days: opts.days, suggestions };
|
|
133
|
-
if (opts.json) {
|
|
137
|
+
if (opts.json && !opts.evolve) {
|
|
134
138
|
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
135
139
|
}
|
|
136
|
-
else {
|
|
140
|
+
else if (!opts.evolve) {
|
|
137
141
|
process.stderr.write(formatReport(report));
|
|
138
142
|
}
|
|
143
|
+
// --evolve: convert traces → epigenetic markers
|
|
144
|
+
if (opts.evolve) {
|
|
145
|
+
const outcomes = traceToOutcomes(traces);
|
|
146
|
+
if (outcomes.length === 0) {
|
|
147
|
+
process.stderr.write(formatReport(report));
|
|
148
|
+
process.stderr.write("\nNo actionable gene feedback found in traces — nothing to evolve.\n");
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const store = new EvolutionStore();
|
|
152
|
+
const existingMarkers = await store.loadMarkers("default");
|
|
153
|
+
const changes = describeMarkerChanges(outcomes, existingMarkers);
|
|
154
|
+
process.stderr.write(formatReport(report));
|
|
155
|
+
process.stderr.write("\n" + summarizeConversion(outcomes) + "\n\n");
|
|
156
|
+
process.stderr.write("Marker changes (preview):\n");
|
|
157
|
+
for (const c of changes) {
|
|
158
|
+
if (c.type === "create") {
|
|
159
|
+
process.stderr.write(` + CREATE ${c.gene} → ${c.action} x${c.factor.toFixed(2)} (${c.feedback_count} feedbacks)\n`);
|
|
160
|
+
}
|
|
161
|
+
else if (c.type === "update") {
|
|
162
|
+
process.stderr.write(` ~ UPDATE ${c.gene} → ${c.action} x${c.factor.toFixed(2)} (${c.feedback_count} feedbacks)\n`);
|
|
163
|
+
}
|
|
164
|
+
else if (c.type === "neutral") {
|
|
165
|
+
process.stderr.write(` = NEUTRAL ${c.gene} x${c.factor.toFixed(2)} (${c.feedback_count} feedbacks)\n`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
process.stderr.write(` ? INSUFFICIENT ${c.gene} (${c.feedback_count}/${c.needed} needed)\n`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
process.stderr.write("\nRun `dna evolve --apply` to persist markers.\n");
|
|
172
|
+
}
|
|
139
173
|
return 0;
|
|
140
174
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,7 @@ Commands:
|
|
|
29
29
|
show Show gene expression state
|
|
30
30
|
evolve Generate epigenetic markers from outcomes
|
|
31
31
|
epigenetic Record outcomes, view markers and summaries
|
|
32
|
-
feedback Analyze trace data, suggest template optimizations (--days <N>, --json)
|
|
32
|
+
feedback Analyze trace data, suggest template optimizations (--days <N>, --json, --evolve)
|
|
33
33
|
|
|
34
34
|
Options:
|
|
35
35
|
--help, -h Show help for a command
|
|
@@ -412,6 +412,7 @@ async function main() {
|
|
|
412
412
|
options: {
|
|
413
413
|
days: { type: "string", short: "d", default: "7" },
|
|
414
414
|
json: { type: "boolean", default: false },
|
|
415
|
+
evolve: { type: "boolean", default: false },
|
|
415
416
|
},
|
|
416
417
|
strict: false,
|
|
417
418
|
});
|
|
@@ -419,6 +420,7 @@ async function main() {
|
|
|
419
420
|
const code = await runFeedback({
|
|
420
421
|
days: parseInt(fbValues.days, 10) || 7,
|
|
421
422
|
json: fbValues.json,
|
|
423
|
+
evolve: fbValues.evolve,
|
|
422
424
|
});
|
|
423
425
|
process.exit(code);
|
|
424
426
|
break;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent DNA — Trace-to-Evolution Bridge
|
|
3
|
+
*
|
|
4
|
+
* Converts hook trace data into ExecutionOutcomes that the epigenetic
|
|
5
|
+
* marker generator can process. This closes the feedback loop:
|
|
6
|
+
* traces (observed behavior) → outcomes → markers → gene modulation
|
|
7
|
+
*
|
|
8
|
+
* Gene extraction from trace reasons:
|
|
9
|
+
* - Tool filters: "(gene: <name>)" in reason
|
|
10
|
+
* - Role scope: "Role '<name>'" → gene "scope:<role>"
|
|
11
|
+
* - Gates: "(gene: <name>)" pattern or fallback to "gate"
|
|
12
|
+
*/
|
|
13
|
+
import type { TraceEntry } from "../hooks/state.js";
|
|
14
|
+
import type { ExecutionOutcome } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Extract the gene name from a trace entry's reason string.
|
|
17
|
+
* Returns null if no gene can be determined.
|
|
18
|
+
*/
|
|
19
|
+
export declare function extractGeneFromReason(reason: string | undefined): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Convert trace entries into ExecutionOutcomes for the evolution engine.
|
|
22
|
+
*
|
|
23
|
+
* Groups block/warn traces by gene, creates one outcome per unique
|
|
24
|
+
* (gene, decision) pair. Allow decisions are not converted since
|
|
25
|
+
* they don't carry gene information.
|
|
26
|
+
*/
|
|
27
|
+
export declare function traceToOutcomes(traces: TraceEntry[], dnaId?: string): ExecutionOutcome[];
|
|
28
|
+
/**
|
|
29
|
+
* Summarize trace-to-outcome conversion for human review.
|
|
30
|
+
*/
|
|
31
|
+
export declare function summarizeConversion(outcomes: ExecutionOutcome[]): string;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent DNA — Trace-to-Evolution Bridge
|
|
3
|
+
*
|
|
4
|
+
* Converts hook trace data into ExecutionOutcomes that the epigenetic
|
|
5
|
+
* marker generator can process. This closes the feedback loop:
|
|
6
|
+
* traces (observed behavior) → outcomes → markers → gene modulation
|
|
7
|
+
*
|
|
8
|
+
* Gene extraction from trace reasons:
|
|
9
|
+
* - Tool filters: "(gene: <name>)" in reason
|
|
10
|
+
* - Role scope: "Role '<name>'" → gene "scope:<role>"
|
|
11
|
+
* - Gates: "(gene: <name>)" pattern or fallback to "gate"
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
/**
|
|
15
|
+
* Extract the gene name from a trace entry's reason string.
|
|
16
|
+
* Returns null if no gene can be determined.
|
|
17
|
+
*/
|
|
18
|
+
export function extractGeneFromReason(reason) {
|
|
19
|
+
if (!reason)
|
|
20
|
+
return null;
|
|
21
|
+
// Pattern: (gene: <name>) — from tool filters and gates
|
|
22
|
+
const geneMatch = reason.match(/\(gene:\s*(\w+)\)/);
|
|
23
|
+
if (geneMatch)
|
|
24
|
+
return geneMatch[1];
|
|
25
|
+
// Pattern: Role '<name>' — from scope enforcement
|
|
26
|
+
const roleMatch = reason.match(/Role '(\w+)'/);
|
|
27
|
+
if (roleMatch)
|
|
28
|
+
return `scope:${roleMatch[1]}`;
|
|
29
|
+
// Pattern: source_gene in gate messages
|
|
30
|
+
const sourceMatch = reason.match(/source_gene:\s*(\w+)/);
|
|
31
|
+
if (sourceMatch)
|
|
32
|
+
return sourceMatch[1];
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Convert trace entries into ExecutionOutcomes for the evolution engine.
|
|
37
|
+
*
|
|
38
|
+
* Groups block/warn traces by gene, creates one outcome per unique
|
|
39
|
+
* (gene, decision) pair. Allow decisions are not converted since
|
|
40
|
+
* they don't carry gene information.
|
|
41
|
+
*/
|
|
42
|
+
export function traceToOutcomes(traces, dnaId = "default") {
|
|
43
|
+
// Collect feedback per gene from block/warn traces
|
|
44
|
+
const geneFeedback = new Map();
|
|
45
|
+
for (const trace of traces) {
|
|
46
|
+
if (trace.decision === "allow")
|
|
47
|
+
continue;
|
|
48
|
+
const gene = extractGeneFromReason(trace.reason);
|
|
49
|
+
if (!gene)
|
|
50
|
+
continue;
|
|
51
|
+
const existing = geneFeedback.get(gene) ?? { hindered: 0, details: [] };
|
|
52
|
+
existing.hindered++;
|
|
53
|
+
if (trace.reason && existing.details.length < 3) {
|
|
54
|
+
const short = trace.reason.split("\n")[0].slice(0, 80);
|
|
55
|
+
existing.details.push(short);
|
|
56
|
+
}
|
|
57
|
+
geneFeedback.set(gene, existing);
|
|
58
|
+
}
|
|
59
|
+
// Convert to ExecutionOutcomes
|
|
60
|
+
const outcomes = [];
|
|
61
|
+
const totalEvents = traces.length;
|
|
62
|
+
const timestamp = new Date().toISOString();
|
|
63
|
+
for (const [gene, stats] of geneFeedback) {
|
|
64
|
+
const feedback = [];
|
|
65
|
+
// Each block counts as a "hindered" feedback
|
|
66
|
+
for (let i = 0; i < stats.hindered; i++) {
|
|
67
|
+
feedback.push({
|
|
68
|
+
gene,
|
|
69
|
+
effect: "hindered",
|
|
70
|
+
detail: stats.details[i] ?? `blocked ${stats.hindered} times`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
outcomes.push({
|
|
74
|
+
id: `trace_${randomUUID().slice(0, 8)}`,
|
|
75
|
+
timestamp,
|
|
76
|
+
dna_id: dnaId,
|
|
77
|
+
action: `enforcement: ${gene} blocked ${stats.hindered}/${totalEvents} events`,
|
|
78
|
+
active_genes: [gene],
|
|
79
|
+
constraints_applied: [gene],
|
|
80
|
+
success: false, // Blocks indicate friction
|
|
81
|
+
constraint_feedback: feedback,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return outcomes;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Summarize trace-to-outcome conversion for human review.
|
|
88
|
+
*/
|
|
89
|
+
export function summarizeConversion(outcomes) {
|
|
90
|
+
if (outcomes.length === 0)
|
|
91
|
+
return "No actionable gene feedback found in traces.";
|
|
92
|
+
const lines = ["Trace → Epigenetic conversion:"];
|
|
93
|
+
for (const o of outcomes) {
|
|
94
|
+
const gene = o.active_genes[0];
|
|
95
|
+
const count = o.constraint_feedback.length;
|
|
96
|
+
lines.push(` ${gene}: ${count} block${count > 1 ? "s" : ""} → will suppress`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
package/dist/hooks/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ 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, allowOutput } from "./protocol.js";
|
|
21
|
-
import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, } from "./enforce.js";
|
|
21
|
+
import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, } from "./enforce.js";
|
|
22
22
|
import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces } from "./state.js";
|
|
23
23
|
// ── Constants ──────────────────────────────────────────────
|
|
24
24
|
const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
|
|
@@ -92,18 +92,21 @@ async function main() {
|
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
const state = {};
|
|
95
|
-
// Load workflow state for events that need handoff context
|
|
96
|
-
if (event === "PreToolUse") {
|
|
95
|
+
// Load workflow state for events that need it (handoff context + PreCompact preservation)
|
|
96
|
+
if (event === "PreToolUse" || event === "PreCompact") {
|
|
97
97
|
const wfState = await readWorkflowState(projectDir, sessionId);
|
|
98
98
|
if (wfState && wfState.active) {
|
|
99
99
|
state.workflowState = {
|
|
100
100
|
current_step: wfState.current_step,
|
|
101
101
|
workflow: wfState.workflow,
|
|
102
|
+
current_role: wfState.current_role,
|
|
102
103
|
completed_artifacts: wfState.completed_artifacts,
|
|
103
104
|
};
|
|
104
105
|
// Re-run fallback: scan consumed artifact paths on disk so
|
|
105
106
|
// enforceHandoffConsumes can skip blocks for files that already exist.
|
|
106
|
-
|
|
107
|
+
if (event === "PreToolUse") {
|
|
108
|
+
state.existingArtifactPaths = await scanConsumedArtifactPaths(projectDir, ir, wfState.workflow, wfState.current_step);
|
|
109
|
+
}
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
// Special handling for Stop — needs async workflow state read + session summary
|
|
@@ -154,17 +157,37 @@ async function main() {
|
|
|
154
157
|
}
|
|
155
158
|
// Dispatch to enforcement engine with timing
|
|
156
159
|
const start = Date.now();
|
|
157
|
-
|
|
160
|
+
let output = dispatch(event, ir, rawInput, state);
|
|
158
161
|
const durationMs = Date.now() - start;
|
|
162
|
+
// Extract target file path for trace + pattern detection
|
|
163
|
+
const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
|
|
164
|
+
? rawInput.tool_input : undefined;
|
|
165
|
+
let targetPath = typeof toolInput?.file_path === "string" ? toolInput.file_path : undefined;
|
|
166
|
+
if (!targetPath && typeof toolInput?.command === "string") {
|
|
167
|
+
const bashPaths = extractBashWritePaths(toolInput.command);
|
|
168
|
+
if (bashPaths.length > 0)
|
|
169
|
+
targetPath = bashPaths[0];
|
|
170
|
+
}
|
|
171
|
+
// O4: Block pattern recognition — suggest scope expansion on repeated blocks
|
|
172
|
+
if (event === "PreToolUse" && output.continue === false && targetPath) {
|
|
173
|
+
try {
|
|
174
|
+
const traces = await readTraces(projectDir, 1);
|
|
175
|
+
const blockCount = traces.filter(t => t.decision === "block" && t.target_path === targetPath).length;
|
|
176
|
+
if (blockCount >= 2) {
|
|
177
|
+
output = {
|
|
178
|
+
...output,
|
|
179
|
+
reason: (output.reason ?? "") +
|
|
180
|
+
`\n\n[Intent DNA] Pattern detected: '${targetPath}' blocked ${blockCount + 1} times this session. Consider expanding the role's write scope.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* fail-open: pattern detection never blocks */ }
|
|
185
|
+
}
|
|
159
186
|
writeOutput(output);
|
|
160
187
|
// Trace logging (async, fail-open)
|
|
161
188
|
const decision = output.continue === false ? "block"
|
|
162
189
|
: output.hookSpecificOutput?.additionalContext?.startsWith("WARN") ? "warn"
|
|
163
190
|
: "allow";
|
|
164
|
-
// Extract target file path from tool_input (Write/Edit/Read)
|
|
165
|
-
const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
|
|
166
|
-
? rawInput.tool_input : undefined;
|
|
167
|
-
const targetPath = typeof toolInput?.file_path === "string" ? toolInput.file_path : undefined;
|
|
168
191
|
appendTrace(projectDir, {
|
|
169
192
|
trace_id: randomUUID(),
|
|
170
193
|
event,
|
|
@@ -221,7 +244,11 @@ function dispatch(event, ir, input, state) {
|
|
|
221
244
|
agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
|
|
222
245
|
});
|
|
223
246
|
case "PreCompact":
|
|
224
|
-
return enforcePreCompact(ir
|
|
247
|
+
return enforcePreCompact(ir, state.workflowState ? {
|
|
248
|
+
workflow: state.workflowState.workflow,
|
|
249
|
+
current_step: state.workflowState.current_step,
|
|
250
|
+
current_role: state.workflowState.current_role ?? "",
|
|
251
|
+
} : null);
|
|
225
252
|
case "Notification":
|
|
226
253
|
return enforceNotification(ir, {
|
|
227
254
|
title: typeof input.title === "string" ? input.title : undefined,
|
package/dist/hooks/enforce.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface EnforceState {
|
|
|
26
26
|
workflowState?: {
|
|
27
27
|
current_step: string;
|
|
28
28
|
workflow: string;
|
|
29
|
+
current_role?: string;
|
|
29
30
|
completed_artifacts?: CompletedArtifactEntry[];
|
|
30
31
|
};
|
|
31
32
|
/** Artifact paths that exist on disk — fallback for re-run idempotency.
|
|
@@ -41,7 +42,8 @@ export interface EnforceState {
|
|
|
41
42
|
export declare function enforcePreToolUse(ir: ConstraintIR, input: PreToolUseInput, state?: EnforceState, roles?: Record<string, RoleDef>): HookOutput;
|
|
42
43
|
/**
|
|
43
44
|
* Enforce PostToolUse validators.
|
|
44
|
-
*
|
|
45
|
+
* 1. Runs post-execution validators (audit)
|
|
46
|
+
* 2. Verifies Bash write paths were within scope (defense-in-depth)
|
|
45
47
|
*/
|
|
46
48
|
export declare function enforcePostToolUse(ir: ConstraintIR, input: PostToolUseInput): HookOutput;
|
|
47
49
|
/**
|
|
@@ -55,8 +57,13 @@ export declare function enforceUserPromptSubmit(ir: ConstraintIR, _input: UserPr
|
|
|
55
57
|
export declare function enforceSubagentStop(ir: ConstraintIR, input: SubagentStopInput): HookOutput;
|
|
56
58
|
/**
|
|
57
59
|
* Preserve DNA directive summary before context compaction.
|
|
60
|
+
* Includes: high-priority directives, role scope map, active workflow state.
|
|
58
61
|
*/
|
|
59
|
-
export declare function enforcePreCompact(ir: ConstraintIR
|
|
62
|
+
export declare function enforcePreCompact(ir: ConstraintIR, workflowContext?: {
|
|
63
|
+
workflow: string;
|
|
64
|
+
current_step: string;
|
|
65
|
+
current_role: string;
|
|
66
|
+
} | null): HookOutput;
|
|
60
67
|
/**
|
|
61
68
|
* Check if a notification is a DNA violation and return audit info.
|
|
62
69
|
* The CLI handles actual audit file writes.
|
|
@@ -124,3 +131,9 @@ export declare function evaluateGateCondition(condition: string, input: PreToolU
|
|
|
124
131
|
* Resolve a tool filter target to concrete Claude Code tool names.
|
|
125
132
|
*/
|
|
126
133
|
export declare function resolveToolTarget(target?: string): string[] | null;
|
|
134
|
+
/**
|
|
135
|
+
* Extract file paths that a Bash command writes to.
|
|
136
|
+
* Detects: output redirection (> / >>), tee, cp, mv.
|
|
137
|
+
* Returns deduplicated list of target paths.
|
|
138
|
+
*/
|
|
139
|
+
export declare function extractBashWritePaths(command: string): string[];
|
package/dist/hooks/enforce.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Step checkpoints are handled by the CLI, not here.
|
|
15
15
|
*/
|
|
16
16
|
import { allowOutput, blockOutput, escalateOutput, silentOutput } from "./protocol.js";
|
|
17
|
-
import { relative } from "node:path";
|
|
17
|
+
import { relative, normalize } from "node:path";
|
|
18
18
|
// ── PreToolUse Enforcement ─────────────────────────────────
|
|
19
19
|
/**
|
|
20
20
|
* Enforce PreToolUse constraints.
|
|
@@ -58,16 +58,31 @@ export function enforcePreToolUse(ir, input, state, roles) {
|
|
|
58
58
|
// ── PostToolUse Enforcement ────────────────────────────────
|
|
59
59
|
/**
|
|
60
60
|
* Enforce PostToolUse validators.
|
|
61
|
-
*
|
|
61
|
+
* 1. Runs post-execution validators (audit)
|
|
62
|
+
* 2. Verifies Bash write paths were within scope (defense-in-depth)
|
|
62
63
|
*/
|
|
63
64
|
export function enforcePostToolUse(ir, input) {
|
|
64
|
-
const validators = ir.post_execution_validators;
|
|
65
|
-
if (validators.length === 0)
|
|
66
|
-
return silentOutput();
|
|
67
65
|
const lines = [];
|
|
66
|
+
// Layer 1: Post-execution validators
|
|
67
|
+
const validators = ir.post_execution_validators;
|
|
68
68
|
for (const v of validators) {
|
|
69
69
|
lines.push(`[Intent DNA] PostToolUse validator: ${v.check} (gene: ${v.source_gene})`);
|
|
70
70
|
}
|
|
71
|
+
// Layer 2: Bash write scope verification (defense-in-depth)
|
|
72
|
+
if (input.tool_name === "Bash" && input.agent_type && ir.roles_scope_map && ir.roles_scope_map.length > 0) {
|
|
73
|
+
const cmd = input.tool_input.command;
|
|
74
|
+
if (typeof cmd === "string") {
|
|
75
|
+
const writePaths = extractBashWritePaths(cmd);
|
|
76
|
+
if (writePaths.length > 0) {
|
|
77
|
+
const violations = verifyPathsAgainstScope(ir.roles_scope_map, input.agent_type, writePaths, input.cwd);
|
|
78
|
+
for (const v of violations) {
|
|
79
|
+
lines.push(`WARN [Intent DNA] PostToolUse: Bash wrote outside scope — ${v}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (lines.length === 0)
|
|
85
|
+
return silentOutput();
|
|
71
86
|
return allowOutput(lines.join("\n"));
|
|
72
87
|
}
|
|
73
88
|
// ── UserPromptSubmit Enforcement ───────────────────────────
|
|
@@ -113,14 +128,18 @@ export function enforceSubagentStop(ir, input) {
|
|
|
113
128
|
// ── PreCompact Enforcement ─────────────────────────────────
|
|
114
129
|
/**
|
|
115
130
|
* Preserve DNA directive summary before context compaction.
|
|
131
|
+
* Includes: high-priority directives, role scope map, active workflow state.
|
|
116
132
|
*/
|
|
117
|
-
export function enforcePreCompact(ir) {
|
|
118
|
-
|
|
133
|
+
export function enforcePreCompact(ir, workflowContext) {
|
|
134
|
+
const hasDirectives = ir.prompt_directives.length > 0;
|
|
135
|
+
const hasRoles = ir.roles_scope_map && ir.roles_scope_map.length > 0;
|
|
136
|
+
const hasWorkflow = !!workflowContext;
|
|
137
|
+
if (!hasDirectives && !hasRoles && !hasWorkflow)
|
|
119
138
|
return silentOutput();
|
|
120
139
|
const critical = ir.prompt_directives.filter(d => d.priority === "high");
|
|
121
140
|
const standard = ir.prompt_directives.filter(d => d.priority === "medium");
|
|
122
141
|
const lines = [
|
|
123
|
-
"[Intent DNA] PreCompact: Preserving
|
|
142
|
+
"[Intent DNA] PreCompact: Preserving governance context for compact",
|
|
124
143
|
];
|
|
125
144
|
if (critical.length > 0) {
|
|
126
145
|
lines.push(" Critical directives:");
|
|
@@ -134,6 +153,19 @@ export function enforcePreCompact(ir) {
|
|
|
134
153
|
lines.push(` - [${d.source_gene}] ${d.text}`);
|
|
135
154
|
}
|
|
136
155
|
}
|
|
156
|
+
// Preserve role scope map so agent knows its boundaries post-compact
|
|
157
|
+
if (hasRoles) {
|
|
158
|
+
lines.push(" Active role scopes:");
|
|
159
|
+
for (const entry of ir.roles_scope_map) {
|
|
160
|
+
const writeGlobs = entry.scope.write ?? [];
|
|
161
|
+
const readGlobs = entry.scope.read ?? [];
|
|
162
|
+
lines.push(` - ${entry.role_name}: write=[${writeGlobs.join(",")}] read=[${readGlobs.join(",")}]`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Preserve workflow state so agent retains step context
|
|
166
|
+
if (hasWorkflow) {
|
|
167
|
+
lines.push(` Active workflow: ${workflowContext.workflow} (step: ${workflowContext.current_step}, role: ${workflowContext.current_role})`);
|
|
168
|
+
}
|
|
137
169
|
return allowOutput(lines.join("\n"));
|
|
138
170
|
}
|
|
139
171
|
// ── Notification Enforcement ───────────────────────────────
|
|
@@ -315,29 +347,72 @@ export function enforceHandoffProduces(ir, wfState) {
|
|
|
315
347
|
function enforceRoleScope(rolesScopeMap, input) {
|
|
316
348
|
if (!input.agent_type)
|
|
317
349
|
return null;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
350
|
+
// Standard write tools: check single file path
|
|
351
|
+
if (isWriteTool(input.tool_name)) {
|
|
352
|
+
const filePath = extractFilePath(input);
|
|
353
|
+
if (!filePath)
|
|
354
|
+
return null;
|
|
355
|
+
return checkPathAgainstScope(rolesScopeMap, input, filePath);
|
|
356
|
+
}
|
|
357
|
+
// Bash: extract potential write targets from command
|
|
358
|
+
if (input.tool_name === "Bash") {
|
|
359
|
+
const cmd = input.tool_input.command;
|
|
360
|
+
if (typeof cmd !== "string")
|
|
361
|
+
return null;
|
|
362
|
+
const writePaths = extractBashWritePaths(cmd);
|
|
363
|
+
if (writePaths.length === 0)
|
|
364
|
+
return null;
|
|
365
|
+
for (const p of writePaths) {
|
|
366
|
+
const result = checkPathAgainstScope(rolesScopeMap, input, p);
|
|
367
|
+
if (result)
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
322
370
|
return null;
|
|
323
|
-
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
/** Check a single file path against role scope. Shared by Write tools and Bash. */
|
|
375
|
+
function checkPathAgainstScope(rolesScopeMap, input, filePath) {
|
|
324
376
|
const cwd = input.cwd;
|
|
325
|
-
|
|
377
|
+
let relativePath = cwd && filePath.startsWith("/") ? relative(cwd, filePath) : filePath;
|
|
378
|
+
// Normalize to resolve traversal (e.g., "src/../../test/x.ts" → "../test/x.ts")
|
|
379
|
+
relativePath = normalize(relativePath);
|
|
326
380
|
for (const entry of rolesScopeMap) {
|
|
327
381
|
const agentTypeName = `dna-${toKebabCase(entry.role_name)}`;
|
|
328
382
|
if (input.agent_type !== agentTypeName)
|
|
329
383
|
continue;
|
|
330
384
|
const writeGlobs = entry.scope.write ?? [];
|
|
331
385
|
if (writeGlobs.length === 0) {
|
|
332
|
-
return
|
|
386
|
+
return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' has no write permission (path: ${filePath}). Merge-time scope gate will filter.`);
|
|
333
387
|
}
|
|
334
388
|
if (!checkWriteAllowed(relativePath, writeGlobs)) {
|
|
335
|
-
return
|
|
389
|
+
return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${writeGlobs.join(", ")}). Merge-time scope gate will filter.`);
|
|
336
390
|
}
|
|
337
391
|
return null; // Role matched, write allowed
|
|
338
392
|
}
|
|
339
393
|
return null; // No matching role — allow
|
|
340
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Verify a list of paths against role scope. Returns violation messages.
|
|
397
|
+
* Used by PostToolUse for defense-in-depth auditing (warn, not block).
|
|
398
|
+
*/
|
|
399
|
+
function verifyPathsAgainstScope(rolesScopeMap, agentType, paths, cwd) {
|
|
400
|
+
const violations = [];
|
|
401
|
+
for (const entry of rolesScopeMap) {
|
|
402
|
+
const agentTypeName = `dna-${toKebabCase(entry.role_name)}`;
|
|
403
|
+
if (agentType !== agentTypeName)
|
|
404
|
+
continue;
|
|
405
|
+
const writeGlobs = entry.scope.write ?? [];
|
|
406
|
+
for (const p of paths) {
|
|
407
|
+
const relativePath = cwd && p.startsWith("/") ? relative(cwd, p) : p;
|
|
408
|
+
if (writeGlobs.length === 0 || !checkWriteAllowed(relativePath, writeGlobs)) {
|
|
409
|
+
violations.push(`role '${entry.role_name}' wrote to '${p}' (allowed: ${writeGlobs.join(", ") || "none"})`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
break; // Found the role, done
|
|
413
|
+
}
|
|
414
|
+
return violations;
|
|
415
|
+
}
|
|
341
416
|
function enforceToolFilters(filters, input) {
|
|
342
417
|
for (const filter of filters) {
|
|
343
418
|
const matchedTools = resolveToolTarget(filter.target);
|
|
@@ -486,12 +561,67 @@ function extractFilePath(input) {
|
|
|
486
561
|
const fp = input.tool_input.file_path;
|
|
487
562
|
if (typeof fp === "string" && fp)
|
|
488
563
|
return fp;
|
|
489
|
-
// Bash tool — try to extract from command
|
|
490
|
-
const cmd = input.tool_input.command;
|
|
491
|
-
if (typeof cmd === "string")
|
|
492
|
-
return null; // Can't reliably extract
|
|
493
564
|
return null;
|
|
494
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Extract file paths that a Bash command writes to.
|
|
568
|
+
* Detects: output redirection (> / >>), tee, cp, mv.
|
|
569
|
+
* Returns deduplicated list of target paths.
|
|
570
|
+
*/
|
|
571
|
+
export function extractBashWritePaths(command) {
|
|
572
|
+
const paths = [];
|
|
573
|
+
// 1. Output redirection: > or >>
|
|
574
|
+
// Skip fd duplication (>&N, >&-), /dev/ paths, and variable expansions
|
|
575
|
+
const redirectRegex = />{1,2}\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;|&><()`$]+))/g;
|
|
576
|
+
let match;
|
|
577
|
+
while ((match = redirectRegex.exec(command)) !== null) {
|
|
578
|
+
const p = match[1] ?? match[2] ?? match[3];
|
|
579
|
+
if (p && !p.startsWith("/dev/"))
|
|
580
|
+
paths.push(p);
|
|
581
|
+
}
|
|
582
|
+
// 2. tee command: writes stdin to file(s)
|
|
583
|
+
// tee [-ai] file1 [file2 ...]
|
|
584
|
+
const teeMatch = command.match(/\btee\s+(.*?)(?:[;|&]|$)/);
|
|
585
|
+
if (teeMatch) {
|
|
586
|
+
const teeArgs = teeMatch[1].trim().split(/\s+/);
|
|
587
|
+
for (const arg of teeArgs) {
|
|
588
|
+
if (arg.startsWith("-"))
|
|
589
|
+
continue;
|
|
590
|
+
if (arg.startsWith("/dev/"))
|
|
591
|
+
continue;
|
|
592
|
+
if (arg.startsWith("$"))
|
|
593
|
+
continue;
|
|
594
|
+
// Handle quoted paths
|
|
595
|
+
const cleaned = arg.replace(/^["']|["']$/g, "");
|
|
596
|
+
if (cleaned)
|
|
597
|
+
paths.push(cleaned);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// 3. cp/mv destination: last non-flag argument
|
|
601
|
+
const cpMvMatch = command.match(/\b(cp|mv)\s+(.*?)(?:[;|&]|$)/);
|
|
602
|
+
if (cpMvMatch) {
|
|
603
|
+
const args = cpMvMatch[2].trim().split(/\s+/).filter(a => !a.startsWith("-"));
|
|
604
|
+
if (args.length >= 2) {
|
|
605
|
+
const dest = args[args.length - 1].replace(/^["']|["']$/g, "");
|
|
606
|
+
if (dest && !dest.startsWith("/dev/") && !dest.startsWith("$")) {
|
|
607
|
+
paths.push(dest);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// 4. sed -i: in-place file editing — the target file is the last argument
|
|
612
|
+
const sedInPlace = command.match(/\bsed\s+[^;|&]*?-i/);
|
|
613
|
+
if (sedInPlace) {
|
|
614
|
+
const sedFull = command.match(/\bsed\s+(.*?)(?:\s*[;|&]|$)/);
|
|
615
|
+
if (sedFull) {
|
|
616
|
+
const tokens = sedFull[1].trim().split(/\s+/);
|
|
617
|
+
const lastToken = tokens[tokens.length - 1]?.replace(/^["']|["']$/g, "");
|
|
618
|
+
if (lastToken && !lastToken.startsWith("/dev/") && !lastToken.startsWith("$") && !lastToken.startsWith("-")) {
|
|
619
|
+
paths.push(lastToken);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return [...new Set(paths)];
|
|
624
|
+
}
|
|
495
625
|
/** Convert string to kebab-case (matches agent-md.ts toKebabCase). */
|
|
496
626
|
function toKebabCase(s) {
|
|
497
627
|
return s
|
|
@@ -258,11 +258,13 @@ function generateGroupExecution(group, stepMap, options, plan) {
|
|
|
258
258
|
}
|
|
259
259
|
// Merge worktrees back (escalate on conflict)
|
|
260
260
|
lines.push("");
|
|
261
|
-
lines.push(" #
|
|
261
|
+
lines.push(" # Scope gate + merge worktrees back to main branch");
|
|
262
262
|
for (const id of group.step_ids) {
|
|
263
|
+
const step = stepMap.get(id);
|
|
263
264
|
const varId = sanitizeForBash(id);
|
|
264
265
|
const safeId = sanitizeForShell(id);
|
|
265
266
|
lines.push(` if [ "$STEP_${varId}_STATUS" -eq 0 ]; then`);
|
|
267
|
+
lines.push(` scope_gate "${safeId}" "dna-wt-${safeId}" "${step.role}" "${safeId}"`);
|
|
266
268
|
lines.push(` merge_worktree "dna-wt-${safeId}" "${safeId}"`);
|
|
267
269
|
lines.push(" fi");
|
|
268
270
|
}
|
|
@@ -336,6 +338,77 @@ function generateMergeWorktreeFn() {
|
|
|
336
338
|
"",
|
|
337
339
|
];
|
|
338
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Generate the scope_gate bash helper function.
|
|
343
|
+
* Filters out-of-scope file changes from a worktree branch before merging.
|
|
344
|
+
* Reads role write scope from .dna/compiled/ir.json, reverts unauthorized files,
|
|
345
|
+
* and writes trace entries for each violation.
|
|
346
|
+
* Fail-open: never prevents merge from proceeding.
|
|
347
|
+
*/
|
|
348
|
+
function generateScopeGateFn() {
|
|
349
|
+
return [
|
|
350
|
+
"# Scope gate: filter out-of-scope changes before merge (three-layer defense L3)",
|
|
351
|
+
"scope_gate() {",
|
|
352
|
+
' local name="$1" branch="$2" role="$3" step_id="$4"',
|
|
353
|
+
' local ir_path="${PROJECT_ROOT}/.dna/compiled/ir.json"',
|
|
354
|
+
"",
|
|
355
|
+
' [ ! -f "$ir_path" ] && return 0',
|
|
356
|
+
"",
|
|
357
|
+
" # Read write scope globs for this role from IR",
|
|
358
|
+
" local allowed_globs",
|
|
359
|
+
" allowed_globs=$(jq -r --arg role \"$role\" \\",
|
|
360
|
+
" '.roles_scope_map[]? | select(.role_name == $role) | .scope.write[]?' \\",
|
|
361
|
+
' "$ir_path" 2>/dev/null)',
|
|
362
|
+
"",
|
|
363
|
+
" # No scope defined — allow all changes",
|
|
364
|
+
' [ -z "$allowed_globs" ] && return 0',
|
|
365
|
+
"",
|
|
366
|
+
" # Get files changed by this branch vs main",
|
|
367
|
+
" local changed",
|
|
368
|
+
' changed=$(git diff --name-only "$MAIN_BRANCH...$branch" 2>/dev/null)',
|
|
369
|
+
' [ -z "$changed" ] && return 0',
|
|
370
|
+
"",
|
|
371
|
+
" local rejected=()",
|
|
372
|
+
' while IFS= read -r file; do',
|
|
373
|
+
' [ -z "$file" ] && continue',
|
|
374
|
+
" local allowed=false",
|
|
375
|
+
' while IFS= read -r glob; do',
|
|
376
|
+
' [ -z "$glob" ] && continue',
|
|
377
|
+
" # Prefix matching (consistent with enforce.ts globToPrefix)",
|
|
378
|
+
' local prefix="${glob%%\\**}"',
|
|
379
|
+
' if [ -z "$prefix" ] || [[ "$file" == "${prefix}"* ]]; then',
|
|
380
|
+
" allowed=true",
|
|
381
|
+
" break",
|
|
382
|
+
" fi",
|
|
383
|
+
' done <<< "$allowed_globs"',
|
|
384
|
+
' if [ "$allowed" = false ]; then',
|
|
385
|
+
' rejected+=("$file")',
|
|
386
|
+
" fi",
|
|
387
|
+
' done <<< "$changed"',
|
|
388
|
+
"",
|
|
389
|
+
' [ ${#rejected[@]} -eq 0 ] && return 0',
|
|
390
|
+
"",
|
|
391
|
+
" echo \"[Intent DNA] Scope gate: ${#rejected[@]} file(s) outside role '${role}' scope, reverted:\"",
|
|
392
|
+
' printf " - %s\\n" "${rejected[@]}"',
|
|
393
|
+
"",
|
|
394
|
+
" # Revert out-of-scope files in worktree branch",
|
|
395
|
+
' for file in "${rejected[@]}"; do',
|
|
396
|
+
' (cd ".dna/worktrees/${name}" && git checkout "$MAIN_BRANCH" -- "$file") 2>/dev/null || true',
|
|
397
|
+
" done",
|
|
398
|
+
' (cd ".dna/worktrees/${name}" && git add -A && git commit --amend --no-edit) 2>/dev/null || true',
|
|
399
|
+
"",
|
|
400
|
+
" # Write trace entries for scope violations",
|
|
401
|
+
' local trace_dir="${PROJECT_ROOT}/.dna/state/trace"',
|
|
402
|
+
' mkdir -p "$trace_dir" 2>/dev/null || true',
|
|
403
|
+
' local trace_file="${trace_dir}/trace-$(date +%Y-%m-%d).jsonl"',
|
|
404
|
+
' for file in "${rejected[@]}"; do',
|
|
405
|
+
" printf '{\"trace_id\":\"%s\",\"event\":\"ScopeGate\",\"agent_type\":\"dna-%s\",\"step\":\"%s\",\"decision\":\"warn\",\"reason\":\"Merge-time scope gate reverted out-of-scope file\",\"target_path\":\"%s\",\"duration_ms\":0,\"timestamp\":\"%s\"}\\n' \\",
|
|
406
|
+
" \"$(uuidgen | tr '[:upper:]' '[:lower:]')\" \"$role\" \"$step_id\" \"$file\" \"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\" >> \"$trace_file\"",
|
|
407
|
+
" done",
|
|
408
|
+
"}",
|
|
409
|
+
"",
|
|
410
|
+
];
|
|
411
|
+
}
|
|
339
412
|
/**
|
|
340
413
|
* Generate transition check code after all groups execute.
|
|
341
414
|
*/
|
|
@@ -404,9 +477,10 @@ export function compileWorkflowToShell(plan, options) {
|
|
|
404
477
|
// run_agent function
|
|
405
478
|
lines.push(...generateRunAgentFn(opts));
|
|
406
479
|
lines.push("");
|
|
407
|
-
// merge_worktree
|
|
480
|
+
// merge_worktree + scope_gate functions (only if needed)
|
|
408
481
|
if (needsWorktreeSupport(plan)) {
|
|
409
482
|
lines.push(...generateMergeWorktreeFn());
|
|
483
|
+
lines.push(...generateScopeGateFn());
|
|
410
484
|
}
|
|
411
485
|
// Main execution
|
|
412
486
|
if (plan.retry.max_retries > 0) {
|
package/package.json
CHANGED
|
@@ -19,8 +19,31 @@ DNA 模板可声明并行执行策略和文件隔离策略,编译到 workflow-
|
|
|
19
19
|
| Schema 声明 | step 属性 + parallel block 双支持 | 灵活性 + 简洁性兼顾 |
|
|
20
20
|
| auto 模式 | 编译时 scope overlap 检测 | DNA 独有能力——Role scope 编译时已知 |
|
|
21
21
|
| merge 策略 | escalate only (MVP) | 冲突报告给用户,不自动解决 |
|
|
22
|
+
| scope.write 语义 | merge-time accept filter | 不是运行时 block rule,是合并时的产出过滤器 |
|
|
22
23
|
| 模型差异 | 暂不考虑 | 后续扩展 |
|
|
23
24
|
|
|
25
|
+
## 设计哲学:默会知识与三层防御
|
|
26
|
+
|
|
27
|
+
参考波兰尼(Michael Polanyi)默会知识理论:意图不能被规则穷尽,但可以通过适当的"身体"来实现。
|
|
28
|
+
|
|
29
|
+
**核心洞察**:`scope.write: ["test/**"]` 表达的是模板作者的**意图**("这个角色只该写测试文件"),不应该被实现为运行时工具拦截(显性规则,不完备),而应该被实现为物理隔离 + 出口把关(身体 + 审查)。
|
|
30
|
+
|
|
31
|
+
### 为什么工具拦截不可靠
|
|
32
|
+
|
|
33
|
+
路径级 scope enforce 通过拦截工具检查文件路径(Write/Edit/Bash/MCP...)。但写文件的工具不可枚举——每出现一个新工具就是一个绕过点。这是认识论问题,不是实现问题:显性规则永远追不上默会知识的丰富度。
|
|
34
|
+
|
|
35
|
+
### 三层对应
|
|
36
|
+
|
|
37
|
+
| 层 | 时机 | 机制 | 波兰尼对应 | 可靠度 |
|
|
38
|
+
|---|---|---|---|---|
|
|
39
|
+
| **工具控制** | PreToolUse | `tool_permissions.deny` — 禁止工具本身 | 显性知识(可完全条文化) | 高——工具名有限可枚举 |
|
|
40
|
+
| **过程提示** | PreToolUse | 当前 scope enforce(保留为软防线) | 辅助线索(from) | 低——最佳努力,不可依赖 |
|
|
41
|
+
| **出口把关** | Merge time | **scope gate 过滤 git diff** — 只接受 scope 内改动 | 焦点判断(to) | **确定性——基于实际产出** |
|
|
42
|
+
|
|
43
|
+
scope.write 的真正语义:**merge 时的 accept filter**,不是运行时的 block rule。
|
|
44
|
+
|
|
45
|
+
agent 在 worktree 内有完整读权限(理解上下文)和写自由(不被微管理),但只有符合角色意图的产出才能合并回主分支。如同管理者不盯每次敲键盘,而在 review 时评估产出。
|
|
46
|
+
|
|
24
47
|
## Schema 设计
|
|
25
48
|
|
|
26
49
|
### isolation 取值
|
|
@@ -152,6 +175,39 @@ git worktree remove .dna/worktrees/fix-auth 2>/dev/null
|
|
|
152
175
|
git worktree remove .dna/worktrees/fix-api 2>/dev/null
|
|
153
176
|
```
|
|
154
177
|
|
|
178
|
+
### Merge-time scope gate
|
|
179
|
+
|
|
180
|
+
合并前过滤越权改动(scope.write 的真正执行点):
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# scope gate: 只接受 scope 内的改动
|
|
184
|
+
scope_gate() {
|
|
185
|
+
local branch=$1 role=$2
|
|
186
|
+
# 获取该 role 的 write scope globs (从 IR 读取)
|
|
187
|
+
local allowed_globs=$(read_scope_globs "$role")
|
|
188
|
+
|
|
189
|
+
# 检查所有改动文件
|
|
190
|
+
local changed=$(git diff --name-only HEAD...$branch)
|
|
191
|
+
local rejected=()
|
|
192
|
+
for file in $changed; do
|
|
193
|
+
if ! matches_any_glob "$file" $allowed_globs; then
|
|
194
|
+
rejected+=("$file")
|
|
195
|
+
# 在 worktree 中 revert 越权改动
|
|
196
|
+
(cd .dna/worktrees/$branch && git checkout HEAD -- "$file")
|
|
197
|
+
fi
|
|
198
|
+
done
|
|
199
|
+
|
|
200
|
+
if [ ${#rejected[@]} -gt 0 ]; then
|
|
201
|
+
echo "[Intent DNA] Scope gate: ${#rejected[@]} file(s) outside scope, reverted:"
|
|
202
|
+
printf " - %s\n" "${rejected[@]}"
|
|
203
|
+
# trace 记录越权尝试 → 表观遗传反馈
|
|
204
|
+
fi
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
scope_gate "fix-auth" "surgeon"
|
|
208
|
+
scope_gate "fix-api" "surgeon"
|
|
209
|
+
```
|
|
210
|
+
|
|
155
211
|
## Constraint IR 扩展
|
|
156
212
|
|
|
157
213
|
```typescript
|
|
@@ -175,11 +231,14 @@ interface WorkflowIR {
|
|
|
175
231
|
|
|
176
232
|
| DNA 概念 | 在并行隔离中的角色 |
|
|
177
233
|
|---|---|
|
|
234
|
+
| **Role scope.write** | **merge-time scope gate 的输入**——决定哪些改动可以合并回主分支 |
|
|
178
235
|
| **Role scope** | auto 模式的编译时 overlap 分析输入 |
|
|
236
|
+
| **tool_permissions.deny** | 工具级控制(可靠层)——reviewer 不能用 Edit |
|
|
179
237
|
| **Handoff** | 并行 step 的 artifact 传递(已实现) |
|
|
180
238
|
| **Threshold codon** | 可声明 `parallel_without_isolation == false` 硬约束 |
|
|
181
239
|
| **Attract codon** | `attract: worktree_per_task` 软提示(保留兼容) |
|
|
182
|
-
| **Hooks enforce** |
|
|
240
|
+
| **Hooks enforce (PreToolUse)** | 保留为过程提示(辅助软防线),不作为安全保障依赖 |
|
|
241
|
+
| **表观遗传** | scope gate 越权记录 → trace → epigenetic marker 反馈 |
|
|
183
242
|
|
|
184
243
|
## 不做
|
|
185
244
|
|
|
@@ -187,6 +246,7 @@ interface WorkflowIR {
|
|
|
187
246
|
- LLM 模型差异适配(暂不考虑)
|
|
188
247
|
- context budget 管理(已由独立进程 + Handoff 解决)
|
|
189
248
|
- container 级隔离(过度设计)
|
|
249
|
+
- sparse-checkout(限制读权限会破坏 agent 理解上下文的能力)
|
|
190
250
|
|
|
191
251
|
## Acceptance Criteria
|
|
192
252
|
|
|
@@ -197,9 +257,12 @@ interface WorkflowIR {
|
|
|
197
257
|
- [ ] Compiler: parallel block 展开为 step + depends_on
|
|
198
258
|
- [ ] workflow-runner: isolation=none 行为不变(向后兼容)
|
|
199
259
|
- [ ] workflow-runner: isolation=worktree 生成 git worktree 生命周期脚本
|
|
260
|
+
- [ ] workflow-runner: merge 前执行 scope gate(过滤越权改动)
|
|
200
261
|
- [ ] workflow-runner: merge 冲突时 escalate(报告 + 保留分支)
|
|
262
|
+
- [ ] scope gate: 越权改动被 revert + trace 记录
|
|
201
263
|
- [ ] E2E: 两个并行 step 写不同目录,auto → none,正确执行
|
|
202
264
|
- [ ] E2E: 两个并行 step 写相同目录,auto → worktree,隔离执行 + merge
|
|
265
|
+
- [ ] E2E: agent 写越权文件,scope gate 过滤 + trace 记录
|
|
203
266
|
- [ ] Dogfood: flutter-rewrite rescue workflow 可配置 isolation
|
|
204
267
|
- [ ] IR: ParallelGroup 包含 isolation + scope_overlap 信息
|
|
205
268
|
- [ ] 测试覆盖所有新增代码
|