@really-knows-ai/foundry 2.1.0 → 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.
@@ -9,17 +9,26 @@
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';
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';
22
+ import { createHash, randomUUID } from 'node:crypto';
23
+ import { readOrCreateSecret } from '../../scripts/lib/secret.js';
24
+ import { createPendingStore } from '../../scripts/lib/pending.js';
25
+ import { signToken, verifyToken } from '../../scripts/lib/token.js';
26
+ import {
27
+ ensureFoundryDir, readActiveStage, writeActiveStage, clearActiveStage,
28
+ readLastStage, writeLastStage,
29
+ } from '../../scripts/lib/state.js';
30
+ import { requireNoActiveStage, requireActiveStage, stageBaseOf } from '../../scripts/lib/stage-guard.js';
31
+ import { finalizeStage } from '../../scripts/lib/finalize.js';
23
32
 
24
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
34
  const packageRoot = path.resolve(__dirname, '../..');
@@ -93,7 +102,7 @@ new skills). It does NOT apply to running an existing, defined flow.
93
102
 
94
103
  ## Available skills
95
104
 
96
- - **Pipeline:** forge, quench, appraise, cycle, flow, sort, human-appraise
105
+ - **Pipeline:** forge, quench, appraise, orchestrate, flow, human-appraise
97
106
  - **Authoring:** add-artefact-type, add-law, add-appraiser, add-cycle, add-flow, init-foundry
98
107
  - **Maintenance:** upgrade-foundry, refresh-agents, list-agents
99
108
 
@@ -115,11 +124,27 @@ function makeIO(directory) {
115
124
  readFile: (p) => readFileSync(resolve(p), 'utf-8'),
116
125
  writeFile: (p, content) => writeFileSync(resolve(p), content, 'utf-8'),
117
126
  readDir: (p) => readdirSync(resolve(p)),
127
+ mkdir: (p) => mkdirSync(resolve(p), { recursive: true }),
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'] }),
118
135
  };
119
136
  }
120
137
 
