@really-knows-ai/foundry 2.1.0 → 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,10 +9,10 @@
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';
@@ -20,6 +20,16 @@ import { getCycleDefinition, getArtefactType, getLaws, getValidation, getApprais
20
20
  import { slugify } from '../../scripts/lib/slug.js';
21
21
  import { runSort } from '../../scripts/sort.js';
22
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';
23
33
 
24
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
35
  const packageRoot = path.resolve(__dirname, '../..');
@@ -115,11 +125,21 @@ function makeIO(directory) {
115
125
  readFile: (p) => readFileSync(resolve(p), 'utf-8'),
116
126
  writeFile: (p, content) => writeFileSync(resolve(p), content, 'utf-8'),
117
127
  readDir: (p) => readdirSync(resolve(p)),
128
+ mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
129
+ unlink: (p) => { if (existsSync(resolve(p))) unlinkSync(resolve(p)); },
118
130
  };
119
131
  }
120
132
 
121
133
  export const FoundryPlugin = async ({ directory }) => {
122
- 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 = {
123
143
  config: async (config) => {
124
144
  config.skills = config.skills || {};
125
145
  config.skills.paths = config.skills.paths || [];
@@ -151,12 +171,25 @@ export const FoundryPlugin = async ({ directory }) => {
151
171
  cycle: tool.schema.string().describe('Cycle name'),
152
172
  stage: tool.schema.string().describe('Stage name'),
153
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'),
154
175
  },
155
176
  async execute(args, context) {
156
177
  const io = makeIO(context.worktree);
178
+ const guard = requireNoActiveStage(io);
179
+ if (!guard.ok) return JSON.stringify({ error: `foundry_history_append ${guard.error}` });
157
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
+ }
158
191
  const iteration = getIteration(historyPath, args.cycle, io);
159
- 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);
160
193
  return JSON.stringify({ ok: true, iteration });
161
194
  },
162
195
  }),
@@ -174,6 +207,119 @@ export const FoundryPlugin = async ({ directory }) => {
174
207
  },
175
208
  }),
176
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
+
177
323
  // ── Workfile tools ──
178
324
  foundry_workfile_create: tool({
179
325
  description: 'Create WORK.md with frontmatter and goal',
@@ -186,16 +332,19 @@ export const FoundryPlugin = async ({ directory }) => {
186
332
  models: tool.schema.string().optional().describe('Per-stage model overrides as JSON object, e.g. \'{"forge":"openai/gpt-4o"}\''),
187
333
  },
188
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}` });
189
338
  const workPath = path.join(context.worktree, 'WORK.md');
190
339
  if (existsSync(workPath)) {
191
- return JSON.stringify({ error: 'WORK.md already exists' });
340
+ return JSON.stringify({ error: 'foundry_workfile_create requires no WORK.md; current: exists' });
192
341
  }
193
342
  const fm = { flow: args.flow, cycle: args.cycle };
194
343
  if (args.stages) {
195
344
  fm.stages = enrichStages(args.stages, args.cycle);
196
345
  }
197
346
  if (args.maxIterations !== undefined) {
198
- fm.maxIterations = args.maxIterations;
347
+ fm['max-iterations'] = args.maxIterations;
199
348
  }
200
349
  if (args.models) {
201
350
  fm.models = parseModelsValue(args.models);
@@ -225,10 +374,17 @@ export const FoundryPlugin = async ({ directory }) => {
225
374
  foundry_workfile_set: tool({
226
375
  description: 'Update a single frontmatter field in WORK.md',
227
376
  args: {
228
- key: tool.schema.string().describe('Frontmatter key'),
377
+ key: tool.schema.string().describe('Frontmatter key (cycle|stages|max-iterations|models)'),
229
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"}\')'),
230
379
  },
231
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
+ }
232
388
  const workPath = path.join(context.worktree, 'WORK.md');
233
389
  if (!existsSync(workPath)) {
234
390
  return JSON.stringify({ error: 'WORK.md not found' });
@@ -265,10 +421,78 @@ export const FoundryPlugin = async ({ directory }) => {
265
421
  },
266
422
  }),
267
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
+
268
484
  foundry_workfile_delete: tool({
269
- description: 'Delete WORK.md and WORK.history.yaml',
270
- args: {},
271
- 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
+ }
272
496
  const workPath = path.join(context.worktree, 'WORK.md');
273
497
  const historyPath = path.join(context.worktree, 'WORK.history.yaml');
274
498
  if (existsSync(workPath)) {
@@ -282,48 +506,45 @@ export const FoundryPlugin = async ({ directory }) => {
282
506
  }),
283
507
 
284
508
  // ── Artefacts tools ──
285
- foundry_artefacts_add: tool({
286
- 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)',
287
514
  args: {
288
515
  file: tool.schema.string().describe('Artefact file path'),
289
- type: tool.schema.string().describe('Artefact type'),
290
- cycle: tool.schema.string().describe('Cycle name'),
291
- status: tool.schema.string().optional().describe('Status (default: draft)'),
516
+ status: tool.schema.string().describe('New status (done|blocked)'),
292
517
  },
293
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}` });
294
522
  const workPath = path.join(context.worktree, 'WORK.md');
