intentdna 1.5.8 → 1.5.10

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.8",
12
+ "version": "1.5.10",
13
13
  "source": "./"
14
14
  }
15
15
  ]
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.8",
3
+ "version": "1.5.10",
4
4
  "description": "Declarative policy layer for AI agent governance"
5
5
  }
@@ -20,6 +20,7 @@ export interface ActivatedGene {
20
20
  codons: Codon[];
21
21
  tags: string[];
22
22
  expression_level: number;
23
+ role?: "constraint" | "workflow_hint";
23
24
  }
24
25
  /**
25
26
  * Activate a cascaded DNA for a given context and role.
@@ -74,6 +74,7 @@ export function activateDNA(cascaded, contextName = null, activeRole = null) {
74
74
  codons: [...gene.codons],
75
75
  tags: [...(gene.tags ?? [])],
76
76
  expression_level: 1.0,
77
+ role: gene.role,
77
78
  };
78
79
  }
79
80
  // Apply context modifiers if a context is active
@@ -55,6 +55,14 @@ function geneToDirectives(name, gene) {
55
55
  if (weights.length > 0) {
56
56
  parts.push(`Trade-offs: ${weights.join("; ")}.`);
57
57
  }
58
+ // For workflow_hint genes, threshold codons become prompt directives instead of gates
59
+ if (gene.role === "workflow_hint") {
60
+ for (const codon of gene.codons) {
61
+ if (codon.type === "threshold") {
62
+ parts.push(`Condition: ${codon.condition}.`);
63
+ }
64
+ }
65
+ }
58
66
  if (parts.length > 1) {
59
67
  directives.push({
60
68
  priority,
@@ -153,9 +161,11 @@ export function compileDNA(activated, cascaded) {
153
161
  const injections = [];
154
162
  for (const [name, gene] of Object.entries(activated.genes)) {
155
163
  directives.push(...geneToDirectives(name, gene));
156
- toolFilters.push(...geneToToolFilters(name, gene));
157
- gates.push(...geneToGates(name, gene));
158
- validators.push(...geneToValidators(name, gene));
164
+ if (gene.role !== "workflow_hint") {
165
+ toolFilters.push(...geneToToolFilters(name, gene));
166
+ gates.push(...geneToGates(name, gene));
167
+ validators.push(...geneToValidators(name, gene));
168
+ }
159
169
  }
160
170
  // Compile role constraints
161
171
  let roleToolPermissions;
@@ -226,7 +226,7 @@ function resolveRunIf(runIf) {
226
226
  * Map WorkflowStepDef to compiled WorkflowStep.
227
227
  */
228
228
  function toWorkflowStep(def) {
229
- return {
229
+ const step = {
230
230
  id: def.id,
231
231
  role: def.role,
232
232
  description: def.description ?? def.id,
@@ -238,6 +238,19 @@ function toWorkflowStep(def) {
238
238
  checkpoints: def.checkpoints && def.checkpoints.length > 0 ? [...def.checkpoints] : null,
239
239
  handoff: def.handoff ?? null,
240
240
  };
241
+ if (def.max_attempts !== undefined)
242
+ step.max_attempts = def.max_attempts;
243
+ if (def.on_fail !== undefined)
244
+ step.on_fail = def.on_fail;
245
+ if (def.handoff_to !== undefined)
246
+ step.handoff_to = def.handoff_to;
247
+ if (def.max_handoffs !== undefined)
248
+ step.max_handoffs = def.max_handoffs;
249
+ if (def.on_handoff_exhausted !== undefined)
250
+ step.on_handoff_exhausted = def.on_handoff_exhausted;
251
+ if (def.blocked_items_path !== undefined)
252
+ step.blocked_items_path = def.blocked_items_path;
253
+ return step;
241
254
  }
242
255
  /**
243
256
  * Generate a Mermaid flowchart diagram from a WorkflowPlan.
package/dist/hooks/cli.js CHANGED
@@ -15,11 +15,11 @@
15
15
  * Fail-open: all errors → { continue: true, suppressOutput: true }
16
16
  */
17
17
  import { readFile, stat } from "node:fs/promises";
18
- import { resolve } from "node:path";
18
+ import { join, resolve } from "node:path";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { readStdin, writeOutput, silentOutput, allowOutput } from "./protocol.js";
21
- import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, } from "./enforce.js";
22
- import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState } from "./state.js";
21
+ import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, checkReflectionLimit, } from "./enforce.js";
22
+ import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState, readSurgeonAttempts, writeSurgeonAttempts } from "./state.js";
23
23
  // ── Constants ──────────────────────────────────────────────
