@really-knows-ai/foundry 2.0.1 → 2.2.1

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.
@@ -9,16 +9,27 @@
9
9
 
10
10
  import path from 'path';
11
11
  import fs from 'fs';
12
- import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
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 } from '../../scripts/lib/history.js';
15
+ import { loadHistory, appendEntry, getIteration, readLastSortRoute } from '../../scripts/lib/history.js';
16
16
  import { parseFrontmatter, createWorkfile, setFrontmatterField, getFrontmatterField, enrichStages, parseStagesValue, 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
+ import { slugify } from '../../scripts/lib/slug.js';
20
21
  import { runSort } from '../../scripts/sort.js';
21
- import { execSync } from 'child_process';
22
+ import { execSync, execFileSync } from 'child_process';
23
+ import { createHash, randomUUID } from 'node:crypto';
24
+ import { readOrCreateSecret } from '../../scripts/lib/secret.js';
25
+ import { createPendingStore } from '../../scripts/lib/pending.js';
26
+ import { signToken, verifyToken } from '../../scripts/lib/token.js';
27
+ import {
28
+ ensureFoundryDir, readActiveStage, writeActiveStage, clearActiveStage,
29
+ readLastStage, writeLastStage,
30
+ } from '../../scripts/lib/state.js';
31
+ import { requireNoActiveStage, requireActiveStage, stageBaseOf } from '../../scripts/lib/stage-guard.js';
32
+ import { finalizeStage } from '../../scripts/lib/finalize.js';
22
33
 
23
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
35
  const packageRoot = path.resolve(__dirname, '../..');
@@ -114,11 +125,21 @@ function makeIO(directory) {
114
125
  readFile: (p) => readFileSync(resolve(p), 'utf-8'),
115
126
  writeFile: (p, content) => writeFileSync(resolve(p), content, 'utf-8'),
116
127
  readDir: (p) => readdirSync(resolve(p)),
128
+ mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
129
+ unlink: (p) => { if (existsSync(resolve(p))) unlinkSync(resolve(p)); },
117
130
  };
118
131
  }
119
132
 
120
133
  export const FoundryPlugin = async ({ directory }) => {
121
- return {
134
+ // Bootstrap per-worktree HMAC secret (created on first boot, persisted to .foundry/secret).
135
+ // Note: `directory` is the worktree root at plugin-boot time. Per-invocation `context.worktree`
136
+ // may differ in multi-worktree setups — we still use `context.worktree` inside tool `execute`
137
+ // bodies to locate `.foundry/` on disk, and use the plugin-boot `secret` only for
138
+ // signing/verifying. A worktree change mid-session would mismatch; deferred out of v2.2.0 scope.
139
+ const secret = readOrCreateSecret(directory);
140
+ const pending = createPendingStore();
141
+
142
+ const plugin = {
122
143
  config: async (config) => {
123
144
  config.skills = config.skills || {};
124
145
  config.skills.paths = config.skills.paths || [];
@@ -150,12 +171,25 @@ export const FoundryPlugin = async ({ directory }) => {
150
171
  cycle: tool.schema.string().describe('Cycle name'),
151
172
  stage: tool.schema.string().describe('Stage name'),
152
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'),
153
175
  },
154
176
  async execute(args, context) {
155
177
  const io = makeIO(context.worktree);
178
+ const guard = requireNoActiveStage(io);
179
+ if (!guard.ok) return JSON.stringify({ error: `foundry_history_append ${guard.error}` });
156
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
+ }
157
191
  const iteration = getIteration(historyPath, args.cycle, io);
158
- appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment }, io);
192
+ appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment, route: args.route }, io);
159
193
  return JSON.stringify({ ok: true, iteration });
160
194
  },
161
195
  }),
@@ -173,23 +207,145 @@ export const FoundryPlugin = async ({ directory }) => {
173
207
  },
174
208
  }),
175
209
 