295
523
  const text = readFileSync(workPath, 'utf-8');
296
- const updated = addArtefactRow(text, { file: args.file, type: args.type, cycle: args.cycle, status: args.status || 'draft' });
297
- writeFileSync(workPath, updated, 'utf-8');
298
- 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
+ }
299
531
  },
300
532
  }),
301
533
 
302
- foundry_artefacts_set_status: tool({
303
- 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.',
304
536
  args: {
305
- file: tool.schema.string().describe('Artefact file path'),
306
- status: tool.schema.string().describe('New status'),
537
+ cycle: tool.schema.string().optional().describe('Only return rows whose Cycle column matches this value'),
307
538
  },
308
539
  async execute(args, context) {
309
- const workPath = path.join(context.worktree, 'WORK.md');
310
- const text = readFileSync(workPath, 'utf-8');
311
- const updated = setArtefactStatus(text, args.file, args.status);
312
- writeFileSync(workPath, updated, 'utf-8');
313
- return JSON.stringify({ ok: true });
314
- },
315
- }),
316
-
317
- foundry_artefacts_list: tool({
318
- description: 'List all artefacts from the WORK.md table',
319
- args: {},
320
- async execute(_args, context) {
321
540
  const workPath = path.join(context.worktree, 'WORK.md');
322
541
  if (!existsSync(workPath)) {
323
542
  return JSON.stringify({ error: 'WORK.md not found' });
324
543
  }
325
544
  const text = readFileSync(workPath, 'utf-8');
326
- 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);
327
548
  },
328
549
  }),
329
550
 
@@ -336,11 +557,28 @@ export const FoundryPlugin = async ({ directory }) => {
336
557
  tag: tool.schema.string().describe('Tag for the feedback item'),
337
558
  },
338
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
+ }
339
577
  const workPath = path.join(context.worktree, 'WORK.md');
340
578
  const content = readFileSync(workPath, 'utf-8');
341
- const updated = addFeedbackItem(content, args.file, args.text, args.tag);
342
- writeFileSync(workPath, updated, 'utf-8');
343
- 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 });
344
582
  },
345
583
  }),
346
584
 
@@ -351,10 +589,18 @@ export const FoundryPlugin = async ({ directory }) => {
351
589
  index: tool.schema.number().describe('Zero-based index of the feedback item'),
352
590
  },
353
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
+ }
354
599
  const workPath = path.join(context.worktree, 'WORK.md');
355
600
  const content = readFileSync(workPath, 'utf-8');
356
- const updated = actionFeedbackItem(content, args.file, args.index);
357
- 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');
358
604
  return JSON.stringify({ ok: true });
359
605
  },
360
606
  }),
@@ -367,10 +613,18 @@ export const FoundryPlugin = async ({ directory }) => {
367
613
  reason: tool.schema.string().describe('Reason for wont-fix'),
368
614
  },