121
138
  export const FoundryPlugin = async ({ directory }) => {
122
- return {
139
+ // Bootstrap per-worktree HMAC secret (created on first boot, persisted to .foundry/secret).
140
+ // Note: `directory` is the worktree root at plugin-boot time. Per-invocation `context.worktree`
141
+ // may differ in multi-worktree setups — we still use `context.worktree` inside tool `execute`
142
+ // bodies to locate `.foundry/` on disk, and use the plugin-boot `secret` only for
143
+ // signing/verifying. A worktree change mid-session would mismatch; deferred out of v2.2.0 scope.
144
+ const secret = readOrCreateSecret(directory);
145
+ const pending = createPendingStore();
146
+
147
+ const plugin = {
123
148
  config: async (config) => {
124
149
  config.skills = config.skills || {};
125
150
  config.skills.paths = config.skills.paths || [];
@@ -145,32 +170,78 @@ export const FoundryPlugin = async ({ directory }) => {
145
170
 
146
171
  tool: {
147
172
  // ── History tools ──
148
- foundry_history_append: tool({
149
- description: 'Append an entry to the cycle history (WORK.history.yaml)',
173
+ foundry_history_list: tool({
174
+ description: 'List history entries for a cycle',
150
175
  args: {
151
176
  cycle: tool.schema.string().describe('Cycle name'),
152
- stage: tool.schema.string().describe('Stage name'),
153
- comment: tool.schema.string().describe('Comment for this entry'),
154
177
  },
155
178
  async execute(args, context) {
156
179
  const io = makeIO(context.worktree);
157
180
  const historyPath = path.join(context.worktree, 'WORK.history.yaml');
158
- const iteration = getIteration(historyPath, args.cycle, io);
159
- appendEntry(historyPath, { cycle: args.cycle, stage: args.stage, iteration, comment: args.comment }, io);
160
- return JSON.stringify({ ok: true, iteration });
181
+ const entries = loadHistory(historyPath, args.cycle, io);
182
+ return JSON.stringify(entries);
161
183
  },
162
184
  }),
163
185
 
164
- foundry_history_list: tool({
165
- description: 'List history entries for a cycle',
186
+ // ── Stage lifecycle tools ──
187
+ foundry_stage_begin: tool({
188
+ description: 'Open a subagent work stage; consumes a dispatch token from foundry_sort.',
166
189
  args: {
190
+ stage: tool.schema.string().describe('Stage alias, e.g. "forge:create-haiku"'),
167
191
  cycle: tool.schema.string().describe('Cycle name'),
192
+ token: tool.schema.string().describe('Token received from foundry_sort via the dispatch prompt'),
168
193
  },
169
194
  async execute(args, context) {
170
195
  const io = makeIO(context.worktree);
171
- const historyPath = path.join(context.worktree, 'WORK.history.yaml');
172
- const entries = loadHistory(historyPath, args.cycle, io);
173
- return JSON.stringify(entries);
196
+ // Precondition: no active stage.
197
+ const current = readActiveStage(io);
198
+ if (current) {
199
+ return JSON.stringify({ error: `foundry_stage_begin requires no active stage; current: ${current.stage}` });
200
+ }
201
+ // Verify token signature + expiry.
202
+ const v = verifyToken(args.token, secret);
203
+ if (!v.ok) return JSON.stringify({ error: `foundry_stage_begin: token ${v.reason}` });
204
+ // Payload must match args.
205
+ if (v.payload.route !== args.stage || v.payload.cycle !== args.cycle) {
206
+ return JSON.stringify({ error: `foundry_stage_begin: token payload mismatch (route=${v.payload.route}, cycle=${v.payload.cycle})` });
207
+ }
208
+ // Single-use nonce check.
209
+ const meta = pending.consume(v.payload.nonce);
210
+ if (!meta) return JSON.stringify({ error: `foundry_stage_begin: nonce not pending or already consumed` });
211
+
212
+ // Resolve base SHA from git.
213
+ let baseSha;
214
+ try {
215
+ baseSha = execSync('git rev-parse HEAD', { cwd: context.worktree }).toString().trim();
216
+ } catch {
217
+ return JSON.stringify({ error: `foundry_stage_begin: git rev-parse HEAD failed (no commits?)` });
218
+ }
219
+
220
+ const tokenHash = createHash('sha256').update(args.token).digest('hex');
221
+ const active = {
222
+ cycle: args.cycle,
223
+ stage: args.stage,
224
+ tokenHash,
225
+ baseSha,
226
+ startedAt: new Date().toISOString(),
227
+ };
228
+ writeActiveStage(io, active);
229
+ return JSON.stringify({ ok: true, active });
230
+ },
231
+ }),
232
+
233
+ foundry_stage_end: tool({
234
+ description: 'Close the active subagent work stage; preserves baseSha for finalize.',
235
+ args: {
236
+ summary: tool.schema.string().describe('Short summary of the work done'),
237
+ },
238
+ async execute(args, context) {
239
+ const io = makeIO(context.worktree);
240
+ const active = readActiveStage(io);
241
+ if (!active) return JSON.stringify({ error: 'foundry_stage_end requires active stage; current: none' });
242
+ writeLastStage(io, { cycle: active.cycle, stage: active.stage, baseSha: active.baseSha, summary: args.summary });
243
+ clearActiveStage(io);
244
+ return JSON.stringify({ ok: true, summary: args.summary });
174
245
  },
175
246
  }),
176
247
 
@@ -186,16 +257,19 @@ export const FoundryPlugin = async ({ directory }) => {
186
257
  models: tool.schema.string().optional().describe('Per-stage model overrides as JSON object, e.g. \'{"forge":"openai/gpt-4o"}\''),
187
258
  },
188
259
  async execute(args, context) {
260
+ const io = makeIO(context.worktree);
261
+ const guard = requireNoActiveStage(io);
262
+ if (!guard.ok) return JSON.stringify({ error: `foundry_workfile_create ${guard.error}` });
189
263
  const workPath = path.join(context.worktree, 'WORK.md');
190
264
  if (existsSync(workPath)) {
191
- return JSON.stringify({ error: 'WORK.md already exists' });
265
+ return JSON.stringify({ error: 'foundry_workfile_create requires no WORK.md; current: exists' });
192
266
  }
193
267
  const fm = { flow: args.flow, cycle: args.cycle };
194
268
  if (args.stages) {
195
269
  fm.stages = enrichStages(args.stages, args.cycle);
196
270
  }
197
271
  if (args.maxIterations !== undefined) {
198
- fm.maxIterations = args.maxIterations;
272
+ fm['max-iterations'] = args.maxIterations;
199
273
  }
200
274
  if (args.models) {
201
275
  fm.models = parseModelsValue(args.models);
@@ -222,53 +296,18 @@ export const FoundryPlugin = async ({ directory }) => {
222
296
  },
223
297
  }),
224
298
 
225
- foundry_workfile_set: tool({
226
- 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)',
227
301
  args: {
228
- key: tool.schema.string().describe('Frontmatter key'),
229
- 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'),
230
303
  },
231
304
  async execute(args, context) {
232
- const workPath = path.join(context.worktree, 'WORK.md');
233
- if (!existsSync(workPath)) {
234
- return JSON.stringify({ error: 'WORK.md not found' });
235
- }
236
- const text = readFileSync(workPath, 'utf-8');
237
- // Parse JSON values for arrays/objects, keep strings as-is
238
- let value = args.value;
239
- if (args.key === 'stages') {
240
- // Always parse stages into an array (handles JSON arrays and comma-separated strings)
241
- value = parseStagesValue(args.value);
242
- } else if (args.key === 'models') {
243
- // Always parse models into an object (handles JSON objects and "key: value" strings)
244
- value = parseModelsValue(args.value);
245
- } else {
246
- try {
247
- const parsed = JSON.parse(args.value);
248
- if (typeof parsed === 'object' || Array.isArray(parsed) || typeof parsed === 'number') {
249
- value = parsed;
250
- }
251
- } catch {
252
- // Not JSON, use as plain string
253
- }
254
- }
255
- // Auto-enrich bare stage names with cycle ID alias
256
- if (args.key === 'stages' && Array.isArray(value)) {
257
- const fm = parseFrontmatter(text);
258
- if (fm.cycle) {
259
- value = enrichStages(value, fm.cycle);
260
- }
305
+ const io = makeIO(context.worktree);
306
+ const guard = requireNoActiveStage(io);
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}' });
261
310
  }
262
- const updated = setFrontmatterField(text, args.key, value);
263
- writeFileSync(workPath, updated, 'utf-8');
264
- return JSON.stringify({ ok: true });
265
- },
266
- }),
267
-
268
- foundry_workfile_delete: tool({
269
- description: 'Delete WORK.md and WORK.history.yaml',
270
- args: {},
271
- async execute(_args, context) {
272
311
  const workPath = path.join(context.worktree, 'WORK.md');
273
312
  const historyPath = path.join(context.worktree, 'WORK.history.yaml');
274
313
  if (existsSync(workPath)) {
@@ -281,49 +320,130 @@ export const FoundryPlugin = async ({ directory }) => {
281
320
  },
282
321
  }),
283
322
 
284
- // ── Artefacts tools ──
285
- foundry_artefacts_add: tool({
286
- description: 'Add an artefact row to the WORK.md table',
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.',
287
326
  args: {
288
- 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)'),
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)'),
292
332
  },
293
333
  async execute(args, context) {
294
- const workPath = path.join(context.worktree, 'WORK.md');
295
- 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 });
334
+ const { runOrchestrate } = await import('../../scripts/orchestrate.js');
335
+ const io = makeIO(context.worktree);
336
+ const cwd = context.worktree;
337
+
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
+ };
345
+
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();
352
+ },
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
+ };
358
+
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: [] });
403
+ }
299
404
  },
300
405
  }),