210
+ // ── Stage lifecycle tools ──
211
+ foundry_stage_begin: tool({
212
+ description: 'Open a subagent work stage; consumes a dispatch token from foundry_sort.',
213
+ args: {
214
+ stage: tool.schema.string().describe('Stage alias, e.g. "forge:create-haiku"'),
215
+ cycle: tool.schema.string().describe('Cycle name'),
216
+ token: tool.schema.string().describe('Token received from foundry_sort via the dispatch prompt'),
217
+ },
218
+ async execute(args, context) {
219
+ const io = makeIO(context.worktree);
220
+ // Precondition: no active stage.
221
+ const current = readActiveStage(io);
222
+ if (current) {
223
+ return JSON.stringify({ error: `foundry_stage_begin requires no active stage; current: ${current.stage}` });
224
+ }
225
+ // Verify token signature + expiry.
226
+ const v = verifyToken(args.token, secret);
227
+ if (!v.ok) return JSON.stringify({ error: `foundry_stage_begin: token ${v.reason}` });
228
+ // Payload must match args.
229
+ if (v.payload.route !== args.stage || v.payload.cycle !== args.cycle) {
230
+ return JSON.stringify({ error: `foundry_stage_begin: token payload mismatch (route=${v.payload.route}, cycle=${v.payload.cycle})` });
231
+ }
232
+ // Single-use nonce check.
233
+ const meta = pending.consume(v.payload.nonce);
234
+ if (!meta) return JSON.stringify({ error: `foundry_stage_begin: nonce not pending or already consumed` });
235
+
236
+ // Resolve base SHA from git.
237
+ let baseSha;
238
+ try {
239
+ baseSha = execSync('git rev-parse HEAD', { cwd: context.worktree }).toString().trim();
240
+ } catch {
241
+ return JSON.stringify({ error: `foundry_stage_begin: git rev-parse HEAD failed (no commits?)` });
242
+ }
243
+
244
+ const tokenHash = createHash('sha256').update(args.token).digest('hex');
245
+ const active = {
246
+ cycle: args.cycle,
247
+ stage: args.stage,
248
+ tokenHash,
249
+ baseSha,
250
+ startedAt: new Date().toISOString(),
251
+ };
252
+ writeActiveStage(io, active);
253
+ return JSON.stringify({ ok: true, active });
254
+ },
255
+ }),
256
+
257
+ foundry_stage_end: tool({
258
+ description: 'Close the active subagent work stage; preserves baseSha for finalize.',
259
+ args: {
260
+ summary: tool.schema.string().describe('Short summary of the work done'),
261
+ },
262
+ async execute(args, context) {
263
+ const io = makeIO(context.worktree);
264
+ const active = readActiveStage(io);
265
+ if (!active) return JSON.stringify({ error: 'foundry_stage_end requires active stage; current: none' });
266
+ writeLastStage(io, { cycle: active.cycle, stage: active.stage, baseSha: active.baseSha, summary: args.summary });
267
+ clearActiveStage(io);
268
+ return JSON.stringify({ ok: true, summary: args.summary });
269
+ },
270
+ }),
271
+
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
+
176
323
  // ── Workfile tools ──
177
324
  foundry_workfile_create: tool({
178
325
  description: 'Create WORK.md with frontmatter and goal',
179
326
  args: {
180
327
  flow: tool.schema.string().describe('Flow name'),
181
328
  cycle: tool.schema.string().describe('Cycle name'),
182
- stages: tool.schema.array(tool.schema.string()).describe('Ordered stage names'),
183
- maxIterations: tool.schema.number().describe('Maximum iterations'),
329
+ stages: tool.schema.array(tool.schema.string()).optional().describe('Ordered stage names'),
330
+ maxIterations: tool.schema.number().optional().describe('Maximum iterations'),
184
331
  goal: tool.schema.string().describe('Goal text'),
185
332
  models: tool.schema.string().optional().describe('Per-stage model overrides as JSON object, e.g. \'{"forge":"openai/gpt-4o"}\''),
186
333
  },
187
334
  async execute(args, context) {
335
+ const io = makeIO(context.worktree);
336
+ const guard = requireNoActiveStage(io);
337
+ if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_create ${guard.error}` });
188
338
  const workPath = path.join(context.worktree, 'WORK.md');
189
339
  if (existsSync(workPath)) {
190
- return JSON.stringify({ error: 'WORK.md already exists' });
340
+ return JSON.stringify({ error: 'foundry_workfile_create requires no WORK.md; current: exists' });
341
+ }
342
+ const fm = { flow: args.flow, cycle: args.cycle };
343
+ if (args.stages) {
344
+ fm.stages = enrichStages(args.stages, args.cycle);
345
+ }
346
+ if (args.maxIterations !== undefined) {
347
+ fm['max-iterations'] = args.maxIterations;
191
348
  }
192
- const fm = { flow: args.flow, cycle: args.cycle, stages: enrichStages(args.stages, args.cycle), maxIterations: args.maxIterations };
193
349
  if (args.models) {
194
350
  fm.models = parseModelsValue(args.models);
195
351
  }
@@ -218,10 +374,17 @@ export const FoundryPlugin = async ({ directory }) => {
218
374
  foundry_workfile_set: tool({
219
375
  description: 'Update a single frontmatter field in WORK.md',
220
376
  args: {
221
- key: tool.schema.string().describe('Frontmatter key'),
377
+ key: tool.schema.string().describe('Frontmatter key (cycle|stages|max-iterations|models)'),
222
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"}\')'),
223
379
  },
224
380
  async execute(args, context) {
381
+ const io = makeIO(context.worktree);
382
+ 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}` });
387
+ }
225
388
  const workPath = path.join(context.worktree, 'WORK.md');
