intentdna 1.5.3 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "intentdna",
11
11
  "description": "DNA template compilation + runtime enforcement",
12
- "version": "1.5.3",
12
+ "version": "1.5.5",
13
13
  "source": "./"
14
14
  }
15
15
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Declarative policy layer for AI agent governance"
5
5
  }
@@ -54,4 +54,27 @@ export { getPackageVersion } from "../util/version.js";
54
54
  * Throws if two configs declare the same namespace.
55
55
  */
56
56
  export declare function checkNamespaceCollisions(configPaths: string[]): Promise<void>;
57
+ /**
58
+ * Compare two semver strings. Returns:
59
+ * 1 if a > b
60
+ * 0 if a == b
61
+ * -1 if a < b
62
+ * Handles major.minor.patch format. Non-numeric parts are ignored.
63
+ */
64
+ export declare function compareSemver(a: string, b: string): -1 | 0 | 1;
65
+ /** Info about a single template that has an available upgrade. */
66
+ export interface TemplateUpgradeInfo {
67
+ configPath: string;
68
+ templateName: string;
69
+ currentVersion: string;
70
+ availableVersion: string;
71
+ }
72
+ /**
73
+ * Check template versions against current package version.
74
+ * Returns upgrade info for outdated configs and prints human-readable suggestions.
75
+ *
76
+ * U1: Semver-aware comparison — only suggests upgrades when package is strictly newer.
77
+ * Handles missing _template_version (old configs created before versioning).
78
+ */
79
+ export declare function checkTemplateVersions(configPaths: string[]): Promise<TemplateUpgradeInfo[]>;
57
80
  export declare function runSync(opts: SyncOptions): Promise<number>;
@@ -253,28 +253,72 @@ export async function checkNamespaceCollisions(configPaths) {
253
253
  }
254
254
  }
255
255
  }
256
+ /**
257
+ * Compare two semver strings. Returns:
258
+ * 1 if a > b
259
+ * 0 if a == b
260
+ * -1 if a < b
261
+ * Handles major.minor.patch format. Non-numeric parts are ignored.
262
+ */
263
+ export function compareSemver(a, b) {
264
+ const parse = (v) => v.split(".").map(s => parseInt(s, 10) || 0);
265
+ const pa = parse(a);
266
+ const pb = parse(b);
267
+ const len = Math.max(pa.length, pb.length);
268
+ for (let i = 0; i < len; i++) {
269
+ const va = pa[i] ?? 0;
270
+ const vb = pb[i] ?? 0;
271
+ if (va > vb)
272
+ return 1;
273
+ if (va < vb)
274
+ return -1;
275
+ }
276
+ return 0;
277
+ }
256
278
  /**
257
279
  * Check template versions against current package version.
258
- * Prints upgrade suggestions for outdated configs.
280
+ * Returns upgrade info for outdated configs and prints human-readable suggestions.
281
+ *
282
+ * U1: Semver-aware comparison — only suggests upgrades when package is strictly newer.
283
+ * Handles missing _template_version (old configs created before versioning).
259
284
  */
