@really-knows-ai/foundry 3.8.5 → 3.9.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,174 @@
1
+ // src/scripts/lib/stage-output-schemas.js
2
+ // Stage output validation schemas for forge, appraise, and human-appraise.
3
+ // Each exported function validates a plain object against the stage's output
4
+ // schema and returns { ok: true } or { ok: false, errors: [...] }.
5
+
6
+ // ── JSON Schema definitions ──────────────────────────────────────────
7
+
8
+ const FORGE_SCHEMA = {
9
+ type: 'object',
10
+ required: ['status'],
11
+ properties: {
12
+ status: { enum: ['done', 'actioned', 'wont-fix'] },
13
+ reason: { type: 'string' },
14
+ },
15
+ allOf: [
16
+ {
17
+ if: { properties: { status: { const: 'wont-fix' } } },
18
+ then: { required: ['reason'] },
19
+ },
20
+ ],
21
+ };
22
+
23
+ const APPRAISE_SCHEMA = {
24
+ type: 'object',
25
+ required: ['file', 'law', 'text'],
26
+ properties: {
27
+ file: { type: 'string', minLength: 1 },
28
+ law: { type: 'string', minLength: 1 },
29
+ text: { type: 'string', minLength: 1 },
30
+ evidence: { type: 'string' },
31
+ severity: { type: 'string' },
32
+ location: { type: 'string' },
33
+ },
34
+ };
35
+
36
+ const HUMAN_APPRAISE_SCHEMA = {
37
+ type: 'object',
38
+ required: ['verdict'],
39
+ properties: {
40
+ verdict: { const: 'approved' },
41
+ },
42
+ };
43
+
44
+ // ── Schema validator ─────────────────────────────────────────────────
45
+
46
+ function checkObjectType(schema, data, path) {
47
+ if (schema.type !== 'object') return [];
48
+ if (isRecord(data)) return [];
49
+ return [`${path} — must be a plain object`];
50
+ }
51
+
52
+ function checkStringType(schema, data, path) {
53
+ if (schema.type !== 'string') return [];
54
+ if (typeof data !== 'string') {
55
+ return [`${path} — must be a string`];
56
+ }
57
+ if (schema.minLength !== undefined && data.length < schema.minLength) {
58
+ return [`${path} — must be a non-empty string`];
59
+ }
60
+ return [];
61
+ }
62
+
63
+ function checkType(schema, data, path) {
64
+ return [
65
+ ...checkObjectType(schema, data, path),
66
+ ...checkStringType(schema, data, path),
67
+ ];
68
+ }
69
+
70
+ function isRecord(v) {
71
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
72
+ }
73
+
74
+ function checkRequired(schema, data, path) {
75
+ if (!schema.required || !isRecord(data)) return [];
76
+ return schema.required
77
+ .filter(key => !(key in data))
78
+ .map(key => `${path}: ${key} — is required`);
79
+ }
80
+
81
+ function checkProperties(schema, data, path) {
82
+ if (!schema.properties || !isRecord(data)) return [];
83
+ const errors = [];
84
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
85
+ if (key in data) {
86
+ errors.push(...validateSchema(propSchema, data[key], `${path}: ${key}`));
87
+ }
88
+ }
89
+ return errors;
90
+ }
91
+
92
+ function checkAdditionalProperties(schema, data, path) {
93
+ if (schema.additionalProperties !== false || !schema.properties || !isRecord(data)) return [];
94
+ const allowed = new Set(Object.keys(schema.properties));
95
+ return Object.keys(data)
96
+ .filter(key => !allowed.has(key))
97
+ .map(key => `${path}: ${key} — unknown field`);
98
+ }
99
+
100
+ function checkEnum(schema, data, path) {
101
+ if (schema.enum === undefined) return [];
102
+ if (schema.enum.includes(data)) return [];
103
+ const allowed = schema.enum.map(v => JSON.stringify(v)).join(', ');
104
+ return [`${path} — must be one of ${allowed}`];
105
+ }
106
+
107
+ function checkConst(schema, data, path) {
108
+ if (schema.const === undefined) return [];
109
+ if (data === schema.const) return [];
110
+ return [`${path} — must be ${JSON.stringify(schema.const)}`];
111
+ }
112
+
113
+ function checkNot(schema, data, path) {
114
+ if (!schema.not || !schema.not.required || !isRecord(data)) return [];
115
+ return schema.not.required
116
+ .filter(key => key in data)
117
+ .map(key => `${path}: ${key} — must not be present`);
118
+ }
119
+
120
+ function skipAllOfItem(item, data) {
121
+ if (!item.if) return true;
122
+ if (!item.if.properties) return false;
123
+ return Object.keys(item.if.properties).some(key => !(key in data));
124
+ }
125
+
126
+ function checkAllOf(schema, data, path) {
127
+ if (!schema.allOf) return [];
128
+ const errors = [];
129
+ for (const item of schema.allOf) {
130
+ if (skipAllOfItem(item, data)) continue;
131
+ const ifErrors = validateSchema(item.if, data, path);
132
+ if (ifErrors.length === 0) {
133
+ errors.push(...validateSchema(item.then, data, path));
134
+ }
135
+ }
136
+ return errors;
137
+ }
138
+
139
+ function validateSchema(schema, data, path = '') {
140
+ const typeErrors = checkType(schema, data, path);
141
+ if (typeErrors.length > 0) return typeErrors;
142
+
143
+ return [
144
+ ...checkRequired(schema, data, path),
145
+ ...checkProperties(schema, data, path),
146
+ ...checkAdditionalProperties(schema, data, path),
147
+ ...checkEnum(schema, data, path),
148
+ ...checkConst(schema, data, path),
149
+ ...checkNot(schema, data, path),
150
+ ...checkAllOf(schema, data, path),
151
+ ];
152
+ }
153
+ // ── Exported validators ──────────────────────────────────────────────
154
+
155
+ export function validateForgeOutput(data) {
156
+ const stage = 'forge';
157
+ const errors = validateSchema(FORGE_SCHEMA, data, stage);
158
+ if (errors.length) return { ok: false, errors };
159
+ return { ok: true };
160
+ }
161
+
162
+ export function validateAppraiseOutput(data) {
163
+ const stage = 'appraise';
164
+ const errors = validateSchema(APPRAISE_SCHEMA, data, stage);
165
+ if (errors.length) return { ok: false, errors };
166
+ return { ok: true };
167
+ }
168
+
169
+ export function validateHumanAppraiseOutput(data) {
170
+ const stage = 'human-appraise';
171
+ const errors = validateSchema(HUMAN_APPRAISE_SCHEMA, data, stage);
172
+ if (errors.length) return { ok: false, errors };
173
+ return { ok: true };
174
+ }
@@ -266,17 +266,17 @@ function buildForgePromptLines({ cycle, outputType, forgeItem }) {
266
266
  `File: ${forgeItem.file}`,
267
267
  `Issue: ${forgeItem.text}`,
268
268
  ``,
269
- `Respond with EXACTLY one of:`,
270
- ` - ACTIONED — fix the issue by changing the artefact file`,
271
- ` - WONT-FIX: <justification> — the issue is already resolved or does not apply`,
269
+ `Call foundry_stage_output with the correct status:`,
270
+ ` - foundry_stage_output({ status: "actioned" }) — fix the issue by changing the artefact file`,
271
+ ` - foundry_stage_output({ status: "wont-fix", reason: "<justification>" }) — the issue is already resolved or does not apply`,
272
272
  ``,
273
- `Write NOTHING else in the stage_end summary no descriptions, no explanations.`,
273
+ `Then call foundry_stage_end(). Write nothing else format is validated by the tool.`,
274
274
  );