226
389
  if (!existsSync(workPath)) {
227
390
  return JSON.stringify({ error: 'WORK.md not found' });
@@ -258,10 +421,78 @@ export const FoundryPlugin = async ({ directory }) => {
258
421
  },
259
422
  }),
260
423
 
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).',
426
+ 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>)'),
429
+ },
430
+ async execute(args, context) {
431
+ 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}` });
434
+
435
+ const workPath = path.join(context.worktree, 'WORK.md');
436
+ if (!existsSync(workPath)) {
437
+ return JSON.stringify({ error: 'WORK.md not found' });
438
+ }
439
+
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 } : {}),
479
+ },
480
+ });
481
+ },
482
+ }),
483
+
261
484
  foundry_workfile_delete: tool({
262
- description: 'Delete WORK.md and WORK.history.yaml',
263
- args: {},
264
- async execute(_args, context) {
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
+ }
265
496
  const workPath = path.join(context.worktree, 'WORK.md');
266
497
  const historyPath = path.join(context.worktree, 'WORK.history.yaml');
267
498
  if (existsSync(workPath)) {
@@ -275,48 +506,45 @@ export const FoundryPlugin = async ({ directory }) => {
275
506
  }),
276
507
 
277
508
  // ── Artefacts tools ──
278
- foundry_artefacts_add: tool({
279
- description: 'Add an artefact row to the WORK.md table',
509
+ // NOTE: `foundry_artefacts_add` was removed in v2.2.0. Artefacts are now
510
+ // registered automatically by `foundry_stage_finalize` as drafts, then
511
+ // promoted to done|blocked via `foundry_artefacts_set_status`.
512
+ foundry_artefacts_set_status: tool({
513
+ description: 'Update the status of an artefact in WORK.md (done|blocked only)',
280
514
  args: {
281
515
  file: tool.schema.string().describe('Artefact file path'),
282
- type: tool.schema.string().describe('Artefact type'),
283
- cycle: tool.schema.string().describe('Cycle name'),
284
- status: tool.schema.string().optional().describe('Status (default: draft)'),
516
+ status: tool.schema.string().describe('New status (done|blocked)'),
285
517
  },
286
518
  async execute(args, context) {
519
+ const io = makeIO(context.worktree);
520
+ const guard = requireNoActiveStage(io);
521
+ if (!guard.ok) return JSON.stringify({ error: `foundry_artefacts_set_status ${guard.error}` });
287
522
  const workPath = path.join(context.worktree, 'WORK.md');
288
523
  const text = readFileSync(workPath, 'utf-8');
289
- const updated = addArtefactRow(text, { file: args.file, type: args.type, cycle: args.cycle, status: args.status || 'draft' });
290
- writeFileSync(workPath, updated, 'utf-8');
291
- return JSON.stringify({ ok: true });
524
+ try {
525
+ const updated = setArtefactStatus(text, args.file, args.status);
526
+ writeFileSync(workPath, updated, 'utf-8');
527
+ return JSON.stringify({ ok: true });
528
+ } catch (e) {
529
+ return JSON.stringify({ error: e.message });
530
+ }
292
531
  },
293
532
  }),
294
533
 
295
- foundry_artefacts_set_status: tool({
296
- description: 'Update the status of an artefact in WORK.md',
534
+ foundry_artefacts_list: tool({
535
+ description: 'List artefacts from the WORK.md table. Optionally filter by cycle — callers should always pass the current cycle to avoid picking up stale rows from prior sessions.',
297
536
  args: {
298
- file: tool.schema.string().describe('Artefact file path'),
299
- status: tool.schema.string().describe('New status'),
537
+ cycle: tool.schema.string().optional().describe('Only return rows whose Cycle column matches this value'),
300
538
  },
301
539
  async execute(args, context) {
302
- const workPath = path.join(context.worktree, 'WORK.md');
303
- const text = readFileSync(workPath, 'utf-8');
304
- const updated = setArtefactStatus(text, args.file, args.status);
305
- writeFileSync(workPath, updated, 'utf-8');
306
- return JSON.stringify({ ok: true });
307
- },
308
- }),
309
-
310
- foundry_artefacts_list: tool({
311
- description: 'List all artefacts from the WORK.md table',
312
- args: {},
313
- async execute(_args, context) {
314
540
  const workPath = path.join(context.worktree, 'WORK.md');
315
541
  if (!existsSync(workPath)) {
316
542
  return JSON.stringify({ error: 'WORK.md not found' });
317
543
  }
318
544
  const text = readFileSync(workPath, 'utf-8');
319
- return JSON.stringify(parseArtefactsTable(text));
545
+ const rows = parseArtefactsTable(text);
546
+ const filtered = args.cycle ? rows.filter(r => r.cycle === args.cycle) : rows;
547
+ return JSON.stringify(filtered);
320
548
  },
321
549
  }),
322
550
 
@@ -329,11 +557,28 @@ export const FoundryPlugin = async ({ directory }) => {
329
557
  tag: tool.schema.string().describe('Tag for the feedback item'),
330
558
  },
331
559
  async execute(args, context) {
560
+ const io = makeIO(context.worktree);
561
+ const guard = requireActiveStage(io);
562
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_add requires active stage; ${guard.error}` });
563
+ const stageBase = stageBaseOf(guard.active.stage);
564
+ // Per-stage tag allow-list.
565
+ if (stageBase === 'forge') {
566
+ return JSON.stringify({ error: 'foundry_feedback_add: forge stages do not add feedback' });
567
+ }
568
+ if (stageBase === 'quench' && args.tag !== 'validation') {
569
+ return JSON.stringify({ error: `foundry_feedback_add: quench may only add tag "validation"; got "${args.tag}"` });
570
+ }
571
+ if (stageBase === 'appraise' && !args.tag.startsWith('law:')) {
572
+ return JSON.stringify({ error: `foundry_feedback_add: appraise tag must start with "law:"; got "${args.tag}"` });
573
+ }
574
+ if (stageBase === 'human-appraise' && args.tag !== 'human') {
575
+ return JSON.stringify({ error: `foundry_feedback_add: human-appraise may only add tag "human"; got "${args.tag}"` });
576
+ }
332
577
  const workPath = path.join(context.worktree, 'WORK.md');