301
406
 
407
+ // ── Artefacts tools ──
408
+ // NOTE: `foundry_artefacts_add` was removed in v2.2.0. Artefacts are now
409
+ // registered automatically by `foundry_stage_finalize` as drafts, then
410
+ // promoted to done|blocked via `foundry_artefacts_set_status`.
302
411
  foundry_artefacts_set_status: tool({
303
- description: 'Update the status of an artefact in WORK.md',
412
+ description: 'Update the status of an artefact in WORK.md (done|blocked only)',
304
413
  args: {
305
414
  file: tool.schema.string().describe('Artefact file path'),
306
- status: tool.schema.string().describe('New status'),
415
+ status: tool.schema.string().describe('New status (done|blocked)'),
307
416
  },
308
417
  async execute(args, context) {
418
+ const io = makeIO(context.worktree);
419
+ const guard = requireNoActiveStage(io);
420
+ if (!guard.ok) return JSON.stringify({ error: `foundry_artefacts_set_status ${guard.error}` });
309
421
  const workPath = path.join(context.worktree, 'WORK.md');
310
422
  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 });
423
+ try {
424
+ const updated = setArtefactStatus(text, args.file, args.status);
425
+ writeFileSync(workPath, updated, 'utf-8');
426
+ return JSON.stringify({ ok: true });
427
+ } catch (e) {
428
+ return JSON.stringify({ error: e.message });
429
+ }
314
430
  },
