@really-knows-ai/foundry 3.3.9 → 3.5.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Shared validation utilities extracted from the plugin's validate-tools.
3
+ *
4
+ * These functions run deterministic validator commands (no LLM involvement)
5
+ * and return structured results consumed by the quench module and the
6
+ * plugin's `foundry_validate_run` tool.
7
+ */
8
+
9
+ import { execSync } from 'child_process';
10
+ import { readdir } from 'fs/promises';
11
+ import { join, relative, dirname, sep } from 'path';
12
+ import { minimatch } from 'minimatch';
13
+ import { getLawsForQuench, getArtefactType } from './config.js';
14
+ import { parseValidatorJsonl } from './validator-jsonl.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Private helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function shellQuote(value) {
21
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
22
+ }
23
+
24
+ function validatePatterns(patterns, typeId) {
25
+ if (!Array.isArray(patterns) || patterns.length === 0) {
26
+ return { ok: false, error: `Artefact type ${typeId} has no file-patterns` };
27
+ }
28
+ return null;
29
+ }
30
+
31
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
32
+
33
+ function toPosix(p) {
34
+ return sep === '/' ? p : p.split(sep).join('/');
35
+ }
36
+
37
+ async function readdirSafe(dir) {
38
+ try {
39
+ return await readdir(dir, { withFileTypes: true });
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ async function* walkFiles(root, dir) {
46
+ for (const entry of await readdirSafe(dir)) {
47
+ const full = join(dir, entry.name);
48
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
49
+ yield* walkFiles(root, full);
50
+ } else if (entry.isFile()) {
51
+ yield toPosix(relative(root, full));
52
+ }
53
+ }
54
+ }
55
+
56
+ function fileMatchesAnyPattern(rel, patterns) {
57
+ for (const pattern of patterns) {
58
+ if (minimatch(rel, pattern)) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Exported functions
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Read file-patterns for an artefact type from its definition.
69
+ */
70
+ export async function getValidationPatterns(foundryDir, typeId, io) {
71
+ const artType = await getArtefactType(foundryDir, typeId, io);
72
+ return artType.frontmatter['file-patterns'] || [];
73
+ }
74
+
75
+ /**
76
+ * Run validation commands for an artefact type deterministically.
77
+ *
78
+ * Accepts an IO interface and foundryDir directly (no plugin context
79
+ * dependency). When `artefacts` is provided, the `{files}` substitution
80
+ * uses non-deleted files from the artefact list instead of expanding
81
+ * patterns across the worktree.
82
+ *
83
+ * @param {{ typeId: string, io: object, foundryDir: string, artefacts?: Array<{file: string, state: string}> }} params
84
+ * @returns {Promise<{ ok: boolean, validatorsRun: number, items: Array, errors: Array }>}
85
+ */
86
+ export async function performValidation({ typeId, io, foundryDir, artefacts }) {
87
+ let patterns;
88
+ try {
89
+ patterns = await getValidationPatterns(foundryDir, typeId, io);
90
+ } catch (err) {
91
+ return { ok: false, error: err.message };
92
+ }
93
+
94
+ const validationErr = validatePatterns(patterns, typeId);
95
+ if (validationErr) return validationErr;
96
+
97
+ const laws = await getLawsForQuench(foundryDir, io, { typeId });
98
+ if (!laws?.length) {
99
+ return { ok: true, validatorsRun: 0, items: [], errors: [] };
100
+ }
101
+ return runValidatorsAndReport(laws, patterns, foundryDir, artefacts);
102
+ }
103
+
104
+ /**
105
+ * Run validators for a set of laws and build the aggregated result.
106
+ *
107
+ * When `artefacts` is provided, the `{files}` substitution uses non-deleted
108
+ * files from the artefact list. Otherwise patterns are expanded across the
109
+ * worktree (backwards compatibility for callers like `foundry_validate_run`).
110
+ */
111
+ export async function runValidatorsAndReport(laws, patterns, foundryDir, artefacts) {
112
+ const worktree = dirname(foundryDir);
113
+ let expandedFiles;
114
+ if (artefacts) {
115
+ expandedFiles = artefacts
116
+ .filter(({ state }) => state !== 'deleted')
117
+ .map(({ file }) => file)
118
+ .sort();
119
+ } else {
120
+ expandedFiles = await expandPatterns(patterns, worktree);
121
+ }
122
+ const substitutions = {
123
+ pattern: patterns.map(shellQuote).join(' '),
124
+ files: expandedFiles.map(shellQuote).join(' '),
125
+ };
126
+ const results = await runValidators(laws, patterns, substitutions, worktree);
127
+
128
+ return {
129
+ ok: results.errors.length === 0,
130
+ validatorsRun: results.validatorsRun,
131
+ items: results.items,
132
+ errors: results.errors,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Expand glob patterns to actual file paths in the worktree.
138
+ *
139
+ * Uses readdir + minimatch for Node 20 compatibility (glob added in Node 22).
140
+ */
141
+ export async function expandPatterns(patterns, worktree) {
142
+ const files = new Set();
143
+ for await (const rel of walkFiles(worktree, worktree)) {
144
+ if (fileMatchesAnyPattern(rel, patterns)) {
145
+ files.add(rel);
146
+ }
147
+ }
148
+ return Array.from(files).sort();
149
+ }
150
+
151
+ /**
152
+ * Run all validators across a list of laws and collect results.
153
+ */
154
+ export async function runValidators(laws, patterns, substitutions, worktree) {
155
+ const results = {
156
+ validatorsRun: 0,
157
+ items: [],
158
+ errors: [],
159
+ };
160
+
161
+ for (const law of laws) {
162
+ if (!law.validators || law.validators.length === 0) continue;
163
+ await runLawValidators(law, patterns, substitutions, worktree, results);
164
+ }
165
+
166
+ return results;
167
+ }
168
+
169
+ /**
170
+ * Run validators for a single law.
171
+ */
172
+ export async function runLawValidators(law, patterns, substitutions, worktree, results) {
173
+ for (const validator of law.validators) {
174
+ // Skip commands that require {files} when there are no matching files
175
+ if (substitutions.files === '' && /(?:^|\s)\{files\}(?=\s|$)/.test(validator.command)) {
176
+ continue;
177
+ }
178
+ results.validatorsRun++;
179
+ const expanded = expandValidatorCommand(validator.command, substitutions);
180
+ const parseResult = await executeValidator(expanded, worktree, patterns);
181
+ collectValidatorResult(parseResult, law.id, validator.id, results);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Fold a single validator's parse result into the aggregate results.
187
+ */
188
+ export function collectValidatorResult(parseResult, lawId, validatorId, results) {
189
+ for (const item of parseResult.items) {
190
+ results.items.push({ lawId, validatorId, ...item });
191
+ }
192
+ for (const message of parseResult.parseErrors) {
193
+ results.errors.push({ lawId, validatorId, type: 'parse', message });
194
+ }
195
+ for (const message of parseResult.patternErrors) {
196
+ results.errors.push({ lawId, validatorId, type: 'pattern-mismatch', message });
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Execute a validator command and parse its JSONL output.
202
+ */
203
+ export async function executeValidator(expanded, worktree, patterns) {
204
+ try {
205
+ const output = execSync(expanded, {
206
+ cwd: worktree,
207
+ encoding: 'utf8',
208
+ stdio: ['pipe', 'pipe', 'pipe'],
209
+ });
210
+ const { Readable } = await import('stream');
211
+ const stream = Readable.from([output]);
212
+ return await parseValidatorJsonl(stream, patterns);
213
+ } catch (err) {
214
+ // Validator command failed — prefer stdout for JSONL
215
+ // (tools like rg exit 1 with results on stdout)
216
+ const output = (err.stdout || err.stderr || err.message || '').trim();
217
+ const { Readable } = await import('stream');
218
+ const stream = Readable.from([output]);
219
+ return await parseValidatorJsonl(stream, patterns);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Expand validator command placeholders {pattern} and {files}.
225
+ *
226
+ * Both placeholders are recognised only as standalone tokens bounded by
227
+ * whitespace or start/end of string. Surrounding single or double quotes
228
+ * around the placeholder are stripped first.
229
+ */
230
+ export function expandValidatorCommand(command, { pattern, files }) {
231
+ let cmd = command
232
+ .replace(/"\{pattern\}"/g, '{pattern}')
233
+ .replace(/'\{pattern\}'/g, '{pattern}')
234
+ .replace(/"\{files\}"/g, '{files}')
235
+ .replace(/'\{files\}'/g, '{files}');
236
+
237
+ cmd = cmd.replace(/(?:^|\s)\{pattern\}(?=\s|$)/g, (match) =>
238
+ match.startsWith('{') ? pattern : ' ' + pattern);
239
+
240
+ cmd = cmd.replace(/(?:^|\s)\{files\}(?=\s|$)/g, (match) =>
241
+ match.startsWith('{') ? files : ' ' + files);
242
+
243
+ return cmd;
244
+ }
@@ -127,8 +127,5 @@ export function createWorkfile(frontmatter, goal) {
127
127
  # Goal
128
128
 
129
129
  ${goal}
130
-
131
- | File | Type | Cycle | Status |
132
- |------|------|-------|--------|
133
130
  `;
134
131
  }
@@ -5,21 +5,12 @@ import {
5
5
  getCycleDefinition,
6
6
  getArtefactType,
7
7
  } from './lib/config.js';
8
- import { parseArtefactsTable, setArtefactStatus } from './lib/artefacts.js';
9
8
  import { openFeedbackStore } from './lib/feedback-store.js';
10
9
 
11
10
  // ---------------------------------------------------------------------------
12
11
  // Public helpers (re-exported by orchestrate.js for tests).
13
12
  // ---------------------------------------------------------------------------
14
13
 
15
- export function findCycleOutputArtefact(cycleId, io) {
16
- if (!io.exists('WORK.md')) return null;
17
- const content = io.readFile('WORK.md');
18
- const rows = parseArtefactsTable(content);
19
- const match = rows.find(r => r.cycle === cycleId);
20
- return match ? { file: match.file, type: match.type, status: match.status } : null;
21
- }
22
-
23
14
  export async function readCycleTargets(cycleId, io) {
24
15
  try {
25
16
  const cd = await getCycleDefinition('foundry', cycleId, io);
@@ -47,7 +38,11 @@ export async function readForgeFilePatterns(cycleId, io) {
47
38
  }
48
39
  const output = extractOutputType(cd);
49
40
  if (!output) return null;
50
- return fetchFilePatterns(output, io);
41
+ try {
42
+ return await fetchFilePatterns(output, io);
43
+ } catch {
44
+ return null;
45
+ }
51
46
  }
52
47
 
53
48
  // ---------------------------------------------------------------------------
@@ -148,31 +143,7 @@ export function tryCommit(git, message, allowedPatterns, phase) {
148
143
  }
149
144
  }
150
145
 
151
- function findCycleRow(cycleId, rows) {
152
- return rows.find(r => r.cycle === cycleId);
153
- }
154
146
 
155
- function writeBlockedStatus(content, row, io) {
156
- try {
157
- io.writeFile('WORK.md', setArtefactStatus(content, row.file, 'blocked'));
158
- return { ok: true };
159
- } catch (e) {
160
- return { ok: false, error: e?.message || String(e) };
161
- }
162
- }
163
-
164
- export function markArtefactBlocked(cycleId, io) {
165
- if (!io.exists('WORK.md')) return { ok: true };
166
- const content = io.readFile('WORK.md');
167
- const rows = parseArtefactsTable(content);
168
- const row = findCycleRow(cycleId, rows);
169
- if (!row) return { ok: true };
170
- return writeBlockedStatus(content, row, io);
171
- }
172
-
173
- export function formatBlockNote(blockResult) {
174
- return blockResult.ok ? '' : ` (also: failed to mark artefact blocked: ${blockResult.error})`;
175
- }
176
147
 
177
148
  // ---------------------------------------------------------------------------
178
149
  // Stage synthesis (pure utility, used by setupWorkfile and exported publicly).
@@ -188,6 +159,71 @@ export function synthesizeStages({ cycleId, hasValidation, humanAppraise, assay
188
159
  return stages;
189
160
  }
190
161
 
162
+ // ---------------------------------------------------------------------------
163
+ // Dispatch-multi action constants and validators.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export const DISPATCH_MULTI_ACTION = 'dispatch_multi';
167
+
168
+ /**
169
+ * Validate a dispatch_multi action object.
170
+ *
171
+ * Checks action type, tasks array shape, and each task's required fields.
172
+ * Returns undefined on success, a violation object on failure.
173
+ */
174
+ function validString(value) {
175
+ return typeof value === 'string' && value.length > 0;
176
+ }
177
+
178
+ function checkActionType(action) {
179
+ if (!action || action.action !== DISPATCH_MULTI_ACTION) {
180
+ return violation(`action must be "${DISPATCH_MULTI_ACTION}"`, []);
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ function validateAllTasks(tasks) {
186
+ for (let i = 0; i < tasks.length; i++) {
187
+ const err = checkDispatchTask(tasks[i], i);
188
+ if (err) return err;
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ export function validateDispatchMulti(action) {
194
+ const typeErr = checkActionType(action);
195
+ if (typeErr) return typeErr;
196
+ if (!Array.isArray(action.tasks)) {
197
+ return violation('dispatch_multi tasks must be an array', []);
198
+ }
199
+ return validateAllTasks(action.tasks);
200
+ }
201
+
202
+ function checkDispatchTask(task, index) {
203
+ if (!validString(task.subagent_type)) {
204
+ return violation(`dispatch_multi tasks[${index}]: subagent_type must be a non-empty string`, []);
205
+ }
206
+ if (!validString(task.prompt)) {
207
+ return violation(`dispatch_multi tasks[${index}]: prompt must be a non-empty string`, []);
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ /**
213
+ * Build a dispatch_multi response object.
214
+ *
215
+ * Pure utility for constructing the action envelope expected by the
216
+ * orchestrate skill's LLM loop.
217
+ */
218
+ export function buildDispatchMultiResponse(tasks, stage, cycle) {
219
+ return {
220
+ action: DISPATCH_MULTI_ACTION,
221
+ tasks,
222
+ stage,
223
+ cycle,
224
+ };
225
+ }
226
+
191
227
  // ---------------------------------------------------------------------------
192
228
  // Dispatch prompt rendering (pure utility, used by handleSortResult and exported publicly).
193
229
  // ---------------------------------------------------------------------------
@@ -13,38 +13,62 @@ import { stageBaseOf } from './lib/stage-guard.js';
13
13
  import { allowedPatternsForStage } from './lib/git-policy.js';
14
14
  import { loadExtractor } from './lib/assay/loader.js';
15
15
  import { checkExtractorAgainstCycle } from './lib/assay/permissions.js';
16
+ import { getArtefactFiles } from './lib/artefacts.js';
16
17
  import {
17
- findCycleOutputArtefact,
18
18
  readCycleTargets,
19
19
  readForgeFilePatterns,
20
20
  readRecentFeedback,
21
21
  computeOpenFeedback,
22
22
  violation,
23
23
  tryCommit,
24
- markArtefactBlocked,
25
- formatBlockNote,
26
24
  synthesizeStages,
27
25
  renderDispatchPrompt,
28
26
  } from './orchestrate-cycle.js';
29
27
 
30
- async function doneAction(cycleId, io) {
31
- const art = findCycleOutputArtefact(cycleId, io);
32
- return { action: 'done', cycle: cycleId, artefact_file: art?.file ?? null, next_cycles: await readCycleTargets(cycleId, io) };
28
+ async function findOutputArtefacts(cfm, io, foundryDir, baseBranch) {
29
+ const outputType = cfm ? cfm['output-type'] : undefined;
30
+ if (!outputType) return null;
31
+ const artefacts = await getArtefactFiles(foundryDir, outputType, io, { baseBranch });
32
+ return artefacts.find(a => a.state !== 'deleted') || null;
33
33
  }
34
34
 
35
- function blockedAction(cycleId, io, details) {
36
- const art = findCycleOutputArtefact(cycleId, io);
37
- return { action: 'blocked', cycle: cycleId, artefact_file: art?.file ?? null, reason: details ?? 'iteration limit reached with unresolved feedback' };
35
+ async function doneAction(cycleId, io, foundryDir, baseBranch) {
36
+ const fd = foundryDir || 'foundry';
37
+ const base = baseBranch || 'main';
38
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
39
+ const artefact = await findOutputArtefacts(cfm, io, fd, base);
40
+ const artefactFile = artefact ? artefact.file : null;
41
+ return { action: 'done', cycle: cycleId, artefact_file: artefactFile, next_cycles: await readCycleTargets(cycleId, io) };
38
42
  }
39
43
 
40
- function humanAppraiseAction(route, token, cycleId, io) {
41
- const art = findCycleOutputArtefact(cycleId, io);
42
- return { action: 'human_appraise', stage: route, token, context: { cycle: cycleId, artefact_file: art?.file ?? null, recent_feedback: readRecentFeedback(io) } };
44
+ async function blockedAction(cycleId, io, details, foundryDir, baseBranch) {
45
+ const fd = foundryDir || 'foundry';
46
+ const base = baseBranch || 'main';
47
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
48
+ const artefact = await findOutputArtefacts(cfm, io, fd, base);
49
+ const artefactFile = artefact ? artefact.file : null;
50
+ const reason = details || 'iteration limit reached with unresolved feedback';
51
+ return { action: 'blocked', cycle: cycleId, artefact_file: artefactFile, reason };
43
52
  }
44
53
 
45
- function missingModelViolation(cycleId, route, io) {
46
- const art = findCycleOutputArtefact(cycleId, io);
47
- return violation(`cycle ${cycleId} stage ${route} has no model declared in cycle definition`, [art?.file].filter(Boolean));
54
+ async function humanAppraiseAction(route, token, ctx) {
55
+ const { cycleId, io, baseBranch } = ctx;
56
+ const fd = ctx.foundryDir || 'foundry';
57
+ const base = baseBranch || 'main';
58
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
59
+ const artefact = await findOutputArtefacts(cfm, io, fd, base);
60
+ const artefactFile = artefact ? artefact.file : null;
61
+ return { action: 'human_appraise', stage: route, token, context: { cycle: cycleId, artefact_file: artefactFile, recent_feedback: readRecentFeedback(io) } };
62
+ }
63
+
64
+ async function missingModelViolation(cycleId, route, io, foundryDir, baseBranch) {
65
+ const fd = foundryDir || 'foundry';
66
+ const base = baseBranch || 'main';
67
+ const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
68
+ const outputType = cfm ? cfm['output-type'] : undefined;
69
+ const artefacts = outputType ? await getArtefactFiles(fd, outputType, io, { baseBranch: base }) : [];
70
+ const affectedFiles = artefacts.filter(a => a.state !== 'deleted').map(a => a.file);
71
+ return violation(`cycle ${cycleId} stage ${route} has no model declared in cycle definition`, affectedFiles);
48
72
  }
49
73
 
50
74
  function makeDispatchPayload(route, cycleId, token, cwd, filePatterns) {
@@ -52,30 +76,38 @@ function makeDispatchPayload(route, cycleId, token, cwd, filePatterns) {
52
76
  }
53
77
 
54
78
  async function buildDispatchAction(route, model, token, ctx) {
55
- if (!model) return missingModelViolation(ctx.cycleId, route, ctx.io);
79
+ if (!model) return missingModelViolation(ctx.cycleId, route, ctx.io, ctx.foundryDir, ctx.baseBranch ?? 'main');
56
80
  const base = route.split(':')[0];
57
81
  const filePatterns = base === 'forge' ? await readForgeFilePatterns(ctx.cycleId, ctx.io) : null;
58
82
  return { action: 'dispatch', stage: route, subagent_type: model, prompt: renderDispatchPrompt(makeDispatchPayload(route, ctx.cycleId, token, ctx.cwd, filePatterns)) };
59
83
  }
60
84
 
61
- function routeDispatch(route) {
85
+ export function routeDispatch(route) {
62
86
  return typeof route === 'string' ? route.split(':')[0] : '';
63
87
  }
64
88
 
65
89
  async function handleTerminalRoute(route, sortResult, ctx) {
66
- if (route === 'done') return doneAction(ctx.cycleId, ctx.io);
67
- if (route === 'blocked') return blockedAction(ctx.cycleId, ctx.io, sortResult.details);
68
- return violation(sortResult.details ?? 'sort returned violation');
90
+ const baseBranch = ctx.baseBranch || 'main';
91
+ if (route === 'done') return doneAction(ctx.cycleId, ctx.io, ctx.foundryDir, baseBranch);
92
+ if (route === 'blocked') return blockedAction(ctx.cycleId, ctx.io, sortResult.details, ctx.foundryDir, baseBranch);
93
+ const details = sortResult.details || 'sort returned violation';
94
+ return violation(details);
95
+ }
96
+
97
+ function isTerminalRoute(route) {
98
+ return route === 'done' || route === 'blocked' || route === 'violation';
99
+ }
100
+
101
+ function getRouteBase(route) {
102
+ return routeDispatch(route);
69
103
  }
70
104
 
71
105
  export async function handleSortResult(sortResult, ctx) {
72
106
  const { route, model, token } = sortResult;
73
- if (route === 'done' || route === 'blocked' || route === 'violation') {
74
- return handleTerminalRoute(route, sortResult, ctx);
75
- }
76
- if (routeDispatch(route) === 'human-appraise') {
77
- return humanAppraiseAction(route, token, ctx.cycleId, ctx.io);
78
- }
107
+ const routeBase = getRouteBase(route);
108
+ if (isTerminalRoute(route)) return handleTerminalRoute(route, sortResult, ctx);
109
+ if (routeBase === 'quench' || routeBase === 'appraise') return violation(routeBase + ' route reached handleSortResult');
110
+ if (routeBase === 'human-appraise') return humanAppraiseAction(route, token, ctx);
79
111
  return buildDispatchAction(route, model, token, ctx);
80
112
  }
81
113
 
@@ -236,12 +268,11 @@ function readOriginalState(io) {
236
268
  return { workMd: io.readFile('WORK.md'), history: io.exists('WORK.history.yaml') ? io.readFile('WORK.history.yaml') : null };
237
269
  }
238
270
 
239
- function buildFinalizeViolation(finalizeResult, blockResult) {
240
- const blockNote = formatBlockNote(blockResult);
271
+ function buildFinalizeViolation(finalizeResult) {
241
272
  if (finalizeResult.error === 'unexpected_files') {
242
- return violation(`unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}${blockNote}`, finalizeResult.files || []);
273
+ return violation(`unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}`, finalizeResult.files || []);
243
274
  }
244
- return violation(`stage_finalize error: ${finalizeResult.error}${blockNote}`, []);
275
+ return violation(`stage_finalize error: ${finalizeResult.error}`, []);
245
276
  }
246
277
 
247
278
  function writeHistoryEntries(ctx) {
@@ -284,9 +315,8 @@ export async function finaliseStage(args) {
284
315
  }
285
316
  const finalizeResult = await finalize({ cycleId, stage: lastStage.stage, baseSha: lastStage.baseSha, io });
286
317
  if (!finalizeResult.ok) {
287
- const blockResult = markArtefactBlocked(cycleId, io);
288
318
  clearStageState(activeStage, null, io);
289
- return buildFinalizeViolation(finalizeResult, blockResult);
319
+ return buildFinalizeViolation(finalizeResult);
290
320
  }
291
321
  const historyPath = 'WORK.history.yaml';
292
322
  const iteration = getIteration(historyPath, cycleId, io);
@@ -303,12 +333,12 @@ export async function finaliseStage(args) {
303
333
  }
304
334
 
305
335
  export function handleViolation(args) {
306
- const { lastResult, activeStage, lastStage, cycleId, io } = args;
336
+ const { lastResult, activeStage, lastStage } = args;
307
337
  const failedStage = activeStage || lastStage;
308
338
  if (!failedStage) { return violation('lastResult.ok=false but no stage recorded — orphaned state'); }
309
- const blockResult = markArtefactBlocked(cycleId, io);
310
- clearStageState(activeStage, lastStage, io);
311
- const art = findCycleOutputArtefact(cycleId, io);
312
- const blockNote = formatBlockNote(blockResult);
313
- return violation(`subagent dispatch failed: ${lastResult.error || 'unknown error'}${blockNote}`, [art?.file].filter(Boolean));
339
+ clearStageState(activeStage, lastStage, args.io);
340
+ return violation(
341
+ `subagent dispatch failed: ${lastResult.error || 'unknown error'}`,
342
+ lastResult.affected_files || [],
343
+ );
314
344
  }