333
578
  const content = readFileSync(workPath, 'utf-8');
334
- const updated = addFeedbackItem(content, args.file, args.text, args.tag);
335
- writeFileSync(workPath, updated, 'utf-8');
336
- return JSON.stringify({ ok: true });
579
+ const r = addFeedbackItem(content, args.file, args.text, args.tag);
580
+ if (!r.deduped) writeFileSync(workPath, r.text, 'utf-8');
581
+ return JSON.stringify({ ok: true, deduped: r.deduped });
337
582
  },
338
583
  }),
339
584
 
@@ -344,10 +589,18 @@ export const FoundryPlugin = async ({ directory }) => {
344
589
  index: tool.schema.number().describe('Zero-based index of the feedback item'),
345
590
  },
346
591
  async execute(args, context) {
592
+ const io = makeIO(context.worktree);
593
+ const guard = requireActiveStage(io);
594
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_action requires active stage; ${guard.error}` });
595
+ const stageBase = stageBaseOf(guard.active.stage);
596
+ if (stageBase !== 'forge') {
597
+ return JSON.stringify({ error: `foundry_feedback_action requires active forge stage; current: ${guard.active.stage}` });
598
+ }
347
599
  const workPath = path.join(context.worktree, 'WORK.md');
348
600
  const content = readFileSync(workPath, 'utf-8');
349
- const updated = actionFeedbackItem(content, args.file, args.index);
350
- writeFileSync(workPath, updated, 'utf-8');
601
+ const r = actionFeedbackItem(content, args.file, args.index, stageBase);
602
+ if (!r.ok) return JSON.stringify({ error: r.error });
603
+ writeFileSync(workPath, r.text, 'utf-8');
351
604
  return JSON.stringify({ ok: true });
352
605
  },
353
606
  }),
@@ -360,10 +613,18 @@ export const FoundryPlugin = async ({ directory }) => {
360
613
  reason: tool.schema.string().describe('Reason for wont-fix'),
361
614
  },
362
615
  async execute(args, context) {
616
+ const io = makeIO(context.worktree);
617
+ const guard = requireActiveStage(io);
618
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_wontfix requires active stage; ${guard.error}` });
619
+ const stageBase = stageBaseOf(guard.active.stage);
620
+ if (stageBase !== 'forge') {
621
+ return JSON.stringify({ error: `foundry_feedback_wontfix requires active forge stage; current: ${guard.active.stage}` });
622
+ }
363
623
  const workPath = path.join(context.worktree, 'WORK.md');
