@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.
@@ -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, appendEntry, getIteration, readLastSortRoute } from '../../scripts/lib/history.js';
16
- import { parseFrontmatter, createWorkfile, setFrontmatterField, getFrontmatterField, enrichStages, parseStagesValue, parseModelsValue } from '../../scripts/lib/workfile.js';
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, cycle, flow, sort, human-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
- foundry_workfile_set: tool({
375
- description: 'Update a single frontmatter field in WORK.md',
299
+ foundry_workfile_delete: tool({
300
+ description: 'Delete WORK.md and WORK.history.yaml (requires confirm:true)',
376
301
  args: {
377
- key: tool.schema.string().describe('Frontmatter key (cycle|stages|max-iterations|models)'),
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: `foundry_workfile_set ${guard.error}` });
384
- const ALLOWED_KEYS = new Set(['cycle', 'stages', 'max-iterations', 'maxIterations', 'models', 'human-appraise', 'deadlock-appraise', 'deadlock-iterations']);
385
- if (!ALLOWED_KEYS.has(args.key)) {
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
- if (!existsSync(workPath)) {
390
- return JSON.stringify({ error: 'WORK.md not found' });
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
- // Auto-enrich bare stage names with cycle ID alias
412
- if (args.key === 'stages' && Array.isArray(value)) {
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
- foundry_workfile_configure_from_cycle: tool({
425
- description: 'Populate WORK.md frontmatter from a cycle definition in one pass. Reads the cycle def for max-iterations, human-appraise, deadlock-appraise, deadlock-iterations, and models; applies defaults for anything missing. Caller must supply the synthesized stages list (the skill owns stage synthesis).',
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
- cycleId: tool.schema.string().describe('Cycle ID — used to locate foundry/cycles/<cycleId>.md'),
428
- stages: tool.schema.array(tool.schema.string()).describe('Ordered stage aliases (bare names will be enriched with :<cycleId>)'),
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 guard = requireNoActiveStage(io);
433
- if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_configure_from_cycle ${guard.error}` });
336
+ const cwd = context.worktree;
434
337
 
435
- const workPath = path.join(context.worktree, 'WORK.md');
436
- if (!existsSync(workPath)) {
437
- return JSON.stringify({ error: 'WORK.md not found' });
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
- let cycleDoc;
441
- try {
442
- cycleDoc = await getCycleDefinition('foundry', args.cycleId, io);
443
- } catch (e) {
444
- return JSON.stringify({ error: `foundry_workfile_configure_from_cycle: ${e.message}` });
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
- foundry_workfile_delete: tool({
485
- description: 'Delete WORK.md and WORK.history.yaml (requires confirm:true)',
486
- args: {
487
- confirm: tool.schema.boolean().describe('Must be true to confirm deletion'),
488
- },
489
- async execute(args, context) {
490
- const io = makeIO(context.worktree);
491
- const guard = requireNoActiveStage(io);
492
- if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_delete ${guard.error}` });
493
- if (args.confirm !== true) {
494
- return JSON.stringify({ error: 'foundry_workfile_delete requires {confirm: true}' });
495
- }
496
- const workPath = path.join(context.worktree, 'WORK.md');
497
- const historyPath = path.join(context.worktree, 'WORK.history.yaml');
498
- if (existsSync(workPath)) {
499
- unlinkSync(workPath);
500
- }
501
- if (existsSync(historyPath)) {
502
- unlinkSync(historyPath);
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 = execSync('git branch --show-current', opts).trim();
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
- execSync('git add -A', opts);
763
- const status = execSync('git status --porcelain', opts).trim();
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
- execSync(`git commit -m "${cleanupMsg.replace(/"/g, '\\"')}"`, opts);
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
- execSync(`git checkout ${base}`, opts);
772
- execSync(`git merge --squash ${workBranch}`, opts);
773
- const msg = args.message.replace(/"/g, '\\"');
774
- execSync(`git commit -m "${msg}"`, opts);
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
- execSync(`git branch -D ${workBranch}`, opts);
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 foundry cycle skill when starting each foundry cycle (reads artefact type to determine if quench is needed)
29
- - `max-iterations` — set by the foundry cycle skill (default 3, could be overridden in foundry cycle definition)
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` (cycle skill) | `foundry_workfile_set` (reset on each new cycle) |
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` (cycle skill) |
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.2.1",
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 };
@@ -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: [cycle]
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 `cycle` skill will read the cycle definition and populate those via `foundry_workfile_set` in the next step.
34
- 6. Execute the cycle by invoking the cycle skill
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 `foundry_workfile_set` with `key: "cycle"`, `value: <next-cycle-id>`
53
- - Reset stages and iteration count for the new cycle
54
- - Execute the cycle by invoking the cycle skill
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:
@@ -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.
@@ -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).