275
275
  } else {
276
276
  lines.push(
277
277
  ``,
278
278
  `First generation — no feedback to address yet.`,
279
- `Produce the artefact and call foundry_stage_end({summary: "DONE"}).`,
279
+ `Produce the artefact, call foundry_stage_output({ status: "done" }), then foundry_stage_end().`,
280
280
  );
281
281
  }
282
282
  return lines;
@@ -301,7 +301,7 @@ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, o
301
301
  lines.push(
302
302
  ``,
303
303
  `Your FIRST tool call MUST be foundry_stage_begin({stage, cycle, token}) using the values above.`,
304
- `Your LAST tool call MUST be foundry_stage_end({summary}).`,
304
+ `Your LAST tool call MUST be foundry_stage_end().`,
305
305
  );
306
306
  return lines.join('\n');
307
307
  }
@@ -0,0 +1,240 @@
1
+ // Foundry v3.x orchestrate: post-dispatch handlers for structured output
2
+ // reading from .foundry/stage-outputs/ files.
3
+
4
+ import path from 'node:path';
5
+ import { computeArtefactVersion } from './lib/artefacts.js';
6
+ import { enforceForgeContract } from './lib/forge-contract.js';
7
+ import { loadHistory } from './lib/history.js';
8
+ import { stageBaseOf } from './lib/stage-guard.js';
9
+ import { readForgeFilePatterns, violation } from './orchestrate-cycle.js';
10
+ import { openFeedbackStore } from './lib/feedback-store.js';
11
+ import { finaliseStage } from './orchestrate-phases.js';
12
+
13
+ const FORGE_CTX = '.foundry/forge-context.json';
14
+
15
+ export function safeUnlink(io, filePath) {
16
+ try { io.unlink(filePath); } catch (err) {
17
+ if (err.code !== 'ENOENT') throw err;
18
+ console.warn(`ENOENT: file already removed: ${filePath}`);
19
+ }
20
+ }
21
+
22
+ export async function readStageOutput(filePath, io) {
23
+ try {
24
+ const content = await io.readFile(filePath);
25
+ const lines = content.trim().split('\n').filter(Boolean);
26
+ if (lines.length === 0) return [];
27
+ return lines.map(line => JSON.parse(line));
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ async function readForgeStageOutput(io) {
34
+ let entries;
35
+ try { entries = await io.readDir('.foundry/stage-outputs'); } catch { entries = []; }
36
+ return parseForgeOutput(entries, io);
37
+ }
38
+
39
+ async function parseForgeOutput(entries, io) {
40
+ const files = (Array.isArray(entries) ? entries : []).filter(f => f.endsWith('.jsonl'));
41
+ if (files.length === 0) return { ok: false, error: 'forge: no stage output found' };
42
+
43
+ const allOutputs = [];
44
+ for (const f of files) {
45
+ const filePath = path.join('.foundry/stage-outputs', f);
46
+ const outputs = await readStageOutput(filePath, io);
47
+ allOutputs.push(...outputs);
48
+ safeUnlink(io, filePath);
49
+ }
50
+
51
+ if (allOutputs.length === 0) return { ok: false, error: 'forge: malformed stage output' };
52
+ return { ok: true, outputs: allOutputs };
53
+ }
54
+
55
+ export async function enforceForgeStage(forgeCtx, fgResult, cycleId, io, cwd) {
56
+ const postVersion = await computeArtefactVersion('foundry', fgResult.outputType, io, cwd);
57
+ const feedbackStore = openFeedbackStore('WORK.feedback.yaml', io);
58
+
59
+ const stageResult = await readForgeStageOutput(io);
60
+ if (!stageResult.ok) return stageResult;
61
+
62
+ const outputs = stageResult.outputs;
63
+ if (outputs.length !== 1) {
64
+ return { ok: false, error: `forge stage_end: expected exactly 1 output object, got ${outputs.length}` };
65
+ }
66
+ const output = outputs[0];
67
+ const item = forgeCtx.forgeItem || null;
68
+
69
+ const { contractPassed } = enforceForgeContract({
70
+ item, preVersion: forgeCtx.forgePreVersion, postVersion, output, feedbackStore, cycleId,
71
+ });
72
+
73
+ if (checkConsecutiveFailures(contractPassed, io, cycleId)) {
74
+ return { violation: 'forge contract failed 3 consecutive times — unable to satisfy feedback requirements' };
75
+ }
76
+ return { postVersion, contractPassed, output };
77
+ }
78
+
79
+ function countConsecutiveForgeFailures(io, cycleId) {
80
+ if (!io.exists('WORK.history.yaml')) return 0;
81
+ const entries = loadHistory('WORK.history.yaml', cycleId, io);
82
+ let count = 0;
83
+ for (let i = entries.length - 1; i >= 0; i--) {
84
+ if (stageBaseOf(entries[i].stage) !== 'forge') break;
85
+ if (entries[i].contract_passed === false) count++;
86
+ else break;
87
+ }
88
+ return count;
89
+ }
90
+
91
+ export function checkConsecutiveFailures(contractPassed, io, cycleId) {
92
+ if (!contractPassed) {
93
+ return countConsecutiveForgeFailures(io, cycleId) + 1 >= 3;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ export async function captureForgeContext(sortResult, args, preCheck, io) {
99
+ const fgResult = await readForgeFilePatterns(preCheck.cycleId, io);
100
+ if (!fgResult) return;
101
+ const preVersion = await computeArtefactVersion('foundry', fgResult.outputType, io, args.cwd);
102
+ const allItems = openFeedbackStore('WORK.feedback.yaml', io).list();
103
+ // Only capture unresolved items (open/rejected) — resolved items are terminal
104
+ // and presenting them to forge causes the contract to fail and revert them.
105
+ const unresolvedItems = allItems.filter(item => {
106
+ const state = item.history?.[0]?.state ?? 'open';
107
+ return state === 'open' || state === 'rejected';
108
+ });
109
+ if (!io.exists('.foundry')) io.mkdir('.foundry');
110
+ const forgeItem = unresolvedItems.length > 0
111
+ ? ({
112
+ id: unresolvedItems[0].id,
113
+ file: unresolvedItems[0].file,
114
+ tag: unresolvedItems[0].tag,
115
+ text: unresolvedItems[0].text,
116
+ source: (typeof unresolvedItems[0].source === 'string'
117
+ ? unresolvedItems[0].source.split(':')[0]
118
+ : unresolvedItems[0].source),
119
+ sourceAlias: unresolvedItems[0].source,
120
+ })
121
+ : null;
122
+ const ctx = { forgePreVersion: preVersion, forgeItem };
123
+ io.writeFile(FORGE_CTX, JSON.stringify(ctx));
124
+ }
125
+
126
+ export async function runForgePostDispatch(args, activeStage, lastStage, cycleId, io) {
127
+ const fgResult = await readForgeFilePatterns(cycleId, io);
128
+ const base = { lastStage, activeStage, cycleId, io, finalize: args.finalize, git: args.git };
129
+ if (!fgResult) return finaliseStage(base);
130
+ if (!io.exists(FORGE_CTX)) return finaliseStage(base);
131
+ const forgeCtx = JSON.parse(io.readFile(FORGE_CTX));
132
+ io.unlink(FORGE_CTX);
133
+ const result = await enforceForgeStage(forgeCtx, fgResult, cycleId, io, args.cwd);
134
+ if (result.violation) return violation(result.violation, []);
135
+ if (result.error) return violation(result.error, []);
136
+ return finaliseStage({
137
+ ...base,
138
+ postVersion: result.postVersion,
139
+ contractPassed: result.contractPassed,
140
+ structuredSummary: JSON.stringify(result.output),
141
+ });
142
+ }
143
+
144
+ function isVerdictApproved(output) {
145
+ return output && output.verdict === 'approved';
146
+ }
147
+
148
+ async function readStageOutputFiles(io) {
149
+ const dir = '.foundry/stage-outputs';
150
+ let entries;
151
+ try { entries = await io.readDir(dir); } catch { entries = []; }
152
+ return (entries || []).filter(f => f.endsWith('.jsonl'));
153
+ }
154
+
155
+ function resolveHumanAppraiseVerdict(output) {
156
+ return isVerdictApproved(output) ? 'approved' : '';
157
+ }
158
+
159
+ function cleanupHumanAppraiseFiles(io, files) {
160
+ for (const f of files) {
161
+ safeUnlink(io, path.join('.foundry/stage-outputs', f));
162
+ }
163
+ }
164
+
165
+ export async function runHumanAppraisePostDispatch(args, activeStage, lastStage, cycleId, io) {
166
+ const files = await readStageOutputFiles(io);
167
+
168
+ if (files.length === 0) {
169
+ return finaliseStage({
170
+ lastStage, activeStage, cycleId, io,
171
+ finalize: args.finalize, git: args.git,
172
+ structuredSummary: '',
173
+ });
174
+ }
175
+
176
+ if (files.length > 1) {
177
+ cleanupHumanAppraiseFiles(io, files);
178
+ return finaliseStage({
179
+ lastStage, activeStage, cycleId, io,
180
+ finalize: args.finalize, git: args.git,
181
+ structuredSummary: '',
182
+ });
183
+ }
184
+
185
+ const outputs = await readStageOutput(path.join('.foundry/stage-outputs', files[0]), io);
186
+ cleanupHumanAppraiseFiles(io, files);
187
+
188
+ // Any approved verdict in the outputs means approved
189
+ const approved = outputs.some(o => o && o.verdict === 'approved');
190
+ const structuredSummary = approved ? 'approved' : '';
191
+
192
+ return finaliseStage({
193
+ lastStage, activeStage, cycleId, io,
194
+ finalize: args.finalize, git: args.git,
195
+ structuredSummary,
196
+ });
197
+ }
198
+
199
+ export async function runAppraisePostDispatch(args, activeStage, lastStage, cycleId, io) {
200
+ const files = await readStageOutputFiles(io);
201
+
202
+ if (files.length === 0) {
203
+ return finaliseStage({
204
+ lastStage, activeStage, cycleId, io,
205
+ finalize: args.finalize, git: args.git,
206
+ structuredSummary: undefined,
207
+ });
208
+ }
209
+
210
+ let totalCount = 0;
211
+ for (const f of files) {
212
+ const filePath = path.join('.foundry/stage-outputs', f);
213
+ const outputs = await readStageOutput(filePath, io);
214
+ totalCount += outputs.length;
215
+ }
216
+
217
+ // Do NOT clean up files — consolidateAppraise handles cleanup later
218
+
219
+ const structuredSummary = totalCount > 0 ? `found:${totalCount}` : undefined;
220
+
221
+ return finaliseStage({
222
+ lastStage, activeStage, cycleId, io,
223
+ finalize: args.finalize, git: args.git,
224
+ structuredSummary,
225
+ });
226
+ }
227
+
228
+ export function routePostDispatchStage(baseStageName, opts) {
229
+ if (baseStageName === 'forge') return runForgePostDispatch(opts.args, opts.activeStage, opts.lastStage, opts.cycleId, opts.io);
230
+ if (baseStageName === 'human-appraise') return runHumanAppraisePostDispatch(opts.args, opts.activeStage, opts.lastStage, opts.cycleId, opts.io);
231
+ if (baseStageName === 'appraise') return runAppraisePostDispatch(opts.args, opts.activeStage, opts.lastStage, opts.cycleId, opts.io);
232
+ return finaliseStage({
233
+ lastStage: opts.lastStage,
234
+ activeStage: opts.activeStage,
235
+ cycleId: opts.cycleId,
236
+ io: opts.io,
237
+ finalize: opts.args.finalize,
238
+ git: opts.args.git,
239
+ });
240
+ }
@@ -20,22 +20,25 @@ function buildFinalizeViolation(finalizeResult) {
20
20
  return violation(`stage_finalize error: ${finalizeResult.error}`, []);
21
21
  }
22
22
 
23
+ function resolveStageSummary(ctx) {
24
+ return ctx.structuredSummary || ctx.lastStage.summary || '(no summary)';
25
+ }
26
+
23
27
  function buildStageEntryBase(ctx) {
24
- const summary = ctx.lastStage.summary || '(no summary)';
28
+ const summary = resolveStageSummary(ctx);
25
29
  const changed = ctx.lastStage.changedFiles ?? [];
26
- return { cycle: ctx.cycleId, stage: ctx.lastStage.stage,
30
+ const base = { cycle: ctx.cycleId, stage: ctx.lastStage.stage,
27
31
  iteration: ctx.iteration, comment: summary,
28
32
  openFeedback: ctx.openFeedback, changedFiles: changed,
29
- ...(baseStage(ctx.lastStage.stage || '') === 'forge'
30
- ? buildForgeHistoryEntry({
31
- cycle: ctx.cycleId, stage: ctx.lastStage.stage,
32
- iteration: ctx.iteration, comment: summary,
33
- artefactVersion: ctx.artefactVersion,
34
- contractPassed: ctx.contractPassed,
35
- changedFiles: changed,
36
- })
37
- : {}),
38
33
  };
34
+ if (baseStage(ctx.lastStage.stage || '') !== 'forge') return base;
35
+ return { ...base, ...buildForgeHistoryEntry({
36
+ cycle: ctx.cycleId, stage: ctx.lastStage.stage,
37
+ iteration: ctx.iteration, comment: summary,
38
+ artefactVersion: ctx.artefactVersion,
39
+ contractPassed: ctx.contractPassed,
40
+ changedFiles: changed,
41
+ }) };
39
42
  }
40
43
 
41
44
  function writeHistoryEntries(ctx) {
@@ -58,8 +61,8 @@ async function computeAllowedPatterns(lastStage, cycleId, io) {
58
61
  return allowedPatternsForStage({ stageBase: stageB, forgeFilePatterns });
59
62
  }
60
63
 
61
- function buildCommitMessage(cycleId, lastStage) {
62
- return `[${cycleId}] ${lastStage.stage}: ${lastStage.summary || '(no summary)'}`;
64
+ function buildCommitMessage(cycleId, lastStage, structuredSummary) {
65
+ return `[${cycleId}] ${lastStage.stage}: ${structuredSummary || lastStage.summary || '(no summary)'}`;
63
66
  }
64
67
 
65
68
  function rollbackState(io, original) {
@@ -68,10 +71,10 @@ function rollbackState(io, original) {
68
71
  else if (io.exists('WORK.history.yaml')) { io.unlink('WORK.history.yaml'); }
69
72
  }
70
73
 
71
- async function tryStageCommit(git, lastStage, cycleId, io) {
74
+ async function tryStageCommit(git, lastStage, cycleId, io, structuredSummary) {
72
75
  if (!git || typeof git.commit !== 'function') return null;
73
76
  const allowedPatterns = await computeAllowedPatterns(lastStage, cycleId, io);
74
- return tryCommit(git, buildCommitMessage(cycleId, lastStage), allowedPatterns, lastStage.stage);
77
+ return tryCommit(git, buildCommitMessage(cycleId, lastStage, structuredSummary), allowedPatterns, lastStage.stage);
75
78
  }
76
79
 
77
80
  function clearStageState(activeStage, lastStage, io) {
@@ -80,7 +83,7 @@ function clearStageState(activeStage, lastStage, io) {
80
83
  }
81
84
 
82
85
  export async function finaliseStage(args) {
83
- const { lastStage, activeStage, cycleId, io, finalize, git, postVersion, contractPassed } = args;
86
+ const { lastStage, activeStage, cycleId, io, finalize, git, postVersion, contractPassed, structuredSummary } = args;
84
87
  const original = {
85
88
  workMd: io.readFile('WORK.md'),
86
89
  history: io.exists('WORK.history.yaml') ? io.readFile('WORK.history.yaml') : null,
@@ -104,8 +107,9 @@ export async function finaliseStage(args) {
104
107
  lastStage: { ...lastStage, changedFiles: finalizeResult.changedFiles },
105
108
  iteration, openFeedback, io,
106
109
  artefactVersion: postVersion, contractPassed,
110
+ structuredSummary,
107
111
  });
108
- const commitErr = await tryStageCommit(git, lastStage, cycleId, io);
112
+ const commitErr = await tryStageCommit(git, lastStage, cycleId, io, structuredSummary);
109
113
  if (commitErr) {
110
114
  rollbackState(io, original);
111
115
  clearStageState(activeStage, null, io);
@@ -7,9 +7,6 @@ import matter from 'gray-matter';
7
7
  import { readActiveStage, readLastStage, writeActiveStage, clearActiveStage } from './lib/state.js';
8
8
  import { stageBaseOf } from './lib/stage-guard.js';
9
9
  import { ulid as defaultUlid } from './lib/ulid.js';
10
- import { computeArtefactVersion } from './lib/artefacts.js';
11
- import { enforceForgeContract } from './lib/forge-contract.js';
12
- import { loadHistory } from './lib/history.js';
13
10
  import { getCycleDefinition } from './lib/config.js';
14
11
  import {
15
12
  readCycleTargets,
@@ -33,6 +30,11 @@ import { runQuench } from './quench-module.js';
33
30
  import { gatherAppraiseContext, consolidateAppraise } from './appraise-module.js';
34
31
  import { openFeedbackStore } from './lib/feedback-store.js';
35
32
  import { guardNoWorkMd, guardMissingCycleId, guardSetupInconsistent, guardOrphanedStage, guardMissingLastStage, guardLastResults } from './lib/orchestrate-guards.js';
33
+ import {
34
+ captureForgeContext,
35
+ enforceForgeStage,
36
+ routePostDispatchStage,
37
+ } from './orchestrate-dispatch.js';
36
38
 
37
39
  export {
38
40
  renderDispatchPrompt, synthesizeStages, computeOpenFeedback,
@@ -112,7 +114,8 @@ function buildQuenchContext(cycleId, args, io) {
112
114
 
113
115
  async function buildAppraiseCtx(cycleId, args, io) {
114
116
  const stageId = `appraise:${cycleId}`;
115
- const defaultModel = args.defaultModel ?? await readAppraiseModel(cycleId, io);
117
+ const cycleModel = await readAppraiseModel(cycleId, io);
118
+ const defaultModel = cycleModel || args.defaultModel;
116
119
  return { cycleId, io, git: args.git, finalize: buildFinalizeWrapper(cycleId, args, io),
117
120
  foundryDir: 'foundry', defaultModel,
118
121
  baseBranch: args.baseBranch ?? 'main', cwd: args.cwd ?? process.cwd(),
@@ -139,77 +142,6 @@ function writeStageRecord(io, cycleId, route) {
139
142
  writeActiveStage(io, { cycle: cycleId, stage: `${route}`, token: null, baseSha: resolveBaseSha(io) });
140
143
  }
141
144
 
142
- const FORGE_CTX = '.foundry/forge-context.json';
143
-
144
- async function captureForgeContext(sortResult, args, preCheck, io) {
145
- const fgResult = await readForgeFilePatterns(preCheck.cycleId, io);
146
- if (!fgResult) return;
147
- const preVersion = await computeArtefactVersion('foundry', fgResult.outputType, io, args.cwd);
148
- const allItems = openFeedbackStore('WORK.feedback.yaml', io).list();
149
- // Only capture unresolved items (open/rejected) — resolved items are terminal
150
- // and presenting them to forge causes the contract to fail and revert them.
151
- const unresolvedItems = allItems.filter(item => {
152
- const state = item.history?.[0]?.state ?? 'open';
153
- return state === 'open' || state === 'rejected';
154
- });
155
- if (!io.exists('.foundry')) io.mkdir('.foundry');
156
- const forgeItem = unresolvedItems.length > 0
157
- ? ({
158
- id: unresolvedItems[0].id,
159
- file: unresolvedItems[0].file,
160
- tag: unresolvedItems[0].tag,
161
- text: unresolvedItems[0].text,
162
- source: (typeof unresolvedItems[0].source === 'string'
163
- ? unresolvedItems[0].source.split(':')[0]
164
- : unresolvedItems[0].source),
165
- sourceAlias: unresolvedItems[0].source,
166
- })
167
- : null;
168
- const ctx = { forgePreVersion: preVersion, forgeItem };
169
- io.writeFile(FORGE_CTX, JSON.stringify(ctx));
170
- }
171
-
172
- function countConsecutiveForgeFailures(io, cycleId) {
173
- if (!io.exists('WORK.history.yaml')) return 0;
174
- const entries = loadHistory('WORK.history.yaml', cycleId, io);
175
- let count = 0;
176
- for (let i = entries.length - 1; i >= 0; i--) {
177
- if (stageBaseOf(entries[i].stage) !== 'forge') break;
178
- if (entries[i].contract_passed === false) count++;
179
- else break;
180
- }
181
- return count;
182
- }
183
-
184
- function checkConsecutiveFailures(contractPassed, io, cycleId) {
185
- if (!contractPassed) {
186
- return countConsecutiveForgeFailures(io, cycleId) + 1 >= 3;
187
- }
188
- return false;
189
- }
190
-
191
- async function enforceForgeStage(forgeCtx, fgResult, cycleId, io, cwd) {
192
- const postVersion = await computeArtefactVersion('foundry', fgResult.outputType, io, cwd);
193
- const feedbackStore = openFeedbackStore('WORK.feedback.yaml', io);
194
- const lastStage = readLastStage(io);
195
- const summary = (lastStage && lastStage.summary) || '';
196
- const item = forgeCtx.forgeItem || null;
197
-
198
- const { contractPassed } = enforceForgeContract({
199
- item,
200
- preVersion: forgeCtx.forgePreVersion,
201
- postVersion,
202
- summary,
203
- feedbackStore,
204
- cycleId,
205
- });
206
-
207
- if (checkConsecutiveFailures(contractPassed, io, cycleId)) {
208
- return { violation: 'forge contract failed 3 consecutive times — unable to satisfy feedback requirements' };
209
- }
210
- return { postVersion, contractPassed };
211
- }
212
-
213
145
  async function handleQuenchRoute(sortResult, preCheck, args, io) {
214
146
  writeStageRecord(io, preCheck.cycleId, sortResult.route);
215
147
  const quenchCtx = buildQuenchContext(preCheck.cycleId, args, io);
@@ -269,7 +201,7 @@ export async function runOrchestrate(args, io) {
269
201
  function checkFlowGuards(args, activeStage, lastStage) {
270
202
  const lastResultsErr = guardLastResults(args, activeStage, lastStage);
271
203
  if (lastResultsErr) return lastResultsErr;
272
- // Only flag orphaned stage when not on consolidation path (lastResults path has activeStage but no lastResult)
204
+ // Only flag orphaned stage when not on consolidation path
273
205
  if (args.lastResults === undefined) return guardOrphanedStage(activeStage, args.lastResult);
274
206
  return null;
275
207
  }
@@ -305,25 +237,11 @@ async function runSetupIfNeeded(preCheck, args, io) {
305
237
  return err || setupWorkfile(buildSetupArgs(preCheck, args, io));
306
238
  }
307
239
 
308
- async function runForgePostDispatch(args, activeStage, lastStage, cycleId, io) {
309
- const fgResult = await readForgeFilePatterns(cycleId, io);
310
- const base = { lastStage, activeStage, cycleId, io, finalize: args.finalize, git: args.git };
311
- if (!fgResult) return finaliseStage(base);
312
- if (!io.exists(FORGE_CTX)) return finaliseStage(base);
313
- const forgeCtx = JSON.parse(io.readFile(FORGE_CTX));
314
- io.unlink(FORGE_CTX);
315
- const result = await enforceForgeStage(forgeCtx, fgResult, cycleId, io, args.cwd);
316
- if (result.violation) return violation(result.violation, []);
317
- return finaliseStage({ ...base, postVersion: result.postVersion, contractPassed: result.contractPassed });
318
- }
319
-
320
240
  async function runPostDispatch(args, activeStage, lastStage, cycleId, io) {
321
241
  if (!args.lastResult) return null;
322
242
  if (args.lastResult.ok === false) {
323
243
  return handleViolation({ lastResult: args.lastResult, activeStage, lastStage, cycleId, io });
324
244
  }
325
- const stageErr = guardMissingLastStage(lastStage);
326
- if (stageErr) return stageErr;
327
- if (stageBaseOf(lastStage.stage) === 'forge') return runForgePostDispatch(args, activeStage, lastStage, cycleId, io);
328
- return finaliseStage({ lastStage, activeStage, cycleId, io, finalize: args.finalize, git: args.git });
245
+ if (guardMissingLastStage(lastStage)) return guardMissingLastStage(lastStage);
246
+ return routePostDispatchStage(stageBaseOf(lastStage.stage), { args, activeStage, lastStage, cycleId, io });
329
247
  }