364
624
  const content = readFileSync(workPath, 'utf-8');
365
- const updated = wontfixFeedbackItem(content, args.file, args.index, args.reason);
366
- writeFileSync(workPath, updated, 'utf-8');
625
+ const r = wontfixFeedbackItem(content, args.file, args.index, args.reason, stageBase);
626
+ if (!r.ok) return JSON.stringify({ error: r.error });
627
+ writeFileSync(workPath, r.text, 'utf-8');
367
628
  return JSON.stringify({ ok: true });
368
629
  },
369
630
  }),
@@ -377,10 +638,18 @@ export const FoundryPlugin = async ({ directory }) => {
377
638
  reason: tool.schema.string().optional().describe('Reason (required if rejected)'),
378
639
  },
379
640
  async execute(args, context) {
641
+ const io = makeIO(context.worktree);
642
+ const guard = requireActiveStage(io);
643
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_resolve requires active stage; ${guard.error}` });
644
+ const stageBase = stageBaseOf(guard.active.stage);
645
+ if (!['quench', 'appraise', 'human-appraise'].includes(stageBase)) {
646
+ return JSON.stringify({ error: `foundry_feedback_resolve requires active quench|appraise|human-appraise stage; current: ${guard.active.stage}` });
647
+ }
380
648
  const workPath = path.join(context.worktree, 'WORK.md');
381
649
  const content = readFileSync(workPath, 'utf-8');
382
- const updated = resolveFeedbackItem(content, args.file, args.index, args.resolution, args.reason);
383
- writeFileSync(workPath, updated, 'utf-8');
650
+ const r = resolveFeedbackItem(content, args.file, args.index, args.resolution, args.reason, stageBase);
651
+ if (!r.ok) return JSON.stringify({ error: r.error });
652
+ writeFileSync(workPath, r.text, 'utf-8');
384
653
  return JSON.stringify({ ok: true });
385
654
  },
386
655
  }),
@@ -405,13 +674,21 @@ export const FoundryPlugin = async ({ directory }) => {
405
674
 
406
675
  // ── Sort tool ──
407
676
  foundry_sort: tool({
408
- description: 'Run sort routing to determine the next stage',
677
+ description: 'Determine the next stage for the current cycle and (if dispatchable) mint a single-use token.',
409
678
  args: {
410
679
  cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
411
680
  },
412
681
  async execute(args, context) {
413
682
  const io = makeIO(context.worktree);
414
- const result = runSort({ cycleDef: args.cycleDef }, io);
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);
415
692
  return JSON.stringify(result);
416
693
  },
417
694
  }),
@@ -424,8 +701,13 @@ export const FoundryPlugin = async ({ directory }) => {
424
701
  description: tool.schema.string().describe('Branch description suffix'),
425
702
  },
426
703
  async execute(args, context) {
427
- const branch = `work/${args.flowId}-${args.description}`;
428
- execSync(`git checkout -b ${branch}`, { cwd: context.worktree, encoding: 'utf8' });
704
+ const io = makeIO(context.worktree);
705
+ const guard = requireNoActiveStage(io);
706
+ if (!guard.ok) return JSON.stringify({ error: `foundry_git_branch ${guard.error}` });
707
+ const flowSlug = slugify(args.flowId);
708
+ const descSlug = slugify(args.description);
709
+ const branch = `work/${flowSlug}-${descSlug}`;
710
+ execFileSync('git', ['checkout', '-b', branch], { cwd: context.worktree, encoding: 'utf8', stdio: 'pipe' });
429
711
  return JSON.stringify({ ok: true, branch });
430
712
  },
431
713
  }),
@@ -438,6 +720,9 @@ export const FoundryPlugin = async ({ directory }) => {
438
720
  description: tool.schema.string().describe('Commit description'),
439
721
  },
440
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}` });
441
726
  execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