260
- async function checkTemplateVersions(configPaths) {
285
+ export async function checkTemplateVersions(configPaths) {
261
286
  const pkgVersion = await getPackageVersion();
262
287
  if (!pkgVersion)
263
- return;
288
+ return [];
289
+ const upgrades = [];
264
290
  for (const configPath of configPaths) {
265
291
  try {
266
292
  const content = await readFile(configPath, "utf-8");
267
293
  const data = parseYAML(content);
268
294
  const tmplVersion = typeof data._template_version === "string" ? data._template_version : undefined;
269
295
  const tmplName = typeof data._source_template === "string" ? data._source_template : undefined;
270
- if (tmplVersion && tmplName && tmplVersion !== pkgVersion) {
271
- process.stderr.write(`Template "${tmplName}" has update: ${tmplVersion} ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`);
296
+ if (tmplName && !tmplVersion) {
297
+ // Old config without version tracking suggest upgrade
298
+ upgrades.push({
299
+ configPath,
300
+ templateName: tmplName,
301
+ currentVersion: "unknown",
302
+ availableVersion: pkgVersion,
303
+ });
304
+ process.stderr.write(`Upgrade available: "${tmplName}" has no version tag. Run: dna init --upgrade ${tmplName}\n`);
305
+ }
306
+ else if (tmplVersion && tmplName && compareSemver(pkgVersion, tmplVersion) > 0) {
307
+ // Package is strictly newer than template — suggest upgrade
308
+ upgrades.push({
309
+ configPath,
310
+ templateName: tmplName,
311
+ currentVersion: tmplVersion,
312
+ availableVersion: pkgVersion,
313
+ });
314
+ process.stderr.write(`Upgrade available: "${tmplName}" ${tmplVersion} → ${pkgVersion}. Run: dna init --upgrade ${tmplName}\n`);
272
315
  }
273
316
  }
274
317
  catch {
275
318
  // Non-critical — skip version check for unparseable files
276
319
  }
277
320
  }
321
+ return upgrades;
278
322
  }
279
323
  function resolveSpeciesPath(ref) {
280
324
  if (!ref.startsWith("species:"))
@@ -12,6 +12,7 @@
12
12
  export interface VerifyOptions {
13
13
  lockFile: string;
14
14
  stats?: boolean;
15
+ health?: boolean;
15
16
  }
16
17
  export interface LockFileEntry {
17
18
  sha256: string;
@@ -31,3 +32,13 @@ export declare function sha256(content: string): string;
31
32
  export declare function parseLockFile(raw: string): LockFile;
32
33
  export declare function verifyLock(lock: LockFile): Promise<VerifyResult[]>;
33
34
  export declare function runVerify(opts: VerifyOptions): Promise<number>;
35
+ export interface HealthCheckResult {
36
+ name: string;
37
+ status: "pass" | "warn" | "fail";
38
+ detail: string;
39
+ }
40
+ /**
41
+ * U3: Comprehensive health report for DNA installation.
42
+ * Checks IR, configs, templates, hooks, and trace system.
43
+ */
44
+ export declare function runHealth(): Promise<number>;
@@ -54,6 +54,10 @@ export async function verifyLock(lock) {
54
54
  }
55
55
  // ── CLI entry ──────────────────────────────────────────────
56
56
  export async function runVerify(opts) {
57
+ // Handle --health mode
58
+ if (opts.health) {
59
+ return runHealth();
60
+ }
57
61
  // Handle --stats mode
58
62
  if (opts.stats) {
59
63
  return runStats();
@@ -214,3 +218,189 @@ function truncate(s, max) {
214
218
  return s;
215
219
  return s.slice(0, max - 3) + "...";
216
220
  }
221
+ /**
222
+ * U3: Comprehensive health report for DNA installation.
223
+ * Checks IR, configs, templates, hooks, and trace system.
224
+ */
225
+ export async function runHealth() {
226
+ const cwd = process.cwd();
227
+ const checks = [];
228
+ const { resolve } = await import("node:path");
229
+ const { stat, readFile: rf, readdir } = await import("node:fs/promises");
230
+ // 1. Check compiled IR
231
+ const irPath = resolve(cwd, ".dna", "compiled", "ir.json");
232
+ try {
233
+ const raw = await rf(irPath, "utf-8");
234
+ const data = JSON.parse(raw);
235
+ if (data.ir_version && data.ir) {
236
+ const age = Date.now() - new Date(data.compiled_at ?? data.ir.compiled_at).getTime();
237
+ const ageHours = Math.round(age / 3600000);
238
+ checks.push({
239
+ name: "Compiled IR",
240
+ status: "pass",
241
+ detail: `v${data.ir_version}, compiled ${ageHours}h ago, ${data.ir.source_dna_ids?.length ?? 0} template(s)`,
242
+ });
243
+ }
244
+ else if (data.compiled_at) {
245
+ checks.push({ name: "Compiled IR", status: "warn", detail: "Legacy format — run `dna sync` to upgrade" });
246
+ }
247
+ else {
248
+ checks.push({ name: "Compiled IR", status: "fail", detail: "Unrecognized format" });
249
+ }
250
+ }
251
+ catch {
252
+ checks.push({ name: "Compiled IR", status: "fail", detail: "Not found — run `dna sync`" });
253
+ }
254
+ // 2. Check DNA config files
255
+ const { autoDetectConfigs } = await import("./sync.js");
256
+ const configs = await autoDetectConfigs();
257
+ if (configs.length > 0) {
258
+ checks.push({ name: "DNA configs", status: "pass", detail: `${configs.length} config(s) found` });
259
+ }
260
+ else {
261
+ checks.push({ name: "DNA configs", status: "fail", detail: "No config files found — run `dna init --template <name>`" });
262
+ }
263
+ // 3. Check config validity
264
+ if (configs.length > 0) {
265
+ const { loadDNA } = await import("../../compiler/index.js");
266
+ let valid = 0;
267
+ let invalid = 0;
268
+ const errors = [];
269
+ for (const configPath of configs) {
270
+ try {
271
+ await loadDNA(configPath);
272
+ valid++;
273
+ }
274
+ catch (err) {
275
+ invalid++;
276
+ const { basename } = await import("node:path");
277
+ errors.push(`${basename(configPath)}: ${err instanceof Error ? err.message : String(err)}`);
278
+ }
279
+ }
280
+ if (invalid === 0) {
281
+ checks.push({ name: "Config validity", status: "pass", detail: `${valid} config(s) valid` });
282
+ }
283
+ else {
284
+ checks.push({ name: "Config validity", status: "fail", detail: errors.join("; ") });
285
+ }
286
+ }
287
+ // 4. Check template versions
288
+ if (configs.length > 0) {
289
+ const { checkTemplateVersions: checkVersions } = await import("./sync.js");
290
+ // Suppress stderr output from checkTemplateVersions — we'll report ourselves
291
+ const origWrite = process.stderr.write;
292
+ process.stderr.write = (() => true);
293
+ try {
294
+ const upgrades = await checkVersions(configs);
295
+ if (upgrades.length === 0) {
296
+ checks.push({ name: "Template versions", status: "pass", detail: "All up to date" });
297
+ }
298
+ else {
299
+ const names = upgrades.map(u => `${u.templateName} (${u.currentVersion} → ${u.availableVersion})`);
300
+ checks.push({ name: "Template versions", status: "warn", detail: `Upgrade available: ${names.join(", ")}` });
301
+ }
302
+ }
303
+ catch {
304
+ checks.push({ name: "Template versions", status: "warn", detail: "Could not check versions" });
305
+ }
306
+ finally {
307
+ process.stderr.write = origWrite;
308
+ }
309
+ }
310
+ // 5. Check .claude settings or plugin registration
311
+ const settingsPath = resolve(cwd, ".claude", "settings.json");
312
+ try {
313
+ const raw = await rf(settingsPath, "utf-8");
314
+ const settings = JSON.parse(raw);
315
+ const hooks = settings.hooks;
316
+ const hasHooks = hooks && typeof hooks === "object" && Object.keys(hooks).length > 0;
317
+ if (hasHooks) {
318
+ const eventCount = Object.keys(hooks).length;
319
+ checks.push({ name: "Hook registration", status: "pass", detail: `${eventCount} event(s) in settings.json` });
320
+ }
321
+ else {
322
+ checks.push({ name: "Hook registration", status: "warn", detail: "No hooks in settings.json — using plugin mode?" });
323
+ }
324
+ }
325
+ catch {
326
+ // Check for plugin registration instead
327
+ const mcpPath = resolve(cwd, ".claude", ".mcp.json");
328
+ try {
329
+ const raw = await rf(mcpPath, "utf-8");
330
+ const mcp = JSON.parse(raw);
331
+ if (mcp.mcpServers?.intentdna) {
332
+ checks.push({ name: "MCP server", status: "pass", detail: "dna-mcp registered in .mcp.json" });
333
+ }
334
+ else {
335
+ checks.push({ name: "Hook registration", status: "warn", detail: "No hooks or MCP server found" });
336
+ }
337
+ }
338
+ catch {
339
+ checks.push({ name: "Hook registration", status: "warn", detail: "No settings.json or .mcp.json — run `dna setup` or `dna sync`" });
340
+ }
341
+ }
342
+ // 6. Check trace system
343
+ const traceDir = resolve(cwd, ".dna", "state", "trace");
344
+ try {
345
+ const files = await readdir(traceDir);
346
+ const traceFiles = files.filter(f => f.startsWith("trace-") && f.endsWith(".jsonl"));
347
+ if (traceFiles.length > 0) {
348
+ // Read latest trace file size
349
+ const latest = traceFiles.sort().pop();
350
+ const latestStat = await stat(resolve(traceDir, latest));
351
+ const sizeKB = Math.round(latestStat.size / 1024);
352
+ checks.push({ name: "Trace system", status: "pass", detail: `${traceFiles.length} file(s), latest ${sizeKB}KB` });
353
+ }
354
+ else {
355
+ checks.push({ name: "Trace system", status: "warn", detail: "No trace files — hooks not fired yet" });
356
+ }
357
+ }
358
+ catch {
359
+ checks.push({ name: "Trace system", status: "warn", detail: "No trace directory — will be created on first hook call" });
360
+ }
361
+ // 7. Check lock file drift
362
+ const lockPath = resolve(cwd, ".dna", "lock");
363
+ try {
364
+ const raw = await rf(lockPath, "utf-8");
365
+ const lock = parseLockFile(raw);
366
+ const results = await verifyLock(lock);
367
+ const drifted = results.filter(r => r.status !== "match");
368
+ if (drifted.length === 0) {
369
+ checks.push({ name: "Output drift", status: "pass", detail: `${results.length} file(s) match lock` });
370
+ }
371
+ else {
372
+ checks.push({ name: "Output drift", status: "warn", detail: `${drifted.length}/${results.length} file(s) drifted — run \`dna sync\`` });
373
+ }
374
+ }
375
+ catch {
376
+ checks.push({ name: "Output drift", status: "warn", detail: "No lock file — run `dna sync` to generate" });
377
+ }
378
+ // ── Format output ──────────────────────────────────────
379
+ const ICONS = { pass: "ok", warn: "!!", fail: "XX" };
380
+ process.stderr.write("\nIntent DNA Health Report\n");
381
+ process.stderr.write("========================\n\n");
382
+ let hasFailure = false;
383
+ let hasWarning = false;
384
+ for (const check of checks) {
385
+ const icon = ICONS[check.status];
386
+ const label = check.name.padEnd(20);
387
+ process.stderr.write(` [${icon}] ${label} ${check.detail}\n`);
388
+ if (check.status === "fail")
389
+ hasFailure = true;
390
+ if (check.status === "warn")
391
+ hasWarning = true;
392
+ }
393
+ process.stderr.write("\n");
394
+ if (hasFailure) {
395
+ process.stderr.write("Status: issues found — see [XX] items above\n");
396
+ return 1;
397
+ }
398
+ else if (hasWarning) {
399
+ process.stderr.write("Status: healthy with warnings — see [!!] items above\n");
400
+ return 0;
401
+ }
402
+ else {
403
+ process.stderr.write("Status: all checks passed\n");
404
+ return 0;
405
+ }
406
+ }
package/dist/cli/index.js CHANGED
@@ -58,6 +58,7 @@ Examples:
58
58
  dna verify Verify synced files against .dna/lock
59
59
  dna verify --lock /path/to/.dna/lock
60
60
  dna verify --stats Show hook call statistics (last 24h)
61
+ dna verify --health Comprehensive health check of DNA installation
61
62
  dna import . Import existing harness configs into DNA format
62
63
  dna run --dna my.dna.json --workflow dev-pipeline --task P5.8
63
64
  dna run --dna my.dna.json --workflow dev-pipeline --task P5.8 --dry-run
@@ -126,6 +127,7 @@ async function main() {
126
127
  options: {
127
128
  lock: { type: "string", default: ".dna/lock" },
128
129
  stats: { type: "boolean", default: false },
130
+ health: { type: "boolean", default: false },
129
131
  },
130
132
  allowPositionals: true,
131
133
  strict: false,
@@ -134,6 +136,7 @@ async function main() {
134
136
  const code = await runVerify({
135
137
  lockFile: verifyValues.lock,
136
138
  stats: verifyValues.stats,
139
+ health: verifyValues.health,
137
140
  });
138
141
  process.exit(code);
139
142
  break;
package/dist/hooks/cli.js CHANGED
@@ -278,7 +278,8 @@ async function loadIR(irPath) {
278
278
  // Support both raw IR and wrapped CompiledIRFile format
279
279
  if (data.ir_version && data.ir) {
280
280
  if (data.ir_version !== EXPECTED_IR_VERSION) {
281
- process.stderr.write(`[Intent DNA] IR version mismatch: file has v${data.ir_version}, expected v${EXPECTED_IR_VERSION}. Run \`dna sync\` to recompile.\n`);
281
+ process.stderr.write(`\n Intent DNA: IR version mismatch (v${data.ir_version} on disk, v${EXPECTED_IR_VERSION} expected)\n` +
282
+ ` Fix: run \`dna sync\` to recompile\n\n`);
282
283
  }
283
284
  return data.ir;
284
285
  }
@@ -286,9 +287,21 @@ async function loadIR(irPath) {
286
287
  if (data.compiled_at && data.source_dna_ids) {
287
288
  return data;
288
289
  }
290
+ process.stderr.write(`\n Intent DNA: unrecognized IR format at ${irPath}\n` +
291
+ ` Fix: run \`dna sync\` to regenerate\n\n`);
289
292
  return null;
290
293
  }
291
- catch {
294
+ catch (err) {
295
+ // U3: Human-readable error instead of silent failure
296
+ const isNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
297
+ if (isNotFound) {
298
+ process.stderr.write(`\n Intent DNA: no compiled IR found at ${irPath}\n` +
299
+ ` Fix: run \`dna sync\` to compile your DNA config\n\n`);
300
+ }
301
+ else {
302
+ process.stderr.write(`\n Intent DNA: failed to load IR — ${err instanceof Error ? err.message : String(err)}\n` +
303
+ ` Fix: run \`dna sync\` to recompile\n\n`);
304
+ }
292
305
  return null;
293
306
  }
294
307
  }
package/dist/mcp/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { createMCPServer } from "./server.js";
14
14
  import { createStateTools } from "./tools-state.js";
15
15
  import { createCompileTools } from "./tools-compile.js";
16
+ import { createEnforceTools } from "./tools-enforce.js";
16
17
  // Parse args
17
18
  const args = process.argv.slice(2);
18
19
  let projectDir = process.cwd();
@@ -34,5 +35,6 @@ catch { /* use default */ }
34
35
  const tools = [
35
36
  ...createStateTools(projectDir),
36
37
  ...createCompileTools(projectDir),
38
+ ...createEnforceTools(projectDir),
37
39
  ];
38
40
  createMCPServer({ name: "intentdna", version }, tools);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Intent DNA — MCP Enforce Tools (U2)
3
+ *
4
+ * Provides enforcement via MCP server with in-memory IR cache.
5
+ * Zero cold-start: IR is loaded once and cached with mtime-based invalidation.
6
+ *
7
+ * Tools:
8
+ * - dna_enforce: Run enforcement for a hook event (PreToolUse, PostToolUse, etc.)
9
+ */
10
+ import type { ConstraintIR, RoleDef } from "../schema/types.js";
11
+ import type { ToolDef } from "./server.js";
12
+ /** In-memory IR cache with mtime-based invalidation. */
13
+ export declare class IRCache {
14
+ private ir;
15
+ private roles;
16
+ private lastMtime;
17
+ private irPath;
18
+ constructor(projectDir: string);
19
+ /** Get cached IR, reloading from disk only if file changed. */
20
+ get(): Promise<{
21
+ ir: ConstraintIR;
22
+ roles?: Record<string, RoleDef>;
23
+ } | null>;
24
+ /** Invalidate the cache (e.g., after dna sync). */
25
+ invalidate(): void;
26
+ }
27
+ export declare function createEnforceTools(projectDir: string): ToolDef[];
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Intent DNA — MCP Enforce Tools (U2)
3
+ *
4
+ * Provides enforcement via MCP server with in-memory IR cache.
5
+ * Zero cold-start: IR is loaded once and cached with mtime-based invalidation.
6
+ *
7
+ * Tools:
8
+ * - dna_enforce: Run enforcement for a hook event (PreToolUse, PostToolUse, etc.)
9
+ */
10
+ import { resolve } from "node:path";
11
+ import { stat, readFile } from "node:fs/promises";
12
+ import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, } from "../hooks/enforce.js";
13
+ import { textResult, errorResult } from "./server.js";
14
+ // ── IR Cache ────────────────────────────────────────────────
15
+ /** In-memory IR cache with mtime-based invalidation. */
16
+ export class IRCache {
17
+ ir = null;
18
+ roles = undefined;
19
+ lastMtime = 0;
20
+ irPath;
21
+ constructor(projectDir) {
22
+ this.irPath = resolve(projectDir, ".dna", "compiled", "ir.json");
23
+ }
24
+ /** Get cached IR, reloading from disk only if file changed. */
25
+ async get() {
26
+ try {
27
+ const fileStat = await stat(this.irPath);
28
+ const mtime = fileStat.mtimeMs;
29
+ const cachedIR = this.ir;
30
+ if (cachedIR && mtime === this.lastMtime) {
31
+ return { ir: cachedIR, roles: this.roles };
32
+ }
33
+ // File changed or first load — read from disk
34
+ const raw = await readFile(this.irPath, "utf-8");
35
+ const data = JSON.parse(raw);
36
+ if (data.ir_version && data.ir) {
37
+ const ir = data.ir;
38
+ this.ir = ir;
39
+ this.roles = data.roles;
40
+ this.lastMtime = mtime;
41
+ return { ir, roles: this.roles };
42
+ }
43
+ // Direct ConstraintIR format (backward compat)
44
+ if (data.compiled_at && data.source_dna_ids) {
45
+ const ir = data;
46
+ this.ir = ir;
47
+ this.roles = undefined;
48
+ this.lastMtime = mtime;
49
+ return { ir };
50
+ }
51
+ return null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** Invalidate the cache (e.g., after dna sync). */
58
+ invalidate() {
59
+ this.ir = null;
60
+ this.roles = undefined;
61
+ this.lastMtime = 0;
62
+ }
63
+ }
64
+ const VALID_EVENTS = new Set([
65
+ "PreToolUse", "PostToolUse", "UserPromptSubmit",
66
+ "SubagentStop", "PreCompact", "Notification", "SessionStart",
67
+ ]);
68
+ function dispatchEnforce(event, ir, input, roles) {
69
+ switch (event) {
70
+ case "PreToolUse":
71
+ return enforcePreToolUse(ir, {
72
+ tool_name: String(input.tool_name ?? ""),
73
+ tool_input: (input.tool_input ?? {}),
74
+ agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
75
+ cwd: typeof input.cwd === "string" ? input.cwd : undefined,
76
+ }, undefined, roles);
77
+ case "PostToolUse":
78
+ return enforcePostToolUse(ir, {
79
+ tool_name: String(input.tool_name ?? ""),
80
+ tool_input: (input.tool_input ?? {}),
81
+ tool_output: typeof input.tool_output === "string" ? input.tool_output : undefined,
82
+ agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
83
+ });
84
+ case "UserPromptSubmit":
85
+ return enforceUserPromptSubmit(ir, {
86
+ prompt: typeof input.prompt === "string" ? input.prompt : undefined,
87
+ });
88
+ case "SubagentStop":
89
+ return enforceSubagentStop(ir, {
90
+ agent_name: typeof input.agent_name === "string" ? input.agent_name : undefined,
91
+ agent_type: typeof input.agent_type === "string" ? input.agent_type : undefined,
92
+ });
93
+ case "PreCompact":
94
+ return enforcePreCompact(ir);
95
+ case "Notification":
96
+ return enforceNotification(ir, {
97
+ title: typeof input.title === "string" ? input.title : undefined,
98
+ message: typeof input.message === "string" ? input.message : undefined,
99
+ });
100
+ case "SessionStart":
101
+ return enforceSessionStart(ir, {
102
+ cwd: typeof input.cwd === "string" ? input.cwd : undefined,
103
+ session_id: typeof input.session_id === "string" ? input.session_id : undefined,
104
+ });
105
+ default:
106
+ return { continue: true, suppressOutput: true };
107
+ }
108
+ }
109
+ // ── Tool Registration ───────────────────────────────────────
110
+ export function createEnforceTools(projectDir) {
111
+ const cache = new IRCache(projectDir);
112
+ return [
113
+ {
114
+ name: "dna_enforce",
115
+ description: "Run DNA enforcement for a hook event. Uses in-memory cached IR for zero cold-start. " +
116
+ "Returns the same result as the dna-hook binary but without spawning a new process.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ event: {
121
+ type: "string",
122
+ description: "Hook event type",
123
+ enum: [...VALID_EVENTS],
124
+ },
125
+ input: {
126
+ type: "object",
127
+ description: "Hook input (same fields as Claude Code passes to hooks via stdin)",
128
+ },
129
+ },
130
+ required: ["event", "input"],
131
+ },
132
+ handler: async (args) => {
133
+ const event = String(args.event);
134
+ if (!VALID_EVENTS.has(event)) {
135
+ return errorResult(`Invalid event: ${event}. Valid: ${[...VALID_EVENTS].join(", ")}`);
136
+ }
137
+ const cached = await cache.get();
138
+ if (!cached) {
139
+ return errorResult("No compiled IR found. Run `dna sync` to compile your DNA config.");
140
+ }
141
+ const input = (args.input ?? {});
142
+ const result = dispatchEnforce(event, cached.ir, input, cached.roles);
143
+ // Format as human-readable text
144
+ const lines = [];
145
+ lines.push(`Decision: ${result.continue ? "allow" : "BLOCK"}`);
146
+ if (result.reason)
147
+ lines.push(`Reason: ${result.reason}`);
148
+ if (result.hookSpecificOutput?.additionalContext) {
149
+ lines.push(`Context: ${result.hookSpecificOutput.additionalContext}`);
150
+ }
151
+ return textResult(lines.join("\n"));
152
+ },
153
+ },
154
+ {
155
+ name: "dna_enforce_cache_status",
156
+ description: "Check IR cache status — whether IR is loaded, file path, and last load time",
157
+ inputSchema: { type: "object", properties: {} },
158
+ handler: async () => {
159
+ const cached = await cache.get();
160
+ if (!cached) {
161
+ return textResult("IR cache: empty (no compiled IR found at .dna/compiled/ir.json)");
162
+ }
163
+ const lines = [
164
+ "IR cache: loaded",
165
+ ` Templates: ${cached.ir.source_dna_ids.join(", ")}`,
166
+ ` Compiled: ${cached.ir.compiled_at}`,
167
+ ` Directives: ${cached.ir.prompt_directives.length}`,
168
+ ` Gates: ${cached.ir.pre_execution_gates.length}`,
169
+ ` Filters: ${cached.ir.tool_filters.length}`,
170
+ ];
171
+ if (cached.roles) {
172
+ lines.push(` Roles: ${Object.keys(cached.roles).join(", ")}`);
173
+ }
174
+ return textResult(lines.join("\n"));
175
+ },
176
+ },
177
+ ];
178
+ }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Parses the YAML subset used by DNA templates and dna import output.
5
5
  * Supports: scalars, arrays (inline and block), objects (indentation),
6
- * comments, quoted strings. Does NOT support: anchors, aliases, tags,
7
- * multi-line scalars (|, >), complex keys, merge keys.
6
+ * comments, quoted strings, block scalars (|, >).
7
+ * Does NOT support: anchors, aliases, tags, complex keys, merge keys.
8
8
  *
9
9
  * Zero external dependencies.
10
10
  */
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Parses the YAML subset used by DNA templates and dna import output.
5
5
  * Supports: scalars, arrays (inline and block), objects (indentation),
6
- * comments, quoted strings. Does NOT support: anchors, aliases, tags,
7
- * multi-line scalars (|, >), complex keys, merge keys.
6
+ * comments, quoted strings, block scalars (|, >).
7
+ * Does NOT support: anchors, aliases, tags, complex keys, merge keys.
8
8
  *
9
9
  * Zero external dependencies.
10
10
  */
@@ -49,6 +49,34 @@ function parseBlock(lines, startLine, baseIndent) {
49
49
  const key = content.slice(0, colonIdx).trim();
50
50
  const valueStr = content.slice(colonIdx + 1).trim();
51
51
  if (valueStr === "" || valueStr === "|" || valueStr === ">") {
52
+ // Block scalar: collect indented lines as a single string
53
+ if (valueStr === "|" || valueStr === ">") {
54
+ const blockLines = [];
55
+ let j = i + 1;
56
+ const blockIndent = j < lines.length ? getIndent(lines[j]) : indent + 2;
57
+ while (j < lines.length) {
58
+ const bLine = lines[j];
59
+ const bStripped = bLine.trimEnd();
60
+ if (bStripped === "") {
61
+ blockLines.push("");
62
+ j++;
63
+ continue;
64
+ }
65
+ const bIndent = getIndent(bLine);
66
+ if (bIndent < blockIndent)
67
+ break;
68
+ blockLines.push(bLine.slice(blockIndent));
69
+ j++;
70
+ }
71
+ // Trim trailing empty lines
72
+ while (blockLines.length > 0 && blockLines[blockLines.length - 1] === "") {
73
+ blockLines.pop();
74
+ }
75
+ const sep = valueStr === "|" ? "\n" : " ";
76
+ obj[key] = blockLines.join(sep) + "\n";
77
+ i = j;
78
+ continue;
79
+ }
52
80
  // Value is a nested block — check next line's indent
53
81
  const nextNonEmpty = findNextNonEmptyLine(lines, i + 1);
54
82
  if (nextNonEmpty < lines.length) {
@@ -98,6 +98,25 @@ genes:
98
98
  signal: uncertain_information
99
99
  response: escalate_to_human
100
100
 
101
+ minimal_change:
102
+ description: Each fix should be the smallest possible change
103
+ codons:
104
+ - type: attract
105
+ target: single_file_change
106
+ - type: repel
107
+ target: unnecessary_refactoring
108
+ - type: threshold
109
+ condition: "files_changed_per_round <= 5"
110
+ action: escalate
111
+
112
+ evidence_based:
113
+ description: All completion claims require fresh test evidence
114
+ codons:
115
+ - type: attract
116
+ target: run_tests_before_claiming_done
117
+ - type: repel
118
+ target: trust_without_evidence
119
+
101
120
  contexts: {}
102
121
 
103
122
 
@@ -147,6 +166,9 @@ roles:
147
166
  - Find where v2 diverges
148
167
  - Report with exact file paths and line numbers
149
168
  - Never suggest code changes
169
+ - "If round > 1: review previous round's git diff first, judge if direction is correct"
170
+ - "If previous fix produced 0 red→green transitions: warn 'no progress'"
171
+ - "Categorize by severity: CRITICAL (compile errors) > HIGH (logic failures) > LOW (widget/style)"
150
172
 
151
173
  surgeon:
152
174
  description: Fixes breakpoints and builds missing layers by understanding v1 intent and rewriting in v2 style.
@@ -165,6 +187,12 @@ roles:
165
187
  - After each change, run tests
166
188
  - If fix doesn't work, revert and re-analyze
167
189
  - Never "improve" code — preserve exact behavior
190
+ - "Before each fix: run `git diff` to confirm previous round's change scope"
191
+ - "After each file change: run `flutter analyze` to verify compilation"
192
+ - "Maximum 5 files per round — if more needed, the scope is too large, split it"
193
+ - "Same issue failed 3 times → STOP and report as blocked, do not retry"
194
+ - "Commit message must include: what changed, why, which test it targets"
195
+ - Do not introduce new abstractions or refactor unrelated code
168
196
 
169
197
  # ── Core align 角色 ──
170
198
  core_aligner:
@@ -188,18 +216,46 @@ workflows:
188
216
  steps:
189
217
  - id: scan
190
218
  role: scanner
191
- description: "Scan v1 module in {{v1_path}}/. Output behavior doc to {{behavior_docs}}/$ARGUMENTS.md. List all user actions with call chains."
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."
219
+ description: "Detect scenario (first-time vs re-run), scan v1 module accordingly."
220
+ prompt: |
221
+ Scenario detection:
222
+ - Check if {{test_path}}/$ARGUMENTS/ has test files AND {{behavior_docs}}/$ARGUMENTS.md exists
223
+ - Both missing → Scenario 1 (first-time): full scan, output complete behavior doc
224
+ - At least one exists → Scenario 2 (re-run/incremental): read existing behavior doc, compare against v1 source changes, output incremental diff only (do NOT rewrite the entire doc)
225
+
226
+ Scenario 1: Scan module '$ARGUMENTS' in {{v1_path}}/. For each page, list: action → function() → return value. Output to {{behavior_docs}}/$ARGUMENTS.md.
227
+ Scenario 2: Read {{behavior_docs}}/$ARGUMENTS.md. Compare against current v1 source in {{v1_path}}/. Output only the DIFF (added/removed/changed behaviors). Append changes to existing doc, do not rewrite.
193
228
  handoff:
194
229
  produces:
195
230
  - type: file
196
231
  path: "{{behavior_docs}}/$ARGUMENTS.md"
197
- description: "Behavior document for module"
232
+ description: "Behavior document for module (full or incremental)"
198
233
  - id: write_tests
199
234
  role: test_writer
200
235
  depends_on: [scan]
201
- description: "Read {{behavior_docs}}/$ARGUMENTS.md, write tests in {{test_path}}/$ARGUMENTS/, run baseline, commit."
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)"
236
+ description: "Write or update tests based on scenario, verify compilation."
237
+ prompt: |
238
+ Scenario detection (same as scan step):
239
+ - {{test_path}}/$ARGUMENTS/ has NO test files → Scenario 1 (first-time)
240
+ - {{test_path}}/$ARGUMENTS/ has existing tests → Scenario 2 (re-run)
241
+
242
+ Scenario 1 (first-time):
243
+ - Read {{behavior_docs}}/$ARGUMENTS.md
244
+ - Write tests in {{test_path}}/$ARGUMENTS/. Test ALL layers: logic, widget, navigation
245
+ - Run `flutter analyze` to verify compilation — do NOT run `flutter test` (no implementation yet)
246
+ - Git commit: "behavior-lock($ARGUMENTS): N tests (fresh, not yet runnable)"
247
+
248
+ Scenario 2 (re-run/incremental):
249
+ - Read the incremental diff from behavior doc
250
+ - Update existing tests incrementally — do NOT rewrite all tests (preserves rescue progress)
251
+ - Run `flutter test {{test_path}}/$ARGUMENTS/` with NO timeout — record baseline
252
+ - Append baseline to behavior doc
253
+ - Git commit: "behavior-lock($ARGUMENTS): N tests (X green, Y red from behavior change)"
254
+
255
+ BANNED patterns:
256
+ - Do NOT use `sleep N && check` polling — run commands in foreground
257
+ - Do NOT use `timeout Nm flutter test` — let tests run to completion
258
+ - Do NOT rewrite existing test files from scratch — update incrementally
203
259
  handoff:
204
260
  consumes:
205
261
  - type: file
@@ -214,12 +270,25 @@ workflows:
214
270
 
215
271
  rescue:
216
272
  name: Rescue
217
- description: "Fix v2 module $ARGUMENTS — logic layer first, then widget layer, commit when done"
273
+ description: "Fix v2 module $ARGUMENTS — investigate, fix, review, verify, report. Max 10 rounds with convergence protection."
274
+ max_rounds: 10
275
+ convergence_rule: "2 consecutive rounds with 0 test progress (green count not increasing) → STOP. Output blocked items + analysis."
218
276
  steps:
219
277
  - id: investigate
220
278
  role: investigator
221
- description: "Run tests, assess current state, pick next targets."
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."
279
+ description: "Run tests, assess current state, pick next targets by severity."
280
+ prompt: |
281
+ Round context:
282
+ - If round > 1: review previous round's git diff first
283
+ - If previous round had 0 test progress (no red→green or skip→green): warn "no progress" and consider changing approach
284
+
285
+ Run tests in {{test_path}}/$ARGUMENTS/. Categorize all non-passing tests by severity:
286
+ CRITICAL: compile errors, import failures — fix first
287
+ HIGH: logic test failures (state/notifier/service) — fix second
288
+ LOW: widget test failures (rendering/navigation) — fix after logic is done
289
+
290
+ Pick highest severity batch. Trace: what does v1 do vs what does v2 do? Find the breakpoints.
291
+ Report findings and the plan for this round.
223
292
  handoff:
224
293
  produces:
225
294
  - type: summary
@@ -230,11 +299,20 @@ workflows:
230
299
  - id: fix
231
300
  role: surgeon
232
301
  depends_on: [investigate]
233
- description: "Fix the identified issues. Handle both logic and widget layers."
302
+ description: "Fix identified issues. Max 5 files per round."
234
303
  checkpoints:
235
304
  - assert: clean_working_tree
236
- message: "Commit all changes before proceeding to report step"
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)"
305
+ message: "Commit all changes before proceeding"
306
+ prompt: |
307
+ Fix the identified breakpoints. Rules:
308
+ - Read v1 intent in {{v1_path}}/, rewrite in v2 style in {{v2_path}}/ (not copy v1 verbatim)
309
+ - Maximum 5 files per round — if more needed, split the scope
310
+ - Run `flutter analyze` after each file change
311
+ - Same issue failed 3 times → STOP, report as blocked, do not retry
312
+ - Logic tests: implement notifier/state/service code
313
+ - Widget tests: copy widget from v1, change bindings (Obx→Consumer, Get.to→context.go), set up infra (ProviderScope, mock providers, GoRouter) if needed
314
+ - Run tests after each fix. Red→green or Skip→green = progress. Still failing = revert and re-analyze
315
+ - Commit: "rescue($ARGUMENTS): round N — what changed, why, which tests targeted"
238
316
  handoff:
239
317
  consumes:
240
318
  - type: summary
@@ -243,11 +321,19 @@ workflows:
243
321
  produces:
244
322
  - type: git_commit
245
323
  description: "Rescue round commit"
246
- - id: report
324
+ - id: review
247
325
  role: investigator
248
326
  depends_on: [fix]
249
- description: "Summarize round, guide next steps."
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."
327
+ description: "Read-only review of surgeon's changes."
328
+ prompt: |
329
+ Review surgeon's git diff (read-only, do NOT modify any files):
330
+ 1. Are changes minimal? No unnecessary files touched?
331
+ 2. Does the code match v2 patterns (Riverpod, GoRouter)?
332
+ 3. Are mocks correct (not faked just to make tests pass)?
333
+ 4. Any new issues introduced?
334
+
335
+ Verdict: APPROVE → proceed to verify
336
+ Verdict: REQUEST_CHANGES → describe specific problems. Next round's investigate step will include this feedback.
251
337
  handoff:
252
338
  consumes:
253
339
  - type: git_commit
@@ -255,7 +341,50 @@ workflows:
255
341
  description: "Committed fix from surgeon"
256
342
  produces:
257
343
  - type: summary
258
- description: "Round summary with test delta"
344
+ description: "Review verdict (APPROVE or REQUEST_CHANGES)"
345
+ - id: verify
346
+ role: investigator
347
+ depends_on: [review]
348
+ description: "Independent test verification + regression check."
349
+ prompt: |
350
+ Run tests independently (do not trust surgeon's reported results):
351
+ 1. `flutter test {{test_path}}/$ARGUMENTS/` (no timeout)
352
+ 2. `flutter analyze` (compilation check)
353
+ 3. Check for regressions in core module tests if applicable
354
+
355
+ Record test delta vs previous round.
356
+ Each acceptance criterion: VERIFIED / PARTIAL / MISSING
357
+ Verdict: PASS or FAIL
358
+ handoff:
359
+ consumes:
360
+ - type: summary
361
+ from: review
362
+ description: "Review verdict"
363
+ produces:
364
+ - type: test_result
365
+ path: "{{test_path}}/$ARGUMENTS/"
366
+ description: "Verified test results"
367
+ - id: report
368
+ role: investigator
369
+ depends_on: [verify]
370
+ description: "Round summary with convergence judgment."
371
+ prompt: |
372
+ Summary:
373
+ 1. Test delta: +N green, -M red, ±K skipped vs last round
374
+ 2. Remaining tests by category (logic vs widget vs platform)
375
+ 3. Convergence check:
376
+ - If 2 consecutive rounds with no test progress → STOP, output blocked items + analysis
377
+ - If all green → "Module $ARGUMENTS rescue complete"
378
+ - Otherwise → "Run /rescue $ARGUMENTS to continue (round N+1 of max 10)"
379
+ 4. If review verdict was REQUEST_CHANGES: include the specific feedback for next round
380
+ handoff:
381
+ consumes:
382
+ - type: test_result
383
+ from: verify
384
+ description: "Verified test results from verify step"
385
+ produces:
386
+ - type: summary
387
+ description: "Round summary with test delta and convergence status"
259
388
 
260
389
  core-align:
261
390
  name: Core Align
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "Intent DNA — Declarative policy layer for AI agent behavior",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,270 @@
1
+ # Spec: flutter-rewrite 模板优化
2
+
3
+ ## 问题
4
+
5
+ flutter-rewrite 模板的 behavior-lock 和 rescue 工作流都有设计缺陷,导致:
6
+ 1. behavior-lock 不区分首次和重跑,首次跑测试浪费时间
7
+ 2. rescue 没有 code review 和 verify 步骤,surgeon 自己写自己审
8
+ 3. 没有回归检查、收敛保护、diff 审查
9
+
10
+ 参考 OMC agent 设计(code-reviewer/verifier/executor/test-engineer)改进。
11
+
12
+ ---
13
+
14
+ ## 一、behavior-lock 优化
15
+
16
+ ### 场景检测
17
+
18
+ ```
19
+ 检查 {{test_path}}/$ARGUMENTS/ 是否已有测试文件
20
+ 检查 {{behavior_docs}}/$ARGUMENTS.md 是否已存在
21
+
22
+ 两者都不存在 → 场景 1(首次)
23
+ 至少一个存在 → 场景 2(重跑/增量)
24
+ ```
25
+
26
+ ### 场景 1: 首次
27
+
28
+ ```
29
+ scan → 全量扫描 v1,输出完整行为文档
30
+ write → 写测试文件 → `flutter analyze` 验证编译 → 不跑 flutter test
31
+ commit: "behavior-lock($MODULE): N tests (fresh, not yet runnable)"
32
+ ```
33
+
34
+ ### 场景 2: 重跑/增量
35
+
36
+ ```
37
+ scan → 读已有行为文档,对比 v1 变化,输出增量 diff
38
+ write → 增量更新测试(不全量重写)→ `flutter test` 无超时 → 记录 baseline
39
+ commit: "behavior-lock($MODULE): N tests (X green, Y red from behavior change)"
40
+ ```
41
+
42
+ ### scanner 增量
43
+
44
+ 场景 2 时 scanner 不重写行为文档,而是:
45
+ 1. 读已有 behavior doc
46
+ 2. 对比 v1 源码变化
47
+ 3. 增量更新
48
+ 4. 输出 diff 摘要
49
+
50
+ ### 禁止的模式
51
+
52
+ | 禁止 | 原因 | 替代 |
53
+ |------|------|------|
54
+ | `sleep N && check` 轮询 | 浪费时间 | 前台跑 |
55
+ | `timeout Nm flutter test` | 测试可能需要很久 | 不设超时 |
56
+ | 全量重写已有测试 | 丢失 rescue 成果 | 增量更新 |
57
+ | 首次跑 `flutter test` | 没实现,编译不过 | `flutter analyze` |
58
+
59
+ ---
60
+
61
+ ## 二、rescue 优化
62
+
63
+ ### 当前流程(缺陷)
64
+
65
+ ```
66
+ investigate → fix → report → 重复
67
+ ↑ 无人审查
68
+ ↑ 无回归检查
69
+ ↑ 无收敛保护
70
+ ```
71
+
72
+ ### 优化后流程
73
+
74
+ ```
75
+ investigate → fix → review → verify → report
76
+ ↑ 只读审查 diff ↑ 自己跑测试+回归
77
+ ```
78
+
79
+ ### 新增: review 步骤(参考 OMC code-reviewer)
80
+
81
+ ```yaml
82
+ - id: review
83
+ role: investigator # read-only,不能改代码
84
+ depends_on: [fix]
85
+ prompt: |
86
+ Review surgeon's changes (git diff):
87
+ 1. 改动是否最小化?有没有改不该改的文件?
88
+ 2. 是否匹配 v2 代码风格(Riverpod, GoRouter)?
89
+ 3. mock 是否正确(不是为了让测试过而写假 mock)?
90
+ 4. 有没有引入新问题?
91
+
92
+ Verdict: APPROVE / REQUEST_CHANGES
93
+ 如果 REQUEST_CHANGES → 说明具体问题,下轮 investigate 带上 review 意见
94
+ ```
95
+
96
+ ### 新增: verify 步骤(参考 OMC verifier)
97
+
98
+ ```yaml
99
+ - id: verify
100
+ role: investigator # read-only
101
+ depends_on: [review] # review APPROVE 后才 verify
102
+ prompt: |
103
+ 自己跑测试验证(不信任 surgeon 的报告):
104
+ 1. flutter test {{test_path}}/$ARGUMENTS/ (当前模块,无超时)
105
+ 2. flutter test 核心模块(回归检查)
106
+ 3. flutter analyze(编译检查)
107
+
108
+ 每个 acceptance criterion: VERIFIED / PARTIAL / MISSING
109
+ Verdict: PASS / FAIL
110
+ ```
111
+
112
+ ### surgeon 加约束(参考 OMC executor)
113
+
114
+ ```yaml
115
+ surgeon:
116
+ instructions:
117
+ # 现有的保留,新增:
118
+ - 每次 fix 前先 `git diff` 确认上轮改动范围
119
+ - 每个文件改动后跑 `flutter analyze`
120
+ - 单轮最多改 5 个文件,超过说明范围太大需要拆分
121
+ - 同一问题失败 3 次 → 停止,报告卡点,不要无限重试
122
+ - commit message 必须包含:改了什么、为什么、对应哪个测试
123
+ - 不引入新抽象,不重构不相关代码
124
+ ```
125
+
126
+ ### investigator 加约束
127
+
128
+ ```yaml
129
+ investigator:
130
+ instructions:
131
+ # 现有的保留,新增:
132
+ - 非首轮:先 review 上轮 git diff,判断方向是否正确
133
+ - 如果上轮 fix 没有让任何测试从 red→green,警告"无进展"
134
+ - 分类时标注严重度:CRITICAL(编译不过)> HIGH(逻辑错误)> LOW(widget 样式)
135
+ ```
136
+
137
+ ### 收敛保护
138
+
139
+ ```yaml
140
+ rescue:
141
+ max_rounds: 10
142
+ convergence_rule: |
143
+ 连续 2 轮无测试进展(green 数没增加)→ 停止
144
+ 输出卡点分析 + 剩余问题清单
145
+ round_budget: |
146
+ 每轮最多改 5 个文件
147
+ 每轮必须让至少 1 个测试从 red/skip → green,否则视为无进展
148
+ ```
149
+
150
+ ### 完整 rescue workflow
151
+
152
+ ```yaml
153
+ rescue:
154
+ name: Rescue
155
+ max_rounds: 10
156
+ steps:
157
+ - id: investigate
158
+ role: investigator
159
+ prompt: |
160
+ Round context:
161
+ - If round > 1: review previous round's git diff first
162
+ - If previous round had 0 progress: warn and consider changing approach
163
+
164
+ Run tests, categorize by severity:
165
+ CRITICAL: compile errors, import failures
166
+ HIGH: logic test failures (state/notifier)
167
+ LOW: widget test failures (rendering/navigation)
168
+
169
+ Pick highest severity batch. Trace v1 vs v2. Report breakpoints.
170
+
171
+ - id: fix
172
+ role: surgeon
173
+ depends_on: [investigate]
174
+ checkpoints:
175
+ - assert: clean_working_tree
176
+ prompt: |
177
+ Fix identified breakpoints. Rules:
178
+ - Read v1 intent, rewrite in v2 style (not copy v1 verbatim)
179
+ - Maximum 5 files per round
180
+ - flutter analyze after each file change
181
+ - Same issue failed 3 times → STOP, report as blocked
182
+ - Commit: rescue($ARGUMENTS): round N — what changed and why
183
+
184
+ - id: review
185
+ role: investigator
186
+ depends_on: [fix]
187
+ prompt: |
188
+ Review surgeon's git diff (read-only):
189
+ 1. Changes minimal? No unnecessary files touched?
190
+ 2. Matches v2 patterns (Riverpod, GoRouter)?
191
+ 3. Mocks correct (not faked to pass)?
192
+ 4. No new issues introduced?
193
+ Verdict: APPROVE → proceed to verify
194
+ Verdict: REQUEST_CHANGES → next round investigate includes review feedback
195
+
196
+ - id: verify
197
+ role: investigator
198
+ depends_on: [review]
199
+ prompt: |
200
+ Run independently (don't trust surgeon's results):
201
+ 1. flutter test {{test_path}}/$ARGUMENTS/ (no timeout)
202
+ 2. flutter analyze
203
+ 3. Check: any regression in core tests?
204
+ Record delta vs last round. PASS or FAIL.
205
+
206
+ - id: report
207
+ role: investigator
208
+ depends_on: [verify]
209
+ prompt: |
210
+ Summary:
211
+ 1. Test delta: +N green, -M red, ±K skipped
212
+ 2. Remaining by category (logic vs widget vs platform)
213
+ 3. Convergence: are we making progress?
214
+ 4. If 2 rounds no progress → STOP, output blocked items
215
+ 5. If all green → "Module $ARGUMENTS rescue complete"
216
+ 6. Otherwise → "Run /rescue $ARGUMENTS to continue"
217
+ ```
218
+
219
+ ---
220
+
221
+ ## 三、全局规则(两个工作流共享)
222
+
223
+ ### DNA gene 层面
224
+
225
+ ```yaml
226
+ genes:
227
+ # 现有 preserve_behavior 等保留,新增:
228
+ minimal_change:
229
+ description: Each fix should be the smallest possible change
230
+ codons:
231
+ - type: attract
232
+ target: single_file_change
233
+ - type: repel
234
+ target: unnecessary_refactoring
235
+ - type: threshold
236
+ condition: "files_changed_per_round <= 5"
237
+ action: escalate
238
+
239
+ evidence_based:
240
+ description: All completion claims require fresh test evidence
241
+ codons:
242
+ - type: attract
243
+ target: run_tests_before_claiming_done
244
+ - type: repel
245
+ target: trust_without_evidence
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Acceptance Criteria
251
+
252
+ ### behavior-lock
253
+ - [ ] 场景检测正确(首次 vs 重跑)
254
+ - [ ] 首次: 只 analyze,不 test
255
+ - [ ] 重跑: 增量更新测试,不全量重写
256
+ - [ ] 重跑: 跑测试无超时
257
+ - [ ] scanner 增量: 输出 diff 而非全量重写
258
+ - [ ] 禁止 sleep 轮询模式
259
+
260
+ ### rescue
261
+ - [ ] review 步骤: investigator 审查 surgeon 的 git diff
262
+ - [ ] verify 步骤: 独立跑测试 + 回归检查
263
+ - [ ] surgeon 约束: 最多 5 文件/轮,3 次失败停止
264
+ - [ ] 收敛保护: 2 轮无进展 → 停止
265
+ - [ ] 轮次上限: 最多 10 轮
266
+ - [ ] report 包含收敛判断
267
+
268
+ ### 全局
269
+ - [ ] minimal_change gene 编译到 IR
270
+ - [ ] evidence_based gene 编译到 IR
@@ -172,6 +172,6 @@ hook 报错时输出人类可读的提示而非 JSON。`dna verify` 输出友好
172
172
  - [x] 远程通信架构预留(接口定义,不实现)
173
173
 
174
174
  ### 第三阶段
175
- - [ ] dna sync 自动检测模板版本 + 提示升级
176
- - [ ] MCP server 常驻进程解决冷启动
177
- - [ ] 错误信息人类可读
175
+ - [x] dna sync 自动检测模板版本 + 提示升级
176
+ - [x] MCP server 常驻进程解决冷启动
177
+ - [x] 错误信息人类可读