@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.
- package/.opencode/plugins/foundry.js +273 -132
- package/CHANGELOG.md +77 -0
- package/docs/work-spec.md +4 -4
- package/package.json +3 -2
- package/scripts/lib/artefacts.js +6 -0
- package/scripts/lib/feedback-transitions.js +25 -0
- package/scripts/lib/feedback.js +146 -9
- package/scripts/lib/finalize.js +41 -0
- package/scripts/lib/history.js +15 -3
- package/scripts/lib/pending.js +18 -0
- package/scripts/lib/secret.js +23 -0
- package/scripts/lib/stage-guard.js +25 -0
- package/scripts/lib/state.js +31 -0
- package/scripts/lib/token.js +26 -0
- package/scripts/lib/workfile.js +12 -1
- package/scripts/orchestrate.js +418 -0
- package/scripts/sort.js +89 -14
- package/skills/add-cycle/SKILL.md +11 -6
- package/skills/appraise/SKILL.md +33 -17
- package/skills/flow/SKILL.md +13 -6
- package/skills/forge/SKILL.md +38 -26
- package/skills/human-appraise/SKILL.md +41 -17
- package/skills/orchestrate/SKILL.md +69 -0
- package/skills/quench/SKILL.md +31 -15
- package/skills/upgrade-foundry/SKILL.md +64 -1
- package/skills/cycle/SKILL.md +0 -81
- package/skills/sort/SKILL.md +0 -79
|
@@ -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
|
|
16
|
-
import { parseFrontmatter, createWorkfile,
|
|
15
|
+
import { loadHistory } from '../../scripts/lib/history.js';
|
|
16
|
+
import { parseFrontmatter, createWorkfile, enrichStages, parseModelsValue } from '../../scripts/lib/workfile.js';
|
|
17
17
|
import { parseArtefactsTable, addArtefactRow, setArtefactStatus } from '../../scripts/lib/artefacts.js';
|
|
18
18
|
import { addFeedbackItem, actionFeedbackItem, wontfixFeedbackItem, resolveFeedbackItem, listFeedback } from '../../scripts/lib/feedback.js';
|
|
19
19
|
import { getCycleDefinition, getArtefactType, getLaws, getValidation, getAppraisers, getFlow, selectAppraisers } from '../../scripts/lib/config.js';
|
|
20
20
|
import { slugify } from '../../scripts/lib/slug.js';
|
|
21
|
-
import { runSort } from '../../scripts/sort.js';
|
|
22
21
|
import { execSync, execFileSync } from 'child_process';
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
description: '
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
226
|
-
description: '
|
|
299
|
+
foundry_workfile_delete: tool({
|
|
300
|
+
description: 'Delete WORK.md and WORK.history.yaml (requires confirm:true)',
|
|
227
301
|
args: {
|
|
228
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
// ──
|
|
285
|
-
|
|
286
|
-
description: '
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
319
|
-
args: {
|
|
320
|
-
|
|
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
|
-
|
|
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
|
|
342
|
-
writeFileSync(workPath,
|
|
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
|
|
357
|
-
|
|
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
|
|
373
|
-
|
|
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
|
|
390
|
-
|
|
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 =
|
|
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
|
-
|
|
484
|
-
const status =
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
};
|