intentdna 1.5.9 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/compiler/workflow.js +14 -1
- package/dist/hooks/cli.js +138 -3
- package/dist/hooks/enforce.d.ts +9 -0
- package/dist/hooks/enforce.js +13 -0
- package/dist/hooks/state.d.ts +26 -0
- package/dist/hooks/state.js +37 -0
- package/dist/runtime/skill-adapter.js +41 -1
- package/dist/schema/types.d.ts +12 -0
- package/dist/schema/validate.js +26 -0
- package/dist/templates/flutter-rewrite.dna.yaml +27 -1
- package/package.json +1 -1
|
@@ -226,7 +226,7 @@ function resolveRunIf(runIf) {
|
|
|
226
226
|
* Map WorkflowStepDef to compiled WorkflowStep.
|
|
227
227
|
*/
|
|
228
228
|
function toWorkflowStep(def) {
|
|
229
|
-
|
|
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
|
package/dist/hooks/enforce.d.ts
CHANGED
|
@@ -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;
|
package/dist/hooks/enforce.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/hooks/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/hooks/state.js
CHANGED
|
@@ -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) {
|
package/dist/schema/types.d.ts
CHANGED
|
@@ -146,6 +146,12 @@ export interface WorkflowStepDef {
|
|
|
146
146
|
relax_after_iteration?: number;
|
|
147
147
|
additional_write_paths?: string[];
|
|
148
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;
|
|
149
155
|
}
|
|
150
156
|
/** Top-level workflow definition */
|
|
151
157
|
export interface WorkflowDef {
|
|
@@ -345,6 +351,12 @@ export interface WorkflowStep {
|
|
|
345
351
|
completion: CompletionCheck[] | null;
|
|
346
352
|
checkpoints: StepCheckpoint[] | null;
|
|
347
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;
|
|
348
360
|
}
|
|
349
361
|
/** A group of steps that can execute in parallel */
|
|
350
362
|
export interface ParallelGroup {
|
package/dist/schema/validate.js
CHANGED
|
@@ -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) {
|
|
@@ -316,6 +316,12 @@ workflows:
|
|
|
316
316
|
role: surgeon
|
|
317
317
|
depends_on: [investigate]
|
|
318
318
|
description: "Fix identified issues. Max 5 files per round."
|
|
319
|
+
max_attempts: 3
|
|
320
|
+
on_fail: handoff
|
|
321
|
+
handoff_to: investigate
|
|
322
|
+
max_handoffs: 3
|
|
323
|
+
on_handoff_exhausted: skip
|
|
324
|
+
blocked_items_path: "docs/behavior/blocked_items.md"
|
|
319
325
|
checkpoints:
|
|
320
326
|
- assert: clean_working_tree
|
|
321
327
|
message: "Commit all changes before proceeding"
|
|
@@ -337,9 +343,26 @@ workflows:
|
|
|
337
343
|
produces:
|
|
338
344
|
- type: git_commit
|
|
339
345
|
description: "Rescue round commit"
|
|
340
|
-
- id:
|
|
346
|
+
- id: progress_check
|
|
341
347
|
role: investigator
|
|
342
348
|
depends_on: [fix]
|
|
349
|
+
description: "Check test progress after surgeon's fix."
|
|
350
|
+
prompt: |
|
|
351
|
+
Run tests. Compare green count with previous round.
|
|
352
|
+
If green count increased: PROGRESS — proceed to review.
|
|
353
|
+
If green count unchanged or decreased: NO_PROGRESS — record what surgeon tried
|
|
354
|
+
and why it didn't work, for the experience chain.
|
|
355
|
+
handoff:
|
|
356
|
+
consumes:
|
|
357
|
+
- type: git_commit
|
|
358
|
+
from: fix
|
|
359
|
+
description: "Fix commit from surgeon"
|
|
360
|
+
produces:
|
|
361
|
+
- type: summary
|
|
362
|
+
description: "Progress check result (PROGRESS or NO_PROGRESS)"
|
|
363
|
+
- id: review
|
|
364
|
+
role: investigator
|
|
365
|
+
depends_on: [progress_check]
|
|
343
366
|
description: "Read-only review of surgeon's changes."
|
|
344
367
|
prompt: |
|
|
345
368
|
Review surgeon's git diff (read-only, do NOT modify any files):
|
|
@@ -352,6 +375,9 @@ workflows:
|
|
|
352
375
|
Verdict: REQUEST_CHANGES → describe specific problems. Next round's investigate step will include this feedback.
|
|
353
376
|
handoff:
|
|
354
377
|
consumes:
|
|
378
|
+
- type: summary
|
|
379
|
+
from: progress_check
|
|
380
|
+
description: "Progress check result"
|
|
355
381
|
- type: git_commit
|
|
356
382
|
from: fix
|
|
357
383
|
description: "Committed fix from surgeon"
|