24
24
  const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
25
25
  const VALID_EVENTS = new Set([
@@ -160,6 +160,22 @@ async function main() {
160
160
  const start = Date.now();
161
161
  let output = dispatch(event, ir, rawInput, state);
162
162
  const durationMs = Date.now() - start;
163
+ // Surgeon reflection gate (PostToolUse only, async)
164
+ if (event === "PostToolUse") {
165
+ try {
166
+ const reflectionOutput = await handleSurgeonReflection(ir, rawInput, projectDir, sessionId);
167
+ if (reflectionOutput) {
168
+ // Merge: keep original output but append reflection warning
169
+ if (output.suppressOutput) {
170
+ output = reflectionOutput;
171
+ }
172
+ else if (output.reason) {
173
+ output = { ...output, reason: output.reason + "\n" + (reflectionOutput.reason ?? "") };
174
+ }
175
+ }
176
+ }
177
+ catch { /* fail-open */ }
178
+ }
163
179
  // Extract target file path for trace + pattern detection
164
180
  const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
165
181
  ? rawInput.tool_input : undefined;
@@ -335,6 +351,125 @@ async function scanConsumedArtifactPaths(projectDir, ir, workflowName, currentSt
335
351
  }
336
352
  return paths;
337
353
  }
354
+ // ── Surgeon Reflection Gate ──────────────────────────────
355
+ /** Regex to parse test green count from vitest/flutter test output */
356
+ const TEST_PASSED_RE = /(\d+)\s+(?:tests?\s+)?passed/i;
357
+ /**
358
+ * Handle surgeon reflection gate for PostToolUse events.
359
+ * Tracks edit/test progress and warns when no progress is detected.
360
+ * Returns modified HookOutput with warning injected, or null to use original.
361
+ */
362
+ async function handleSurgeonReflection(ir, input, projectDir, sessionId) {
363
+ const agentType = typeof input.agent_type === "string" ? input.agent_type : undefined;
364
+ if (!agentType)
365
+ return null;
366
+ const toolName = String(input.tool_name ?? "");
367
+ const toolOutput = typeof input.tool_output === "string" ? input.tool_output : undefined;
368
+ // Find if any workflow step with max_attempts matches this agent_type
369
+ const workflows = ir.workflows_ir;
370
+ if (!workflows || workflows.length === 0)
371
+ return null;
372
+ let maxAttempts;
373
+ let handoffTo;
374
+ let maxHandoffs;
375
+ let blockedItemsPath;
376
+ // Search all workflows for a step whose role matches agent_type and has max_attempts
377
+ // We need the original DNA step defs, which are encoded in the IR's step_enforce_rules
378
+ // But the original step config (max_attempts etc) isn't in the IR yet.
379
+ // Instead, scan workflow state to find the current step, then check if the IR source has the fields.
380
+ const wfState = await readWorkflowState(projectDir, sessionId);
381
+ if (!wfState || !wfState.active)
382
+ return null;
383
+ // Match agent_type to current role in workflow
384
+ const expectedAgentType = `dna-${wfState.current_role.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase()}`;
385
+ if (agentType !== expectedAgentType)
386
+ return null;
387
+ // Look up step config from the compiled IR's workflow
388
+ // We need the original step definitions — they are not directly in IR.
389
+ // For now, we encode max_attempts info in the IR via step_enforce_rules extension.
390
+ // Fallback: check if workflow state has reflection config.
391
+ // Since the IR doesn't carry max_attempts yet, we use a convention:
392
+ // The plugin-adapter will encode reflection fields into step_enforce_rules.
393
+ // For Phase 1, we read from a sidecar file: .dna/compiled/reflection-config.json
394
+ const reflectionConfig = await loadReflectionConfig(projectDir, wfState.workflow, wfState.current_step);
395
+ if (!reflectionConfig)
396
+ return null;
397
+ maxAttempts = reflectionConfig.max_attempts;
398
+ handoffTo = reflectionConfig.handoff_to;
399
+ maxHandoffs = reflectionConfig.max_handoffs ?? 3;
400
+ blockedItemsPath = reflectionConfig.blocked_items_path;
401
+ if (!maxAttempts)
402
+ return null;
403
+ // Read current surgeon state
404
+ const surgeonState = await readSurgeonAttempts(projectDir, sessionId);
405
+ // Track edits
406
+ if (toolName === "Edit") {
407
+ surgeonState.edit_count++;
408
+ await writeSurgeonAttempts(projectDir, surgeonState, sessionId);
409
+ return null;
410
+ }
411
+ // Track test results from Bash
412
+ if (toolName === "Bash" && toolOutput) {
413
+ const match = toolOutput.match(TEST_PASSED_RE);
414
+ if (!match)
415
+ return null; // Can't parse — fail-open
416
+ const greenCount = parseInt(match[1], 10);
417
+ surgeonState.bash_test_count++;
418
+ surgeonState.current_green_count = greenCount;
419
+ if (!surgeonState.baseline_established) {
420
+ surgeonState.baseline_established = true;
421
+ surgeonState.last_green_count = greenCount;
422
+ await writeSurgeonAttempts(projectDir, surgeonState, sessionId);
423
+ return null;
424
+ }
425
+ // Check progress: green count should increase after edits
426
+ if (surgeonState.edit_count >= 2 && greenCount <= surgeonState.last_green_count) {
427
+ surgeonState.fail_count++;
428
+ const result = checkReflectionLimit(surgeonState.fail_count, maxAttempts, surgeonState.handoff_count, maxHandoffs);
429
+ // Update last_green_count for next comparison
430
+ surgeonState.last_green_count = greenCount;
431
+ surgeonState.edit_count = 0;
432
+ await writeSurgeonAttempts(projectDir, surgeonState, sessionId);
433
+ if (result.action === "handoff" || result.action === "warn") {
434
+ const target = handoffTo ? ` Hand off to '${handoffTo}' for re-analysis.` : "";
435
+ return allowOutput(`WARN [Intent DNA] Reflection Gate: ${result.reason}${target}\n` +
436
+ `Review experience_chain in .dna/state/workflow/surgeon-attempts.json before next attempt.`);
437
+ }
438
+ if (result.action === "skip") {
439
+ const path = blockedItemsPath ? ` Record blocked item to ${blockedItemsPath}.` : "";
440
+ return allowOutput(`WARN [Intent DNA] Reflection Gate: ${result.reason}${path}\n` +
441
+ `SKIP this item and move to the next.`);
442
+ }
443
+ }
444
+ else {
445
+ // Progress made — reset counters
446
+ surgeonState.last_green_count = greenCount;
447
+ surgeonState.edit_count = 0;
448
+ if (greenCount > surgeonState.last_green_count) {
449
+ surgeonState.fail_count = 0;
450
+ }
451
+ }
452
+ await writeSurgeonAttempts(projectDir, surgeonState, sessionId);
453
+ }
454
+ return null;
455
+ }
456
+ /**
457
+ * Load reflection config for a specific workflow step.
458
+ * Reads from .dna/compiled/ir.json — scans workflows_ir for step metadata.
459
+ * Falls back to sidecar .dna/compiled/reflection-config.json for Phase 1.
460
+ */
461
+ async function loadReflectionConfig(projectDir, _workflowName, _stepId) {
462
+ // Phase 1: read sidecar config
463
+ const configPath = join(projectDir, ".dna", "compiled", "reflection-config.json");
464
+ try {
465
+ const raw = await readFile(configPath, "utf-8");
466
+ const config = JSON.parse(raw);
467
+ return config[_workflowName]?.[_stepId] ?? null;
468
+ }
469
+ catch {
470
+ return null;
471
+ }
472
+ }
338
473
  // ── Entry Point ────────────────────────────────────────────
339
474
  main().catch(() => {
340
475
  // Fail-open: never block Claude Code on unexpected errors
@@ -139,3 +139,12 @@ export declare function resolveToolTarget(target?: string): string[] | null;
139
139
  * Returns deduplicated list of target paths.
140
140
  */
141
141
  export declare function extractBashWritePaths(command: string): string[];
142
+ export interface ReflectionResult {
143
+ action: "continue" | "warn" | "handoff" | "skip";
144
+ reason: string;
145
+ }
146
+ /**
147
+ * Determine the reflection gate action based on fail/handoff counts.
148
+ * Pure function — no I/O.
149
+ */
150
+ export declare function checkReflectionLimit(failCount: number, maxAttempts: number, handoffCount: number, maxHandoffs: number): ReflectionResult;
@@ -690,3 +690,16 @@ function toKebabCase(s) {
690
690
  .replace(/[_\s]+/g, "-")
691
691
  .toLowerCase();
692
692
  }
693
+ /**
694
+ * Determine the reflection gate action based on fail/handoff counts.
695
+ * Pure function — no I/O.
696
+ */
697
+ export function checkReflectionLimit(failCount, maxAttempts, handoffCount, maxHandoffs) {
698
+ if (failCount < maxAttempts) {
699
+ return { action: "continue", reason: `Attempt ${failCount + 1}/${maxAttempts}` };
700
+ }
701
+ if (handoffCount < maxHandoffs) {
702
+ return { action: "handoff", reason: `No progress after ${maxAttempts} attempts, handing off for re-analysis (handoff ${handoffCount + 1}/${maxHandoffs})` };
703
+ }
704
+ return { action: "skip", reason: `No progress after ${maxAttempts} attempts and ${maxHandoffs} handoffs exhausted — skipping` };
705
+ }
@@ -30,6 +30,24 @@ export interface AuditEntry {
30
30
  message: string;
31
31
  session_id?: string;
32
32
  }
33
+ /** Surgeon attempt state for reflection gate tracking */
34
+ export interface SurgeonAttemptState {
35
+ round: number;
36
+ edit_count: number;
37
+ bash_test_count: number;
38
+ last_green_count: number;
39
+ current_green_count: number;
40
+ fail_count: number;
41
+ handoff_count: number;
42
+ baseline_established: boolean;
43
+ experience_chain: Array<{
44
+ round: number;
45
+ analysis_summary: string;
46
+ attempt: string;
47
+ result: string;
48
+ lesson: string;
49
+ }>;
50
+ }
33
51
  /**
34
52
  * Resolve state directory path.
35
53
  * With session isolation: `.dna/state/sessions/<sessionId>/`
@@ -64,6 +82,14 @@ export declare function appendCompletedArtifact(projectDir: string, stepId: stri
64
82
  }, sessionId?: string): Promise<void>;
65
83
  /** Atomic write: write to temp file then rename. */
66
84
  export declare function atomicWrite(filePath: string, data: string): Promise<void>;
85
+ /**
86
+ * Read surgeon attempt state. Returns default state if not found.
87
+ */
88
+ export declare function readSurgeonAttempts(projectDir: string, sessionId?: string): Promise<SurgeonAttemptState>;
89
+ /**
90
+ * Write surgeon attempt state atomically.
91
+ */
92
+ export declare function writeSurgeonAttempts(projectDir: string, state: SurgeonAttemptState, sessionId?: string): Promise<void>;
67
93
  /** Trace entry for hook call observability */
68
94
  export interface TraceEntry {
69
95
  trace_id: string;
@@ -136,6 +136,43 @@ export async function atomicWrite(filePath, data) {
136
136
  await writeFile(tmpPath, data, "utf-8");
137
137
  await rename(tmpPath, filePath);
138
138
  }
139
+ // ── Surgeon Attempt State ────────────────────────────────
140
+ const SURGEON_ATTEMPTS_FILE = "workflow/surgeon-attempts.json";
141
+ function defaultSurgeonState() {
142
+ return {
143
+ round: 0,
144
+ edit_count: 0,
145
+ bash_test_count: 0,
146
+ last_green_count: 0,
147
+ current_green_count: 0,
148
+ fail_count: 0,
149
+ handoff_count: 0,
150
+ baseline_established: false,
151
+ experience_chain: [],
152
+ };
153
+ }
154
+ /**
155
+ * Read surgeon attempt state. Returns default state if not found.
156
+ */
157
+ export async function readSurgeonAttempts(projectDir, sessionId) {
158
+ const stateDir = resolveStateDir(projectDir, sessionId);
159
+ const filePath = join(stateDir, SURGEON_ATTEMPTS_FILE);
160
+ try {
161
+ const raw = await readFile(filePath, "utf-8");
162
+ return JSON.parse(raw);
163
+ }
164
+ catch {
165
+ return defaultSurgeonState();
166
+ }
167
+ }
168
+ /**
169
+ * Write surgeon attempt state atomically.
170
+ */
171
+ export async function writeSurgeonAttempts(projectDir, state, sessionId) {
172
+ const stateDir = resolveStateDir(projectDir, sessionId);
173
+ const filePath = join(stateDir, SURGEON_ATTEMPTS_FILE);
174
+ await atomicWrite(filePath, JSON.stringify(state, null, 2));
175
+ }
139
176
  const TRACE_DIR = "trace";
140
177
  const MAX_TRACE_SIZE = 10 * 1024 * 1024; // 10MB
141
178
  const TRACE_RETENTION_DAYS = 7;
@@ -44,6 +44,12 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
44
44
  }
45
45
  // Steps — each step MUST be executed via Agent() tool call
46
46
  const usedRoles = new Set(plan.steps.map((s) => s.role));
47
+ // Pre-compute handoff targets: steps that are pointed to by another step's handoff_to
48
+ const handoffTargets = new Set();
49
+ for (const step of plan.steps) {
50
+ if (step.handoff_to)
51
+ handoffTargets.add(step.handoff_to);
52
+ }
47
53
  lines.push("<Steps>");
48
54
  let stepNumber = 0;
49
55
  for (let i = 0; i < plan.parallel_groups.length; i++) {
@@ -58,8 +64,24 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
58
64
  const promptParts = [
59
65
  "Do NOT spawn sub-agents.",
60
66
  `You are the ${agentType} agent.`,
61
- step.prompt || step.description,
62
67
  ];
68
+ // Reflection gate: inject Previous_Attempts for handoff target steps
69
+ if (handoffTargets.has(step.id)) {
70
+ promptParts.push("<Previous_Attempts>\\n" +
71
+ "If this is not the first round, the workflow state contains an experience_chain " +
72
+ "with previous analysis, what surgeon tried, results, and lessons learned. " +
73
+ "Read the experience chain from workflow state before analyzing. " +
74
+ "DO NOT repeat approaches that already failed.\\n" +
75
+ "</Previous_Attempts>");
76
+ }
77
+ // Reflection gate: inject Failed_Approaches for steps with max_attempts + handoff_to
78
+ if (step.max_attempts && step.handoff_to) {
79
+ promptParts.push("<Failed_Approaches>\\n" +
80
+ "If previous attempts exist in the experience chain, review what was tried " +
81
+ "and why it failed. You MUST use a DIFFERENT approach.\\n" +
82
+ "</Failed_Approaches>");
83
+ }
84
+ promptParts.push(step.prompt || step.description);
63
85
  const agentPrompt = escapePrompt(promptParts.join(" "));
64
86
  lines.push(`${stepNumber}. **${step.id}**${optional}`);
65
87
  lines.push(` ${step.description}${runIf}`);
@@ -77,6 +99,18 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
77
99
  lines.push(` Verify produced artifacts: ${artifacts}`);
78
100
  }
79
101
  lines.push("");
102
+ // Reflection gate: inject Reflection_Gate after steps with max_attempts
103
+ if (step.max_attempts) {
104
+ const handoffStep = step.handoff_to ?? "previous step";
105
+ const maxH = step.max_handoffs ?? 3;
106
+ const blockedPath = step.blocked_items_path ?? "blocked_items.md";
107
+ lines.push(`<Reflection_Gate>`);
108
+ lines.push(`max_attempts=${step.max_attempts}, handoff_to=${handoffStep}, max_handoffs=${maxH}`);
109
+ lines.push(`No test progress after ${step.max_attempts} attempts → handoff to ${handoffStep} for re-analysis.`);
110
+ lines.push(`After ${maxH} handoffs with no progress → SKIP and record to ${blockedPath}.`);
111
+ lines.push(`</Reflection_Gate>`);
112
+ lines.push("");
113
+ }
80
114
  }
81
115
  }
82
116
  lines.push("</Steps>");
@@ -184,6 +218,12 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
184
218
  lines.push("</Variables>");
185
219
  lines.push("");
186
220
  }
221
+ // Workflow Boundary — prevent cross-workflow execution
222
+ lines.push("<Workflow_Boundary>");
223
+ lines.push("This workflow is COMPLETE. Do NOT proceed to any other workflow.");
224
+ lines.push("Report your results and STOP. The user will decide the next step.");
225
+ lines.push("</Workflow_Boundary>");
226
+ lines.push("");
187
227
  let content = lines.join("\n") + "\n";
188
228
  // Substitute project variables: {{var_name}} → value
189
229
  if (variables) {
@@ -37,6 +37,7 @@ export interface Gene {
37
37
  description: string;
38
38
  codons: Codon[];
39
39
  tags?: string[];
40
+ role?: "constraint" | "workflow_hint";
40
41
  }
41
42
  export type ModifierAction = "amplify" | "suppress" | "activate" | "deactivate";
42
43
  export interface GeneModifier {
@@ -145,6 +146,12 @@ export interface WorkflowStepDef {
145
146
  relax_after_iteration?: number;
146
147
  additional_write_paths?: string[];
147
148
  };
149
+ max_attempts?: number;
150
+ on_fail?: "retry_with_feedback" | "handoff" | "skip";
151
+ handoff_to?: string;
152
+ max_handoffs?: number;
153
+ on_handoff_exhausted?: "skip" | "stop";
154
+ blocked_items_path?: string;
148
155
  }
149
156
  /** Top-level workflow definition */
150
157
  export interface WorkflowDef {
@@ -344,6 +351,12 @@ export interface WorkflowStep {
344
351
  completion: CompletionCheck[] | null;
345
352
  checkpoints: StepCheckpoint[] | null;
346
353
  handoff: StepHandoff | null;
354
+ max_attempts?: number;
355
+ on_fail?: "retry_with_feedback" | "handoff" | "skip";
356
+ handoff_to?: string;
357
+ max_handoffs?: number;
358
+ on_handoff_exhausted?: "skip" | "stop";
359
+ blocked_items_path?: string;
347
360
  }
348
361
  /** A group of steps that can execute in parallel */
349
362
  export interface ParallelGroup {
@@ -235,6 +235,20 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
235
235
  if (step.prompt !== undefined && (typeof step.prompt !== "string" || !step.prompt)) {
236
236
  errors.push({ path: `${stepPath}.prompt`, message: "prompt must be a non-empty string" });
237
237
  }
238
+ // on_fail: must be a valid enum value
239
+ if (step.on_fail !== undefined) {
240
+ const validOnFail = ["retry_with_feedback", "handoff", "skip"];
241
+ if (!validOnFail.includes(step.on_fail)) {
242
+ errors.push({ path: `${stepPath}.on_fail`, message: `must be one of: ${validOnFail.join(", ")}` });
243
+ }
244
+ }
245
+ // on_handoff_exhausted: must be a valid enum value
246
+ if (step.on_handoff_exhausted !== undefined) {
247
+ const validExhausted = ["skip", "stop"];
248
+ if (!validExhausted.includes(step.on_handoff_exhausted)) {
249
+ errors.push({ path: `${stepPath}.on_handoff_exhausted`, message: `must be one of: ${validExhausted.join(", ")}` });
250
+ }
251
+ }
238
252
  // isolation: must be a valid enum value
239
253
  if (step.isolation !== undefined) {
240
254
  const validIsolation = ["none", "worktree", "auto"];
@@ -292,6 +306,18 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
292
306
  });
293
307
  }
294
308
  }
309
+ // handoff_to: must reference an existing step in this workflow
310
+ if (step.handoff_to !== undefined) {
311
+ if (typeof step.handoff_to !== "string" || !step.handoff_to) {
312
+ errors.push({ path: `${stepPath}.handoff_to`, message: "handoff_to must be a non-empty string" });
313
+ }
314
+ else if (!stepIds.has(step.handoff_to)) {
315
+ errors.push({
316
+ path: `${stepPath}.handoff_to`,
317
+ message: `step '${step.handoff_to}' not found in workflow`,
318
+ });
319
+ }
320
+ }
295
321
  }
