@really-knows-ai/foundry 2.1.0 → 2.3.0

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,418 @@
1
+ // Foundry v2.3.0 orchestrate: deterministic cycle orchestration.
2
+ // Composes internal functions (sort, finalize, history, commit, configure)
3
+ // into a single entry point the LLM drives via a 3-line loop.
4
+
5
+ import { runSort } from './sort.js';
6
+ import {
7
+ getCycleDefinition,
8
+ getArtefactType,
9
+ getValidation,
10
+ } from './lib/config.js';
11
+ import { parseFrontmatter, writeFrontmatter } from './lib/workfile.js';
12
+ import { parseArtefactsTable, addArtefactRow, setArtefactStatus } from './lib/artefacts.js';
13
+ import { readActiveStage, readLastStage, clearActiveStage } from './lib/state.js';
14
+ import { appendEntry, getIteration } from './lib/history.js';
15
+ import { listFeedback } from './lib/feedback.js';
16
+
17
+ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns }) {
18
+ const lines = [
19
+ `You are a Foundry stage agent. Invoke the ${stage.split(':')[0]} skill and follow its instructions exactly.`,
20
+ ``,
21
+ `Stage: ${stage}`,
22
+ `Cycle: ${cycle}`,
23
+ `Token: ${token}`,
24
+ `Working directory: ${cwd}`,
25
+ ];
26
+ if (filePatterns && filePatterns.length) {
27
+ lines.push(`File patterns (forge only): ${JSON.stringify(filePatterns)}`);
28
+ }
29
+ lines.push(
30
+ ``,
31
+ `Your FIRST tool call MUST be foundry_stage_begin({stage, cycle, token}) using the values above.`,
32
+ `Your LAST tool call MUST be foundry_stage_end({summary}).`,
33
+ ``,
34
+ `When done, report back a brief summary. Do NOT call foundry_history_append, foundry_git_commit, or foundry_artefacts_add — the orchestrator handles all of those.`
35
+ );
36
+ return lines.join('\n');
37
+ }
38
+
39
+ export function synthesizeStages({ cycleId, hasValidation, humanAppraise }) {
40
+ const stages = [`forge:${cycleId}`];
41
+ if (hasValidation) stages.push(`quench:${cycleId}`);
42
+ stages.push(`appraise:${cycleId}`);
43
+ if (humanAppraise) stages.push(`human-appraise:${cycleId}`);
44
+ return stages;
45
+ }
46
+
47
+ export function needsSetup(workMdContent) {
48
+ const match = workMdContent.match(/^---\n([\s\S]*?)\n---/);
49
+ if (!match) return true;
50
+ const fm = match[1];
51
+ return !/^stages:/m.test(fm);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Task-6 stub helpers (wired in later). readForgeFilePatterns is real now
56
+ // because the first-call dispatch prompt needs it.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function findCycleOutputArtefact(cycleId, io) {
60
+ if (!io.exists('WORK.md')) return null;
61
+ const content = io.readFile('WORK.md');
62
+ const rows = parseArtefactsTable(content);
63
+ const match = rows.find(r => r.cycle === cycleId);
64
+ return match ? { file: match.file, type: match.type, status: match.status } : null;
65
+ }
66
+
67
+ export async function readCycleTargets(cycleId, io) {
68
+ try {
69
+ const cd = await getCycleDefinition('foundry', cycleId, io);
70
+ return cd.frontmatter?.targets ?? [];
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ export async function readForgeFilePatterns(cycleId, io) {
77
+ try {
78
+ const cd = await getCycleDefinition('foundry', cycleId, io);
79
+ const output = cd.frontmatter?.output;
80
+ if (!output) return null;
81
+ const at = await getArtefactType('foundry', output, io);
82
+ return at.frontmatter?.['file-patterns'] ?? null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function readRecentFeedback(cycleId, io, limit = 5) {
89
+ // Best-effort: surface recent deadlocked items (rejected or wont-fix) for
90
+ // the human-appraise checkpoint. Returns last `limit` matching entries.
91
+ // On any parse error, return [] rather than crashing the cycle.
92
+ try {
93
+ if (!io.exists('WORK.md')) return [];
94
+ const content = io.readFile('WORK.md');
95
+ const rows = parseArtefactsTable(content);
96
+ const items = listFeedback(content, cycleId, rows);
97
+ const deadlocked = items.filter(
98
+ it => it.state === 'wont-fix' || it.state === 'rejected'
99
+ );
100
+ return deadlocked.slice(-limit);
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ function violation(details, affectedFiles = []) {
107
+ return {
108
+ action: 'violation',
109
+ details,
110
+ recoverable: false,
111
+ affected_files: affectedFiles,
112
+ };
113
+ }
114
+
115
+ function markArtefactBlocked(cycleId, io) {
116
+ if (!io.exists('WORK.md')) return { ok: true };
117
+ const content = io.readFile('WORK.md');
118
+ const rows = parseArtefactsTable(content);
119
+ const row = rows.find(r => r.cycle === cycleId);
120
+ if (!row) return { ok: true };
121
+ try {
122
+ io.writeFile('WORK.md', setArtefactStatus(content, row.file, 'blocked'));
123
+ return { ok: true };
124
+ } catch (e) {
125
+ // Surface to caller: setArtefactStatus is strict (e.g. row already
126
+ // blocked/done, invalid status). Don't crash; let caller annotate
127
+ // the violation.
128
+ return { ok: false, error: e?.message || String(e) };
129
+ }
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Sort result -> action shape
134
+ // ---------------------------------------------------------------------------
135
+
136
+ async function handleSortResult(sortResult, { cycleId, cwd, io }) {
137
+ const { route, model, token, details } = sortResult;
138
+ const base = typeof route === 'string' ? route.split(':')[0] : '';
139
+
140
+ if (route === 'done') {
141
+ const art = findCycleOutputArtefact(cycleId, io);
142
+ return {
143
+ action: 'done',
144
+ cycle: cycleId,
145
+ artefact_file: art?.file ?? null,
146
+ next_cycles: await readCycleTargets(cycleId, io),
147
+ };
148
+ }
149
+
150
+ if (route === 'blocked') {
151
+ const art = findCycleOutputArtefact(cycleId, io);
152
+ return {
153
+ action: 'blocked',
154
+ cycle: cycleId,
155
+ artefact_file: art?.file ?? null,
156
+ reason: details ?? 'iteration limit reached with unresolved feedback',
157
+ };
158
+ }
159
+
160
+ if (route === 'violation') {
161
+ return violation(details ?? 'sort returned violation');
162
+ }
163
+
164
+ if (base === 'human-appraise') {
165
+ const art = findCycleOutputArtefact(cycleId, io);
166
+ return {
167
+ action: 'human_appraise',
168
+ stage: route,
169
+ token,
170
+ context: {
171
+ cycle: cycleId,
172
+ artefact_file: art?.file ?? null,
173
+ recent_feedback: readRecentFeedback(cycleId, io),
174
+ },
175
+ };
176
+ }
177
+
178
+ // forge | quench | appraise
179
+ if (!model) {
180
+ const art = findCycleOutputArtefact(cycleId, io);
181
+ return violation(
182
+ `cycle ${cycleId} stage ${route} has no model declared in cycle definition (\`models:\` field) and no default available`,
183
+ [art?.file].filter(Boolean)
184
+ );
185
+ }
186
+
187
+ const filePatterns = base === 'forge'
188
+ ? await readForgeFilePatterns(cycleId, io)
189
+ : null;
190
+
191
+ return {
192
+ action: 'dispatch',
193
+ stage: route,
194
+ subagent_type: model,
195
+ prompt: renderDispatchPrompt({
196
+ stage: route,
197
+ cycle: cycleId,
198
+ token,
199
+ cwd,
200
+ filePatterns,
201
+ }),
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Main entry point
207
+ // ---------------------------------------------------------------------------
208
+
209
+ export async function runOrchestrate(args = {}, io) {
210
+ const {
211
+ cwd = process.cwd(),
212
+ cycleDef: cycleDefOverride = null,
213
+ git,
214
+ mint,
215
+ now = Date.now,
216
+ lastResult = null,
217
+ finalize = null,
218
+ } = args;
219
+
220
+ if (!io.exists('WORK.md')) {
221
+ return violation('no WORK.md; flow skill must create it first');
222
+ }
223
+
224
+ let workContent = io.readFile('WORK.md');
225
+ const fm = parseFrontmatter(workContent);
226
+ const cycleId = fm.cycle;
227
+ if (!cycleId) {
228
+ return violation('WORK.md frontmatter missing cycle field', ['WORK.md']);
229
+ }
230
+
231
+ if (needsSetup(workContent)) {
232
+ if (lastResult) {
233
+ return violation(
234
+ 'inconsistent state: lastResult provided but WORK.md still needs setup',
235
+ ['WORK.md']
236
+ );
237
+ }
238
+
239
+ const foundryDir = 'foundry';
240
+ let cycleDefDoc;
241
+ try {
242
+ cycleDefDoc = await getCycleDefinition(foundryDir, cycleId, io);
243
+ } catch {
244
+ return violation(`cycle definition not found for id: ${cycleId}`, ['WORK.md']);
245
+ }
246
+ const cfm = cycleDefDoc.frontmatter || {};
247
+
248
+ const outputType = cfm.output;
249
+ if (!outputType) {
250
+ return violation(`cycle ${cycleId} missing output field`, ['WORK.md']);
251
+ }
252
+
253
+ try {
254
+ await getArtefactType(foundryDir, outputType, io);
255
+ } catch {
256
+ return violation(`artefact type not found: ${outputType}`, ['WORK.md']);
257
+ }
258
+
259
+ const validation = await getValidation(foundryDir, outputType, io);
260
+
261
+ let stages;
262
+ if (Array.isArray(cfm.stages)) {
263
+ if (cfm.stages.length === 0) {
264
+ const art = findCycleOutputArtefact(cycleId, io);
265
+ return violation(
266
+ `cycle ${cycleId} has no stages declared in cycle definition`,
267
+ [art?.file, 'WORK.md'].filter(Boolean)
268
+ );
269
+ }
270
+ stages = cfm.stages.map(s =>
271
+ typeof s === 'string' && s.includes(':') ? s : `${s}:${cycleId}`
272
+ );
273
+ } else {
274
+ stages = synthesizeStages({
275
+ cycleId,
276
+ hasValidation: !!validation && validation.length > 0,
277
+ humanAppraise: cfm['human-appraise'] === true,
278
+ });
279
+ }
280
+
281
+ const newFm = { ...fm };
282
+ newFm.stages = stages;
283
+ newFm['max-iterations'] = cfm['max-iterations'] ?? 3;
284
+ newFm['human-appraise'] = cfm['human-appraise'] === true;
285
+ newFm['deadlock-appraise'] = cfm['deadlock-appraise'] !== false;
286
+ newFm['deadlock-iterations'] = cfm['deadlock-iterations'] ?? 5;
287
+ if (cfm.models) newFm.models = cfm.models;
288
+
289
+ const body = workContent.replace(/^---\n[\s\S]+?\n---\n?/, '');
290
+ const fmBlock = writeFrontmatter(newFm);
291
+ const newWork = body ? `${fmBlock}\n${body}` : fmBlock;
292
+ io.writeFile('WORK.md', newWork);
293
+
294
+ if (git && typeof git.commit === 'function') {
295
+ git.commit(`[${cycleId}] setup: configure stages and limits`);
296
+ }
297
+
298
+ workContent = io.readFile('WORK.md');
299
+ }
300
+
301
+ const activeStage = readActiveStage(io);
302
+ const lastStage = readLastStage(io);
303
+
304
+ if (activeStage && !lastResult) {
305
+ return violation(
306
+ `prior stage ${activeStage.stage} orphaned — no lastResult provided but active stage exists. ` +
307
+ `Likely cause: previous orchestrate call returned dispatch but caller did not follow up.`,
308
+ []
309
+ );
310
+ }
311
+
312
+ if (lastResult) {
313
+ // Subagent crash path: stage_end may NOT have been called, so activeStage
314
+ // can still exist and lastStage may be stale or absent. Prefer activeStage
315
+ // (current dispatch) over lastStage (could be from a prior cycle).
316
+ if (lastResult.ok === false) {
317
+ const failedStage = activeStage || lastStage;
318
+ if (!failedStage) {
319
+ return violation('lastResult.ok=false but no stage recorded — orphaned state');
320
+ }
321
+ const blockResult = markArtefactBlocked(cycleId, io);
322
+ if (activeStage) clearActiveStage(io);
323
+ const art = findCycleOutputArtefact(cycleId, io);
324
+ const blockNote = blockResult.ok ? '' : ` (also: failed to mark artefact blocked: ${blockResult.error})`;
325
+ return violation(
326
+ `subagent dispatch failed: ${lastResult.error || 'unknown error'}${blockNote}`,
327
+ [art?.file].filter(Boolean)
328
+ );
329
+ }
330
+
331
+ // Happy path: foundry_stage_end has run, which writes lastStage and clears
332
+ // activeStage. lastStage is the canonical source of stage identity & baseSha.
333
+ if (!lastStage) {
334
+ return violation('lastResult provided but no last stage recorded — orphaned state');
335
+ }
336
+
337
+ let finalizeResult;
338
+ if (typeof finalize !== 'function') {
339
+ return violation(
340
+ 'orchestrate caller must inject a `finalize` function when providing lastResult; ' +
341
+ 'the plugin wires lib/finalize.finalizeStage; tests must pass a stub.',
342
+ []
343
+ );
344
+ }
345
+ finalizeResult = await finalize({
346
+ cycleId,
347
+ stage: lastStage.stage,
348
+ baseSha: lastStage.baseSha,
349
+ io,
350
+ });
351
+
352
+ if (!finalizeResult.ok) {
353
+ const blockResult = markArtefactBlocked(cycleId, io);
354
+ if (activeStage) clearActiveStage(io);
355
+ const blockNote = blockResult.ok ? '' : ` (also: failed to mark artefact blocked: ${blockResult.error})`;
356
+ if (finalizeResult.error === 'unexpected_files') {
357
+ return violation(
358
+ `unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}${blockNote}`,
359
+ finalizeResult.files || []
360
+ );
361
+ }
362
+ return violation(`stage_finalize error: ${finalizeResult.error}${blockNote}`, []);
363
+ }
364
+
365
+ for (const a of finalizeResult.artefacts ?? []) {
366
+ let wm = io.readFile('WORK.md');
367
+ const rows = parseArtefactsTable(wm);
368
+ if (!rows.some(r => r.file === a.file)) {
369
+ wm = addArtefactRow(wm, {
370
+ file: a.file,
371
+ type: a.type,
372
+ cycle: cycleId,
373
+ status: a.status ?? 'draft',
374
+ });
375
+ io.writeFile('WORK.md', wm);
376
+ }
377
+ }
378
+
379
+ const summary = lastStage.summary || '(no summary)';
380
+ const historyPath = 'WORK.history.yaml';
381
+ const iteration = getIteration(historyPath, cycleId, io);
382
+
383
+ appendEntry(historyPath, {
384
+ cycle: cycleId,
385
+ stage: 'sort',
386
+ iteration,
387
+ route: lastStage.stage,
388
+ comment: `route ${lastStage.stage}`,
389
+ }, io);
390
+ appendEntry(historyPath, {
391
+ cycle: cycleId,
392
+ stage: lastStage.stage,
393
+ iteration,
394
+ comment: summary,
395
+ }, io);
396
+
397
+ if (git && typeof git.commit === 'function') {
398
+ git.commit(`[${cycleId}] ${lastStage.stage}: ${summary}`);
399
+ }
400
+ // Defensive: stage_end clears activeStage already; this is a no-op in the
401
+ // normal lifecycle but cleans up if the subagent skipped stage_end.
402
+ if (activeStage) clearActiveStage(io);
403
+ }
404
+
405
+ const sortResult = runSort(
406
+ {
407
+ cycleDef: cycleDefOverride,
408
+ mint,
409
+ now: typeof now === 'function' ? now() : now,
410
+ },
411
+ io
412
+ );
413
+
414
+ return handleSortResult(sortResult, { cycleId, cwd, io });
415
+ }
416
+
417
+ // Test-only export; keep underscored to discourage runtime use.
418
+ export { handleSortResult as __handleSortResultForTest };
package/scripts/sort.js CHANGED
@@ -64,7 +64,7 @@ const defaultIO = {
64
64
  // Routing logic
65
65
  // ---------------------------------------------------------------------------
66
66
 
67
- function determineRoute(stages, history, feedback, maxIterations) {
67
+ function determineRoute(stages, history, feedback, maxIterations, opts = {}) {
68
68
  const forgeCount = history.filter(e => baseStage(e.stage || '') === 'forge').length;
69
69
 
70
70
  const nonSortHistory = history.filter(e => baseStage(e.stage || '') !== 'sort');
@@ -83,11 +83,11 @@ function determineRoute(stages, history, feedback, maxIterations) {
83
83
  }
84
84
 
85
85
  if (lastBase === 'appraise') {
86
- return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory);
86
+ return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory, opts);
87
87
  }
88
88
 
89
89
  if (lastBase === 'human-appraise') {
90
- return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory);
90
+ return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory, opts);
91
91
  }
92
92
 
93
93
  return 'blocked';
@@ -103,16 +103,31 @@ function nextAfterQuench(stages, current, feedback, forgeCount, maxIterations) {
103
103
  return nextInRoute(stages, current) ?? 'done';
104
104
  }
105
105
 
106
- function nextAfterAppraise(stages, current, feedback, forgeCount, maxIterations, history = []) {
107
- // Check for deadlock escalation
108
- const deadlocked = detectDeadlocks(feedback, history);
106
+ function nextAfterAppraise(stages, current, feedback, forgeCount, maxIterations, history = [], opts = {}) {
107
+ const {
108
+ humanAppraise: humanAppraiseEnabled = false,
109
+ deadlockAppraise = true,
110
+ deadlockIterations = 5,
111
+ cycle = null,
112
+ } = opts;
113
+
114
+ // Check for deadlock escalation using configured threshold
115
+ const deadlocked = detectDeadlocks(feedback, history, deadlockIterations);
109
116
  if (deadlocked.length > 0) {
110
- const humanAppraise = findFirst(stages, 'human-appraise');
111
- if (humanAppraise && baseStage(current) !== 'human-appraise') {
112
- return humanAppraise;
117
+ const alreadyInHumanAppraise = baseStage(current) === 'human-appraise';
118
+ if (alreadyInHumanAppraise) {
119
+ // Human-appraise ran and deadlock still present — give up.
120
+ return 'blocked';
113
121
  }
114
- // Human-appraise not available or we're already in it — blocked
115
- if (forgeCount >= maxIterations) return 'blocked';
122
+ if (deadlockAppraise) {
123
+ // Route to human-appraise. Prefer one in `stages`; else synthesize via cycle id.
124
+ const inStages = findFirst(stages, 'human-appraise');
125
+ if (inStages) return inStages;
126
+ if (cycle) return `human-appraise:${cycle}`;
127
+ return 'blocked';
128
+ }
129
+ // deadlock-appraise disabled — block the cycle.
130
+ return 'blocked';
116
131
  }
117
132
 
118
133
  const needsForge = feedback.some(f => f.state === 'open' || f.state === 'rejected');
@@ -205,11 +220,43 @@ function checkModifiedFiles(lastBase, foundryDir, cycleDef, cycle, io = defaultI
205
220
  return { ok: violations.length === 0, violations };
206
221
  }
207
222
 
223
+ // ---------------------------------------------------------------------------
224
+ // Micro-commit enforcement
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Return a list of tool-managed files that have uncommitted changes
229
+ * (modified, staged, or untracked) in the working tree.
230
+ *
231
+ * Tool-managed files are WORK.md, WORK.history.yaml, and anything under
232
+ * .foundry/. The sort skill is the sole writer of these between stages,
233
+ * and every stage must end with `foundry_git_commit`. If this function
234
+ * returns a non-empty list at the start of a sort invocation, a prior
235
+ * stage skipped the commit step.
236
+ */
237
+ function getDirtyToolManagedFiles(io = defaultIO) {
238
+ try {
239
+ const output = io.exec('git status --porcelain -- WORK.md WORK.history.yaml .foundry');
240
+ return output
241
+ .split('\n')
242
+ .map(line => line.trim())
243
+ .filter(Boolean)
244
+ .map(line => line.replace(/^[\sMADRCU?!]+/, '').trim())
245
+ .filter(Boolean);
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
250
+
208
251
  // ---------------------------------------------------------------------------
209
252
  // Exported runSort — structured result for programmatic use
210
253
  // ---------------------------------------------------------------------------
211
254
 
212
- export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml', foundryDir = 'foundry', cycleDef, agentsDir = '.opencode/agents' } = {}, io = defaultIO) {
255
+ function isDispatchableRoute(route) {
256
+ return typeof route === 'string' && /^(forge|quench|appraise|human-appraise):/.test(route);
257
+ }
258
+
259
+ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml', foundryDir = 'foundry', cycleDef, agentsDir = '.opencode/agents', mint, now = Date.now() } = {}, io = defaultIO) {
213
260
  if (!io.exists(workPath)) {
214
261
  return { route: 'blocked', details: 'WORK.md not found' };
215
262
  }
@@ -220,6 +267,9 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
220
267
  const cycle = frontmatter.cycle;
221
268
  const stages = frontmatter.stages;
222
269
  const maxIterations = frontmatter['max-iterations'] ?? 3;
270
+ const humanAppraiseEnabled = frontmatter['human-appraise'] === true;
271
+ const deadlockAppraise = frontmatter['deadlock-appraise'] !== false; // default true
272
+ const deadlockIterations = frontmatter['deadlock-iterations'] ?? 5;
223
273
 
224
274
  if (!cycle) return { route: 'blocked', details: 'No cycle in WORK.md frontmatter' };
225
275
  if (!stages || !Array.isArray(stages)) return { route: 'blocked', details: 'No stages in WORK.md frontmatter' };
@@ -229,6 +279,20 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
229
279
  const history = loadHistory(historyPath, cycle, io);
230
280
  const feedback = parseFeedback(workText, cycle, artefacts);
231
281
 
282
+ // Micro-commit enforcement: if any prior stage ran (history non-empty),
283
+ // all tool-managed files must be committed before the next sort call.
284
+ // The first sort of a cycle has empty history — WORK.md may be untracked
285
+ // or dirty at that point, which is fine.
286
+ if (history.length > 0) {
287
+ const dirty = getDirtyToolManagedFiles(io);
288
+ if (dirty.length > 0) {
289
+ return {
290
+ route: 'violation',
291
+ details: `Uncommitted tool-managed files since last sort: ${dirty.join(', ')}. Call foundry_git_commit for the prior stage before invoking sort again.`,
292
+ };
293
+ }
294
+ }
295
+
232
296
  // File modification enforcement
233
297
  const nonSortHistory = history.filter(e => baseStage(e.stage || '') !== 'sort');
234
298
  if (nonSortHistory.length > 0) {
@@ -248,7 +312,12 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
248
312
  return { route: 'violation', details: `Feedback tag validation failed: ${details}` };
249
313
  }
250
314
 
251
- const route = determineRoute(stages, history, feedback, maxIterations);
315
+ const route = determineRoute(stages, history, feedback, maxIterations, {
316
+ humanAppraise: humanAppraiseEnabled,
317
+ deadlockAppraise,
318
+ deadlockIterations,
319
+ cycle,
320
+ });
252
321
 
253
322
  // Model resolution
254
323
  let model = null;
@@ -267,7 +336,12 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
267
336
  }
268
337
  }
269
338
 
270
- return { route, ...(model ? { model } : {}) };
339
+ const result = { route, ...(model ? { model } : {}) };
340
+ if (mint && isDispatchableRoute(route)) {
341
+ const token = mint({ route, cycle, exp: now + 10 * 60 * 1000 });
342
+ if (token) result.token = token;
343
+ }
344
+ return result;
271
345
  }
272
346
 
273
347
  // ---------------------------------------------------------------------------
@@ -290,6 +364,7 @@ export {
290
364
  getModifiedFiles,
291
365
  getAllowedPatterns,
292
366
  checkModifiedFiles,
367
+ getDirtyToolManagedFiles,
293
368
  };
294
369
 
295
370
 
@@ -54,10 +54,15 @@ Only stages with an explicitly specified model are included in the `models` fron
54
54
 
55
55
  Ask the user:
56
56
 
57
- > Do you want a human quality gate on this cycle? If enabled, a human reviewer will check the artefact after LLM appraisers pass, and can break deadlocks between forge and appraisers.
57
+ > Human-appraise has two independent knobs:
58
58
  >
59
- > - Enable human-appraise? (yes/no)
60
- > - If yes, deadlock threshold (default: 3 number of forge/appraise iterations before escalating to human)
59
+ > 1. `human-appraise` should a human review the artefact every iteration? Default: no.
60
+ > 2. `deadlock-appraise` should a human be pulled in only when LLM appraisers deadlock? Default: yes.
61
+ > 3. If either is enabled, `deadlock-iterations` sets the deadlock threshold (default: 5).
62
+ >
63
+ > - human-appraise: yes/no (default no)
64
+ > - deadlock-appraise: yes/no (default yes)
65
+ > - deadlock-iterations: number (default 5)
61
66
 
62
67
  ### 5. Validate artefact types
63
68
 
@@ -108,9 +113,9 @@ inputs:
108
113
  - <artefact-type-id>
109
114
  targets:
110
115
  - <cycle-id>
111
- human-appraise:
112
- enabled: <true|false>
113
- deadlock-threshold: <number>
116
+ human-appraise: <true|false>
117
+ deadlock-appraise: <true|false>
118
+ deadlock-iterations: <number>
114
119
  models:
115
120
  appraise: <model-id>
116
121
  ---