@really-knows-ai/foundry 3.3.8 → 3.4.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.
- package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +2 -0
- package/dist/.opencode/plugins/foundry-tools/validate-tools.js +8 -245
- package/dist/CHANGELOG.md +57 -0
- package/dist/scripts/appraise-module.js +452 -0
- package/dist/scripts/lib/artefacts.js +12 -0
- package/dist/scripts/lib/validation.js +230 -0
- package/dist/scripts/orchestrate-cycle.js +65 -0
- package/dist/scripts/orchestrate-phases.js +9 -2
- package/dist/scripts/orchestrate.js +167 -43
- package/dist/scripts/quench-module.js +153 -0
- package/dist/scripts/sort.js +24 -7
- package/dist/skills/orchestrate/SKILL.md +29 -1
- package/package.json +5 -5
|
@@ -188,6 +188,71 @@ export function synthesizeStages({ cycleId, hasValidation, humanAppraise, assay
|
|
|
188
188
|
return stages;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Dispatch-multi action constants and validators.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export const DISPATCH_MULTI_ACTION = 'dispatch_multi';
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Validate a dispatch_multi action object.
|
|
199
|
+
*
|
|
200
|
+
* Checks action type, tasks array shape, and each task's required fields.
|
|
201
|
+
* Returns undefined on success, a violation object on failure.
|
|
202
|
+
*/
|
|
203
|
+
function validString(value) {
|
|
204
|
+
return typeof value === 'string' && value.length > 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkActionType(action) {
|
|
208
|
+
if (!action || action.action !== DISPATCH_MULTI_ACTION) {
|
|
209
|
+
return violation(`action must be "${DISPATCH_MULTI_ACTION}"`, []);
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function validateAllTasks(tasks) {
|
|
215
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
216
|
+
const err = checkDispatchTask(tasks[i], i);
|
|
217
|
+
if (err) return err;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function validateDispatchMulti(action) {
|
|
223
|
+
const typeErr = checkActionType(action);
|
|
224
|
+
if (typeErr) return typeErr;
|
|
225
|
+
if (!Array.isArray(action.tasks)) {
|
|
226
|
+
return violation('dispatch_multi tasks must be an array', []);
|
|
227
|
+
}
|
|
228
|
+
return validateAllTasks(action.tasks);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function checkDispatchTask(task, index) {
|
|
232
|
+
if (!validString(task.subagent_type)) {
|
|
233
|
+
return violation(`dispatch_multi tasks[${index}]: subagent_type must be a non-empty string`, []);
|
|
234
|
+
}
|
|
235
|
+
if (!validString(task.prompt)) {
|
|
236
|
+
return violation(`dispatch_multi tasks[${index}]: prompt must be a non-empty string`, []);
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build a dispatch_multi response object.
|
|
243
|
+
*
|
|
244
|
+
* Pure utility for constructing the action envelope expected by the
|
|
245
|
+
* orchestrate skill's LLM loop.
|
|
246
|
+
*/
|
|
247
|
+
export function buildDispatchMultiResponse(tasks, stage, cycle) {
|
|
248
|
+
return {
|
|
249
|
+
action: DISPATCH_MULTI_ACTION,
|
|
250
|
+
tasks,
|
|
251
|
+
stage,
|
|
252
|
+
cycle,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
191
256
|
// ---------------------------------------------------------------------------
|
|
192
257
|
// Dispatch prompt rendering (pure utility, used by handleSortResult and exported publicly).
|
|
193
258
|
// ---------------------------------------------------------------------------
|
|
@@ -58,7 +58,7 @@ async function buildDispatchAction(route, model, token, ctx) {
|
|
|
58
58
|
return { action: 'dispatch', stage: route, subagent_type: model, prompt: renderDispatchPrompt(makeDispatchPayload(route, ctx.cycleId, token, ctx.cwd, filePatterns)) };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function routeDispatch(route) {
|
|
61
|
+
export function routeDispatch(route) {
|
|
62
62
|
return typeof route === 'string' ? route.split(':')[0] : '';
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -68,11 +68,18 @@ async function handleTerminalRoute(route, sortResult, ctx) {
|
|
|
68
68
|
return violation(sortResult.details ?? 'sort returned violation');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function isTerminalRoute(route) {
|
|
72
|
+
return route === 'done' || route === 'blocked' || route === 'violation';
|
|
73
|
+
}
|
|
74
|
+
|
|
71
75
|
export async function handleSortResult(sortResult, ctx) {
|
|
72
76
|
const { route, model, token } = sortResult;
|
|
73
|
-
if (route
|
|
77
|
+
if (isTerminalRoute(route)) {
|
|
74
78
|
return handleTerminalRoute(route, sortResult, ctx);
|
|
75
79
|
}
|
|
80
|
+
if (routeDispatch(route) === 'quench' || routeDispatch(route) === 'appraise') {
|
|
81
|
+
return violation(`${routeDispatch(route)} route reached handleSortResult — should have been handled upstream in orchestrate.js`);
|
|
82
|
+
}
|
|
76
83
|
if (routeDispatch(route) === 'human-appraise') {
|
|
77
84
|
return humanAppraiseAction(route, token, ctx.cycleId, ctx.io);
|
|
78
85
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { runSort } from './sort.js';
|
|
6
6
|
import { parseFrontmatter } from './lib/workfile.js';
|
|
7
|
-
import { readActiveStage, readLastStage } from './lib/state.js';
|
|
7
|
+
import { readActiveStage, readLastStage, writeActiveStage, clearActiveStage } from './lib/state.js';
|
|
8
|
+
import { stageBaseOf } from './lib/stage-guard.js';
|
|
8
9
|
import { ulid as defaultUlid } from './lib/ulid.js';
|
|
9
10
|
import {
|
|
10
11
|
findCycleOutputArtefact,
|
|
@@ -14,15 +15,27 @@ import {
|
|
|
14
15
|
synthesizeStages,
|
|
15
16
|
violation,
|
|
16
17
|
computeOpenFeedback,
|
|
18
|
+
markArtefactBlocked,
|
|
19
|
+
DISPATCH_MULTI_ACTION,
|
|
20
|
+
validateDispatchMulti,
|
|
21
|
+
buildDispatchMultiResponse,
|
|
17
22
|
} from './orchestrate-cycle.js';
|
|
18
23
|
import {
|
|
19
24
|
handleSortResult,
|
|
20
25
|
setupWorkfile,
|
|
21
26
|
finaliseStage,
|
|
22
27
|
handleViolation,
|
|
28
|
+
routeDispatch,
|
|
23
29
|
} from './orchestrate-phases.js';
|
|
30
|
+
import { runQuench } from './quench-module.js';
|
|
31
|
+
import { gatherAppraiseContext, consolidateAppraise } from './appraise-module.js';
|
|
32
|
+
import { openFeedbackStore } from './lib/feedback-store.js';
|
|
24
33
|
|
|
25
|
-
export {
|
|
34
|
+
export {
|
|
35
|
+
renderDispatchPrompt, synthesizeStages, computeOpenFeedback,
|
|
36
|
+
DISPATCH_MULTI_ACTION, validateDispatchMulti, buildDispatchMultiResponse,
|
|
37
|
+
};
|
|
38
|
+
export { gatherAppraiseContext, consolidateAppraise };
|
|
26
39
|
export { findCycleOutputArtefact, readCycleTargets, readForgeFilePatterns };
|
|
27
40
|
export { handleSortResult as __handleSortResultForTest };
|
|
28
41
|
|
|
@@ -38,28 +51,19 @@ export function needsSetup(workMdContent) {
|
|
|
38
51
|
// ---------------------------------------------------------------------------
|
|
39
52
|
|
|
40
53
|
function guardNoWorkMd(io) {
|
|
41
|
-
if (!io.exists('WORK.md'))
|
|
42
|
-
return violation('no WORK.md; flow skill must create it first');
|
|
43
|
-
}
|
|
54
|
+
if (!io.exists('WORK.md')) return violation('no WORK.md; flow skill must create it first');
|
|
44
55
|
return null;
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
function guardMissingCycleId(io) {
|
|
48
59
|
const workContent = io.readFile('WORK.md');
|
|
49
60
|
const fm = parseFrontmatter(workContent);
|
|
50
|
-
if (!fm.cycle)
|
|
51
|
-
return violation('WORK.md frontmatter missing cycle field', ['WORK.md']);
|
|
52
|
-
}
|
|
61
|
+
if (!fm.cycle) return violation('WORK.md frontmatter missing cycle field', ['WORK.md']);
|
|
53
62
|
return { cycleId: fm.cycle, workContent };
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
function guardSetupInconsistent(lastResult) {
|
|
57
|
-
if (lastResult)
|
|
58
|
-
return violation(
|
|
59
|
-
'inconsistent state: lastResult provided but WORK.md still needs setup',
|
|
60
|
-
['WORK.md'],
|
|
61
|
-
);
|
|
62
|
-
}
|
|
66
|
+
if (lastResult) return violation('inconsistent state: lastResult provided but WORK.md still needs setup', ['WORK.md']);
|
|
63
67
|
return null;
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -75,18 +79,44 @@ function guardOrphanedStage(activeStage, lastResult) {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
function guardMissingLastStage(lastStage) {
|
|
78
|
-
if (!lastStage)
|
|
79
|
-
return violation('lastResult provided but no last stage recorded — orphaned state');
|
|
80
|
-
}
|
|
82
|
+
if (!lastStage) return violation('lastResult provided but no last stage recorded — orphaned state');
|
|
81
83
|
return null;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
function checkLastResultsConflict(args) {
|
|
87
|
+
if (args.lastResult !== undefined && args.lastResults !== undefined) return violation('lastResult and lastResults are mutually exclusive');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkLastResultsShape(args) {
|
|
92
|
+
if (args.lastResults === undefined) return null;
|
|
93
|
+
if (!Array.isArray(args.lastResults)) return violation('lastResults must be an array');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isDuplicateConsolidation(lastStage, activeStage) {
|
|
98
|
+
return lastStage && lastStage.stage === activeStage.stage;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function checkLastResultsStageContext(args, activeStage, lastStage) {
|
|
102
|
+
if (args.lastResults === undefined) return null;
|
|
103
|
+
if (!activeStage) return violation('lastResults provided but no active stage exists');
|
|
104
|
+
if (stageBaseOf(activeStage.stage) !== 'appraise') return violation(`lastResults provided but active stage "${activeStage.stage}" is not an appraise stage`);
|
|
105
|
+
if (isDuplicateConsolidation(lastStage, activeStage)) return violation(`duplicate lastResults: consolidation already completed for this appraise stage "${activeStage.stage}"`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function guardLastResults(args, activeStage, lastStage) {
|
|
110
|
+
return checkLastResultsConflict(args)
|
|
111
|
+
?? checkLastResultsShape(args)
|
|
112
|
+
?? checkLastResultsStageContext(args, activeStage, lastStage);
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
function buildSortArgs(args, now) {
|
|
85
116
|
return {
|
|
86
|
-
cycleDef: args.cycleDef ?? null,
|
|
87
|
-
mint: args.mint,
|
|
117
|
+
cycleDef: args.cycleDef ?? null, mint: args.mint,
|
|
88
118
|
now: typeof now === 'function' ? now() : now,
|
|
89
|
-
ulid: args.ulid ?? defaultUlid,
|
|
119
|
+
ulid: args.ulid ?? defaultUlid, defaultModel: args.defaultModel,
|
|
90
120
|
};
|
|
91
121
|
}
|
|
92
122
|
|
|
@@ -95,40 +125,141 @@ function buildSortContext(cycleId, args, io) {
|
|
|
95
125
|
}
|
|
96
126
|
|
|
97
127
|
function buildSetupArgs(cycleResult, args, io) {
|
|
98
|
-
return {
|
|
99
|
-
cycleId: cycleResult.cycleId,
|
|
100
|
-
workContent: cycleResult.workContent,
|
|
101
|
-
io,
|
|
102
|
-
git: args.git,
|
|
103
|
-
foundryDir: 'foundry',
|
|
104
|
-
};
|
|
128
|
+
return { cycleId: cycleResult.cycleId, workContent: cycleResult.workContent, io, git: args.git, foundryDir: 'foundry' };
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
function isViolation(result) {
|
|
108
132
|
return result && result.action === 'violation';
|
|
109
133
|
}
|
|
110
134
|
|
|
135
|
+
function buildFinalizeWrapper(cycleId, args, io) {
|
|
136
|
+
return ({ lastStage, activeStage }) =>
|
|
137
|
+
finaliseStage({ lastStage, activeStage, cycleId, io, finalize: args.finalize, git: args.git });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildFeedback(cycleId, stageId, io) {
|
|
141
|
+
return {
|
|
142
|
+
add: (item) => {
|
|
143
|
+
const store = openFeedbackStore('WORK.feedback.yaml', io);
|
|
144
|
+
return store.add({ file: item.file, tag: item.tag, text: item.text, source: stageId, cycle: cycleId });
|
|
145
|
+
},
|
|
146
|
+
list: (query) => {
|
|
147
|
+
const store = openFeedbackStore('WORK.feedback.yaml', io);
|
|
148
|
+
let items = store.list();
|
|
149
|
+
if (query?.file) items = items.filter(it => it.file === query.file);
|
|
150
|
+
if (query?.source) items = items.filter(it => it.source === query.source);
|
|
151
|
+
return items;
|
|
152
|
+
},
|
|
153
|
+
resolve: (id, decision, reason) => {
|
|
154
|
+
const store = openFeedbackStore('WORK.feedback.yaml', io);
|
|
155
|
+
const target = decision === 'approved' ? 'resolved' : 'rejected';
|
|
156
|
+
return store.transition({ id, target, stage: stageId, cycle: cycleId });
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildQuenchContext(cycleId, args, io) {
|
|
162
|
+
const stageId = `quench:${cycleId}`;
|
|
163
|
+
return { cycleId, stageId, io, git: args.git, finalize: buildFinalizeWrapper(cycleId, args, io),
|
|
164
|
+
now: args.now, ulid: args.ulid, mint: args.mint, foundryDir: 'foundry', defaultModel: args.defaultModel,
|
|
165
|
+
feedback: buildFeedback(cycleId, stageId, io) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildAppraiseCtx(cycleId, args, io) {
|
|
169
|
+
const stageId = `appraise:${cycleId}`;
|
|
170
|
+
return { cycleId, io, git: args.git, finalize: buildFinalizeWrapper(cycleId, args, io),
|
|
171
|
+
foundryDir: 'foundry', defaultModel: args.defaultModel,
|
|
172
|
+
activeStage: readActiveStage(io), lastStage: readLastStage(io),
|
|
173
|
+
feedback: buildFeedback(cycleId, stageId, io) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveBaseSha(io) {
|
|
177
|
+
try {
|
|
178
|
+
const sha = io.exec(['git', 'rev-parse', 'HEAD']);
|
|
179
|
+
if (sha && typeof sha === 'string' && sha.trim()) return sha.trim();
|
|
180
|
+
} catch { /* use default */ }
|
|
181
|
+
return '0000000';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeStageRecord(io, cycleId, route) {
|
|
185
|
+
writeActiveStage(io, { cycle: cycleId, stage: `${route}`, token: null, baseSha: resolveBaseSha(io) });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function handleQuenchRoute(sortResult, preCheck, args, io) {
|
|
189
|
+
writeStageRecord(io, preCheck.cycleId, sortResult.route);
|
|
190
|
+
const quenchCtx = buildQuenchContext(preCheck.cycleId, args, io);
|
|
191
|
+
const quenchResult = await runQuench(quenchCtx);
|
|
192
|
+
if (quenchResult.ok === false) return violation(quenchResult.error || 'quench failed');
|
|
193
|
+
const nextSort = runSort(buildSortArgs(args, args.now ?? Date.now), io);
|
|
194
|
+
return dispatchByRoute(nextSort, args, preCheck, io);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function handleAppraiseGatherRoute(sortResult, preCheck, args, io) {
|
|
198
|
+
writeStageRecord(io, preCheck.cycleId, sortResult.route);
|
|
199
|
+
const result = await gatherAppraiseContext(buildAppraiseCtx(preCheck.cycleId, args, io));
|
|
200
|
+
if (result.action === 'violation') { clearActiveStage(io); return result; }
|
|
201
|
+
if (!result.tasks || result.tasks.length === 0) {
|
|
202
|
+
// No appraisers/artefacts — consolidate with empty results to advance
|
|
203
|
+
return handleAppraiseConsolidateRoute(sortResult, preCheck, { ...args, lastResults: [] }, io);
|
|
204
|
+
}
|
|
205
|
+
const validationErr = validateDispatchMulti(result);
|
|
206
|
+
if (validationErr) return validationErr;
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function handleAppraiseConsolidateRoute(sortResult, preCheck, args, io) {
|
|
211
|
+
const ctx = buildAppraiseCtx(preCheck.cycleId, args, io);
|
|
212
|
+
const result = await consolidateAppraise(ctx, args.lastResults);
|
|
213
|
+
if (result.action === 'violation') {
|
|
214
|
+
markArtefactBlocked(preCheck.cycleId, io);
|
|
215
|
+
clearActiveStage(io);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
if (!result.ok) return violation(result.error || 'appraise consolidation failed');
|
|
219
|
+
const nextSort = runSort(buildSortArgs(args, args.now ?? Date.now), io);
|
|
220
|
+
return dispatchByRoute(nextSort, args, preCheck, io);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function dispatchByRoute(sortResult, args, preCheck, io) {
|
|
224
|
+
const base = routeDispatch(sortResult.route);
|
|
225
|
+
if (base === 'quench') return handleQuenchRoute(sortResult, preCheck, args, io);
|
|
226
|
+
if (base === 'appraise') {
|
|
227
|
+
return args.lastResults
|
|
228
|
+
? handleAppraiseConsolidateRoute(sortResult, preCheck, args, io)
|
|
229
|
+
: handleAppraiseGatherRoute(sortResult, preCheck, args, io);
|
|
230
|
+
}
|
|
231
|
+
return handleSortResult(sortResult, buildSortContext(preCheck.cycleId, args, io));
|
|
232
|
+
}
|
|
233
|
+
|
|
111
234
|
export async function runOrchestrate(args, io) {
|
|
112
235
|
const preCheck = runPreChecks(io);
|
|
113
236
|
if (preCheck.error) return preCheck.error;
|
|
114
237
|
return runOrchestrateFlow(preCheck, args, io);
|
|
115
238
|
}
|
|
116
239
|
|
|
240
|
+
function checkFlowGuards(args, activeStage, lastStage) {
|
|
241
|
+
const lastResultsErr = guardLastResults(args, activeStage, lastStage);
|
|
242
|
+
if (lastResultsErr) return lastResultsErr;
|
|
243
|
+
// Only flag orphaned stage when not on consolidation path (lastResults path has activeStage but no lastResult)
|
|
244
|
+
if (args.lastResults === undefined) return guardOrphanedStage(activeStage, args.lastResult);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
117
248
|
async function runOrchestrateFlow(preCheck, args, io) {
|
|
118
249
|
const setupResult = await runSetupIfNeeded(preCheck, args, io);
|
|
119
250
|
if (isViolation(setupResult)) return setupResult;
|
|
120
|
-
|
|
121
251
|
const activeStage = readActiveStage(io);
|
|
122
252
|
const lastStage = readLastStage(io);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (orphanErr) return orphanErr;
|
|
126
|
-
|
|
253
|
+
const guardErr = checkFlowGuards(args, activeStage, lastStage);
|
|
254
|
+
if (guardErr) return guardErr;
|
|
127
255
|
const postDispatchResult = await runPostDispatch(args, activeStage, lastStage, preCheck.cycleId, io);
|
|
128
256
|
if (isViolation(postDispatchResult)) return postDispatchResult;
|
|
257
|
+
return runSortAndDispatch(args, preCheck, io);
|
|
258
|
+
}
|
|
129
259
|
|
|
260
|
+
async function runSortAndDispatch(args, preCheck, io) {
|
|
130
261
|
const sortResult = runSort(buildSortArgs(args, args.now ?? Date.now), io);
|
|
131
|
-
return
|
|
262
|
+
return dispatchByRoute(sortResult, args, preCheck, io);
|
|
132
263
|
}
|
|
133
264
|
|
|
134
265
|
function runPreChecks(io) {
|
|
@@ -142,8 +273,7 @@ function runPreChecks(io) {
|
|
|
142
273
|
async function runSetupIfNeeded(preCheck, args, io) {
|
|
143
274
|
if (!needsSetup(preCheck.workContent)) return null;
|
|
144
275
|
const err = guardSetupInconsistent(args.lastResult);
|
|
145
|
-
|
|
146
|
-
return setupWorkfile(buildSetupArgs(preCheck, args, io));
|
|
276
|
+
return err || setupWorkfile(buildSetupArgs(preCheck, args, io));
|
|
147
277
|
}
|
|
148
278
|
|
|
149
279
|
async function runPostDispatch(args, activeStage, lastStage, cycleId, io) {
|
|
@@ -151,13 +281,7 @@ async function runPostDispatch(args, activeStage, lastStage, cycleId, io) {
|
|
|
151
281
|
if (args.lastResult.ok === false) {
|
|
152
282
|
return handleViolation({ lastResult: args.lastResult, activeStage, lastStage, cycleId, io });
|
|
153
283
|
}
|
|
154
|
-
|
|
155
284
|
const stageErr = guardMissingLastStage(lastStage);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return finaliseStage({
|
|
159
|
-
lastStage, activeStage, cycleId, io,
|
|
160
|
-
finalize: args.finalize ?? null,
|
|
161
|
-
git: args.git,
|
|
162
|
-
});
|
|
285
|
+
const finaliseArgs = { lastStage, activeStage, cycleId, io, finalize: args.finalize ?? null, git: args.git };
|
|
286
|
+
return stageErr || finaliseStage(finaliseArgs);
|
|
163
287
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quench module — deterministic validation run entirely within the orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* The module runs validators for each draft artefact in the current cycle,
|
|
5
|
+
* posts feedback for each validation item, resolves prior quench feedback,
|
|
6
|
+
* and finalises the stage. No LLM involvement.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readActiveStage } from './lib/state.js';
|
|
10
|
+
import { getArtefactsForCycle, setArtefactStatus } from './lib/artefacts.js';
|
|
11
|
+
import { performValidation } from './lib/validation.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run quench (deterministic validation) for a cycle.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} ctx - Context object with io, feedback, finalize, etc.
|
|
17
|
+
* @returns {Promise<{ok: boolean, summary?: string, error?: string}>}
|
|
18
|
+
*/
|
|
19
|
+
export async function runQuench(ctx) {
|
|
20
|
+
const activeStageRecord = readActiveStage(ctx.io);
|
|
21
|
+
if (!activeStageRecord) {
|
|
22
|
+
return { ok: false, error: 'No active stage found' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const artefacts = getArtefactsForCycle(ctx.cycleId, ctx.io);
|
|
26
|
+
|
|
27
|
+
if (artefacts.length === 0) {
|
|
28
|
+
return await handleNoArtefacts(ctx, activeStageRecord);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return await processArtefacts(ctx, artefacts, activeStageRecord);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle the case where no artefacts exist for this cycle.
|
|
36
|
+
*/
|
|
37
|
+
async function handleNoArtefacts(ctx, activeStageRecord) {
|
|
38
|
+
const summary = 'SKIP: no artefacts';
|
|
39
|
+
await ctx.finalize({
|
|
40
|
+
lastStage: { stage: ctx.stageId, summary, baseSha: activeStageRecord.baseSha },
|
|
41
|
+
activeStage: activeStageRecord,
|
|
42
|
+
});
|
|
43
|
+
return { ok: true, summary };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Process each artefact: run validation, post feedback, handle errors.
|
|
48
|
+
*/
|
|
49
|
+
async function processArtefacts(ctx, artefacts, activeStageRecord) {
|
|
50
|
+
const perArtefact = [];
|
|
51
|
+
const currentFeedback = [];
|
|
52
|
+
let allOk = true;
|
|
53
|
+
|
|
54
|
+
for (const artefact of artefacts) {
|
|
55
|
+
const result = await performValidation({
|
|
56
|
+
typeId: artefact.type,
|
|
57
|
+
io: ctx.io,
|
|
58
|
+
foundryDir: ctx.foundryDir,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const outcome = handleArtefactResult(ctx, artefact, result, currentFeedback);
|
|
62
|
+
perArtefact.push(outcome.text);
|
|
63
|
+
if (!outcome.ok) allOk = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const summary = perArtefact.join('; ');
|
|
67
|
+
resolvePriorFeedback(ctx, currentFeedback);
|
|
68
|
+
|
|
69
|
+
await ctx.finalize({
|
|
70
|
+
lastStage: { stage: ctx.stageId, summary, baseSha: activeStageRecord.baseSha },
|
|
71
|
+
activeStage: activeStageRecord,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!allOk) {
|
|
75
|
+
return { ok: false, summary, error: 'One or more artefacts failed validation' };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, summary };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handle validation result for a single artefact.
|
|
82
|
+
*
|
|
83
|
+
* Returns { ok, text } where `ok` indicates whether the artefact passed
|
|
84
|
+
* validation and `text` is the per-artefact summary line.
|
|
85
|
+
*/
|
|
86
|
+
function handleArtefactResult(ctx, artefact, result, currentFeedback) {
|
|
87
|
+
if (isNoValidators(result)) {
|
|
88
|
+
return { ok: true, text: `${artefact.file}: OK: no validators` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (result.error) {
|
|
92
|
+
markArtefactBlocked(ctx.io, artefact.file);
|
|
93
|
+
return { ok: false, text: `${artefact.file}: ${result.error}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isAllErrors(result)) {
|
|
97
|
+
markArtefactBlocked(ctx.io, artefact.file);
|
|
98
|
+
const messages = result.errors.map(e => e.message).join('; ');
|
|
99
|
+
return { ok: false, text: `${artefact.file}: ${messages}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
postFeedbackItems(ctx, artefact, result, currentFeedback);
|
|
103
|
+
return { ok: true, text: `${artefact.file}: ${result.items.length} issues found` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when no validators are configured for this artefact type.
|
|
108
|
+
*/
|
|
109
|
+
function isNoValidators(result) {
|
|
110
|
+
return result.ok && result.validatorsRun === 0 && result.items.length === 0 && result.errors.length === 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when validators ran but produced only errors with no valid items.
|
|
115
|
+
*/
|
|
116
|
+
function isAllErrors(result) {
|
|
117
|
+
return result.items.length === 0 && result.errors.length > 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Post feedback items for validation results and track for resolution.
|
|
122
|
+
*/
|
|
123
|
+
function postFeedbackItems(ctx, artefact, result, currentFeedback) {
|
|
124
|
+
for (const item of result.items) {
|
|
125
|
+
const tag = `law:${item.lawId}:${item.validatorId}`;
|
|
126
|
+
ctx.feedback.add({ file: artefact.file, text: item.text, tag });
|
|
127
|
+
currentFeedback.push({ file: artefact.file, tag });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve prior quench feedback items against current results.
|
|
133
|
+
*/
|
|
134
|
+
function resolvePriorFeedback(ctx, currentFeedback) {
|
|
135
|
+
const currentSigs = new Set(currentFeedback.map(fb => `${fb.file}:${fb.tag}`));
|
|
136
|
+
const priorItems = ctx.feedback.list({ source: ctx.stageId });
|
|
137
|
+
|
|
138
|
+
for (const prior of priorItems) {
|
|
139
|
+
if (prior.state === 'resolved') continue;
|
|
140
|
+
const sig = `${prior.file}:${prior.tag}`;
|
|
141
|
+
const decision = currentSigs.has(sig) ? 'rejected' : 'approved';
|
|
142
|
+
ctx.feedback.resolve(prior.id, decision);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mark an artefact as blocked in the artefacts table.
|
|
148
|
+
*/
|
|
149
|
+
function markArtefactBlocked(io, file) {
|
|
150
|
+
const workText = io.readFile('WORK.md');
|
|
151
|
+
const updated = setArtefactStatus(workText, file, 'blocked');
|
|
152
|
+
io.writeFile('WORK.md', updated);
|
|
153
|
+
}
|
package/dist/scripts/sort.js
CHANGED
|
@@ -147,10 +147,26 @@ function resolveRoute(ctx) {
|
|
|
147
147
|
return determineRoute(ctx.stages, ctx.history, ctx.feedback, ctx.maxIterations);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
function firstModelValue(models) {
|
|
151
|
+
if (!models) return undefined;
|
|
152
|
+
const keys = Object.keys(models);
|
|
153
|
+
return keys.length > 0 ? models[keys[0]] : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveModelId(routeBase, models, defaultModel) {
|
|
157
|
+
return models[routeBase] || defaultModel || models.default || firstModelValue(models);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pickModelId(route, frontmatter, defaultModel) {
|
|
161
|
+
const models = frontmatter.models;
|
|
162
|
+
if (!models) return defaultModel || null;
|
|
163
|
+
return resolveModelId(baseStage(route), models, defaultModel) || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolveModel(route, frontmatter, agentsDir, io, defaultModel) {
|
|
167
|
+
const modelId = pickModelId(route, frontmatter, defaultModel);
|
|
168
|
+
if (!modelId) return null;
|
|
169
|
+
|
|
154
170
|
const model = `foundry-${modelId.replace(/[/.]/g, '-')}`;
|
|
155
171
|
const agentPath = `${agentsDir}/${model}.md`;
|
|
156
172
|
if (!io.exists(agentPath)) {
|
|
@@ -162,8 +178,8 @@ function resolveModel(route, frontmatter, agentsDir, io) {
|
|
|
162
178
|
return model;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
function checkModel(route, frontmatter, agentsDir, io) {
|
|
166
|
-
const modelResult = resolveModel(route, frontmatter, agentsDir, io);
|
|
181
|
+
function checkModel(route, frontmatter, agentsDir, io, defaultModel) {
|
|
182
|
+
const modelResult = resolveModel(route, frontmatter, agentsDir, io, defaultModel);
|
|
167
183
|
if (modelResult && modelResult.error) return { error: modelResult.error };
|
|
168
184
|
return { model: typeof modelResult === 'string' ? modelResult : null };
|
|
169
185
|
}
|
|
@@ -217,6 +233,7 @@ const RUN_SORT_DEFAULTS = Object.freeze({
|
|
|
217
233
|
historyPath: 'WORK.history.yaml',
|
|
218
234
|
foundryDir: 'foundry',
|
|
219
235
|
cycleDef: undefined,
|
|
236
|
+
defaultModel: undefined,
|
|
220
237
|
agentsDir: '.opencode/agents',
|
|
221
238
|
mint: undefined,
|
|
222
239
|
});
|
|
@@ -246,7 +263,7 @@ export function runSort(args = {}, io = defaultIO) {
|
|
|
246
263
|
if (prep.kind !== 'ok') return { route: prep.kind, details: prep.details };
|
|
247
264
|
|
|
248
265
|
const route = resolveRoute(buildRouteCtx(prep));
|
|
249
|
-
const modelCheck = checkModel(route, prep.frontmatter, opts.agentsDir, io);
|
|
266
|
+
const modelCheck = checkModel(route, prep.frontmatter, opts.agentsDir, io, opts.defaultModel);
|
|
250
267
|
if (modelCheck.error) return { route: 'violation', details: modelCheck.error };
|
|
251
268
|
|
|
252
269
|
return mintToken({
|
|
@@ -47,6 +47,34 @@ task tool:
|
|
|
47
47
|
|
|
48
48
|
When the task returns, call `foundry_orchestrate({lastResult: {ok: true}})`. If the task tool itself errored or reported a subagent crash, pass `{ok: false, error: '<message>'}`.
|
|
49
49
|
|
|
50
|
+
### `dispatch_multi`
|
|
51
|
+
|
|
52
|
+
Payload: `{stage, cycle, tasks}`.
|
|
53
|
+
|
|
54
|
+
Fire all tasks in parallel by making multiple `task` tool calls in a single response:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
task tool:
|
|
58
|
+
subagent_type: <task.subagent_type>
|
|
59
|
+
description: "Appraise <artefact> for <cycle>"
|
|
60
|
+
prompt: <task.prompt — pass verbatim>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Repeat for every entry in the `tasks` array. If `tasks` is empty, call
|
|
64
|
+
`foundry_orchestrate({lastResults: []})` directly — no dispatch needed.
|
|
65
|
+
|
|
66
|
+
When all tasks complete, collect their outputs into a `lastResults` array:
|
|
67
|
+
|
|
68
|
+
- Task succeeded: `{ok: true, output: "<subagent output text>"}`
|
|
69
|
+
- Task failed: `{ok: false, error: "<error message>"}`
|
|
70
|
+
|
|
71
|
+
Then call `foundry_orchestrate({lastResults})`.
|
|
72
|
+
|
|
73
|
+
Each appraiser sub-agent prompt already contains the appraiser personality,
|
|
74
|
+
artefact content, and applicable laws. Do NOT inject additional instructions.
|
|
75
|
+
Do NOT call `foundry_stage_begin` or `foundry_stage_end` — the appraise
|
|
76
|
+
module handles lifecycle internally.
|
|
77
|
+
|
|
50
78
|
### `human_appraise`
|
|
51
79
|
|
|
52
80
|
Payload: `{stage, token, context}`.
|
|
@@ -77,7 +105,7 @@ Report to the user: "Cycle halted (violation): `<details>`. Affected files: `<af
|
|
|
77
105
|
|
|
78
106
|
## What you do NOT do
|
|
79
107
|
|
|
80
|
-
- You do NOT inline forge
|
|
108
|
+
- You do NOT inline forge work. Always dispatch forge via `task`. Quench runs internally in the orchestrator. Appraise uses `dispatch_multi` for parallel subagent dispatch followed by internal consolidation.
|
|
81
109
|
- You do NOT mint, modify, or cache tokens. The `prompt` from orchestrate already contains the token verbatim.
|
|
82
110
|
- `foundry_history_append`, `foundry_git_commit`, `foundry_stage_finalize`, and `foundry_sort` are not registered tools; orchestrate handles them internally via the loop.
|
|
83
111
|
- You do NOT reorder the protocol. `foundry_orchestrate` returns, you act, you call back. Nothing else between.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@really-knows-ai/foundry",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "A skill-driven framework for governed artefact generation with AI coding tools. Define your own artefact types, laws, and flows — Foundry handles the forge → quench → appraise pipeline with deterministic routing, quality gates, and iterative refinement.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/.opencode/plugins/foundry.js",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"build": "node scripts/build.js",
|
|
54
|
-
"test": "find tests -name '*.test.js' ! -name '*.integration.test.js' ! -name '*.e2e.test.js' -print0 | xargs -0 node --test --test-reporter=dot",
|
|
54
|
+
"test": "find tests -name '*.test.js' ! -name '*.integration.test.js' ! -name '*.e2e.test.js' -print0 | xargs -0 node --test --experimental-test-module-mocks --test-reporter=dot",
|
|
55
55
|
"test:unit": "pnpm run test",
|
|
56
|
-
"test:integration": "find tests -name '*.integration.test.js' -print0 | xargs -0 node --test --test-reporter=dot",
|
|
57
|
-
"test:e2e": "find tests -name '*.e2e.test.js' -print0 | xargs -0 node --test --test-reporter=dot",
|
|
58
|
-
"test:all": "node --test --test-reporter=dot",
|
|
56
|
+
"test:integration": "find tests -name '*.integration.test.js' -print0 | xargs -0 node --test --experimental-test-module-mocks --test-reporter=dot",
|
|
57
|
+
"test:e2e": "find tests -name '*.e2e.test.js' -print0 | xargs -0 node --test --experimental-test-module-mocks --test-reporter=dot",
|
|
58
|
+
"test:all": "node --test --experimental-test-module-mocks --test-reporter=dot",
|
|
59
59
|
"test:coverage": "node --test --experimental-test-coverage --test-reporter=dot",
|
|
60
60
|
"lint": "eslint src/ tests/ scripts/",
|
|
61
61
|
"build:full": "pnpm run lint --fix && pnpm run test:all && pnpm run build",
|