369
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
+ }
370
623
  const workPath = path.join(context.worktree, 'WORK.md');
371
624
  const content = readFileSync(workPath, 'utf-8');
372
- const updated = wontfixFeedbackItem(content, args.file, args.index, args.reason);
373
- 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');
374
628
  return JSON.stringify({ ok: true });
375
629
  },
376
630
  }),
@@ -384,10 +638,18 @@ export const FoundryPlugin = async ({ directory }) => {
384
638
  reason: tool.schema.string().optional().describe('Reason (required if rejected)'),
385
639
  },
386
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
+ }
387
648
  const workPath = path.join(context.worktree, 'WORK.md');
388
649
  const content = readFileSync(workPath, 'utf-8');
389
- const updated = resolveFeedbackItem(content, args.file, args.index, args.resolution, args.reason);
390
- 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');
391
653
  return JSON.stringify({ ok: true });
392
654
  },
393
655
  }),
@@ -412,13 +674,21 @@ export const FoundryPlugin = async ({ directory }) => {
412
674
 
413
675
  // ── Sort tool ──
414
676
  foundry_sort: tool({
415
- 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.',
416
678
  args: {
417
679
  cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
418
680
  },
419
681
  async execute(args, context) {
420
682
  const io = makeIO(context.worktree);
421
- 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);
422
692
  return JSON.stringify(result);
423
693
  },
424
694
  }),
@@ -431,6 +701,9 @@ export const FoundryPlugin = async ({ directory }) => {
431
701
  description: tool.schema.string().describe('Branch description suffix'),
432
702
  },
433
703
  async execute(args, context) {
704
+ const io = makeIO(context.worktree);
705
+ const guard = requireNoActiveStage(io);
706
+ if (!guard.ok) return JSON.stringify({ error: `foundry_git_branch ${guard.error}` });
434
707
  const flowSlug = slugify(args.flowId);
435
708
  const descSlug = slugify(args.description);
436
709
  const branch = `work/${flowSlug}-${descSlug}`;
@@ -447,6 +720,9 @@ export const FoundryPlugin = async ({ directory }) => {
447
720
  description: tool.schema.string().describe('Commit description'),
448
721
  },
449
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}` });
450
726
  execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
451
727
  const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
452
728
  execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
@@ -462,6 +738,9 @@ export const FoundryPlugin = async ({ directory }) => {
462
738
  baseBranch: tool.schema.string().optional().describe('Target branch (default: main)'),
463
739
  },
464
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}` });
465
744
  const base = args.baseBranch || 'main';
466
745
  const cwd = context.worktree;
467
746
  const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
@@ -617,4 +896,8 @@ export const FoundryPlugin = async ({ directory }) => {
617
896
  }),
618
897
  },
619
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;
620
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.1.0",
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",
@@ -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;
@@ -0,0 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Matrix: [current][target] => set of allowed stageBases
4
+ const MATRIX = {
5
+ open: { actioned: ['forge'], 'wont-fix': ['forge'] },
6
+ actioned: { approved: ['quench', 'appraise', 'human-appraise'], rejected: ['quench', 'appraise', 'human-appraise'] },
7
+ 'wont-fix': { approved: ['appraise', 'human-appraise'], rejected: ['appraise', 'human-appraise'] },
8
+ rejected: { actioned: ['forge'], 'wont-fix': ['forge'] },
9
+ approved: {}, // terminal
10
+ };
11
+
12
+ export function validateTransition(current, target, stageBase) {
13
+ const row = MATRIX[current];
14
+ if (!row) return { ok: false, reason: `unknown state: ${current}` };
15
+ const allowedStages = row[target];
16
+ if (!allowedStages) return { ok: false, reason: `invalid transition ${current} → ${target}` };
17
+ if (!allowedStages.includes(stageBase)) {
18
+ return { ok: false, reason: `stage ${stageBase} cannot transition ${current} → ${target}` };
19
+ }
20
+ return { ok: true };
21
+ }
22
+
23
+ export function hashText(text) {
24
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
25
+ }