315
431
  }),
316
432
 
317
433
  foundry_artefacts_list: tool({
318
- description: 'List all artefacts from the WORK.md table',
319
- args: {},
320
- async execute(_args, context) {
434
+ 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.',
435
+ args: {
436
+ cycle: tool.schema.string().optional().describe('Only return rows whose Cycle column matches this value'),
437
+ },
438
+ async execute(args, context) {
321
439
  const workPath = path.join(context.worktree, 'WORK.md');
322
440
  if (!existsSync(workPath)) {
323
441
  return JSON.stringify({ error: 'WORK.md not found' });
324
442
  }
325
443
  const text = readFileSync(workPath, 'utf-8');
326
- return JSON.stringify(parseArtefactsTable(text));
444
+ const rows = parseArtefactsTable(text);
445
+ const filtered = args.cycle ? rows.filter(r => r.cycle === args.cycle) : rows;
446
+ return JSON.stringify(filtered);
327
447
  },
328
448
  }),
329
449
 
@@ -336,11 +456,28 @@ export const FoundryPlugin = async ({ directory }) => {
336
456
  tag: tool.schema.string().describe('Tag for the feedback item'),
337
457
  },
338
458
  async execute(args, context) {
459
+ const io = makeIO(context.worktree);
460
+ const guard = requireActiveStage(io);
461
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_add requires active stage; ${guard.error}` });
462
+ const stageBase = stageBaseOf(guard.active.stage);
463
+ // Per-stage tag allow-list.
464
+ if (stageBase === 'forge') {
465
+ return JSON.stringify({ error: 'foundry_feedback_add: forge stages do not add feedback' });
466
+ }
467
+ if (stageBase === 'quench' && args.tag !== 'validation') {
468
+ return JSON.stringify({ error: `foundry_feedback_add: quench may only add tag "validation"; got "${args.tag}"` });
469
+ }
470
+ if (stageBase === 'appraise' && !args.tag.startsWith('law:')) {
471
+ return JSON.stringify({ error: `foundry_feedback_add: appraise tag must start with "law:"; got "${args.tag}"` });
472
+ }
473
+ if (stageBase === 'human-appraise' && args.tag !== 'human') {
474
+ return JSON.stringify({ error: `foundry_feedback_add: human-appraise may only add tag "human"; got "${args.tag}"` });
475
+ }
339
476
  const workPath = path.join(context.worktree, 'WORK.md');
340
477
  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 });
478
+ const r = addFeedbackItem(content, args.file, args.text, args.tag);
479
+ if (!r.deduped) writeFileSync(workPath, r.text, 'utf-8');
480
+ return JSON.stringify({ ok: true, deduped: r.deduped });
344
481
  },
345
482
  }),
346
483
 
@@ -351,10 +488,18 @@ export const FoundryPlugin = async ({ directory }) => {
351
488
  index: tool.schema.number().describe('Zero-based index of the feedback item'),
352
489
  },
353
490
  async execute(args, context) {
491
+ const io = makeIO(context.worktree);
492
+ const guard = requireActiveStage(io);
493
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_action requires active stage; ${guard.error}` });
494
+ const stageBase = stageBaseOf(guard.active.stage);
495
+ if (stageBase !== 'forge') {
496
+ return JSON.stringify({ error: `foundry_feedback_action requires active forge stage; current: ${guard.active.stage}` });
497
+ }
354
498
  const workPath = path.join(context.worktree, 'WORK.md');
