@really-knows-ai/foundry 2.2.1 → 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.
- package/.opencode/plugins/foundry.js +101 -243
- package/CHANGELOG.md +22 -0
- package/docs/work-spec.md +4 -4
- package/package.json +1 -1
- package/scripts/orchestrate.js +418 -0
- package/skills/flow/SKILL.md +6 -6
- package/skills/human-appraise/SKILL.md +12 -0
- package/skills/orchestrate/SKILL.md +69 -0
- package/skills/upgrade-foundry/SKILL.md +31 -0
- package/skills/cycle/SKILL.md +0 -87
- package/skills/sort/SKILL.md +0 -111
|
@@ -12,13 +12,12 @@ import fs from 'fs';
|
|
|
12
12
|
import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, mkdirSync } from 'fs';
|
|
13
13
|
import { fileURLToPath } from 'url';
|
|
14
14
|
import { tool } from '@opencode-ai/plugin';
|
|
15
|
-
import { loadHistory
|
|
16
|
-
import { parseFrontmatter, createWorkfile,
|
|
15
|
+
import { loadHistory } from '../../scripts/lib/history.js';
|
|
16
|
+
import { parseFrontmatter, createWorkfile, enrichStages, parseModelsValue } from '../../scripts/lib/workfile.js';
|
|
17
17
|
import { parseArtefactsTable, addArtefactRow, setArtefactStatus } from '../../scripts/lib/artefacts.js';
|
|
18
18
|
import { addFeedbackItem, actionFeedbackItem, wontfixFeedbackItem, resolveFeedbackItem, listFeedback } from '../../scripts/lib/feedback.js';
|
|
19
19
|
import { getCycleDefinition, getArtefactType, getLaws, getValidation, getAppraisers, getFlow, selectAppraisers } from '../../scripts/lib/config.js';
|
|
20
20
|
import { slugify } from '../../scripts/lib/slug.js';
|
|
21
|
-
import { runSort } from '../../scripts/sort.js';
|
|
22
21
|
import { execSync, execFileSync } from 'child_process';
|
|
23
22
|
import { createHash, randomUUID } from 'node:crypto';
|
|
24
23
|
import { readOrCreateSecret } from '../../scripts/lib/secret.js';
|
|
@@ -103,7 +102,7 @@ new skills). It does NOT apply to running an existing, defined flow.
|
|
|
103
102
|
|
|
104
103
|
## Available skills
|
|
105
104
|
|
|
106
|
-
- **Pipeline:** forge, quench, appraise,
|
|
105
|
+
- **Pipeline:** forge, quench, appraise, orchestrate, flow, human-appraise
|
|
107
106
|
- **Authoring:** add-artefact-type, add-law, add-appraiser, add-cycle, add-flow, init-foundry
|
|
108
107
|
- **Maintenance:** upgrade-foundry, refresh-agents, list-agents
|
|
109
108
|
|
|
@@ -127,6 +126,12 @@ function makeIO(directory) {
|
|
|
127
126
|
readDir: (p) => readdirSync(resolve(p)),
|
|
128
127
|
mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
|
|
129
128
|
unlink: (p) => { if (existsSync(resolve(p))) unlinkSync(resolve(p)); },
|
|
129
|
+
// exec: run a shell command in the worktree and return stdout as a UTF-8 string.
|
|
130
|
+
// Used by sort.js (getDirtyToolManagedFiles, getModifiedFiles) for git enforcement.
|
|
131
|
+
// Call sites pass full shell strings (e.g. 'git status --porcelain ...'), so we
|
|
132
|
+
// must use execSync rather than execFileSync. Throws on non-zero exit; callers
|
|
133
|
+
// already wrap in try/catch.
|
|
134
|
+
exec: (cmd) => execSync(cmd, { cwd: directory, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }),
|
|
130
135
|
};
|
|
131
136
|
}
|
|
132
137
|
|
|
@@ -165,35 +170,6 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
165
170
|
|
|
166
171
|
tool: {
|
|
167
172
|
// ── History tools ──
|
|
168
|
-
foundry_history_append: tool({
|
|
169
|
-
description: 'Append an entry to the cycle history (WORK.history.yaml)',
|
|
170
|
-
args: {
|
|
171
|
-
cycle: tool.schema.string().describe('Cycle name'),
|
|
172
|
-
stage: tool.schema.string().describe('Stage name'),
|
|
173
|
-
comment: tool.schema.string().describe('Comment for this entry'),
|
|
174
|
-
route: tool.schema.string().optional().describe('When stage=sort, the stage alias the sort routed to'),
|
|
175
|
-
},
|
|
176
|
-
async execute(args, context) {
|
|
177
|
-
const io = makeIO(context.worktree);
|
|
178
|
-
const guard = requireNoActiveStage(io);
|
|
179
|
-
if (!guard.ok) return JSON.stringify({ error: `foundry_history_append ${guard.error}` });
|
|
180
|
-
const historyPath = path.join(context.worktree, 'WORK.history.yaml');
|
|
181
|
-
// Sort-route alias check: if this entry is NOT a sort, it must match
|
|
182
|
-
// the most recent sort's `route` for this cycle.
|
|
183
|
-
if (args.stage !== 'sort') {
|
|
184
|
-
const expected = readLastSortRoute(historyPath, args.cycle, io);
|
|
185
|
-
if (args.stage !== expected) {
|
|
186
|
-
return JSON.stringify({
|
|
187
|
-
error: `foundry_history_append: stage ${args.stage} does not match last sort route ${expected ?? 'none'}`,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const iteration = getIteration(historyPath, args.cycle, io);
|
|
192
|
-
appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment, route: args.route }, io);
|
|
193
|
-
return JSON.stringify({ ok: true, iteration });
|
|
194
|
-
},
|
|
195
|
-
}),
|
|
196
|
-
|
|
197
173
|
foundry_history_list: tool({
|
|
198
174
|
description: 'List history entries for a cycle',
|
|
199
175
|
args: {
|
|
@@ -269,57 +245,6 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
269
245
|
},
|
|
270
246
|
}),
|
|
271
247
|
|
|
272
|
-
foundry_stage_finalize: tool({
|
|
273
|
-
description: 'Verify stage output matches allowed file patterns; register artefacts as drafts.',
|
|
274
|
-
args: {
|
|
275
|
-
cycle: tool.schema.string().describe('Cycle name'),
|
|
276
|
-
},
|
|
277
|
-
async execute(args, context) {
|
|
278
|
-
const io = makeIO(context.worktree);
|
|
279
|
-
const guard = requireNoActiveStage(io);
|
|
280
|
-
if (!guard.ok) return JSON.stringify({ error: guard.error });
|
|
281
|
-
const last = readLastStage(io);
|
|
282
|
-
if (!last) return JSON.stringify({ error: 'foundry_stage_finalize: no last stage recorded; call stage_end first' });
|
|
283
|
-
if (last.cycle !== args.cycle) {
|
|
284
|
-
return JSON.stringify({ error: `foundry_stage_finalize: cycle mismatch (last=${last.cycle}, got=${args.cycle})` });
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Load on-disk definitions and translate to finalizeStage's camelCase contract.
|
|
288
|
-
let cycleDoc;
|
|
289
|
-
try {
|
|
290
|
-
cycleDoc = await getCycleDefinition('foundry', args.cycle, io);
|
|
291
|
-
} catch (e) {
|
|
292
|
-
return JSON.stringify({ error: `foundry_stage_finalize: ${e.message}` });
|
|
293
|
-
}
|
|
294
|
-
const outputType = cycleDoc.frontmatter.output;
|
|
295
|
-
const cycleDef = { outputArtefactType: outputType };
|
|
296
|
-
const artefactTypes = {};
|
|
297
|
-
if (outputType) {
|
|
298
|
-
try {
|
|
299
|
-
const artDoc = await getArtefactType('foundry', outputType, io);
|
|
300
|
-
artefactTypes[outputType] = { filePatterns: artDoc.frontmatter['file-patterns'] || [] };
|
|
301
|
-
} catch {
|
|
302
|
-
artefactTypes[outputType] = { filePatterns: [] };
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const workPath = path.join(context.worktree, 'WORK.md');
|
|
307
|
-
const result = finalizeStage({
|
|
308
|
-
cwd: context.worktree,
|
|
309
|
-
baseSha: last.baseSha,
|
|
310
|
-
stageBase: stageBaseOf(last.stage),
|
|
311
|
-
cycleDef,
|
|
312
|
-
artefactTypes,
|
|
313
|
-
registerArtefact: ({ file, type, status }) => {
|
|
314
|
-
const text = readFileSync(workPath, 'utf-8');
|
|
315
|
-
const updated = addArtefactRow(text, { file, type, cycle: args.cycle, status });
|
|
316
|
-
writeFileSync(workPath, updated, 'utf-8');
|
|
317
|
-
},
|
|
318
|
-
});
|
|
319
|
-
return JSON.stringify(result);
|
|
320
|
-
},
|
|
321
|
-
}),
|
|
322
|
-
|
|
323
248
|
// ── Workfile tools ──
|
|
324
249
|
foundry_workfile_create: tool({
|
|
325
250
|
description: 'Create WORK.md with frontmatter and goal',
|
|
@@ -371,137 +296,111 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
371
296
|
},
|
|
372
297
|
}),
|
|
373
298
|
|
|
374
|
-
|
|
375
|
-
description: '
|
|
299
|
+
foundry_workfile_delete: tool({
|
|
300
|
+
description: 'Delete WORK.md and WORK.history.yaml (requires confirm:true)',
|
|
376
301
|
args: {
|
|
377
|
-
|
|
378
|
-
value: tool.schema.string().describe('Value to set (use JSON for arrays/objects, e.g. \'["forge:a","quench:b"]\' or \'{"forge":"openai/gpt-4o"}\')'),
|
|
302
|
+
confirm: tool.schema.boolean().describe('Must be true to confirm deletion'),
|
|
379
303
|
},
|
|
380
304
|
async execute(args, context) {
|
|
381
305
|
const io = makeIO(context.worktree);
|
|
382
306
|
const guard = requireNoActiveStage(io);
|
|
383
|
-
if (!guard.ok) return JSON.stringify({ error: `
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return JSON.stringify({ error: `foundry_workfile_set: key must be one of cycle|stages|max-iterations|models; got ${args.key}` });
|
|
307
|
+
if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_delete ${guard.error}` });
|
|
308
|
+
if (args.confirm !== true) {
|
|
309
|
+
return JSON.stringify({ error: 'foundry_workfile_delete requires {confirm: true}' });
|
|
387
310
|
}
|
|
388
311
|
const workPath = path.join(context.worktree, 'WORK.md');
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const text = readFileSync(workPath, 'utf-8');
|
|
393
|
-
// Parse JSON values for arrays/objects, keep strings as-is
|
|
394
|
-
let value = args.value;
|
|
395
|
-
if (args.key === 'stages') {
|
|
396
|
-
// Always parse stages into an array (handles JSON arrays and comma-separated strings)
|
|
397
|
-
value = parseStagesValue(args.value);
|
|
398
|
-
} else if (args.key === 'models') {
|
|
399
|
-
// Always parse models into an object (handles JSON objects and "key: value" strings)
|
|
400
|
-
value = parseModelsValue(args.value);
|
|
401
|
-
} else {
|
|
402
|
-
try {
|
|
403
|
-
const parsed = JSON.parse(args.value);
|
|
404
|
-
if (typeof parsed === 'object' || Array.isArray(parsed) || typeof parsed === 'number') {
|
|
405
|
-
value = parsed;
|
|
406
|
-
}
|
|
407
|
-
} catch {
|
|
408
|
-
// Not JSON, use as plain string
|
|
409
|
-
}
|
|
312
|
+
const historyPath = path.join(context.worktree, 'WORK.history.yaml');
|
|
313
|
+
if (existsSync(workPath)) {
|
|
314
|
+
unlinkSync(workPath);
|
|
410
315
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const fm = parseFrontmatter(text);
|
|
414
|
-
if (fm.cycle) {
|
|
415
|
-
value = enrichStages(value, fm.cycle);
|
|
416
|
-
}
|
|
316
|
+
if (existsSync(historyPath)) {
|
|
317
|
+
unlinkSync(historyPath);
|
|
417
318
|
}
|
|
418
|
-
const updated = setFrontmatterField(text, args.key, value);
|
|
419
|
-
writeFileSync(workPath, updated, 'utf-8');
|
|
420
319
|
return JSON.stringify({ ok: true });
|
|
421
320
|
},
|
|
422
321
|
}),
|
|
423
322
|
|
|
424
|
-
|
|
425
|
-
|
|
323
|
+
// ── Orchestrate tool ──
|
|
324
|
+
foundry_orchestrate: tool({
|
|
325
|
+
description: 'Run the next step of the current cycle. Call with no args on first invocation; call with lastResult={ok,error?} after a dispatch/human_appraise completes. Returns {action, ...} describing what the caller should do next.',
|
|
426
326
|
args: {
|
|
427
|
-
|
|
428
|
-
|
|
327
|
+
lastResult: tool.schema.object({
|
|
328
|
+
ok: tool.schema.boolean(),
|
|
329
|
+
error: tool.schema.string().optional(),
|
|
330
|
+
}).optional(),
|
|
331
|
+
cycleDef: tool.schema.string().optional().describe('Test-mode cycle definition override (path to cycle file)'),
|
|
429
332
|
},
|
|
430
333
|
async execute(args, context) {
|
|
334
|
+
const { runOrchestrate } = await import('../../scripts/orchestrate.js');
|
|
431
335
|
const io = makeIO(context.worktree);
|
|
432
|
-
const
|
|
433
|
-
if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_configure_from_cycle ${guard.error}` });
|
|
336
|
+
const cwd = context.worktree;
|
|
434
337
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
338
|
+
// Mint: same pattern as removed foundry_sort.
|
|
339
|
+
const mint = ({ route, cycle, exp }) => {
|
|
340
|
+
const nonce = randomUUID();
|
|
341
|
+
const payload = { route, cycle, nonce, exp };
|
|
342
|
+
pending.add(nonce, payload);
|
|
343
|
+
return signToken(payload, secret);
|
|
344
|
+
};
|
|
439
345
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const fm = cycleDoc.frontmatter || {};
|
|
447
|
-
|
|
448
|
-
const stages = enrichStages(args.stages, args.cycleId);
|
|
449
|
-
|
|
450
|
-
// Apply defaults for each cycle-def-backed field.
|
|
451
|
-
const maxIterations = fm['max-iterations'] ?? 3;
|
|
452
|
-
const humanAppraise = fm['human-appraise'] === true;
|
|
453
|
-
const deadlockAppraise = fm['deadlock-appraise'] !== false; // default true
|
|
454
|
-
const deadlockIterations = fm['deadlock-iterations'] ?? 5;
|
|
455
|
-
const models = fm.models ?? null;
|
|
456
|
-
|
|
457
|
-
let text = readFileSync(workPath, 'utf-8');
|
|
458
|
-
text = setFrontmatterField(text, 'cycle', args.cycleId);
|
|
459
|
-
text = setFrontmatterField(text, 'stages', stages);
|
|
460
|
-
text = setFrontmatterField(text, 'max-iterations', maxIterations);
|
|
461
|
-
text = setFrontmatterField(text, 'human-appraise', humanAppraise);
|
|
462
|
-
text = setFrontmatterField(text, 'deadlock-appraise', deadlockAppraise);
|
|
463
|
-
text = setFrontmatterField(text, 'deadlock-iterations', deadlockIterations);
|
|
464
|
-
if (models && typeof models === 'object' && Object.keys(models).length > 0) {
|
|
465
|
-
text = setFrontmatterField(text, 'models', models);
|
|
466
|
-
}
|
|
467
|
-
writeFileSync(workPath, text, 'utf-8');
|
|
468
|
-
|
|
469
|
-
return JSON.stringify({
|
|
470
|
-
ok: true,
|
|
471
|
-
applied: {
|
|
472
|
-
cycle: args.cycleId,
|
|
473
|
-
stages,
|
|
474
|
-
'max-iterations': maxIterations,
|
|
475
|
-
'human-appraise': humanAppraise,
|
|
476
|
-
'deadlock-appraise': deadlockAppraise,
|
|
477
|
-
'deadlock-iterations': deadlockIterations,
|
|
478
|
-
...(models ? { models } : {}),
|
|
346
|
+
// Git bridge: commit staged changes with a cycle-prefixed message.
|
|
347
|
+
const git = {
|
|
348
|
+
commit: (msg) => {
|
|
349
|
+
execFileSync('git', ['add', '.'], { cwd, encoding: 'utf8' });
|
|
350
|
+
execFileSync('git', ['commit', '-m', msg], { cwd, encoding: 'utf8' });
|
|
351
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd, encoding: 'utf8' }).trim();
|
|
479
352
|
},
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
353
|
+
status: () => {
|
|
354
|
+
const out = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8' }).trim();
|
|
355
|
+
return { clean: out === '', dirty: out.split('\n').filter(Boolean) };
|
|
356
|
+
},
|
|
357
|
+
};
|
|
483
358
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
359
|
+
// Finalize bridge: mimics the deleted foundry_stage_finalize body.
|
|
360
|
+
const finalize = async ({ cycleId, stage, baseSha }) => {
|
|
361
|
+
let cycleDoc;
|
|
362
|
+
try {
|
|
363
|
+
cycleDoc = await getCycleDefinition('foundry', cycleId, io);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
return { ok: false, error: e.message };
|
|
366
|
+
}
|
|
367
|
+
const outputType = cycleDoc.frontmatter.output;
|
|
368
|
+
const cycleDef = { outputArtefactType: outputType };
|
|
369
|
+
const artefactTypes = {};
|
|
370
|
+
if (outputType) {
|
|
371
|
+
try {
|
|
372
|
+
const artDoc = await getArtefactType('foundry', outputType, io);
|
|
373
|
+
artefactTypes[outputType] = { filePatterns: artDoc.frontmatter['file-patterns'] || [] };
|
|
374
|
+
} catch {
|
|
375
|
+
artefactTypes[outputType] = { filePatterns: [] };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const workPath = path.join(cwd, 'WORK.md');
|
|
379
|
+
const result = finalizeStage({
|
|
380
|
+
cwd,
|
|
381
|
+
baseSha,
|
|
382
|
+
stageBase: stageBaseOf(stage),
|
|
383
|
+
cycleDef,
|
|
384
|
+
artefactTypes,
|
|
385
|
+
registerArtefact: ({ file, type, status }) => {
|
|
386
|
+
const text = readFileSync(workPath, 'utf-8');
|
|
387
|
+
const updated = addArtefactRow(text, { file, type, cycle: cycleId, status });
|
|
388
|
+
writeFileSync(workPath, updated, 'utf-8');
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
return result;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const result = await runOrchestrate({
|
|
396
|
+
cwd, cycleDef: args.cycleDef, git, mint, finalize,
|
|
397
|
+
now: () => Date.now(),
|
|
398
|
+
lastResult: args.lastResult ?? null,
|
|
399
|
+
}, io);
|
|
400
|
+
return JSON.stringify(result);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
return JSON.stringify({ action: 'violation', details: `orchestrate threw: ${e.message}`, recoverable: false, affected_files: [] });
|
|
503
403
|
}
|
|
504
|
-
return JSON.stringify({ ok: true });
|
|
505
404
|
},
|
|
506
405
|
}),
|
|
507
406
|
|
|
@@ -672,27 +571,6 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
672
571
|
},
|
|
673
572
|
}),
|
|
674
573
|
|
|
675
|
-
// ── Sort tool ──
|
|
676
|
-
foundry_sort: tool({
|
|
677
|
-
description: 'Determine the next stage for the current cycle and (if dispatchable) mint a single-use token.',
|
|
678
|
-
args: {
|
|
679
|
-
cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
|
|
680
|
-
},
|
|
681
|
-
async execute(args, context) {
|
|
682
|
-
const io = makeIO(context.worktree);
|
|
683
|
-
const guard = requireNoActiveStage(io);
|
|
684
|
-
if (!guard.ok) return JSON.stringify({ error: `foundry_sort ${guard.error}` });
|
|
685
|
-
const mint = ({ route, cycle, exp }) => {
|
|
686
|
-
const nonce = randomUUID();
|
|
687
|
-
const payload = { route, cycle, nonce, exp };
|
|
688
|
-
pending.add(nonce, payload);
|
|
689
|
-
return signToken(payload, secret);
|
|
690
|
-
};
|
|
691
|
-
const result = runSort({ cycleDef: args.cycleDef, mint }, io);
|
|
692
|
-
return JSON.stringify(result);
|
|
693
|
-
},
|
|
694
|
-
}),
|
|
695
|
-
|
|
696
574
|
// ── Git tools ──
|
|
697
575
|
foundry_git_branch: tool({
|
|
698
576
|
description: 'Create and checkout a work branch for a flow',
|
|
@@ -712,25 +590,6 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
712
590
|
},
|
|
713
591
|
}),
|
|
714
592
|
|
|
715
|
-
foundry_git_commit: tool({
|
|
716
|
-
description: 'Stage all changes and commit with a cycle-prefixed message',
|
|
717
|
-
args: {
|
|
718
|
-
cycle: tool.schema.string().describe('Cycle name'),
|
|
719
|
-
stage: tool.schema.string().describe('Stage name'),
|
|
720
|
-
description: tool.schema.string().describe('Commit description'),
|
|
721
|
-
},
|
|
722
|
-
async execute(args, context) {
|
|
723
|
-
const io = makeIO(context.worktree);
|
|
724
|
-
const guard = requireNoActiveStage(io);
|
|
725
|
-
if (!guard.ok) return JSON.stringify({ error: `foundry_git_commit ${guard.error}` });
|
|
726
|
-
execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
|
|
727
|
-
const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
|
|
728
|
-
execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
|
|
729
|
-
const hash = execSync('git rev-parse --short HEAD', { cwd: context.worktree, encoding: 'utf8' }).trim();
|
|
730
|
-
return JSON.stringify({ ok: true, hash });
|
|
731
|
-
},
|
|
732
|
-
}),
|
|
733
|
-
|
|
734
593
|
foundry_git_finish: tool({
|
|
735
594
|
description: 'Clean up work files, squash merge to base branch, and delete the work branch',
|
|
736
595
|
args: {
|
|
@@ -746,7 +605,7 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
746
605
|
const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
|
|
747
606
|
|
|
748
607
|
// Get current branch name
|
|
749
|
-
const workBranch =
|
|
608
|
+
const workBranch = execFileSync('git', ['branch', '--show-current'], opts).trim();
|
|
750
609
|
if (workBranch === base) {
|
|
751
610
|
return JSON.stringify({ error: `Already on ${base} — nothing to merge` });
|
|
752
611
|
}
|
|
@@ -759,23 +618,22 @@ export const FoundryPlugin = async ({ directory }) => {
|
|
|
759
618
|
|
|
760
619
|
// Commit cleanup if there are changes
|
|
761
620
|
try {
|
|
762
|
-
|
|
763
|
-
const status =
|
|
621
|
+
execFileSync('git', ['add', '-A'], opts);
|
|
622
|
+
const status = execFileSync('git', ['status', '--porcelain'], opts).trim();
|
|
764
623
|
if (status) {
|
|
765
624
|
const cleanupMsg = `[${workBranch.replace('work/', '')}] cleanup: remove work files`;
|
|
766
|
-
|
|
625
|
+
execFileSync('git', ['commit', '-m', cleanupMsg], opts);
|
|
767
626
|
}
|
|
768
627
|
} catch { /* no changes to commit */ }
|
|
769
628
|
|
|
770
629
|
// Switch to base and squash merge
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const hash = execSync('git rev-parse --short HEAD', opts).trim();
|
|
630
|
+
execFileSync('git', ['checkout', base], opts);
|
|
631
|
+
execFileSync('git', ['merge', '--squash', workBranch], opts);
|
|
632
|
+
execFileSync('git', ['commit', '-m', args.message], opts);
|
|
633
|
+
const hash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], opts).trim();
|
|
776
634
|
|
|
777
635
|
// Force-delete work branch (required after squash)
|
|
778
|
-
|
|
636
|
+
execFileSync('git', ['branch', '-D', workBranch], opts);
|
|
779
637
|
|
|
780
638
|
return JSON.stringify({ ok: true, hash, branch: base });
|
|
781
639
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.0 — 2026-04-20
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- **LLM orchestration replaced with deterministic `foundry_orchestrate` tool.** The `cycle` and `sort` skills are removed; replaced by a single thin `orchestrate` skill that drives a 3-line loop.
|
|
8
|
+
- **Six tools deregistered** from the plugin (still exist as internal imports for tests): `foundry_sort`, `foundry_history_append`, `foundry_stage_finalize`, `foundry_git_commit`, `foundry_workfile_configure_from_cycle`, `foundry_workfile_set`.
|
|
9
|
+
- Upgrade requires clean main + no in-flight workfile (see `upgrade-foundry` skill).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `foundry_orchestrate` — single tool that owns the sort → history → dispatch → finalize → history → commit loop. Atomic stage completion.
|
|
14
|
+
- `scripts/orchestrate.js` — deterministic orchestration logic, composes existing internal functions.
|
|
15
|
+
- Orphaned-stage detection: if orchestrate is called without `lastResult` but an active stage exists, returns `violation`. Fixes the ses_256c failure mode where an LLM skipped the post-dispatch history append and wedged the cycle.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Root cause of all deferred HARDEN.md bugs (B, C, D, E, G) and the ses_256c bug: LLM misfollowing a deterministic protocol. Protocol now lives inside the plugin tool.
|
|
20
|
+
|
|
21
|
+
### Migration
|
|
22
|
+
|
|
23
|
+
See `skills/upgrade-foundry/SKILL.md` for v2.3.0 pre-flight checks. No automated state migration — complete or discard in-flight cycles on v2.2.x before upgrading.
|
|
24
|
+
|
|
3
25
|
## 2.2.1 — 2026-04-20
|
|
4
26
|
|
|
5
27
|
Follow-up patch addressing the five bugs deferred from v2.2.0 (see `HARDEN.md` §Deferred).
|
package/docs/work-spec.md
CHANGED
|
@@ -25,8 +25,8 @@ The `stages` list is the happy path. Sort follows it but loops back to `forge` w
|
|
|
25
25
|
|
|
26
26
|
- `flow` — set by the foundry flow skill at foundry flow start, never changes
|
|
27
27
|
- `cycle` — set by the foundry flow skill when starting each foundry cycle
|
|
28
|
-
- `stages` — set by the
|
|
29
|
-
- `max-iterations` — set by the
|
|
28
|
+
- `stages` — set by the orchestrate skill when starting each foundry cycle (reads artefact type to determine if quench is needed)
|
|
29
|
+
- `max-iterations` — set by the orchestrate skill (default 3, could be overridden in foundry cycle definition)
|
|
30
30
|
|
|
31
31
|
## Sections
|
|
32
32
|
|
|
@@ -96,9 +96,9 @@ Grouped by artefact file path. Each item is a checklist entry with a tag indicat
|
|
|
96
96
|
| Section | Written by | Updated by |
|
|
97
97
|
|---------|-----------|------------|
|
|
98
98
|
| Frontmatter (`flow`) | `foundry_workfile_create` (flow skill) | nobody |
|
|
99
|
-
| Frontmatter (`cycle`, `stages`, `max-iterations`) | `foundry_workfile_set` (
|
|
99
|
+
| Frontmatter (`cycle`, `stages`, `max-iterations`) | `foundry_workfile_set` (orchestrate skill) | `foundry_workfile_set` (reset on each new cycle) |
|
|
100
100
|
| Goal | `foundry_workfile_create` (flow skill) | nobody |
|
|
101
|
-
| Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (
|
|
101
|
+
| Artefacts | `foundry_artefacts_add` (forge skill) | `foundry_artefacts_set_status` (orchestrate skill) |
|
|
102
102
|
| Feedback | `foundry_feedback_add` (quench/appraise/hitl) | `foundry_feedback_action`/`foundry_feedback_wontfix` (forge), `foundry_feedback_resolve` (quench/appraise/hitl) |
|
|
103
103
|
|
|
104
104
|
## WORK.history.yaml
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@really-knows-ai/foundry",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "A structured framework for AI-driven artefact creation with deterministic routing, quality gates, and iterative refinement cycles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": ".opencode/plugins/foundry.js",
|
|
@@ -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/skills/flow/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: flow
|
|
3
3
|
type: composite
|
|
4
4
|
description: Runs a defined foundry flow to produce artefacts. Use this whenever the user references a flow by id, name, or paraphrase (e.g. "use the creative flow", "run creative-flow"). Do not brainstorm — the flow's cycles already define the work. The user's request is the goal to pass in.
|
|
5
|
-
composes: [
|
|
5
|
+
composes: [orchestrate]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Flow
|
|
@@ -30,8 +30,8 @@ Before running this skill, verify that the `foundry/` directory exists in the pr
|
|
|
30
30
|
- **Resume** — keep the existing workfile and skip to step 6. **Only offer resume if the existing `flow` AND `cycle` match what the user just asked for.** If either differs, do not offer resume — running the wrong cycle against stale state corrupts the workflow.
|
|
31
31
|
- **Discard** — call `foundry_workfile_delete`, then proceed to step 5.
|
|
32
32
|
- **Abort** — stop the skill without modifying anything.
|
|
33
|
-
5. Call `foundry_workfile_create` with **only** the flow ID, chosen cycle ID, and goal — do **not** pass `stages` or `maxIterations`. The `
|
|
34
|
-
6. Execute the cycle by invoking the
|
|
33
|
+
5. Call `foundry_workfile_create` with **only** the flow ID, chosen cycle ID, and goal — do **not** pass `stages` or `maxIterations`. The `orchestrate` skill will read the cycle definition and handle setup on its first call.
|
|
34
|
+
6. Execute the cycle by invoking the orchestrate skill
|
|
35
35
|
|
|
36
36
|
## Between cycles
|
|
37
37
|
|
|
@@ -49,9 +49,9 @@ When a cycle completes (sort returns `done`):
|
|
|
49
49
|
- Check input contracts for each
|
|
50
50
|
- The user chooses which target to pursue (or which to pursue first)
|
|
51
51
|
5. Set up the next cycle:
|
|
52
|
-
- Call `
|
|
53
|
-
-
|
|
54
|
-
- Execute the cycle by invoking the
|
|
52
|
+
- Call `foundry_workfile_delete` to clear the completed cycle's WORK.md
|
|
53
|
+
- Call `foundry_workfile_create` with **only** the flow ID, the next cycle ID, and the goal — do **not** pass `stages` or `maxIterations`. The orchestrate skill will detect `needsSetup` on its first call and bootstrap the rest of the frontmatter from the cycle definition.
|
|
54
|
+
- Execute the cycle by invoking the orchestrate skill
|
|
55
55
|
|
|
56
56
|
## Completing a flow
|
|
57
57
|
|
|
@@ -23,6 +23,18 @@ Human-appraise runs inside an enforced stage. Your **first** and **last** tool c
|
|
|
23
23
|
|
|
24
24
|
Human-appraise makes **no disk writes**. All output flows through `foundry_feedback_add` / `foundry_feedback_resolve` / `foundry_artefacts_set_status`. `foundry_stage_finalize` flags unexpected writes as a violation.
|
|
25
25
|
|
|
26
|
+
## Input
|
|
27
|
+
|
|
28
|
+
When invoked from orchestrate, you receive `{cycle, token, context}`:
|
|
29
|
+
- `cycle` — the current cycle id
|
|
30
|
+
- `token` — single-use token for `foundry_stage_begin`
|
|
31
|
+
- `context.artefact_file` — the target artefact
|
|
32
|
+
- `context.recent_feedback` — recent deadlocked feedback items to present to the user
|
|
33
|
+
|
|
34
|
+
Your FIRST tool call must be `foundry_stage_begin({stage: 'human-appraise:<cycle>', cycle, token})`.
|
|
35
|
+
|
|
36
|
+
Your LAST tool call must be `foundry_stage_end({summary: '<one-sentence description of the user verdict>'})` — orchestrate reads this summary for the commit message.
|
|
37
|
+
|
|
26
38
|
## Protocol
|
|
27
39
|
|
|
28
40
|
1. `foundry_stage_begin(...)`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrate
|
|
3
|
+
description: Runs a foundry cycle by calling foundry_orchestrate in a loop and acting on the returned action.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Orchestrate
|
|
7
|
+
|
|
8
|
+
You drive a foundry cycle by calling `foundry_orchestrate` repeatedly and acting on each returned `action`. The tool owns all step-ordering, history, committing, and routing. Your job is to dispatch subagents, run human-appraise when asked, and report terminal states.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
Before running this skill, verify that `foundry/` exists in the project root and `WORK.md` has been created by the flow skill (with `flow`, `cycle`, and `goal` fields). If not, stop and tell the user to run the flow skill first.
|
|
13
|
+
|
|
14
|
+
## Protocol
|
|
15
|
+
|
|
16
|
+
Loop until `foundry_orchestrate` returns a terminal action (`done`, `blocked`, or `violation`):
|
|
17
|
+
|
|
18
|
+
1. Call `foundry_orchestrate({lastResult})`. Omit `lastResult` on the first iteration. On subsequent iterations, pass `{kind, ok}` reflecting the previous action's outcome.
|
|
19
|
+
|
|
20
|
+
2. Switch on the returned `action`:
|
|
21
|
+
|
|
22
|
+
### `dispatch`
|
|
23
|
+
|
|
24
|
+
Payload: `{stage, subagent_type, prompt}`.
|
|
25
|
+
|
|
26
|
+
Call the `task` tool:
|
|
27
|
+
```
|
|
28
|
+
task tool:
|
|
29
|
+
subagent_type: <subagent_type-from-payload>
|
|
30
|
+
description: "Run <stage> for <cycle>"
|
|
31
|
+
prompt: <prompt-from-payload — pass verbatim>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When the task returns, call `foundry_orchestrate({lastResult: {kind: 'dispatch', ok: true}})`. If the task tool itself errored or reported a subagent crash, pass `{kind: 'dispatch', ok: false, error: '<message>'}`.
|
|
35
|
+
|
|
36
|
+
### `human_appraise`
|
|
37
|
+
|
|
38
|
+
Payload: `{stage, token, context}`.
|
|
39
|
+
|
|
40
|
+
Invoke the `human-appraise` skill inline, passing `{cycle, token, context}`. The skill will prompt the user, collect feedback, and call `foundry_stage_end({summary})`.
|
|
41
|
+
|
|
42
|
+
When it returns, call `foundry_orchestrate({lastResult: {kind: 'human_appraise', ok: true}})`.
|
|
43
|
+
|
|
44
|
+
### `done`
|
|
45
|
+
|
|
46
|
+
Payload: `{cycle, artefact_file, next_cycles}`.
|
|
47
|
+
|
|
48
|
+
1. Call `foundry_artefacts_set_status({file: artefact_file, status: 'done'})`.
|
|
49
|
+
2. Report to the user: "Cycle `<cycle>` complete. Output: `<artefact_file>`. Next cycles available: `<next_cycles>`."
|
|
50
|
+
3. Return control to the flow skill.
|
|
51
|
+
|
|
52
|
+
### `blocked`
|
|
53
|
+
|
|
54
|
+
Payload: `{cycle, artefact_file, reason}`.
|
|
55
|
+
|
|
56
|
+
Report to the user: "Cycle `<cycle>` blocked on `<artefact_file>`: `<reason>`." Return control to the flow skill. The artefact has already been marked blocked.
|
|
57
|
+
|
|
58
|
+
### `violation`
|
|
59
|
+
|
|
60
|
+
Payload: `{details, affected_files}`.
|
|
61
|
+
|
|
62
|
+
Report to the user: "Cycle halted (violation): `<details>`. Affected files: `<affected_files>`." Return control to the flow skill. Affected artefacts have already been marked blocked.
|
|
63
|
+
|
|
64
|
+
## What you do NOT do
|
|
65
|
+
|
|
66
|
+
- You do NOT inline forge / quench / appraise work. Always dispatch via `task`.
|
|
67
|
+
- You do NOT mint, modify, or cache tokens. The `prompt` from orchestrate already contains the token verbatim.
|
|
68
|
+
- You do NOT call `foundry_history_append`, `foundry_git_commit`, `foundry_stage_finalize`, or `foundry_sort`. These are not registered tools in v2.3+; orchestrate handles them internally.
|
|
69
|
+
- You do NOT reorder the protocol. `foundry_orchestrate` returns, you act, you call back. Nothing else between.
|
|
@@ -130,6 +130,37 @@ For each `foundry/cycles/*.md` whose frontmatter has the old nested form, migrat
|
|
|
130
130
|
|
|
131
131
|
The old nested form is no longer read. After migration, verify by asking: "cycle `<id>`: human-appraise every iteration? deadlock-appraise on? deadlock-iterations = N?".
|
|
132
132
|
|
|
133
|
+
### 4c. v2.2.x → v2.3.0
|
|
134
|
+
|
|
135
|
+
v2.3.0 replaces the LLM-driven sort orchestrator with the `foundry_orchestrate` plugin tool. The `cycle` and `sort` skills are removed. Six tools are deregistered: `foundry_sort`, `foundry_history_append`, `foundry_stage_finalize`, `foundry_git_commit`, `foundry_workfile_configure_from_cycle`, `foundry_workfile_set`.
|
|
136
|
+
|
|
137
|
+
#### Pre-flight checks
|
|
138
|
+
|
|
139
|
+
Before upgrading, verify a clean base state. Abort the upgrade if any of these fail:
|
|
140
|
+
|
|
141
|
+
1. **Branch**: must be on `main` (or the user's configured default base branch).
|
|
142
|
+
- Check: `git rev-parse --abbrev-ref HEAD` — must match expected default.
|
|
143
|
+
- If on `work/*`: abort with "You're on a work branch. Switch to main and complete or discard any in-flight flow before upgrading."
|
|
144
|
+
|
|
145
|
+
2. **Working tree**: must be clean.
|
|
146
|
+
- Check: `git status --porcelain` — must be empty.
|
|
147
|
+
- If dirty: abort with "Uncommitted changes. Commit or stash before upgrading."
|
|
148
|
+
|
|
149
|
+
3. **In-flight workfile**: `WORK.md` must not exist.
|
|
150
|
+
- Check: is `WORK.md` present in the repo root?
|
|
151
|
+
- If yes: abort with "In-flight workfile detected. Delete it (`foundry_workfile_delete`) or complete the cycle before upgrading."
|
|
152
|
+
|
|
153
|
+
Only when all three pass, proceed with the plugin swap.
|
|
154
|
+
|
|
155
|
+
#### Upgrade steps
|
|
156
|
+
|
|
157
|
+
1. Install the new plugin package version: `npm install @really-knows-ai/foundry@2.3.0 --save-dev`.
|
|
158
|
+
2. Swap `.opencode/plugins/foundry.js` with the new version from `node_modules/@really-knows-ai/foundry/.opencode/plugins/foundry.js`.
|
|
159
|
+
3. Remove `skills/cycle/` and `skills/sort/` directories from the project if they exist locally (they shouldn't — skills live in the package).
|
|
160
|
+
4. Commit the upgrade: `chore: upgrade foundry to 2.3.0`.
|
|
161
|
+
|
|
162
|
+
No state migration is performed. In-flight cycles from v2.2.x must be completed or discarded before upgrading.
|
|
163
|
+
|
|
133
164
|
### 5. Migrate flows
|
|
134
165
|
|
|
135
166
|
For each flow needing migration:
|
package/skills/cycle/SKILL.md
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: cycle
|
|
3
|
-
type: composite
|
|
4
|
-
description: Runs a foundry cycle by delegating all routing to the sort skill.
|
|
5
|
-
composes: [sort, forge, quench, appraise, human-appraise]
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Cycle
|
|
9
|
-
|
|
10
|
-
A foundry cycle reads its definition, sets up the work file for routing, then hands control to the sort skill which drives the forge/quench/appraise/human-appraise loop.
|
|
11
|
-
|
|
12
|
-
## Prerequisites
|
|
13
|
-
|
|
14
|
-
Before running this skill, verify that the `foundry/` directory exists in the project root. If it does not exist, stop and tell the user:
|
|
15
|
-
|
|
16
|
-
> Foundry is not initialized in this project. Run the `init-foundry` skill first to create the foundry/ directory structure.
|
|
17
|
-
|
|
18
|
-
## Starting a foundry cycle
|
|
19
|
-
|
|
20
|
-
1. Call `foundry_config_cycle` with the cycle ID — get the cycle definition
|
|
21
|
-
2. Call `foundry_config_artefact_type` with the output type ID — get the artefact type definition
|
|
22
|
-
3. Determine the stage route:
|
|
23
|
-
- Use the cycle definition's `stages` field if present
|
|
24
|
-
- Otherwise generate defaults: always `forge`, add `quench` if `foundry_config_validation` returns non-null for the type, always `appraise`
|
|
25
|
-
- If the cycle definition has `human-appraise: true`, append `human-appraise` as the final stage (runs every iteration). If `human-appraise: false` (default), do NOT include it in `stages` — sort will synthesize `human-appraise:<cycle>` on deadlock when needed.
|
|
26
|
-
- Stages should use `base:alias` format (e.g. `forge:write-haiku`, `quench:check-syllables`). If you pass bare names, the tool will auto-append the cycle ID as the alias.
|
|
27
|
-
4. Call `foundry_workfile_configure_from_cycle({cycleId, stages})` with the cycle ID and the stages list from step 3. The tool reads the cycle definition and writes `cycle`, `stages`, `max-iterations`, `human-appraise`, `deadlock-appraise`, `deadlock-iterations`, and (if present) `models` into WORK.md in a single call, applying defaults for anything the cycle def omits. Do **not** use `foundry_workfile_set` for this — the configure tool is the authoritative cycle-def → WORK.md translator.
|
|
28
|
-
5. Invoke the sort skill
|
|
29
|
-
|
|
30
|
-
## Sort drives everything
|
|
31
|
-
|
|
32
|
-
Once sort is invoked, it calls `foundry_sort` to determine the next stage, dispatches the corresponding skill to a fresh subagent with a single-use token, calls `foundry_stage_finalize` to register outputs (or detect file-pattern violations), writes history, and commits. This repeats until sort returns `done`, `blocked`, or `violation`.
|
|
33
|
-
|
|
34
|
-
The cycle skill does not contain routing, finalization, history, or commit logic — sort owns all of that. The cycle skill only sets up the work file and reacts to sort's terminal result.
|
|
35
|
-
|
|
36
|
-
## Completing a foundry cycle
|
|
37
|
-
|
|
38
|
-
When sort returns `done`:
|
|
39
|
-
- Call `foundry_artefacts_set_status(file, 'done')` for the cycle's output artefact.
|
|
40
|
-
- Return control to the flow skill.
|
|
41
|
-
|
|
42
|
-
When sort returns `blocked`:
|
|
43
|
-
- The target artefact is usually already marked `blocked` by sort (on violations) or by human-appraise (on explicit abort). If not, call `foundry_artefacts_set_status(file, 'blocked')`.
|
|
44
|
-
- Return control to the flow skill — the flow decides how to handle it.
|
|
45
|
-
|
|
46
|
-
When sort returns `violation` (e.g., `stage_finalize` `unexpected_files`, missing subagent, or file-pattern violation):
|
|
47
|
-
- Sort has already marked affected artefacts blocked and returned. Treat as the blocked path.
|
|
48
|
-
- Return control to the flow skill.
|
|
49
|
-
|
|
50
|
-
## Human Appraise
|
|
51
|
-
|
|
52
|
-
Human-appraise is controlled by two flat cycle-def keys:
|
|
53
|
-
|
|
54
|
-
- `human-appraise: true` — human-appraise runs every iteration as part of the normal stage flow (appended to `stages`).
|
|
55
|
-
- `deadlock-appraise: true` (default) — if LLM appraisers deadlock on the same feedback for `deadlock-iterations` rounds (default 5), sort routes to human-appraise to resolve it, even when it isn't in `stages`.
|
|
56
|
-
- `deadlock-appraise: false` — no human intervention; deadlock → `blocked`.
|
|
57
|
-
|
|
58
|
-
## Micro commits
|
|
59
|
-
|
|
60
|
-
Every stage ends with a micro commit, written by sort (not cycle, not subagents). The message format is `[<cycle-id>] <base>:<alias>: <brief description>`.
|
|
61
|
-
|
|
62
|
-
Examples:
|
|
63
|
-
- `[haiku-creation] forge:write-haiku: initial draft`
|
|
64
|
-
- `[haiku-creation] quench:check-syllables: checked syllable pattern`
|
|
65
|
-
- `[haiku-creation] forge:write-haiku: addressed validation feedback`
|
|
66
|
-
|
|
67
|
-
## Feedback states
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
open - needs generator action
|
|
71
|
-
actioned - needs approval
|
|
72
|
-
wont-fix - needs approval (appraisal only)
|
|
73
|
-
approved - resolved
|
|
74
|
-
rejected - re-opened
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Tag types: `validation` (from quench), `law:<law-id>` (from appraise), `human` (from human-appraise) — indicates the source and category of feedback.
|
|
78
|
-
|
|
79
|
-
## What you do NOT do
|
|
80
|
-
|
|
81
|
-
- You do not make routing decisions — sort does that.
|
|
82
|
-
- You do not register artefacts — `foundry_stage_finalize` does that (invoked by sort).
|
|
83
|
-
- You do not write history or commits — sort does that.
|
|
84
|
-
- You do not change the laws mid-cycle.
|
|
85
|
-
- You do not decide the artefact is "close enough" — it passes or it doesn't.
|
|
86
|
-
- You do not proceed past a file modification violation — honor sort's `violation`/`blocked` return.
|
|
87
|
-
- You do not modify input artefacts — they are read-only.
|
package/skills/sort/SKILL.md
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: sort
|
|
3
|
-
type: atomic
|
|
4
|
-
description: Deterministic routing for a foundry cycle. Runs the foundry_sort tool and returns the next stage.
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# Sort
|
|
8
|
-
|
|
9
|
-
You are the central dispatcher for a foundry cycle. You call `foundry_sort` to determine what stage to execute next, dispatch that stage to a fresh subagent, finalize the stage's disk output, and log history. You are the sole writer of history and git commits.
|
|
10
|
-
|
|
11
|
-
## Prerequisites
|
|
12
|
-
|
|
13
|
-
Before running this skill, verify that the `foundry/` directory exists in the project root. If it does not exist, stop and tell the user:
|
|
14
|
-
|
|
15
|
-
> Foundry is not initialized in this project. Run the `init-foundry` skill first to create the foundry/ directory structure.
|
|
16
|
-
|
|
17
|
-
## Protocol
|
|
18
|
-
|
|
19
|
-
1. Call `foundry_sort` (optionally passing `cycleDef`). It returns `{route, model?, token?, details?}`. For dispatchable routes (`forge|quench|appraise|human-appraise:*`) the tool mints a single-use, time-limited `token`.
|
|
20
|
-
|
|
21
|
-
2. Call `foundry_history_append({cycle, stage: 'sort', comment, route})` — the `route` field records what sort decided, and **subsequent** `history_append` calls for non-sort stages are enforced to match this route. This is your audit trail.
|
|
22
|
-
|
|
23
|
-
3. Act on the route:
|
|
24
|
-
- `forge:*` / `quench:*` / `appraise:*` — **dispatch** (see §Dispatch).
|
|
25
|
-
- `human-appraise:*` — invoke the human-appraise skill inline (human stage, no subagent) but still pass the `token`; the skill must call `foundry_stage_begin` with it.
|
|
26
|
-
- `done` — cycle is complete, return to the cycle skill.
|
|
27
|
-
- `blocked` — iteration limit hit with unresolved feedback, return to the cycle skill.
|
|
28
|
-
- `violation` — a validation, file-modification, or missing-subagent violation was detected (see `details`). Halt the cycle: call `foundry_artefacts_set_status(file, 'blocked')` for each affected artefact, and return to the cycle skill. If `details` mentions a missing subagent, tell the user to run `refresh-agents` and restart.
|
|
29
|
-
|
|
30
|
-
4. **After** the dispatched subagent returns, call `foundry_stage_finalize({cycle})`. Handle three outcomes:
|
|
31
|
-
- `{ok: true, artefacts: [...]}` — the tool has already registered output artefact rows in WORK.md. Proceed to step 5.
|
|
32
|
-
- `{error: 'unexpected_files', files: [...]}` — the subagent wrote outside the artefact type's `file-patterns`. Mark the cycle's target artefact `blocked` via `foundry_artefacts_set_status` and do **not** re-run the stage. Add a `violation` feedback item describing the offending files, then return to the cycle skill.
|
|
33
|
-
- Any other error — surface it to the user and halt.
|
|
34
|
-
|
|
35
|
-
5. Call `foundry_history_append({cycle, stage: <dispatched-stage-alias>, comment})` summarizing what the subagent reported. The tool enforces that the stage alias matches the most recent sort's `route` — this is why step 2's `route` field matters.
|
|
36
|
-
|
|
37
|
-
6. Call `foundry_git_commit({cycle, stage, description})` to record the stage's disk changes. **This is mandatory.** The next `foundry_sort` call will return `{route: 'violation', details: 'Uncommitted tool-managed files...'}` if WORK.md, WORK.history.yaml, or anything under `.foundry/` is dirty — the tool enforces one commit per stage.
|
|
38
|
-
|
|
39
|
-
7. Return to step 1. Repeat until `done`, `blocked`, or `violation`.
|
|
40
|
-
|
|
41
|
-
## Dispatch
|
|
42
|
-
|
|
43
|
-
Every forge, quench, and appraise stage runs in a **fresh subagent**. Never inline the stage work in the orchestrator conversation — even if the chosen model matches the orchestrator's. The orchestrator's job is to route, dispatch, finalize, and log. Nothing else.
|
|
44
|
-
|
|
45
|
-
### Choosing the subagent
|
|
46
|
-
|
|
47
|
-
- If `foundry_sort` returned a `model` field, use it verbatim as `subagent_type`. It is already in `foundry-<slug>` form.
|
|
48
|
-
- If no `model` field, dispatch to `general`.
|
|
49
|
-
|
|
50
|
-
### Token handling
|
|
51
|
-
|
|
52
|
-
The `token` returned by `foundry_sort` is an opaque signed string. Pass it through the dispatch prompt verbatim. **Never** invent, edit, or re-sign tokens. The subagent's first tool call must be `foundry_stage_begin({stage, cycle, token})` using this exact string; `stage_begin` verifies the signature, expiry, and single-use nonce.
|
|
53
|
-
|
|
54
|
-
### Dispatch call shape
|
|
55
|
-
|
|
56
|
-
Use the `task` tool:
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
task tool:
|
|
60
|
-
subagent_type: <model-slug-from-foundry_sort, or "general">
|
|
61
|
-
description: "Run <stage-alias> for <cycle-id>"
|
|
62
|
-
prompt: |
|
|
63
|
-
You are a Foundry stage agent. Invoke the <stage-base> skill and follow its instructions exactly.
|
|
64
|
-
|
|
65
|
-
Stage: <stage-alias>
|
|
66
|
-
Cycle: <cycle-id>
|
|
67
|
-
Token: <token-verbatim>
|
|
68
|
-
Working directory: <worktree>
|
|
69
|
-
File patterns (forge only): <file-patterns-list>
|
|
70
|
-
|
|
71
|
-
Your FIRST tool call MUST be foundry_stage_begin({stage, cycle, token}) using the values above.
|
|
72
|
-
Your LAST tool call MUST be foundry_stage_end({summary}).
|
|
73
|
-
|
|
74
|
-
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.
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Substitute:
|
|
78
|
-
- `<stage-alias>` — the full route string from `foundry_sort` (e.g., `forge:write-haiku`)
|
|
79
|
-
- `<stage-base>` — the base of the alias
|
|
80
|
-
- `<cycle-id>` — current cycle ID from WORK.md frontmatter
|
|
81
|
-
- `<token-verbatim>` — exactly the `token` string from `foundry_sort` — no quoting transforms, no re-encoding
|
|
82
|
-
- `<file-patterns-list>` — for forge stages, read via `foundry_config_artefact_type` and include so the subagent can avoid violations
|
|
83
|
-
- `<worktree>` — current working directory
|
|
84
|
-
|
|
85
|
-
### Missing subagent (fail-fast)
|
|
86
|
-
|
|
87
|
-
`foundry_sort` verifies that `.opencode/agents/foundry-<slug>.md` exists before returning a `model`. If it doesn't, sort returns `{route: 'violation', details: 'Missing required subagent: ...'}`. Handle as in step 3 above.
|
|
88
|
-
|
|
89
|
-
## Violation handling
|
|
90
|
-
|
|
91
|
-
If `foundry_stage_finalize` returns `{error: 'unexpected_files', files}`:
|
|
92
|
-
|
|
93
|
-
- The stage wrote outside its permitted `file-patterns`. This is unrecoverable within the current cycle.
|
|
94
|
-
- Mark the target artefact `blocked`: `foundry_artefacts_set_status(file, 'blocked')`.
|
|
95
|
-
- Add a feedback item describing the offense: `foundry_feedback_add(file, text: 'unexpected files: …', tag: 'violation')` (if permitted by your stage), or log in the history comment.
|
|
96
|
-
- Do NOT attempt to re-run the stage — the subagent already consumed the stage slot.
|
|
97
|
-
- Return to the cycle skill so the operator can intervene.
|
|
98
|
-
|
|
99
|
-
If `foundry_sort` returns `{route: 'violation', details: 'Uncommitted tool-managed files...'}`:
|
|
100
|
-
|
|
101
|
-
- A prior stage skipped step 6 (the micro-commit). The work is not lost — it's still in the working tree.
|
|
102
|
-
- Call `foundry_git_commit({cycle, stage: <the-stage-that-just-ran>, description})` to record it.
|
|
103
|
-
- Then call `foundry_sort` again. It should route normally.
|
|
104
|
-
- If you genuinely don't know which stage produced the dirty files, read `WORK.history.yaml` — the most recent non-sort entry is the culprit.
|
|
105
|
-
|
|
106
|
-
## What you do NOT do
|
|
107
|
-
|
|
108
|
-
- You do not inline forge/quench/appraise work — always dispatch.
|
|
109
|
-
- You do not mint, modify, or cache tokens — they come from `foundry_sort` and go straight to `foundry_stage_begin`.
|
|
110
|
-
- You do not skip `foundry_stage_finalize` — it is the only mechanism that registers artefacts and detects file-pattern violations.
|
|
111
|
-
- You do not let subagents call `foundry_history_append`, `foundry_git_commit`, or `foundry_artefacts_add` (the last has been removed anyway).
|