intentdna 1.5.21 → 1.6.1

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": "Declarative policy layer for AI agent behavior with plugin-managed hook runtime for Claude Code.",
12
- "version": "1.5.21",
12
+ "version": "1.6.1",
13
13
  "author": {
14
14
  "name": "Samuel"
15
15
  },
@@ -25,5 +25,5 @@
25
25
  ]
26
26
  }
27
27
  ],
28
- "version": "1.5.21"
28
+ "version": "1.6.1"
29
29
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.21",
3
+ "version": "1.6.1",
4
4
  "description": "Declarative policy layer for AI agent behavior",
5
5
  "author": {
6
6
  "name": "Samuel"
@@ -20,7 +20,7 @@ import { parseYAML } from "../../schema/yaml-parser.js";
20
20
  import { compileToMarkdown, injectIntoFile, removeFromFile } from "../../runtime/markdown.js";
21
21
  import { compileAllRolesToAgentMD, writeAgentMDFiles, removeAgentMDFiles } from "../../runtime/agent-md.js";
22
22
  import { removeWorkflowScripts } from "../../runtime/workflow-runner.js";
23
- import { compileWorkflowToSkill, writeSkillFiles } from "../../runtime/skill-adapter.js";
23
+ import { compileWorkflowToSkill, compileControllerToSkill, writeSkillFiles } from "../../runtime/skill-adapter.js";
24
24
  import { compilePluginSettings, detectEnabledEvents, mergeSettingsFile, recommendHookTimeouts } from "../../runtime/settings-adapter.js";
25
25
  import { createCompiledIR, writeCompiledIR } from "../../runtime/plugin-adapter.js";
26
26
  import { cascadeDNA } from "../../compiler/cascade.js";
@@ -127,11 +127,9 @@ export async function cleanStaleDNAFiles(dir, type, activeNamespaces) {
127
127
  const entries = await readdir(dir, { withFileTypes: true });
128
128
  for (const entry of entries) {
129
129
  const fullPath = resolve(dir, entry.name);
130
- // Extract namespace from filename: dna-{namespace}-*
131
- const nsMatch = entry.name.match(/^dna-([a-z0-9_-]+)-/);
132
- const fileNamespace = nsMatch ? nsMatch[1] : null;
133
- // Skip if namespace doesn't match active namespaces
134
- if (!fileNamespace || !activeNamespaces.includes(fileNamespace)) {
130
+ // Match active namespace prefixes directly so multi-hyphen names like
131
+ // dna-frw-fix-loop still resolve to namespace frw.
132
+ if (!activeNamespaces.some(ns => entry.name.startsWith(`dna-${ns}-`))) {
135
133
  continue;
136
134
  }
137
135
  if (type === "agents" && entry.isFile() && entry.name.endsWith(".md")) {
@@ -653,6 +651,7 @@ export async function runSync(opts) {
653
651
  const dnas = loadedDNAs;
654
652
  const cascadedForSkills = cascadeDNA(dnas);
655
653
  const workflows = cascadedForSkills.workflows;
654
+ const controllers = cascadedForSkills.controllers;
656
655
  const roles = cascadedForSkills.roles;
657
656
  // Collect and resolve variables from all DNA files
658
657
  const rawVars = {};
@@ -676,8 +675,8 @@ export async function runSync(opts) {
676
675
  process.stderr.write(`MCP: wrote ${Object.keys(mcpDeps).length} server(s) to ${mcpJsonPath}\n`);
677
676
  }
678
677
  }
679
- if (Object.keys(workflows).length === 0) {
680
- process.stderr.write("No workflow defined — no skills to generate\n");
678
+ if (Object.keys(workflows).length === 0 && Object.keys(controllers).length === 0) {
679
+ process.stderr.write("No workflow or controller defined — no skills to generate\n");
681
680
  }
682
681
  else {
683
682
  const { compileWorkflow } = await import("../../compiler/workflow.js");
@@ -688,6 +687,9 @@ export async function runSync(opts) {
688
687
  skillResults.push(compileWorkflowToSkill(result.plan, roles, ir, variables));
689
688
  }
690
689
  }
690
+ for (const [name, controllerDef] of Object.entries(controllers)) {
691
+ skillResults.push(compileControllerToSkill(name, controllerDef, variables, workflows));
692
+ }
691
693
  if (skillResults.length > 0) {
692
694
  const written = await writeSkillFiles(skillResults, opts.skillsDir);
693
695
  process.stderr.write(`Generated ${written.length} skill file(s) in ${opts.skillsDir}\n`);
@@ -10,12 +10,13 @@
10
10
  * - attract/repel: same target accumulates, different targets independent
11
11
  * - amplify/suppress: factors multiply (1.5x × 2.0x = 3.0x)
12
12
  */
13
- import type { IntentDNA, Gene, ContextRegion, EpigeneticMarker, RoleDef, WorkflowDef, LegibilityAssetMap, VerifierCommandPolicy } from "../schema/types.js";
13
+ import type { IntentDNA, Gene, ContextRegion, EpigeneticMarker, RoleDef, WorkflowDef, LegibilityAssetMap, VerifierCommandPolicy, ControllerDef } from "../schema/types.js";
14
14
  export interface CascadedDNA {
15
15
  genes: Record<string, Gene>;
16
16
  contexts: Record<string, ContextRegion>;
17
17
  roles: Record<string, RoleDef>;
18
18
  workflows: Record<string, WorkflowDef>;
19
+ controllers: Record<string, ControllerDef>;
19
20
  epigenetic_markers: EpigeneticMarker[];
20
21
  source_ids: string[];
21
22
  /** Warnings from cascade process (e.g., cross-namespace gene collisions) */
@@ -91,6 +91,20 @@ function prefixRecordKeys(record, ns) {
91
91
  return record;
92
92
  return Object.fromEntries(Object.entries(record).map(([key, value]) => [`${ns}_${key}`, value]));
93
93
  }
94
+ function prefixController(controller, ns) {
95
+ return {
96
+ ...controller,
97
+ diagnosis_workflow: `${ns}_${controller.diagnosis_workflow}`,
98
+ fix_workflow: `${ns}_${controller.fix_workflow}`,
99
+ roles: {
100
+ analyzer: `${ns}_${controller.roles.analyzer}`,
101
+ analysis_reviewer: `${ns}_${controller.roles.analysis_reviewer}`,
102
+ surgeon: `${ns}_${controller.roles.surgeon}`,
103
+ fix_reviewer: `${ns}_${controller.roles.fix_reviewer}`,
104
+ test_runner: `${ns}_${controller.roles.test_runner}`,
105
+ },
106
+ };
107
+ }
94
108
  function prefixLegibilityAssets(map, ns) {
95
109
  if (!map)
96
110
  return undefined;
@@ -176,6 +190,7 @@ export function cascadeDNA(layers) {
176
190
  const mergedContexts = {};
177
191
  const mergedRoles = {};
178
192
  const mergedWorkflows = {};
193
+ const mergedControllers = {};
179
194
  const allMarkers = [];
180
195
  const sourceIds = [];
181
196
  const warnings = [];
@@ -225,6 +240,15 @@ export function cascadeDNA(layers) {
225
240
  mergedWorkflows[name] = wf;
226
241
  }
227
242
  }
243
+ // Merge controllers: namespace-prefixed keys + workflow/role references
244
+ for (const [name, controller] of Object.entries(dna.controllers ?? {})) {
245
+ if (ns) {
246
+ mergedControllers[`${ns}_${name}`] = prefixController(controller, ns);
247
+ }
248
+ else {
249
+ mergedControllers[name] = controller;
250
+ }
251
+ }
228
252
  // Collect epigenetic markers
229
253
  if (dna.epigenetic?.markers) {
230
254
  allMarkers.push(...dna.epigenetic.markers);
@@ -250,6 +274,7 @@ export function cascadeDNA(layers) {
250
274
  contexts: mergedContexts,
251
275
  roles: mergedRoles,
252
276
  workflows: mergedWorkflows,
277
+ controllers: mergedControllers,
253
278
  epigenetic_markers: allMarkers,
254
279
  source_ids: sourceIds,
255
280
  warnings: warnings.length > 0 ? warnings : undefined,
@@ -114,7 +114,7 @@ export declare function enforceHandoffProduces(ir: ConstraintIR, wfState: {
114
114
  workflow: string;
115
115
  artifact_facts?: ArtifactFact[];
116
116
  }): EnforceResult | null;
117
- /** Check if a file path is allowed by a list of write globs (prefix matching). */
117
+ /** Check if a file path is allowed by a list of write globs. */
118
118
  export declare function checkWriteAllowed(filePath: string, allowedGlobs: string[]): boolean;
119
119
  /**
120
120
  * Extract directory prefix from a glob pattern.
@@ -439,7 +439,7 @@ function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
439
439
  const filePath = extractFilePath(input);
440
440
  if (!filePath)
441
441
  return null;
442
- return checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths);
442
+ return checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths, true);
443
443
  }
444
444
  // Bash: extract potential write targets from command
445
445
  if (input.tool_name === "Bash") {
@@ -450,7 +450,7 @@ function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
450
450
  if (writePaths.length === 0)
451
451
  return null;
452
452
  for (const p of writePaths) {
453
- const result = checkPathAgainstScope(rolesScopeMap, input, p, additionalWritePaths);
453
+ const result = checkPathAgainstScope(rolesScopeMap, input, p, additionalWritePaths, false);
454
454
  if (result)
455
455
  return result;
456
456
  }
@@ -459,7 +459,7 @@ function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
459
459
  return null;
460
460
  }
461
461
  /** Check a single file path against role scope. Shared by Write tools and Bash. */
462
- function checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths = []) {
462
+ function checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths = [], blockViolation = true) {
463
463
  const cwd = input.cwd;
464
464
  let relativePath = cwd && filePath.startsWith("/") ? relative(cwd, filePath) : filePath;
465
465
  // Normalize to resolve traversal (e.g., "src/../../test/x.ts" → "../test/x.ts")
@@ -474,10 +474,16 @@ function checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePa
474
474
  ? [...writeGlobs, ...additionalWritePaths]
475
475
  : writeGlobs;
476
476
  if (allWriteGlobs.length === 0) {
477
- return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' has no write permission (path: ${filePath}). Merge-time scope gate will filter.`);
477
+ const message = `Role '${entry.role_name}' has no write permission (path: ${filePath}).`;
478
+ return blockViolation
479
+ ? blockOutput(`[Intent DNA]: ${message}`)
480
+ : allowOutput(`WARN [Intent DNA]: ${message}`);
478
481
  }
479
482
  if (!checkWriteAllowed(relativePath, allWriteGlobs)) {
480
- return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${allWriteGlobs.join(", ")}). Merge-time scope gate will filter.`);
483
+ const message = `Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${allWriteGlobs.join(", ")}).`;
484
+ return blockViolation
485
+ ? blockOutput(`[Intent DNA]: ${message}`)
486
+ : allowOutput(`WARN [Intent DNA]: ${message}`);
481
487
  }
482
488
  return null; // Role matched, write allowed
483
489
  }
@@ -565,20 +571,48 @@ function enforceOutputSchema(roles, input) {
565
571
  return null;
566
572
  }
567
573
  // ── Public Helpers ──────────────────────────────────────────
568
- /** Check if a file path is allowed by a list of write globs (prefix matching). */
574
+ /** Check if a file path is allowed by a list of write globs. */
569
575
  export function checkWriteAllowed(filePath, allowedGlobs) {
576
+ const normalizedPath = normalize(filePath).replace(/\\/g, "/");
570
577
  for (const glob of allowedGlobs) {
571
- const prefix = globToPrefix(glob);
572
- if (prefix.length === 0)
573
- return true; // Root glob like "*.md" — allow all
574
- if (filePath.startsWith(prefix))
575
- return true;
576
- // Also check exact match for non-glob patterns
577
- if (!glob.includes("*") && filePath === glob)
578
+ const normalizedGlob = normalize(glob).replace(/\\/g, "/");
579
+ if (normalizedGlob.includes("*")) {
580
+ if (globToRegExp(normalizedGlob).test(normalizedPath))
581
+ return true;
582
+ continue;
583
+ }
584
+ if (normalizedPath.startsWith(normalizedGlob))
578
585
  return true;
579
586
  }
580
587
  return false;
581
588
  }
589
+ function globToRegExp(glob) {
590
+ let source = "^";
591
+ for (let i = 0; i < glob.length; i++) {
592
+ const char = glob[i];
593
+ if (char === "*") {
594
+ if (glob[i + 1] === "*") {
595
+ if (glob[i + 2] === "/") {
596
+ source += "(?:.*/)?";
597
+ i += 2;
598
+ }
599
+ else {
600
+ source += ".*";
601
+ i += 1;
602
+ }
603
+ }
604
+ else {
605
+ source += "[^/]*";
606
+ }
607
+ continue;
608
+ }
609
+ source += escapeRegExp(char);
610
+ }
611
+ return new RegExp(`${source}$`);
612
+ }
613
+ function escapeRegExp(char) {
614
+ return char.replace(/[\\^$+?.()|[\]{}]/g, "\\$&");
615
+ }
582
616
  /**
583
617
  * Extract directory prefix from a glob pattern.
584
618
  * "src/**\/*.ts" → "src/"
@@ -649,7 +683,9 @@ function isWriteTool(toolName) {
649
683
  return WRITE_TOOLS.has(toolName);
650
684
  }
651
685
  function extractFilePath(input) {
652
- const fp = input.tool_input.file_path;
686
+ const fp = input.tool_name === "NotebookEdit"
687
+ ? input.tool_input.notebook_path
688
+ : input.tool_input.file_path;
653
689
  if (typeof fp === "string" && fp)
654
690
  return fp;
655
691
  return null;
@@ -6,13 +6,14 @@
6
6
  *
7
7
  * Skills are compiled views of DNA's structured definitions — not stored content.
8
8
  */
9
- import type { WorkflowPlan, RoleDef, ConstraintIR } from "../schema/types.js";
9
+ import type { WorkflowPlan, WorkflowDef, RoleDef, ConstraintIR, ControllerDef } from "../schema/types.js";
10
10
  export interface SkillResult {
11
11
  name: string;
12
12
  fileName: string;
13
13
  dirName: string;
14
14
  content: string;
15
15
  }
16
+ export declare function compileControllerToSkill(controllerKey: string, controller: ControllerDef, variables?: Record<string, string>, workflows?: Record<string, WorkflowDef>): SkillResult;
16
17
  /**
17
18
  * Compile a WorkflowPlan + roles + IR into a SKILL.md file.
18
19
  * Variables from DNA config are substituted into prompts and descriptions.
@@ -10,6 +10,170 @@ import { mkdir, writeFile, readdir, readFile, rm } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { toKebabCase } from "./agent-md.js";
12
12
  const SENTINEL = "<!-- intentdna:managed — do not edit manually -->";
13
+ // ── Compile Controller → Skill ─────────────────────────────
14
+ export function compileControllerToSkill(controllerKey, controller, variables, workflows) {
15
+ if (controller.kind !== "flutter_rewrite_fix_loop") {
16
+ throw new Error(`Unsupported controller kind: ${controller.kind}`);
17
+ }
18
+ const skillName = `dna-${toKebabCase(controllerKey)}`;
19
+ const analyzerAgent = `dna-${toKebabCase(controller.roles.analyzer)}`;
20
+ const analysisReviewerAgent = `dna-${toKebabCase(controller.roles.analysis_reviewer)}`;
21
+ const surgeonAgent = `dna-${toKebabCase(controller.roles.surgeon)}`;
22
+ const fixReviewerAgent = `dna-${toKebabCase(controller.roles.fix_reviewer)}`;
23
+ const testRunnerAgent = `dna-${toKebabCase(controller.roles.test_runner)}`;
24
+ const diagnosisWorkflow = workflows?.[controller.diagnosis_workflow];
25
+ const fixWorkflow = workflows?.[controller.fix_workflow];
26
+ const diagnosisAnalyzeStep = requireStepByRole(diagnosisWorkflow, controller.roles.analyzer, controller.diagnosis_workflow);
27
+ const diagnosisReviewStep = requireStepByRole(diagnosisWorkflow, controller.roles.analysis_reviewer, controller.diagnosis_workflow);
28
+ const fixBugsStep = requireStepByRole(fixWorkflow, controller.roles.surgeon, controller.fix_workflow);
29
+ const reviewChangesStep = requireStepByRole(fixWorkflow, controller.roles.fix_reviewer, controller.fix_workflow);
30
+ const verifyReportStep = requireStepByRole(fixWorkflow, controller.roles.test_runner, controller.fix_workflow);
31
+ const stepPrompt = (step) => step.prompt || step.description || "";
32
+ const lines = [];
33
+ lines.push("---");
34
+ lines.push(`name: ${skillName}`);
35
+ lines.push(`description: "Use when user says /${skillName}. ${escapeYaml(controller.description || controller.name)}"`);
36
+ lines.push("user-invocable: true");
37
+ lines.push("triggers:");
38
+ lines.push(` - "${toKebabCase(controllerKey)}"`);
39
+ lines.push(` - "run ${toKebabCase(controllerKey)}"`);
40
+ lines.push("---");
41
+ lines.push("");
42
+ lines.push(SENTINEL);
43
+ lines.push(`<!-- Compiled: ${new Date().toISOString()} -->`);
44
+ lines.push("");
45
+ lines.push(`# /${skillName} <module>`);
46
+ lines.push("");
47
+ lines.push("<Purpose>");
48
+ lines.push(controller.description || controller.name);
49
+ lines.push("</Purpose>");
50
+ lines.push("");
51
+ lines.push("<Controller>");
52
+ lines.push("kind: flutter_rewrite_fix_loop");
53
+ lines.push(`max_rounds: ${controller.max_rounds}`);
54
+ lines.push(`diagnosis_workflow: ${controller.diagnosis_workflow}`);
55
+ lines.push(`fix_workflow: ${controller.fix_workflow}`);
56
+ lines.push(`progress_artifact_path: ${controller.progress_artifact_path}`);
57
+ lines.push("</Controller>");
58
+ lines.push("");
59
+ lines.push("<Steps>");
60
+ lines.push("0. Validate the module argument before using it in any artifact path.");
61
+ lines.push(" - The module argument must match /^[A-Za-z0-9_-]+$/.");
62
+ lines.push(" - If it contains '/', '.', '..', spaces, braces, glob characters, or path separators, stop with the Chinese report format.");
63
+ lines.push(" - Do not read or write controller artifacts until the module argument is safe.");
64
+ lines.push("1. Check diagnosis artifact freshness before any fix round.");
65
+ lines.push(` - Diagnosis artifact: ${controller.diagnosis_artifact_path}`);
66
+ lines.push(` - Diagnosis review artifact: ${controller.diagnosis_review_artifact_path}`);
67
+ lines.push("2. Create a stable controller_run_id once for this invocation before using any artifact path.");
68
+ lines.push(" - Reuse the same controller_run_id in every child Agent prompt and in every progress validation.");
69
+ lines.push(" - Do not regenerate controller_run_id inside fix rounds.");
70
+ lines.push("3. Reuse diagnosis only when all diagnosis reuse criteria pass:");
71
+ lines.push(` - ${controller.diagnosis_artifact_path} exists`);
72
+ lines.push(` - ${controller.diagnosis_review_artifact_path} exists and verdict is APPROVE`);
73
+ lines.push(" - behavior doc is not newer than diagnosis spec");
74
+ lines.push(" - baseline artifact is not newer than diagnosis spec");
75
+ lines.push(" - user did not explicitly pass reanalyze or scope changed");
76
+ lines.push(" - progress JSON does not say diagnosis_invalid");
77
+ lines.push(`4. If diagnosis is missing, invalid, stale, or scope-changed, run diagnosis once with fresh context and sequential gates:`);
78
+ lines.push(" a. Spawn fresh analyzer agent with controller_run_id, then wait for completion.");
79
+ lines.push(" ```");
80
+ lines.push(" Agent(");
81
+ lines.push(` subagent_type="${analyzerAgent}",`);
82
+ lines.push(` prompt="controller_run_id=<controller_run_id>. ${escapePrompt(stepPrompt(diagnosisAnalyzeStep))}"`);
83
+ lines.push(" )");
84
+ lines.push(" ```");
85
+ lines.push(` b. Diagnosis artifact gate: read ${controller.diagnosis_artifact_path}; validate it exists, is non-empty, names the safe module argument, and is current for this module before continuing.`);
86
+ lines.push(" c. Spawn fresh analysis_reviewer agent with controller_run_id, then wait for completion.");
87
+ lines.push(" ```");
88
+ lines.push(" Agent(");
89
+ lines.push(` subagent_type="${analysisReviewerAgent}",`);
90
+ lines.push(` prompt="controller_run_id=<controller_run_id>; expected_review_artifact=${controller.diagnosis_review_artifact_path}. ${escapePrompt(stepPrompt(diagnosisReviewStep))}"`);
91
+ lines.push(" )");
92
+ lines.push(" ```");
93
+ lines.push(` d. Diagnosis review JSON gate: read ${controller.diagnosis_review_artifact_path}; require existing valid JSON with verdict APPROVE before fix rounds.`);
94
+ lines.push(" If the review JSON is missing, invalid, or verdict is not APPROVE, stop immediately with the Chinese report format; do NOT enter fix rounds.");
95
+ lines.push(`5. For round 1..${controller.max_rounds}, set current_round to the loop number and run one fresh context fix round with sequential gates:`);
96
+ lines.push(" a. Spawn fresh surgeon agent with controller_run_id and current_round, then wait for completion.");
97
+ lines.push(" ```");
98
+ lines.push(" Agent(");
99
+ lines.push(` subagent_type="${surgeonAgent}",`);
100
+ lines.push(` prompt="controller_run_id=<controller_run_id>; current_round=<current_round>. Fresh context round for $ARGUMENTS. ${escapePrompt(stepPrompt(fixBugsStep))}"`);
101
+ lines.push(" )");
102
+ lines.push(" ```");
103
+ lines.push(" b. Fix handoff gate: validate the current round handoff and fix commit, or an explicit no-commit handoff for current_round, before review.");
104
+ lines.push(" c. Spawn fresh fix_reviewer agent with controller_run_id, current_round, and expected review artifact path, then wait for completion.");
105
+ lines.push(" ```");
106
+ lines.push(" Agent(");
107
+ lines.push(` subagent_type="${fixReviewerAgent}",`);
108
+ lines.push(` prompt="controller_run_id=<controller_run_id>; current_round=<current_round>; expected_review_artifact=${controller.review_artifact_path}. Fresh context review for $ARGUMENTS. ${escapePrompt(stepPrompt(reviewChangesStep))}"`);
109
+ lines.push(" )");
110
+ lines.push(" ```");
111
+ lines.push(` d. Review JSON gate: read ${controller.review_artifact_path}; validate it exists, is valid JSON, module equals the safe module argument, round equals the provided current_round, run_id equals the provided controller_run_id, updated_at is present, and verdict is APPROVE or REQUEST_CHANGES before verification.`);
112
+ lines.push(" e. Spawn fresh test_runner agent only after the review gate passes; include controller_run_id and current_round, then wait for completion.");
113
+ lines.push(" ```");
114
+ lines.push(" Agent(");
115
+ lines.push(` subagent_type="${testRunnerAgent}",`);
116
+ lines.push(` prompt="controller_run_id=<controller_run_id>; current_round=<current_round>. Fresh context verification for $ARGUMENTS. The progress JSON run_id must equal the provided controller_run_id and round must equal the provided current_round; do not use manual-run during controller execution. ${escapePrompt(stepPrompt(verifyReportStep))}"`);
117
+ lines.push(" )");
118
+ lines.push(" ```");
119
+ lines.push(` f. Progress JSON read/provenance validation: read ${controller.progress_artifact_path}. The controller must read this JSON sidecar, not Markdown prose.`);
120
+ lines.push(" g. Verify progress JSON provenance before branching:");
121
+ lines.push(" - module equals the safe module argument");
122
+ lines.push(" - round equals the provided current_round");
123
+ lines.push(" - run_id equals the provided controller_run_id");
124
+ lines.push(` - diagnosis_artifact equals ${controller.diagnosis_artifact_path}`);
125
+ lines.push(` - review_artifact equals ${controller.review_artifact_path}`);
126
+ lines.push(" - git_head or last_fix_commit references the current round's fix commit when available");
127
+ lines.push(" - updated_at is not earlier than the current round start time");
128
+ lines.push(" - if provenance is missing, stale, or mismatched, stop with Verdict BLOCKED and failure_reason PROGRESS_PROVENANCE_MISMATCH");
129
+ lines.push(" h. Branch only from the JSON verdict:");
130
+ lines.push(" - PASS -> success stop");
131
+ lines.push(" - CONTINUE -> next fresh context fix round");
132
+ lines.push(" - REQUEST_CHANGES -> stop with Chinese report");
133
+ lines.push(" - BLOCKED -> stop with Chinese report");
134
+ lines.push(`6. If max ${controller.max_rounds} rounds is reached, stop with Chinese report.`);
135
+ lines.push("</Steps>");
136
+ lines.push("");
137
+ lines.push("<Verdict_Mapping>");
138
+ lines.push("PASS / CONTINUE / REQUEST_CHANGES / BLOCKED mapping:");
139
+ lines.push("- PASS: success stop");
140
+ lines.push("- CONTINUE: start the next fresh context round");
141
+ lines.push("- REQUEST_CHANGES: stop, report reviewer/verifier requested changes");
142
+ lines.push("- BLOCKED: stop, report blocker");
143
+ lines.push("- MAX_ROUNDS: stop, report max rounds reached and next focus");
144
+ lines.push("</Verdict_Mapping>");
145
+ lines.push("");
146
+ lines.push("<Stop_Report>");
147
+ lines.push("On REQUEST_CHANGES, BLOCKED, or MAX_ROUNDS, output exactly three Chinese sections:");
148
+ lines.push("结论:...");
149
+ lines.push("卡点:...");
150
+ lines.push("下一步:...");
151
+ lines.push("</Stop_Report>");
152
+ lines.push("");
153
+ lines.push("<Constraints>");
154
+ lines.push("- Do not infer module completion from raw test output.");
155
+ lines.push(`- The controller must schedule only from ${controller.progress_artifact_path}.`);
156
+ lines.push("- Manual /dna-frw-diagnosis and /dna-frw-fix flows remain separate and available.");
157
+ lines.push("- Do not use the generic workflow policy block; branch only from the progress JSON verdict.");
158
+ lines.push("</Constraints>");
159
+ lines.push("");
160
+ let content = lines.join("\n") + "\n";
161
+ if (variables) {
162
+ for (const [key, value] of Object.entries(variables)) {
163
+ content = content.replaceAll(`{{${key}}}`, value);
164
+ }
165
+ }
166
+ const dirName = skillName;
167
+ const fileName = join(dirName, "SKILL.md");
168
+ return { name: skillName, fileName, dirName, content };
169
+ }
170
+ function requireStepByRole(workflow, role, workflowName) {
171
+ const step = workflow?.steps.find((candidate) => candidate.role === role);
172
+ if (!step) {
173
+ throw new Error(`Controller workflow '${workflowName}' must include a step for role '${role}'`);
174
+ }
175
+ return step;
176
+ }
13
177
  // ── Compile Workflow → Skill ───────────────────────────────
14
178
  /**
15
179
  * Compile a WorkflowPlan + roles + IR into a SKILL.md file.
@@ -200,6 +200,27 @@ export interface WorkflowDef {
200
200
  default_isolation?: "none" | "worktree" | "auto";
201
201
  merge_strategy?: "escalate";
202
202
  }
203
+ export type ControllerKind = "flutter_rewrite_fix_loop";
204
+ export interface ControllerDef {
205
+ name: string;
206
+ description?: string;
207
+ kind: ControllerKind;
208
+ max_rounds: number;
209
+ progress_artifact_path: string;
210
+ diagnosis_workflow: string;
211
+ fix_workflow: string;
212
+ diagnosis_artifact_path: string;
213
+ diagnosis_review_artifact_path: string;
214
+ review_artifact_path: string;
215
+ roles: {
216
+ analyzer: string;
217
+ analysis_reviewer: string;
218
+ surgeon: string;
219
+ fix_reviewer: string;
220
+ test_runner: string;
221
+ };
222
+ stop_format: "zh_three_part";
223
+ }
203
224
  /** Parallel block syntax sugar — expanded by compiler into steps with depends_on */
204
225
  export interface ParallelBlockDef {
205
226
  parallel: {
@@ -276,6 +297,7 @@ export interface IntentDNA {
276
297
  roles?: Record<string, RoleDef>;
277
298
  workflow?: WorkflowDef;
278
299
  workflows?: Record<string, WorkflowDef>;
300
+ controllers?: Record<string, ControllerDef>;
279
301
  variables?: Record<string, string | VariableDef>;
280
302
  mcp?: Record<string, MCPServerDef>;
281
303
  legibility_assets?: LegibilityAssetMap;
@@ -299,6 +299,99 @@ function validateCompletionCheck(check, path) {
299
299
  }
300
300
  return errors;
301
301
  }
302
+ function isSafeControllerArtifactPath(path) {
303
+ return path.startsWith(".dna/") &&
304
+ !path.startsWith("/") &&
305
+ !path.includes("..") &&
306
+ !/[*?[\]{}]/.test(path) &&
307
+ !path.includes("{{") &&
308
+ !path.includes("}}") &&
309
+ path.includes("$ARGUMENTS");
310
+ }
311
+ function validateController(controller, roleNames, workflows, path) {
312
+ const errors = [];
313
+ if (!controller.name) {
314
+ errors.push({ path: `${path}.name`, message: "controller requires 'name'" });
315
+ }
316
+ if (controller.kind !== "flutter_rewrite_fix_loop") {
317
+ errors.push({ path: `${path}.kind`, message: "unsupported controller kind" });
318
+ }
319
+ if (!Number.isInteger(controller.max_rounds) || controller.max_rounds < 1 || controller.max_rounds > 20) {
320
+ errors.push({ path: `${path}.max_rounds`, message: "max_rounds must be an integer in 1..20" });
321
+ }
322
+ const requiredStringFields = [
323
+ "progress_artifact_path",
324
+ "diagnosis_workflow",
325
+ "fix_workflow",
326
+ "diagnosis_artifact_path",
327
+ "diagnosis_review_artifact_path",
328
+ "review_artifact_path",
329
+ ];
330
+ for (const field of requiredStringFields) {
331
+ if (typeof controller[field] !== "string" || !controller[field]) {
332
+ errors.push({ path: `${path}.${field}`, message: "must be a non-empty string" });
333
+ }
334
+ }
335
+ const artifactPathFields = [
336
+ "progress_artifact_path",
337
+ "diagnosis_artifact_path",
338
+ "diagnosis_review_artifact_path",
339
+ "review_artifact_path",
340
+ ];
341
+ for (const field of artifactPathFields) {
342
+ if (typeof controller[field] === "string" && !isSafeControllerArtifactPath(controller[field])) {
343
+ errors.push({ path: `${path}.${field}`, message: "must be a relative .dna/ artifact template containing $ARGUMENTS without glob, brace, absolute, or parent traversal syntax" });
344
+ }
345
+ }
346
+ const diagnosisWorkflow = controller.diagnosis_workflow ? workflows[controller.diagnosis_workflow] : undefined;
347
+ const fixWorkflow = controller.fix_workflow ? workflows[controller.fix_workflow] : undefined;
348
+ if (controller.diagnosis_workflow && !diagnosisWorkflow) {
349
+ errors.push({ path: `${path}.diagnosis_workflow`, message: `references unknown workflow '${controller.diagnosis_workflow}'` });
350
+ }
351
+ if (controller.fix_workflow && !fixWorkflow) {
352
+ errors.push({ path: `${path}.fix_workflow`, message: `references unknown workflow '${controller.fix_workflow}'` });
353
+ }
354
+ const roleMap = controller.roles;
355
+ const requiredRoles = ["analyzer", "analysis_reviewer", "surgeon", "fix_reviewer", "test_runner"];
356
+ if (!roleMap || typeof roleMap !== "object") {
357
+ errors.push({ path: `${path}.roles`, message: "controller requires roles" });
358
+ }
359
+ else {
360
+ for (const roleKey of requiredRoles) {
361
+ const roleName = roleMap[roleKey];
362
+ if (typeof roleName !== "string" || !roleName) {
363
+ errors.push({ path: `${path}.roles.${roleKey}`, message: "must be a non-empty role name" });
364
+ }
365
+ else if (!roleNames.has(roleName)) {
366
+ errors.push({ path: `${path}.roles.${roleKey}`, message: `references unknown role '${roleName}'` });
367
+ }
368
+ }
369
+ }
370
+ if (roleMap && typeof roleMap === "object") {
371
+ validateControllerWorkflowRoles(diagnosisWorkflow, ["analyzer", "analysis_reviewer"], roleMap, `${path}.diagnosis_workflow`, errors);
372
+ validateControllerWorkflowRoles(fixWorkflow, ["surgeon", "fix_reviewer", "test_runner"], roleMap, `${path}.fix_workflow`, errors);
373
+ }
374
+ if (controller.stop_format !== "zh_three_part") {
375
+ errors.push({ path: `${path}.stop_format`, message: "stop_format must be 'zh_three_part'" });
376
+ }
377
+ return errors;
378
+ }
379
+ function validateControllerWorkflowRoles(workflow, requiredRoleKeys, roleMap, path, errors) {
380
+ if (!workflow)
381
+ return;
382
+ const workflowRoles = new Set(workflow.steps.map((step) => step.role));
383
+ for (const roleKey of requiredRoleKeys) {
384
+ const roleName = roleMap[roleKey];
385
+ if (typeof roleName !== "string" || !roleName)
386
+ continue;
387
+ if (!workflowRoles.has(roleName)) {
388
+ errors.push({
389
+ path: `${path}.${roleKey}`,
390
+ message: `workflow '${workflow.name}' must include a step with controller role '${roleName}'`,
391
+ });
392
+ }
393
+ }
394
+ }
302
395
  function validateWorkflow(workflow, roleNames, pathPrefix) {
303
396
  const errors = [];
304
397
  const path = pathPrefix ?? "workflow";
@@ -582,6 +675,14 @@ export function validateDNA(dna) {
582
675
  errors.push(...validateWorkflow(wfDef, roleNamesSet, `workflows.${wfName}`));
583
676
  }
584
677
  }
678
+ if (dna.controllers) {
679
+ const workflowsForControllers = { ...(dna.workflows ?? {}) };
680
+ if (dna.workflow)
681
+ workflowsForControllers[dna.workflow.name] = dna.workflow;
682
+ for (const [controllerName, controllerDef] of Object.entries(dna.controllers)) {
683
+ errors.push(...validateController(controllerDef, roleNamesSet, workflowsForControllers, `controllers.${controllerName}`));
684
+ }
685
+ }
585
686
  // Validate mcp
586
687
  if (dna.mcp) {
587
688
  for (const [name, server] of Object.entries(dna.mcp)) {
@@ -134,6 +134,7 @@ context_files:
134
134
  - ".dna/specs/diagnosis-{{ARGUMENTS}}.md"
135
135
  surgeon:
136
136
  - ".dna/specs/diagnosis-{{ARGUMENTS}}.md"
137
+ - ".dna/specs/diagnosis-{{ARGUMENTS}}.review.json"
137
138
  fix_reviewer:
138
139
  - "docs/behavior/{{ARGUMENTS}}.md"
139
140
  - ".dna/specs/diagnosis-{{ARGUMENTS}}.md"
@@ -239,19 +240,20 @@ roles:
239
240
  - "DO NOT run tests. DO NOT edit app code. Analysis only."
240
241
 
241
242
  analysis_reviewer:
242
- description: "Reviews analyzer output quality. Read-only."
243
+ description: "Reviews analyzer output quality and writes durable diagnosis review verdict."
243
244
  tool_permissions:
244
- allow: [Read, Grep, Glob]
245
- deny: [Bash, Edit, Write, NotebookEdit]
245
+ allow: [Read, Grep, Glob, Write]
246
+ deny: [Bash, Edit, NotebookEdit]
246
247
  scope:
247
248
  read: ["**/*"]
248
- write: []
249
+ write: [".dna/specs/diagnosis-*.review.json"]
249
250
  instructions:
250
251
  - "REQUIRED FIRST: Read all context files listed in SKILL.md"
251
252
  - "Verify analyzer spec: v1 references exist? Classifications sound?"
252
253
  - "Check missing: any failing test not covered?"
253
254
  - "Verify diagnosis remains evidence-only: no fix prescriptions, implementation order, call-site instructions, or Notes for Surgeon"
254
- - "Output a structured verdict block with verdict, artifact_reviewed, sections_to_fix, evidence_paths, confidence, and summary"
255
+ - "Output a structured verdict block with verdict, artifact_reviewed, sections_to_fix, evidence_paths, confidence, summary, and updated_at"
256
+ - "Write the same structured verdict JSON to .dna/specs/diagnosis-$ARGUMENTS.review.json before finishing"
255
257
 
256
258
  fix_reviewer:
257
259
  description: "Reviews surgeon changes against approved diagnosis, v1 evidence, and v2 architecture. Writes only the structured review verdict artifact."
@@ -283,6 +285,26 @@ roles:
283
285
  - "Classify red tests by type (compile/logic/widget/hung)"
284
286
  - "Output progress delta, not test content"
285
287
 
288
+ controllers:
289
+ fix-loop:
290
+ name: Fix Loop
291
+ description: "Run diagnosis once if needed, then loop fix/review/verify until PASS or stop condition"
292
+ kind: flutter_rewrite_fix_loop
293
+ max_rounds: 10
294
+ progress_artifact_path: ".dna/fix-progress/$ARGUMENTS.json"
295
+ diagnosis_workflow: diagnosis
296
+ fix_workflow: fix
297
+ diagnosis_artifact_path: ".dna/specs/diagnosis-$ARGUMENTS.md"
298
+ diagnosis_review_artifact_path: ".dna/specs/diagnosis-$ARGUMENTS.review.json"
299
+ review_artifact_path: ".dna/reviews/$ARGUMENTS-review.json"
300
+ roles:
301
+ analyzer: analyzer
302
+ analysis_reviewer: analysis_reviewer
303
+ surgeon: surgeon
304
+ fix_reviewer: fix_reviewer
305
+ test_runner: test_runner
306
+ stop_format: zh_three_part
307
+
286
308
  workflows:
287
309
  behavior-lock:
288
310
  name: Behavior Lock
@@ -411,10 +433,13 @@ workflows:
411
433
  "sections_to_fix": ["section ids or titles; empty when approved"],
412
434
  "evidence_paths": ["paths that justify the verdict"],
413
435
  "confidence": "high" | "medium" | "low",
414
- "summary": "brief reason"
436
+ "summary": "brief reason",
437
+ "updated_at": "ISO-8601 timestamp"
415
438
  }
416
439
  ```
417
440
 
441
+ Write the same verdict JSON to .dna/specs/diagnosis-$ARGUMENTS.review.json before finishing.
442
+
418
443
  APPROVE → diagnosis complete.
419
444
  REQUEST_REANALYSIS → sections_to_fix must be passed back to analyze. docs/behavior/blocked_items.md is historical context only and must not be treated as the current run verdict.
420
445
  handoff:
@@ -423,6 +448,9 @@ workflows:
423
448
  path: ".dna/specs/diagnosis-$ARGUMENTS.md"
424
449
  description: "Diagnosis spec from analyzer"
425
450
  produces:
451
+ - type: file
452
+ path: ".dna/specs/diagnosis-$ARGUMENTS.review.json"
453
+ description: "Durable diagnosis review verdict (APPROVE or REQUEST_REANALYSIS)"
426
454
  - type: summary
427
455
  required: false
428
456
  description: "Review verdict (APPROVE or REQUEST_REANALYSIS)"
@@ -438,13 +466,20 @@ workflows:
438
466
  prompt: |
439
467
  This invocation is one fix round.
440
468
 
441
- Before editing, check whether .dna/fix-progress/$ARGUMENTS.md exists.
442
- - If it exists, read it before the diagnosis spec and continue from its Next Round Focus.
443
- - If it records REQUEST_CHANGES or BLOCKED_BY_REVIEW, address those review issues before starting unrelated diagnosis items.
444
- - Use Failed approaches / Do not repeat to avoid repeating unsuccessful fixes.
445
- - If it does not exist, treat this as round 1.
469
+ Before editing, read .dna/specs/diagnosis-$ARGUMENTS.review.json.
470
+ - If it is missing, invalid, or verdict is not APPROVE, stop immediately; do not edit.
471
+ - Manual fix invocations must obey this same gate.
472
+ - If verdict is REQUEST_REANALYSIS, run diagnosis reanalysis first instead of fixing.
473
+
474
+ Read context + diagnosis spec only after the diagnosis review gate passes.
446
475
 
447
- Read context + diagnosis spec first.
476
+ Progress artifacts:
477
+ - .dna/fix-progress/$ARGUMENTS.json is the machine-readable source of truth for controller scheduling.
478
+ - If JSON exists and provenance matches this module, use its next_round_focus/verdict for the current round.
479
+ - .dna/fix-progress/$ARGUMENTS.md is human-readable legacy/manual context only; read it for Failed approaches / Do not repeat and evidence, but never use it as the controller verdict or source of truth.
480
+ - If .md exists without .json, treat it as legacy context only; do not infer PASS/CONTINUE/BLOCKED from it.
481
+ - If progress records REQUEST_CHANGES or BLOCKED_BY_REVIEW, address those review issues before starting unrelated diagnosis items.
482
+ - If neither progress artifact exists, treat this as round 1.
448
483
 
449
484
  Count diagnosis classifications before editing. If UNIMPLEMENTED count is greater than 100, this round is implementation-first: fix UNIMPLEMENTED items by priority only, and leave TEST_BUG / BUG / INFRA / REMOVED for later rounds unless they block implementation.
450
485
 
@@ -466,6 +501,9 @@ workflows:
466
501
  - type: file
467
502
  path: ".dna/specs/diagnosis-$ARGUMENTS.md"
468
503
  description: "Diagnosis spec"
504
+ - type: file
505
+ path: ".dna/specs/diagnosis-$ARGUMENTS.review.json"
506
+ description: "Approved diagnosis review gate"
469
507
  produces:
470
508
  - type: git_commit
471
509
  description: "Fix commit"
@@ -488,16 +526,26 @@ workflows:
488
526
  ```json
489
527
  {
490
528
  "verdict": "APPROVE" | "REQUEST_CHANGES",
529
+ "module": "$ARGUMENTS",
530
+ "round": 1,
531
+ "run_id": "controller run id or manual-run",
491
532
  "issues": ["specific issue with evidence path; empty when approved"],
492
533
  "evidence_paths": ["paths that justify the verdict"],
493
534
  "failure_reason": "why changes need another round; empty when approved",
494
535
  "next_round_focus": "specific priority/classification/test file for the next fix invocation; empty when approved",
495
- "summary": "brief reason"
536
+ "summary": "brief reason",
537
+ "updated_at": "ISO-8601 timestamp"
496
538
  }
497
539
  ```
498
540
 
499
541
  Write the same verdict JSON to .dna/reviews/$ARGUMENTS-review.json before finishing.
500
542
 
543
+ JSON field rules:
544
+ - module is required and must equal $ARGUMENTS.
545
+ - round is required and must be the provided current_round, or 1 for manual fix invocation.
546
+ - run_id is required and must equal the provided controller_run_id, or "manual-run" for manual fix invocation.
547
+ - updated_at is a required ISO timestamp string.
548
+
501
549
  REQUEST_CHANGES → include failure_reason and concrete next_round_focus for the next fix invocation. The next_round_focus must name the priority/classification and specific test file or evidence path to address first.
502
550
  handoff:
503
551
  consumes:
@@ -526,33 +574,96 @@ workflows:
526
574
 
527
575
  If the review verdict is APPROVE:
528
576
  - Run full module test suite for $ARGUMENTS.
577
+ - Read diagnosis classification summary.
578
+ - Read current test summary.
529
579
  - Compare with diagnosis spec baseline.
580
+ - Compare skipped tests against remaining UNIMPLEMENTED diagnosis items.
581
+
582
+ Hard PASS rules:
583
+ - 0 failed is not sufficient for PASS.
584
+ - PASS is forbidden if any approved diagnosis UNIMPLEMENTED item remains skipped.
585
+ - Diagnosis-backed UNIMPLEMENTED skipped tests count as Remaining, not Blocked.
586
+ - UNIMPLEMENTED skipped tests count as Remaining.
587
+ - Before writing Module complete: yes, read diagnosis classification summary, read current test summary, compare skipped tests against remaining UNIMPLEMENTED diagnosis items, and if any UNIMPLEMENTED skipped remains, Module complete must be no.
588
+
589
+ Verdict mapping:
590
+ - review REQUEST_CHANGES -> Verdict: BLOCKED, Failure reason: BLOCKED_BY_REVIEW.
591
+ - 0 failed + remaining UNIMPLEMENTED skipped > 0 -> Verdict: CONTINUE, Module complete: no, Next Round Focus: next diagnosis-backed UNIMPLEMENTED test/evidence path.
592
+ - progress contradicts diagnosis/test evidence -> Verdict: BLOCKED, Failure reason: PROGRESS_DIAGNOSIS_CONFLICT.
593
+ - new red caused by current round -> Verdict: REQUEST_CHANGES.
594
+ - new red appears to be newly exposed baseline gap AND remaining count decreases -> Verdict: CONTINUE.
595
+ - ambiguous new red -> Verdict: BLOCKED, Failure reason: AMBIGUOUS_NEW_RED.
596
+ - 0 failed + no remaining diagnosis-backed UNIMPLEMENTED skipped + no unresolved actionable BUG / UNIMPLEMENTED items + only TEST_BUG / INFRA / REMOVED / explicitly user-approved blocked items remain -> Verdict: PASS.
530
597
 
531
598
  Output report:
532
599
  - Verdict: PASS / CONTINUE / REQUEST_CHANGES / BLOCKED
533
600
  - Review: APPROVE / REQUEST_CHANGES
601
+ - Module complete: yes/no
534
602
  - Fixed: N tests now green
535
603
  - New red: M tests that regressed
536
- - Blocked: K tests skipped (see blocked_items.md)
537
- - Remaining: R tests still failing
604
+ - Blocked: K tests skipped for TEST_BUG / INFRA / REMOVED / user-approved blocked reasons
605
+ - Remaining: R tests still failing or diagnosis-backed UNIMPLEMENTED skipped
606
+ - Remaining UNIMPLEMENTED skipped: U diagnosis-backed skipped tests still remaining
538
607
  - Failure reason: why progress stopped, if any
539
608
  - Next round focus: the priority/classification/test file to continue with, if verdict is CONTINUE or REQUEST_CHANGES
540
609
 
541
610
  Always write or update .dna/fix-progress/$ARGUMENTS.md before finishing, even when review blocks verification. Include these sections:
542
- - Current verdict: Verdict, Review, Last fix commit, Updated at
543
- - Round summary: Fixed, New red, Blocked, Remaining, Failure reason
611
+ - Current verdict: Verdict, Review, Module complete, Last fix commit, Updated at
612
+ - Round summary: Fixed, New red, Blocked, Remaining, Remaining UNIMPLEMENTED skipped, Failure reason
544
613
  - Next Round Focus: non-empty when Verdict is CONTINUE or REQUEST_CHANGES
545
614
  - Review Issues To Address First: reviewer issues when Review is REQUEST_CHANGES
546
615
  - Failed approaches / Do not repeat: approaches that failed or caused regressions
547
616
  - Evidence: commands run and relevant paths
548
617
 
549
- If Verdict is PASS, mark the module complete in .dna/fix-progress/$ARGUMENTS.md.
618
+ Always write or update .dna/fix-progress/$ARGUMENTS.json before finishing, even when review blocks verification. The JSON must match this contract exactly:
619
+ ```json
620
+ {
621
+ "verdict": "PASS | CONTINUE | REQUEST_CHANGES | BLOCKED",
622
+ "review": "APPROVE | REQUEST_CHANGES",
623
+ "module": "$ARGUMENTS",
624
+ "round": 1,
625
+ "run_id": "controller run id or manual-run",
626
+ "diagnosis_artifact": ".dna/specs/diagnosis-$ARGUMENTS.md",
627
+ "review_artifact": ".dna/reviews/$ARGUMENTS-review.json",
628
+ "git_head": null,
629
+ "module_complete": false,
630
+ "fixed_count": 0,
631
+ "new_red_count": 0,
632
+ "blocked_count": 0,
633
+ "remaining_count": 0,
634
+ "remaining_unimplemented_skipped_count": 0,
635
+ "next_round_focus": "",
636
+ "failure_reason": "",
637
+ "last_fix_commit": null,
638
+ "evidence_paths": [],
639
+ "updated_at": "ISO-8601 timestamp"
640
+ }
641
+ ```
642
+
643
+ JSON field rules:
644
+ - module is required and must equal $ARGUMENTS.
645
+ - round is required and must be the current fix-loop round number, or 1 for manual fix invocation.
646
+ - run_id is required and must identify the current controller run, or "manual-run" for manual fix invocation.
647
+ - diagnosis_artifact is required and must equal .dna/specs/diagnosis-$ARGUMENTS.md.
648
+ - review_artifact is required and must equal .dna/reviews/$ARGUMENTS-review.json.
649
+ - git_head is the current git HEAD after verification if available, otherwise null.
650
+ - verdict is required and must be PASS, CONTINUE, REQUEST_CHANGES, or BLOCKED.
651
+ - review is required and must be APPROVE or REQUEST_CHANGES.
652
+ - module_complete is a required boolean and must match the Markdown Module complete value.
653
+ - fixed_count, new_red_count, blocked_count, remaining_count, and remaining_unimplemented_skipped_count are required non-negative integers.
654
+ - next_round_focus is required and non-empty when verdict is CONTINUE or REQUEST_CHANGES; empty string is allowed when verdict is PASS.
655
+ - failure_reason is required and non-empty when verdict is BLOCKED or REQUEST_CHANGES; empty string is allowed when verdict is PASS or CONTINUE.
656
+ - last_fix_commit is the commit hash string if available, otherwise null.
657
+ - evidence_paths is a required array of command/test/diagnosis/review evidence paths.
658
+ - updated_at is a required ISO timestamp string.
659
+
660
+ If Verdict is PASS, mark Module complete: yes in .dna/fix-progress/$ARGUMENTS.md and set module_complete true in .dna/fix-progress/$ARGUMENTS.json.
550
661
 
551
662
  Verdict meanings:
552
- - PASS: all approved diagnosis items are fixed or intentionally skipped
663
+ - PASS: all actionable BUG / UNIMPLEMENTED items are fixed, with no diagnosis-backed UNIMPLEMENTED skipped tests remaining; only TEST_BUG / INFRA / REMOVED / explicitly user-approved blocked items may remain
553
664
  - CONTINUE: this round made progress and remaining approved items should continue in the next fix invocation
554
- - REQUEST_CHANGES: review or verification found issues in this round's changes
555
- - BLOCKED: user confirmation is needed or repeated no-progress prevents safe continuation
665
+ - REQUEST_CHANGES: verification found issues in this round's changes
666
+ - BLOCKED: review blocks verification, user confirmation is needed, evidence conflicts, or repeated no-progress prevents safe continuation
556
667
  handoff:
557
668
  consumes:
558
669
  - type: file
@@ -569,6 +680,9 @@ workflows:
569
680
  - type: file
570
681
  path: ".dna/fix-progress/$ARGUMENTS.md"
571
682
  description: "Durable cumulative fix-progress artifact for the next fix invocation"
683
+ - type: file
684
+ path: ".dna/fix-progress/$ARGUMENTS.json"
685
+ description: "Machine-readable fix-progress verdict sidecar for controllers"
572
686
  - type: summary
573
687
  required: false
574
688
  description: "Verification report with test delta"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.21",
3
+ "version": "1.6.1",
4
4
  "description": "Intent DNA — Declarative policy layer for AI agent behavior",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",