355
499
  const content = readFileSync(workPath, 'utf-8');
356
- const updated = actionFeedbackItem(content, args.file, args.index);
357
- writeFileSync(workPath, updated, 'utf-8');
500
+ const r = actionFeedbackItem(content, args.file, args.index, stageBase);
501
+ if (!r.ok) return JSON.stringify({ error: r.error });
502
+ writeFileSync(workPath, r.text, 'utf-8');
358
503
  return JSON.stringify({ ok: true });
359
504
  },
360
505
  }),
@@ -367,10 +512,18 @@ export const FoundryPlugin = async ({ directory }) => {
367
512
  reason: tool.schema.string().describe('Reason for wont-fix'),
368
513
  },
369
514
  async execute(args, context) {
515
+ const io = makeIO(context.worktree);
516
+ const guard = requireActiveStage(io);
517
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_wontfix requires active stage; ${guard.error}` });
518
+ const stageBase = stageBaseOf(guard.active.stage);
519
+ if (stageBase !== 'forge') {
520
+ return JSON.stringify({ error: `foundry_feedback_wontfix requires active forge stage; current: ${guard.active.stage}` });
521
+ }
370
522
  const workPath = path.join(context.worktree, 'WORK.md');
371
523
  const content = readFileSync(workPath, 'utf-8');
372
- const updated = wontfixFeedbackItem(content, args.file, args.index, args.reason);
373
- writeFileSync(workPath, updated, 'utf-8');
524
+ const r = wontfixFeedbackItem(content, args.file, args.index, args.reason, stageBase);
525
+ if (!r.ok) return JSON.stringify({ error: r.error });
526
+ writeFileSync(workPath, r.text, 'utf-8');
374
527
  return JSON.stringify({ ok: true });
375
528
  },
376
529
  }),
@@ -384,10 +537,18 @@ export const FoundryPlugin = async ({ directory }) => {
384
537
  reason: tool.schema.string().optional().describe('Reason (required if rejected)'),
385
538
  },
386
539
  async execute(args, context) {
540
+ const io = makeIO(context.worktree);
541
+ const guard = requireActiveStage(io);
542
+ if (!guard.ok) return JSON.stringify({ error: `foundry_feedback_resolve requires active stage; ${guard.error}` });
543
+ const stageBase = stageBaseOf(guard.active.stage);
544
+ if (!['quench', 'appraise', 'human-appraise'].includes(stageBase)) {
545
+ return JSON.stringify({ error: `foundry_feedback_resolve requires active quench|appraise|human-appraise stage; current: ${guard.active.stage}` });
546
+ }
387
547
  const workPath = path.join(context.worktree, 'WORK.md');
388
548
  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');
549
+ const r = resolveFeedbackItem(content, args.file, args.index, args.resolution, args.reason, stageBase);
550
+ if (!r.ok) return JSON.stringify({ error: r.error });
551
+ writeFileSync(workPath, r.text, 'utf-8');
391
552
  return JSON.stringify({ ok: true });
392
553
  },
393
554
  }),
@@ -410,19 +571,6 @@ export const FoundryPlugin = async ({ directory }) => {
410
571
  },
411
572
  }),
412
573
 
413
- // ── Sort tool ──
414
- foundry_sort: tool({
415
- description: 'Run sort routing to determine the next stage',
416
- args: {
417
- cycleDef: tool.schema.string().optional().describe('Path to cycle definition file'),
418
- },
419
- async execute(args, context) {
420
- const io = makeIO(context.worktree);
421
- const result = runSort({ cycleDef: args.cycleDef }, io);
422
- return JSON.stringify(result);
423
- },
424
- }),
425
-
426
574
  // ── Git tools ──
427
575
  foundry_git_branch: tool({
428
576
  description: 'Create and checkout a work branch for a flow',
@@ -431,6 +579,9 @@ export const FoundryPlugin = async ({ directory }) => {
431
579
  description: tool.schema.string().describe('Branch description suffix'),
432
580
  },
433
581
  async execute(args, context) {
582
+ const io = makeIO(context.worktree);
583
+ const guard = requireNoActiveStage(io);
584
+ if (!guard.ok) return JSON.stringify({ error: `foundry_git_branch ${guard.error}` });
434
585
  const flowSlug = slugify(args.flowId);
435
586
  const descSlug = slugify(args.description);
436
587
  const branch = `work/${flowSlug}-${descSlug}`;
@@ -439,22 +590,6 @@ export const FoundryPlugin = async ({ directory }) => {
439
590
  },
440
591
  }),
441
592
 
442
- foundry_git_commit: tool({
443
- description: 'Stage all changes and commit with a cycle-prefixed message',
444
- args: {
445
- cycle: tool.schema.string().describe('Cycle name'),
446
- stage: tool.schema.string().describe('Stage name'),
447
- description: tool.schema.string().describe('Commit description'),
448
- },
449
- async execute(args, context) {
450
- execSync('git add .', { cwd: context.worktree, encoding: 'utf8' });
451
- const msg = `[${args.cycle}] ${args.stage}: ${args.description}`;
452
- execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: context.worktree, encoding: 'utf8' });
453
- const hash = execSync('git rev-parse --short HEAD', { cwd: context.worktree, encoding: 'utf8' }).trim();
454
- return JSON.stringify({ ok: true, hash });
455
- },
456
- }),
457
-
458
593
  foundry_git_finish: tool({
459
594
  description: 'Clean up work files, squash merge to base branch, and delete the work branch',
460
595
  args: {
@@ -462,12 +597,15 @@ export const FoundryPlugin = async ({ directory }) => {
462
597
  baseBranch: tool.schema.string().optional().describe('Target branch (default: main)'),
463
598
  },
464
599
  async execute(args, context) {
600
+ const io = makeIO(context.worktree);
601
+ const guard = requireNoActiveStage(io);
602
+ if (!guard.ok) return JSON.stringify({ error: `foundry_git_finish ${guard.error}` });
465
603
  const base = args.baseBranch || 'main';
466
604
  const cwd = context.worktree;
467
605
  const opts = { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] };
468
606
 
469
607
  // Get current branch name
470
- const workBranch = execSync('git branch --show-current', opts).trim();
608
+ const workBranch = execFileSync('git', ['branch', '--show-current'], opts).trim();
471
609
  if (workBranch === base) {
472
610
  return JSON.stringify({ error: `Already on ${base} — nothing to merge` });
473
611
  }
@@ -480,23 +618,22 @@ export const FoundryPlugin = async ({ directory }) => {
480
618
 
481
619
  // Commit cleanup if there are changes
482
620
  try {
483
- execSync('git add -A', opts);
484
- const status = execSync('git status --porcelain', opts).trim();
621
+ execFileSync('git', ['add', '-A'], opts);
622
+ const status = execFileSync('git', ['status', '--porcelain'], opts).trim();
485
623
  if (status) {
486
624
  const cleanupMsg = `[${workBranch.replace('work/', '')}] cleanup: remove work files`;
487
- execSync(`git commit -m "${cleanupMsg.replace(/"/g, '\\"')}"`, opts);
625
+ execFileSync('git', ['commit', '-m', cleanupMsg], opts);
488
626
  }
489
627
  } catch { /* no changes to commit */ }
490
628
 
491
629
  // Switch to base and squash merge
492
- execSync(`git checkout ${base}`, opts);
493
- execSync(`git merge --squash ${workBranch}`, opts);
494
- const msg = args.message.replace(/"/g, '\\"');
495
- execSync(`git commit -m "${msg}"`, opts);
496
- 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();
497
634
 
498
635
  // Force-delete work branch (required after squash)
499
- execSync(`git branch -D ${workBranch}`, opts);
636
+ execFileSync('git', ['branch', '-D', workBranch], opts);
500
637
 
501
638
  return JSON.stringify({ ok: true, hash, branch: base });
502
639
  },
@@ -617,4 +754,8 @@ export const FoundryPlugin = async ({ directory }) => {
617
754
  }),
618
755
  },
619
756
  };
757
+
758
+ Object.defineProperty(plugin, Symbol.for('foundry.test.pending'), { value: pending });
759
+ Object.defineProperty(plugin, Symbol.for('foundry.test.secret'), { value: secret });
760
+ return plugin;
620
761
  };