442
727
  const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
443
728
  execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
@@ -453,6 +738,9 @@ export const FoundryPlugin = async ({ directory }) => {
453
738
  baseBranch: tool.schema.string().optional().describe('Target branch (default: main)'),
454
739
  },
455
740
  async execute(args, context) {
741
+ const io = makeIO(context.worktree);
742
+ const guard = requireNoActiveStage(io);
743
+ if (!guard.ok) return JSON.stringify({ error: `foundry_git_finish ${guard.error}` });
456
744
  const base = args.baseBranch || 'main';
457
745
  const cwd = context.worktree;
458
746
  const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
@@ -608,4 +896,8 @@ export const FoundryPlugin = async ({ directory }) => {
608
896
  }),
609
897
  },
610
898
  };
899
+
900
+ Object.defineProperty(plugin, Symbol.for('foundry.test.pending'), { value: pending });
901
+ Object.defineProperty(plugin, Symbol.for('foundry.test.secret'), { value: secret });
902
+ return plugin;
611
903
  };
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## 2.2.1 — 2026-04-20
4
+
5
+ Follow-up patch addressing the five bugs deferred from v2.2.0 (see `HARDEN.md` §Deferred).
6
+
7
+ ### Breaking changes
8
+
9
+ - **Cycle-definition deadlock config flattened.** The nested `human-appraise: {enabled, deadlock-threshold}` block is replaced by three flat keys:
10
+ - `human-appraise: <bool>` (default `false`) — include `human-appraise` in the stage loop every iteration
11
+ - `deadlock-appraise: <bool>` (default `true`) — route to `human-appraise` when LLM appraisers deadlock
12
+ - `deadlock-iterations: <number>` (default `5`) — deadlock threshold
13
+ Run the `upgrade-foundry` skill to migrate existing cycle defs — the old nested form is no longer read.
14
+
15
+ ### New
16
+
17
+ - **`foundry_workfile_configure_from_cycle({cycleId, stages})`** — populates WORK.md frontmatter from a cycle definition in one call. Replaces the prior 6–7 sequential `foundry_workfile_set` calls at cycle start. Defaults for `max-iterations`, `human-appraise`, `deadlock-appraise`, `deadlock-iterations`, and `models` now live in plugin code rather than skill prose.
18
+ - **`foundry_artefacts_list({cycle})`** — optional cycle filter. Callers should always pass the current cycle to avoid picking up stale rows from prior aborted sessions.
19
+
20
+ ### Fixed
21
+
22
+ - **Bug B — deadlock routing.** Sort now reads the flat deadlock keys from WORK.md frontmatter and routes to `human-appraise` on deadlock (either an existing `human-appraise:<cycle>` stage in `stages`, or a synthesized one). When `deadlock-appraise: false`, deadlock marks the cycle `blocked`.
23
+ - **Bug C — stale artefact validation.** `quench`, `appraise`, and `human-appraise` skills now pass the current cycle to `foundry_artefacts_list`, scoping validation to artefacts produced by the current cycle instead of every row that has ever landed in WORK.md.
24
+ - **Bug D — overwriting WORK.md.** The `flow` skill now calls `foundry_workfile_get` before `foundry_workfile_create` and prompts the user to resume, discard, or abort when an existing workfile is detected. Silent overwrite is not offered; resume requires matching `flow` and `cycle`.
25
+ - **Bug E — missing micro-commits.** `foundry_sort` now returns `{route: 'violation'}` when `WORK.md`, `WORK.history.yaml`, or anything under `.foundry/` has uncommitted changes at the start of a sort call and history is non-empty. Structurally enforces the one-commit-per-stage contract that previously lived only in skill prose. First sort of a cycle is exempt (empty history).
26
+ - **Bug G — workfile setup boilerplate.** See `foundry_workfile_configure_from_cycle` above.
27
+
28
+ ### Migration
29
+
30
+ Run the `upgrade-foundry` skill to migrate cycle definitions to the flat deadlock keys (Bug B). No other migration required — WORK.md, `.foundry/`, and feedback state are forward-compatible.
31
+
32
+ ## 2.2.0 — 2026-04-19
33
+
34
+ ### Breaking changes
35
+
36
+ - **`foundry_artefacts_add` removed.** Artefact registration now happens exclusively via `foundry_stage_finalize` after a forge stage closes.
37
+ - **`foundry_artefacts_set_status` no longer accepts `draft`.** Only `done` and `blocked` are valid. New artefacts are registered as `draft` automatically by `stage_finalize`.
38
+ - **Feedback / artefact / workfile mutation tools now enforce stage-lock preconditions.** Tools callable by subagents require an active stage matching their role; tools callable by the orchestrator require no active stage. Out-of-band calls return a structured error instead of mutating state.
39
+ - **Feedback state machine strictly enforced.** `approved` is terminal. `quench` cannot approve/reject `wont-fix` items. See `HARDEN.md` §4 for the full matrix.
40
+ - **`foundry_sort` dispatchable routes now return a `token` field.** Subagents must redeem the token via `foundry_stage_begin`; forged or replayed tokens are rejected.
41
+
42
+ ### New
43
+
44
+ - **`foundry_stage_begin(stage, cycle, token)`** — subagents open a work stage by consuming a single-use HMAC-signed token.
45
+ - **`foundry_stage_end(summary)`** — subagents close a stage; preserves `baseSha` for finalize.
46
+ - **`foundry_stage_finalize(cycle)`** — orchestrator verifies stage output against allowed file patterns, registers matching files as draft artefacts, rejects stray writes with `{error: "unexpected_files", files: [...]}`.
47
+ - **`.foundry/` state directory** (gitignored) — holds `.secret` (per-worktree HMAC key, mode 0600), `active-stage.json` (present only during an active stage), `last-stage.json` (for finalize lookup).
48
+
49
+ ### Fixed
50
+
51
+ - Normalized `maxIterations` → `max-iterations` across workfile read/write paths (previously inconsistent between flow and cycle skills, causing latent deadlock-detection issues).
52
+
53
+ ### Migration
54
+
55
+ Upgrade with the `upgrade-foundry` skill. `.foundry/` is created automatically on first plugin boot; `.secret` is generated idempotently. No data migration required — existing `WORK.md` and `foundry/*` configs are compatible.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "2.0.1",
3
+ "version": "2.2.1",
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",
@@ -25,7 +25,7 @@
25
25
  "node": ">=18.3.0"
26
26
  },
27
27
  "scripts": {
28
- "test": "node --test tests/**/*.test.js"
28
+ "test": "node --test"
29
29
  },
30
30
  "dependencies": {
31
31
  "@opencode-ai/plugin": "^1.4.0",
@@ -40,6 +40,7 @@
40
40
  "docs/concepts.md",
41
41
  "docs/getting-started.md",
42
42
  "README.md",
43
- "LICENSE"
43
+ "LICENSE",
44
+ "CHANGELOG.md"
44
45
  ]
45
46
  }
@@ -86,6 +86,12 @@ export function addArtefactRow(text, { file, type, cycle, status }) {
86
86
  * @returns {string} Updated text
87
87
  */
88
88
  export function setArtefactStatus(text, file, newStatus) {
89
+ if (newStatus === 'draft') {
90
+ throw new Error('status draft not permitted; use stage_finalize for registration');
91
+ }
92
+ if (!['done', 'blocked'].includes(newStatus)) {
93
+ throw new Error(`invalid status: ${newStatus}`);
94
+ }
89
95
  const lines = text.split('\n');
90
96
  let inTable = false;
91
97
  let found = false;