296
322
  // Validate default_isolation
297
323
  if (workflow.default_isolation !== undefined) {
@@ -0,0 +1,152 @@
1
+ version: "0.1.0"
2
+ id: template_code_cleanup
3
+ name: Code Cleanup (Regression-Safe)
4
+ type: project
5
+ namespace: cc
6
+
7
+ cascade:
8
+ inherits: ["species:default"]
9
+ priority: 100
10
+
11
+ genes:
12
+ test_between_passes:
13
+ description: Tests must pass between each cleanup pass
14
+ codons:
15
+ - type: threshold
16
+ condition: "tests_passing == true"
17
+ action: block
18
+ - type: attract
19
+ target: run_tests_after_each_pass
20
+
21
+ no_behavior_change:
22
+ description: Cleanup must not change observable behavior
23
+ codons:
24
+ - type: threshold
25
+ condition: "behavior_changed == false"
26
+ action: block
27
+ - type: repel
28
+ target: modify_public_api
29
+ - type: attract
30
+ target: same_inputs_same_outputs
31
+
32
+ smell_focused:
33
+ description: Clean by smell type in order - dead code, duplication, naming
34
+ role: workflow_hint
35
+ codons:
36
+ - type: attract
37
+ target: categorize_before_cleaning
38
+ - type: attract
39
+ target: one_smell_type_per_pass
40
+
41
+ minimal_cleanup:
42
+ description: Each pass focuses on one type of issue
43
+ codons:
44
+ - type: attract
45
+ target: single_concern_per_pass
46
+ - type: repel
47
+ target: mixed_cleanup_types
48
+ - type: threshold
49
+ condition: "files_changed_per_pass <= 10"
50
+ action: escalate
51
+
52
+ contexts: {}
53
+
54
+ roles:
55
+ analyzer:
56
+ description: Analyzes code for cleanup opportunities. Read-only.
57
+ tool_permissions:
58
+ allow: [Read, Grep, Glob, Bash]
59
+ deny: [Edit, Write, NotebookEdit]
60
+ scope:
61
+ read: ["**/*"]
62
+ write: []
63
+ instructions:
64
+ - Classify code smells by type (dead code, duplication, naming, complexity)
65
+ - Prioritize by impact and risk
66
+ - Do NOT suggest behavioral changes
67
+ - Output a categorized cleanup plan
68
+
69
+ cleaner:
70
+ description: Executes cleanup changes. Can modify code.
71
+ tool_permissions:
72
+ allow: [Read, Edit, Write, Grep, Glob, Bash]
73
+ scope:
74
+ read: ["**/*"]
75
+ write: ["src/**", "lib/**"]
76
+ instructions:
77
+ - Follow the analyzer's cleanup plan
78
+ - One smell type per pass
79
+ - Run tests after each file change
80
+ - Revert if tests fail
81
+ - Do not change public APIs or observable behavior
82
+ - Commit after each successful pass
83
+
84
+ reviewer:
85
+ description: Reviews cleanup changes for correctness. Read-only.
86
+ tool_permissions:
87
+ allow: [Read, Grep, Glob, Bash]
88
+ deny: [Edit, Write, NotebookEdit]
89
+ scope:
90
+ read: ["**/*"]
91
+ write: []
92
+ instructions:
93
+ - Review git diff for each cleanup pass
94
+ - Verify no behavioral changes
95
+ - Check test coverage unchanged
96
+ - "Verdict: APPROVE or REQUEST_CHANGES"
97
+
98
+ workflows:
99
+ cleanup:
100
+ name: Code Cleanup
101
+ description: "Regression-safe code cleanup: analyze, classify, clean by smell type, verify"
102
+ steps:
103
+ - id: lock_behavior
104
+ role: analyzer
105
+ description: "Assess current test coverage and establish behavior baseline."
106
+ prompt: |
107
+ Run existing tests to establish baseline.
108
+ Record: total tests, passing, failing.
109
+ If test coverage is insufficient, warn before proceeding.
110
+ Output: baseline test results + coverage assessment.
111
+
112
+ - id: classify
113
+ role: analyzer
114
+ depends_on: [lock_behavior]
115
+ description: "Scan codebase and categorize code smells."
116
+ prompt: |
117
+ Scan the codebase for code smells. Categorize:
118
+ 1. Dead code (unused imports, unreachable branches, commented-out code)
119
+ 2. Duplication (copy-paste patterns, similar functions)
120
+ 3. Naming (unclear names, inconsistent conventions)
121
+ 4. Complexity (long functions, deep nesting)
122
+
123
+ Prioritize: dead code first (safest), then duplication, then naming.
124
+ Output: categorized cleanup plan with file paths and line numbers.
125
+
126
+ - id: clean
127
+ role: cleaner
128
+ depends_on: [classify]
129
+ description: "Execute cleanup one smell type at a time."
130
+ prompt: |
131
+ Follow the cleanup plan. Rules:
132
+ - One smell type per pass (start with dead code)
133
+ - Run tests after each file change
134
+ - If tests fail: revert immediately, report the issue
135
+ - Maximum 10 files per pass
136
+ - Commit after each successful pass: "cleanup($TYPE): description"
137
+ - Do NOT change any public API signatures
138
+ - Do NOT rename exported symbols
139
+ - Do NOT modify test files (unless removing dead test helpers)
140
+
141
+ - id: verify
142
+ role: reviewer
143
+ depends_on: [clean]
144
+ description: "Verify cleanup preserved behavior."
145
+ prompt: |
146
+ Review all cleanup changes (git diff from baseline):
147
+ 1. Any behavioral changes? (API signatures, return values, side effects)
148
+ 2. Test count unchanged or increased?
149
+ 3. All tests still passing?
150
+ 4. No unintended file modifications?
151
+
152
+ Verdict: APPROVE (cleanup complete) or REQUEST_CHANGES (list issues)