@really-knows-ai/foundry 3.2.7 → 3.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/dist/.opencode/plugins/foundry-tools/config-create-tools.js +91 -19
- package/dist/.opencode/plugins/foundry-tools/config-law-tools.js +111 -108
- package/dist/CHANGELOG.md +30 -0
- package/dist/scripts/lib/config-creators/appraiser.js +29 -2
- package/dist/scripts/lib/config-creators/artefact-type.js +49 -2
- package/dist/scripts/lib/config-creators/cycle.js +112 -2
- package/dist/scripts/lib/config-creators/flow.js +27 -2
- package/dist/scripts/lib/config-creators/law.js +269 -0
- package/dist/skills/add-appraiser/SKILL.md +7 -14
- package/dist/skills/add-artefact-type/SKILL.md +14 -28
- package/dist/skills/add-cycle/SKILL.md +17 -26
- package/dist/skills/add-flow/SKILL.md +9 -0
- package/dist/skills/add-law/SKILL.md +17 -22
- package/package.json +1 -1
|
@@ -53,19 +53,42 @@ const VALIDATE_GUARDS = [gitRepoGuard, foundryRootGuard];
|
|
|
53
53
|
|
|
54
54
|
// --- tool factories --------------------------------------------------------
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
const TYPE_NAMES = {
|
|
57
|
+
ZodString: 'string',
|
|
58
|
+
ZodArray: 'string[]',
|
|
59
|
+
ZodBoolean: 'boolean',
|
|
60
|
+
ZodNumber: 'number',
|
|
61
|
+
ZodObject: 'object',
|
|
62
|
+
ZodEnum: 'string',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function unwrapOptional(schema) {
|
|
66
|
+
let s = schema;
|
|
67
|
+
while (s._def && s._def.typeName === 'ZodOptional') s = s._def.innerType;
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function describeField(name, schema) {
|
|
72
|
+
const inner = unwrapOptional(schema);
|
|
73
|
+
const type = inner._def ? (TYPE_NAMES[inner._def.typeName] || 'any') : 'any';
|
|
74
|
+
const required = schema._def && schema._def.required !== false;
|
|
75
|
+
return required ? `${name} (${type})` : `${name} (${type}, optional)`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function humaniseKind(kind) {
|
|
79
|
+
return kind.replace(/_/g, '-');
|
|
80
|
+
}
|
|
61
81
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
// Module-level helper: returns a `makeCreate` function bound to `tool`.
|
|
83
|
+
function createMakeCreate(tool) {
|
|
84
|
+
return function makeCreate(toolName, creator, createArgs) {
|
|
85
|
+
const kind = humaniseKind(toolName.replace('foundry_config_create_', ''));
|
|
86
|
+
const fieldSummary = Object.keys(createArgs).map(k => describeField(k, createArgs[k])).join(', ');
|
|
87
|
+
const description = `Create a new ${kind} definition (config-tier; requires a config/* branch).\nFields: ${fieldSummary}`;
|
|
65
88
|
|
|
66
89
|
return tool({
|
|
67
|
-
description
|
|
68
|
-
args:
|
|
90
|
+
description,
|
|
91
|
+
args: createArgs,
|
|
69
92
|
execute: guarded(toolName, CREATE_GUARDS, async (args, context) => {
|
|
70
93
|
try {
|
|
71
94
|
const io = makeAsyncIO(context.worktree);
|
|
@@ -104,20 +127,69 @@ function createMakeValidate(tool, baseArgs) {
|
|
|
104
127
|
|
|
105
128
|
// --- tool factory ---------------------------------------------------------
|
|
106
129
|
|
|
130
|
+
// Per-tool arg schemas (defined outside createConfigCreateTools to keep it under 40 lines).
|
|
131
|
+
function artefactTypeArgs(s) { return {
|
|
132
|
+
id: s.string().describe('Slugged identifier used as directory name under foundry/artefacts/'),
|
|
133
|
+
name: s.string().describe('Human-readable display name (accepted at boundary, not persisted — id becomes frontmatter.name)'),
|
|
134
|
+
filePatterns: s.array(s.string()).describe('Glob patterns defining forge write scope (written to frontmatter.file-patterns)'),
|
|
135
|
+
description: s.string().describe('Prose description placed under ## Definition'),
|
|
136
|
+
appraisers: s.object({
|
|
137
|
+
count: s.number().optional().describe('Number of appraisers per cycle'),
|
|
138
|
+
allowed: s.array(s.string()).optional().describe('Restrict to specific appraiser IDs'),
|
|
139
|
+
}).optional().describe('Appraiser selection config'),
|
|
140
|
+
}; }
|
|
141
|
+
|
|
142
|
+
function appraiserArgs(s) { return {
|
|
143
|
+
id: s.string().describe('Slugged identifier matching the filename under foundry/appraisers/'),
|
|
144
|
+
name: s.string().describe('Human-readable display name written to frontmatter.name'),
|
|
145
|
+
description: s.string().describe('Prose personality description placed after frontmatter'),
|
|
146
|
+
model: s.string().optional().describe('Optional model override for this appraiser (e.g. openai/gpt-4o)'),
|
|
147
|
+
}; }
|
|
148
|
+
|
|
149
|
+
function flowArgs(s) { return {
|
|
150
|
+
id: s.string().describe('Slugged identifier matching the filename under foundry/flows/'),
|
|
151
|
+
name: s.string().describe('Human-readable display name written to frontmatter.name'),
|
|
152
|
+
startingCycles: s.array(s.string()).describe('Non-empty array of cycle IDs that can start this flow'),
|
|
153
|
+
description: s.string().describe('Prose description placed under ## Cycles'),
|
|
154
|
+
}; }
|
|
155
|
+
|
|
156
|
+
function cycleArgs(s) { return {
|
|
157
|
+
id: s.string().describe('Slugged identifier matching the filename under foundry/cycles/'),
|
|
158
|
+
name: s.string().describe('Human-readable display name written to frontmatter.name'),
|
|
159
|
+
outputType: s.string().describe('Artefact type ID this cycle produces (must exist in foundry/artefacts/)'),
|
|
160
|
+
inputs: s.object({
|
|
161
|
+
type: s.enum(['any-of', 'all-of']).describe('Contract type: any-of (at least one) or all-of (all must exist)'),
|
|
162
|
+
artefacts: s.array(s.string()).describe('Artefact type IDs this cycle reads'),
|
|
163
|
+
}).optional().describe('Input contract for this cycle'),
|
|
164
|
+
targets: s.array(s.string()).optional().describe('Downstream cycle IDs this cycle can route to'),
|
|
165
|
+
humanAppraise: s.boolean().optional().describe('Include human-appraise in every iteration'),
|
|
166
|
+
deadlockAppraise: s.boolean().optional().describe('Route to human-appraise on LLM appraiser deadlock'),
|
|
167
|
+
deadlockIterations: s.number().optional().describe('Iteration threshold for deadlock detection'),
|
|
168
|
+
maxIterations: s.number().optional().describe('Maximum forge iterations before cycle blocks'),
|
|
169
|
+
assay: s.object({
|
|
170
|
+
extractors: s.array(s.string()).describe('Extractor IDs for the assay stage'),
|
|
171
|
+
}).optional().describe('Assay stage configuration'),
|
|
172
|
+
memory: s.object({
|
|
173
|
+
read: s.array(s.string()).describe('Memory store keys this cycle can read'),
|
|
174
|
+
write: s.array(s.string()).describe('Memory store keys this cycle can write'),
|
|
175
|
+
}).optional().describe('Flow memory permissions'),
|
|
176
|
+
models: s.object({}).optional().describe('Per-stage model overrides (e.g. { forge: "openai/gpt-4o" })'),
|
|
177
|
+
description: s.string().optional().describe('Prose description placed after frontmatter'),
|
|
178
|
+
}; }
|
|
179
|
+
|
|
107
180
|
export function createConfigCreateTools({ tool }) {
|
|
108
|
-
const
|
|
181
|
+
const makeValidate = createMakeValidate(tool, {
|
|
109
182
|
name: tool.schema.string(),
|
|
110
183
|
body: tool.schema.string(),
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
const makeValidate = createMakeValidate(tool, baseArgs);
|
|
184
|
+
});
|
|
185
|
+
const makeCreate = createMakeCreate(tool);
|
|
186
|
+
const s = tool.schema;
|
|
115
187
|
|
|
116
188
|
return {
|
|
117
|
-
foundry_config_create_artefact_type: makeCreate('foundry_config_create_artefact_type', createArtefactType),
|
|
118
|
-
foundry_config_create_appraiser: makeCreate('foundry_config_create_appraiser', createAppraiser),
|
|
119
|
-
foundry_config_create_flow: makeCreate('foundry_config_create_flow', createFlow),
|
|
120
|
-
foundry_config_create_cycle: makeCreate('foundry_config_create_cycle', createCycle),
|
|
189
|
+
foundry_config_create_artefact_type: makeCreate('foundry_config_create_artefact_type', createArtefactType, artefactTypeArgs(s)),
|
|
190
|
+
foundry_config_create_appraiser: makeCreate('foundry_config_create_appraiser', createAppraiser, appraiserArgs(s)),
|
|
191
|
+
foundry_config_create_flow: makeCreate('foundry_config_create_flow', createFlow, flowArgs(s)),
|
|
192
|
+
foundry_config_create_cycle: makeCreate('foundry_config_create_cycle', createCycle, cycleArgs(s)),
|
|
121
193
|
|
|
122
194
|
foundry_config_validate_artefact_type: makeValidate('foundry_config_validate_artefact_type', validateArtefactType),
|
|
123
195
|
foundry_config_validate_law: makeValidate('foundry_config_validate_law', validateLaw),
|
|
@@ -11,6 +11,7 @@ import { guarded, notFailedGuard } from '../../../scripts/lib/guards.js';
|
|
|
11
11
|
import { UnexpectedFilesError, commitWithPolicy } from '../../../scripts/lib/git-bridge.js';
|
|
12
12
|
import { makeIO, makeExec, makeAsyncIO, errorJson, branchIoFactory, asyncIoFactory } from './helpers.js';
|
|
13
13
|
import { execFileSync } from 'child_process';
|
|
14
|
+
import { assembleLawMarkdown, assembleEditLawMarkdown } from '../../../scripts/lib/config-creators/law.js';
|
|
14
15
|
|
|
15
16
|
// --- utility functions -------------------------------------------------------
|
|
16
17
|
|
|
@@ -22,18 +23,14 @@ function contentContainsLaw(content, lawId) {
|
|
|
22
23
|
function findLawStart(lines, lawId) {
|
|
23
24
|
for (let i = 0; i < lines.length; i++) {
|
|
24
25
|
const heading = lines[i].match(/^## (.+)/);
|
|
25
|
-
if (heading && heading[1].trim() === lawId)
|
|
26
|
-
return i;
|
|
27
|
-
}
|
|
26
|
+
if (heading && heading[1].trim() === lawId) return i;
|
|
28
27
|
}
|
|
29
28
|
return -1;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
function findLawEnd(lines, startIdx) {
|
|
33
32
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
34
|
-
if (lines[i].match(/^## (.+)/))
|
|
35
|
-
return i;
|
|
36
|
-
}
|
|
33
|
+
if (lines[i].match(/^## (.+)/)) return i;
|
|
37
34
|
}
|
|
38
35
|
return lines.length;
|
|
39
36
|
}
|
|
@@ -45,7 +42,7 @@ function extractLawMarkdown(content, lawId) {
|
|
|
45
42
|
const endIdx = findLawEnd(lines, startIdx);
|
|
46
43
|
const lawLines = lines.slice(startIdx, endIdx);
|
|
47
44
|
while (lawLines.length > 0 && lawLines[lawLines.length - 1] === '') lawLines.pop();
|
|
48
|
-
return lawLines.join('\n')
|
|
45
|
+
return lawLines.join('\n');
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
async function searchGlobalLaws(io, foundryDir, lawId) {
|
|
@@ -84,29 +81,18 @@ async function findLawByID(io, foundryDir, lawId) {
|
|
|
84
81
|
|
|
85
82
|
// --- guard helpers ---------------------------------------------------------
|
|
86
83
|
|
|
87
|
-
function gitRepoGuard(
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function foundryRootGuard(_args, context) {
|
|
92
|
-
return requireFoundryRoot(makeIO(context.worktree));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function configBranchGuard(_args, context) {
|
|
96
|
-
return requireOnConfigBranch({ exec: makeExec(context.worktree) });
|
|
97
|
-
}
|
|
98
|
-
|
|
84
|
+
function gitRepoGuard(_a, c) { return requireGitRepo(makeIO(c.worktree)); }
|
|
85
|
+
function foundryRootGuard(_a, c) { return requireFoundryRoot(makeIO(c.worktree)); }
|
|
86
|
+
function configBranchGuard(_a, c) { return requireOnConfigBranch({ exec: makeExec(c.worktree) }); }
|
|
99
87
|
const gateNotFailed = notFailedGuard(makeIO);
|
|
100
88
|
|
|
101
|
-
const GIT_COMMAND = 'git';
|
|
102
|
-
|
|
103
89
|
function makeExecFile(cwd) {
|
|
104
|
-
return (argv) => execFileSync(
|
|
90
|
+
return (argv) => execFileSync('git', argv, { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
105
91
|
}
|
|
106
92
|
|
|
107
93
|
const READ_GUARDS = [gitRepoGuard, foundryRootGuard];
|
|
108
|
-
const CREATE_GUARDS = [
|
|
109
|
-
const EDIT_GUARDS = [
|
|
94
|
+
const CREATE_GUARDS = [...READ_GUARDS, configBranchGuard, gateNotFailed];
|
|
95
|
+
const EDIT_GUARDS = [...READ_GUARDS, configBranchGuard, gateNotFailed];
|
|
110
96
|
|
|
111
97
|
// --- read law executor -------------------------------------------------------
|
|
112
98
|
|
|
@@ -114,25 +100,10 @@ async function executeReadLaw(args, context) {
|
|
|
114
100
|
try {
|
|
115
101
|
const io = makeAsyncIO(context.worktree);
|
|
116
102
|
const result = await findLawByID(io, 'foundry', args.id);
|
|
117
|
-
if (!result.found) {
|
|
118
|
-
return JSON.stringify({
|
|
119
|
-
ok: false,
|
|
120
|
-
errors: [`Law "${args.id}" not found`],
|
|
121
|
-
});
|
|
122
|
-
}
|
|
103
|
+
if (!result.found) return JSON.stringify({ ok: false, errors: [`Law "${args.id}" not found`] });
|
|
123
104
|
const markdown = extractLawMarkdown(result.fullMarkdown, args.id);
|
|
124
|
-
if (!markdown) {
|
|
125
|
-
|
|
126
|
-
ok: false,
|
|
127
|
-
errors: [`Could not extract law "${args.id}" from file`],
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
return JSON.stringify({
|
|
131
|
-
ok: true,
|
|
132
|
-
id: args.id,
|
|
133
|
-
markdown,
|
|
134
|
-
source: result.source,
|
|
135
|
-
});
|
|
105
|
+
if (!markdown) return JSON.stringify({ ok: false, errors: [`Could not extract law "${args.id}" from file`] });
|
|
106
|
+
return JSON.stringify({ ok: true, id: args.id, markdown, source: result.source });
|
|
136
107
|
} catch (err) {
|
|
137
108
|
return errorJson(err);
|
|
138
109
|
}
|
|
@@ -147,32 +118,24 @@ function validateAddLawTarget(target) {
|
|
|
147
118
|
}
|
|
148
119
|
|
|
149
120
|
function validateAddLawTargetStruct(target) {
|
|
150
|
-
if (!target || typeof target !== 'object')
|
|
151
|
-
return 'target argument is required (object with kind + locator)';
|
|
152
|
-
}
|
|
121
|
+
if (!target || typeof target !== 'object') return 'target argument is required (object with kind + locator)';
|
|
153
122
|
const kinds = ['global', 'type-specific'];
|
|
154
123
|
if (!kinds.includes(target.kind)) return `unknown target.kind: ${target.kind}`;
|
|
155
124
|
return null;
|
|
156
125
|
}
|
|
157
126
|
|
|
158
127
|
function validateGlobalLawTarget(target) {
|
|
159
|
-
if (typeof target.file !== 'string' || !target.file.trim())
|
|
160
|
-
return 'target.file is required for kind: "global"';
|
|
161
|
-
}
|
|
128
|
+
if (typeof target.file !== 'string' || !target.file.trim()) return 'target.file is required for kind: "global"';
|
|
162
129
|
return null;
|
|
163
130
|
}
|
|
164
131
|
|
|
165
132
|
function validateTypeSpecLawTarget(target) {
|
|
166
|
-
if (typeof target.typeId !== 'string' || !target.typeId.trim())
|
|
167
|
-
return 'target.typeId is required for kind: "type-specific"';
|
|
168
|
-
}
|
|
133
|
+
if (typeof target.typeId !== 'string' || !target.typeId.trim()) return 'target.typeId is required for kind: "type-specific"';
|
|
169
134
|
return null;
|
|
170
135
|
}
|
|
171
136
|
|
|
172
137
|
function computeTargetPath(target) {
|
|
173
|
-
if (target?.kind === 'global')
|
|
174
|
-
return join('foundry', 'laws', target.file);
|
|
175
|
-
}
|
|
138
|
+
if (target?.kind === 'global') return join('foundry', 'laws', target.file);
|
|
176
139
|
return join('foundry', 'artefacts', target.typeId, 'laws.md');
|
|
177
140
|
}
|
|
178
141
|
|
|
@@ -195,14 +158,11 @@ async function checkExistingLaw(io, path, lawId) {
|
|
|
195
158
|
async function validateAddLawPrerequisites(io, args) {
|
|
196
159
|
const targetError = validateAddLawTarget(args.target);
|
|
197
160
|
if (targetError) return { error: targetError };
|
|
198
|
-
|
|
199
161
|
const path = computeTargetPath(args.target);
|
|
200
162
|
const validation = await validateLaw({ body: args.body, io });
|
|
201
163
|
if (!validation.ok) return validation;
|
|
202
|
-
|
|
203
164
|
const lawId = extractLawId(args.body);
|
|
204
165
|
if (!lawId) return { error: 'could not determine law id from body (expected "## <law-id>" heading)' };
|
|
205
|
-
|
|
206
166
|
const existing = await checkExistingLaw(io, path, lawId);
|
|
207
167
|
if (existing.error) return { error: existing.error };
|
|
208
168
|
return { ok: true, path, lawId, ...existing };
|
|
@@ -219,34 +179,43 @@ function buildNextContent(existedBefore, priorContent, body) {
|
|
|
219
179
|
}
|
|
220
180
|
|
|
221
181
|
async function rollbackAddLaw(io, path, existedBefore, priorContent) {
|
|
182
|
+
if (!path) return;
|
|
222
183
|
if (existedBefore) await io.writeFile(path, priorContent);
|
|
223
184
|
else await io.rm(path);
|
|
224
185
|
}
|
|
225
186
|
|
|
226
187
|
async function executeAddLaw(args, context) {
|
|
188
|
+
if (!args.id) return JSON.stringify({ ok: false, errors: ['id is required'] });
|
|
189
|
+
|
|
227
190
|
const io = makeAsyncIO(context.worktree);
|
|
228
191
|
const execFile = makeExecFile(context.worktree);
|
|
192
|
+
|
|
193
|
+
const body = assembleLawMarkdown({
|
|
194
|
+
id: args.id, name: args.name, description: args.description,
|
|
195
|
+
passing: args.passing, failing: args.failing, validators: args.validators,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const addArgs = { name: args.id, body, target: args.target };
|
|
229
199
|
let path, existedBefore, priorContent;
|
|
230
200
|
|
|
231
201
|
try {
|
|
232
|
-
const prereq = await validateAddLawPrerequisites(io,
|
|
202
|
+
const prereq = await validateAddLawPrerequisites(io, addArgs);
|
|
233
203
|
if (prereq.error) return JSON.stringify({ ok: false, errors: [prereq.error] });
|
|
234
204
|
if (!prereq.ok) return JSON.stringify(prereq);
|
|
235
205
|
|
|
236
206
|
({ path, existedBefore, priorContent } = prereq);
|
|
237
|
-
const nextContent = buildNextContent(existedBefore, priorContent,
|
|
207
|
+
const nextContent = buildNextContent(existedBefore, priorContent, addArgs.body);
|
|
238
208
|
|
|
239
209
|
await io.mkdirp(dirname(path));
|
|
240
210
|
await io.writeFile(path, nextContent);
|
|
241
211
|
|
|
242
212
|
const sha = commitWithPolicy({
|
|
243
213
|
message: `config: add law ${args.name}\n\nvia foundry_config_add_law`,
|
|
244
|
-
allowedPatterns: ['foundry/**'],
|
|
245
|
-
execFile,
|
|
214
|
+
allowedPatterns: ['foundry/**'], execFile,
|
|
246
215
|
});
|
|
247
216
|
return JSON.stringify({ ok: true, path, sha });
|
|
248
217
|
} catch (err) {
|
|
249
|
-
|
|
218
|
+
await rollbackAddLaw(io, path, existedBefore, priorContent);
|
|
250
219
|
return formatAddLawError(err);
|
|
251
220
|
}
|
|
252
221
|
}
|
|
@@ -268,6 +237,46 @@ function replaceLawInContent(content, lawId, newLawMarkdown) {
|
|
|
268
237
|
return before.concat(newLines, after).join('\n') + '\n';
|
|
269
238
|
}
|
|
270
239
|
|
|
240
|
+
// --- edit law helpers -------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
class EditLawResponse extends Error {
|
|
243
|
+
constructor(response) {
|
|
244
|
+
super();
|
|
245
|
+
this.response = response;
|
|
246
|
+
this.isEditLawResponse = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function hasEditLawFields(args) {
|
|
251
|
+
return args.name !== undefined || args.description !== undefined ||
|
|
252
|
+
args.passing !== undefined || args.failing !== undefined || args.validators !== undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function findAndExtractEditLaw(io, lawId) {
|
|
256
|
+
const result = await findLawByID(io, 'foundry', lawId);
|
|
257
|
+
if (!result.found) throw new EditLawResponse({ ok: false, errors: [`Law "${lawId}" not found`] });
|
|
258
|
+
const existingBody = extractLawMarkdown(result.fullMarkdown, lawId);
|
|
259
|
+
if (!existingBody) throw new EditLawResponse({ ok: false, errors: [`Could not extract law "${lawId}" from file`] });
|
|
260
|
+
return { result, existingBody };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function assembleEditLawBody(existingBody, args, io) {
|
|
264
|
+
const newBody = assembleEditLawMarkdown(existingBody, {
|
|
265
|
+
name: args.name, description: args.description,
|
|
266
|
+
passing: args.passing, failing: args.failing, validators: args.validators,
|
|
267
|
+
});
|
|
268
|
+
const validation = await validateLaw({ body: newBody, io });
|
|
269
|
+
if (!validation.ok) throw new EditLawResponse(validation);
|
|
270
|
+
return newBody;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function commitEditLawChange(result, newBody, lawId, execFile, io) {
|
|
274
|
+
const fileContent = replaceLawInContent(result.fullMarkdown, lawId, newBody);
|
|
275
|
+
await io.writeFile(result.path, fileContent);
|
|
276
|
+
execFile(['add', result.path]);
|
|
277
|
+
execFile(['commit', '-m', `config: edit law ${lawId}\n\nvia foundry_config_edit_law`]);
|
|
278
|
+
}
|
|
279
|
+
|
|
271
280
|
// --- edit law executor -------------------------------------------------------
|
|
272
281
|
|
|
273
282
|
async function executeEditLaw(args, context) {
|
|
@@ -275,34 +284,23 @@ async function executeEditLaw(args, context) {
|
|
|
275
284
|
const execFile = makeExecFile(context.worktree);
|
|
276
285
|
|
|
277
286
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const validation = await validateLaw({ body: args.body, io });
|
|
287
|
-
if (!validation.ok) {
|
|
288
|
-
return JSON.stringify(validation);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const fileContent = replaceLawInContent(result.fullMarkdown, args.id, args.body);
|
|
292
|
-
await io.writeFile(result.path, fileContent);
|
|
293
|
-
execFile(['add', result.path]);
|
|
294
|
-
execFile(['commit', '-m', `config: edit law ${args.id}\n\nvia foundry_config_edit_law`]);
|
|
287
|
+
if (!hasEditLawFields(args)) throw new EditLawResponse({
|
|
288
|
+
ok: false,
|
|
289
|
+
errors: ['at least one field to update must be provided (name, description, passing, failing, validators)'],
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const { result, existingBody } = await findAndExtractEditLaw(io, args.id);
|
|
293
|
+
const newBody = await assembleEditLawBody(existingBody, args, io);
|
|
294
|
+
await commitEditLawChange(result, newBody, args.id, execFile, io);
|
|
295
295
|
|
|
296
296
|
return JSON.stringify({
|
|
297
|
-
ok: true,
|
|
298
|
-
id: args.id,
|
|
297
|
+
ok: true, id: args.id,
|
|
299
298
|
path: result.path.replace(/^foundry\//, 'foundry/'),
|
|
300
299
|
source: result.source,
|
|
301
300
|
});
|
|
302
301
|
} catch (err) {
|
|
303
|
-
if (err
|
|
304
|
-
|
|
305
|
-
}
|
|
302
|
+
if (err.isEditLawResponse) return JSON.stringify(err.response);
|
|
303
|
+
if (err instanceof UnexpectedFilesError) return JSON.stringify({ error: err.message, affected_files: err.files });
|
|
306
304
|
return errorJson(err);
|
|
307
305
|
}
|
|
308
306
|
}
|
|
@@ -312,46 +310,51 @@ async function executeEditLaw(args, context) {
|
|
|
312
310
|
function makeReadLawTool(tool) {
|
|
313
311
|
return tool({
|
|
314
312
|
description: 'Read a law by ID, returning the full markdown including validators block.',
|
|
315
|
-
args: {
|
|
316
|
-
|
|
317
|
-
},
|
|
318
|
-
execute: guarded('foundry_config_read_law', READ_GUARDS, executeReadLaw, {
|
|
319
|
-
branchIo: branchIoFactory,
|
|
320
|
-
io: asyncIoFactory,
|
|
321
|
-
}),
|
|
313
|
+
args: { id: tool.schema.string().describe('Law ID to read') },
|
|
314
|
+
execute: guarded('foundry_config_read_law', READ_GUARDS, executeReadLaw, { branchIo: branchIoFactory, io: asyncIoFactory }),
|
|
322
315
|
});
|
|
323
316
|
}
|
|
324
317
|
|
|
325
318
|
function makeAddLawTool(tool) {
|
|
326
319
|
return tool({
|
|
327
|
-
description: 'Add a new law (config-tier; requires a config/* branch).
|
|
320
|
+
description: 'Add a new law (config-tier; requires a config/* branch). ' +
|
|
321
|
+
'Fields: id, name, description, passing, failing, target ({kind, file|typeId}), validators ([{id, command, failureMeans?}]).',
|
|
328
322
|
args: {
|
|
329
|
-
|
|
330
|
-
|
|
323
|
+
id: tool.schema.string().describe('Law identifier. Becomes the ## <id> heading.'),
|
|
324
|
+
name: tool.schema.string().describe('Human-readable name stored as prose after heading.'),
|
|
325
|
+
description: tool.schema.string().describe('Prose describing what the law covers.'),
|
|
326
|
+
passing: tool.schema.string().describe('Criteria that define a passing artefact.'),
|
|
327
|
+
failing: tool.schema.string().describe('Criteria that define a failing artefact.'),
|
|
331
328
|
target: tool.schema.object({
|
|
332
|
-
kind: tool.schema.
|
|
333
|
-
file: tool.schema.string().optional(),
|
|
334
|
-
typeId: tool.schema.string().optional(),
|
|
335
|
-
}),
|
|
329
|
+
kind: tool.schema.enum(['global', 'type-specific']).describe('Target kind: global or type-specific'),
|
|
330
|
+
file: tool.schema.string().optional().describe('Filename for global laws (e.g. rules.md)'),
|
|
331
|
+
typeId: tool.schema.string().optional().describe('Artefact type ID for type-specific laws'),
|
|
332
|
+
}).describe('Where to write the law'),
|
|
333
|
+
validators: tool.schema.array(tool.schema.object({
|
|
334
|
+
id: tool.schema.string().describe('Validator identifier'),
|
|
335
|
+
command: tool.schema.string().describe('CLI command with optional {pattern} / {files} placeholders'),
|
|
336
|
+
failureMeans: tool.schema.string().optional().describe('Description of what failure means'),
|
|
337
|
+
})).optional().describe('Optional deterministic validators'),
|
|
336
338
|
},
|
|
337
|
-
execute: guarded('foundry_config_add_law', CREATE_GUARDS, executeAddLaw, {
|
|
338
|
-
branchIo: branchIoFactory,
|
|
339
|
-
io: asyncIoFactory,
|
|
340
|
-
}),
|
|
339
|
+
execute: guarded('foundry_config_add_law', CREATE_GUARDS, executeAddLaw, { branchIo: branchIoFactory, io: asyncIoFactory }),
|
|
341
340
|
});
|
|
342
341
|
}
|
|
343
342
|
|
|
344
343
|
function makeEditLawTool(tool) {
|
|
345
344
|
return tool({
|
|
346
|
-
description: 'Edit an existing law by ID
|
|
345
|
+
description: 'Edit an existing law by ID (config-tier; requires a config/* branch). ' +
|
|
346
|
+
'At least one optional field must be provided. Fields: id, name?, description?, passing?, failing?, validators?.',
|
|
347
347
|
args: {
|
|
348
348
|
id: tool.schema.string().describe('Law ID to edit'),
|
|
349
|
-
|
|
349
|
+
name: tool.schema.string().optional().describe('Updated human-readable name'),
|
|
350
|
+
description: tool.schema.string().optional().describe('Updated description'),
|
|
351
|
+
passing: tool.schema.string().optional().describe('Updated passing criteria'),
|
|
352
|
+
failing: tool.schema.string().optional().describe('Updated failing criteria'),
|
|
353
|
+
validators: tool.schema.array(tool.schema.object({
|
|
354
|
+
id: tool.schema.string(), command: tool.schema.string(), failureMeans: tool.schema.string().optional(),
|
|
355
|
+
})).optional().nullable().describe('Updated validators (replaces existing; null removes validators block; omitted leaves unchanged)'),
|
|
350
356
|
},
|
|
351
|
-
execute: guarded('foundry_config_edit_law', EDIT_GUARDS, executeEditLaw, {
|
|
352
|
-
branchIo: branchIoFactory,
|
|
353
|
-
io: asyncIoFactory,
|
|
354
|
-
}),
|
|
357
|
+
execute: guarded('foundry_config_edit_law', EDIT_GUARDS, executeEditLaw, { branchIo: branchIoFactory, io: asyncIoFactory }),
|
|
355
358
|
});
|
|
356
359
|
}
|
|
357
360
|
|
package/dist/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.3.0] - 2026-05-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Structured config tool signatures.** All six config creation and editing
|
|
8
|
+
tools now accept typed, self-documenting fields instead of raw `body`
|
|
9
|
+
strings containing YAML frontmatter. Each tool generates the correct
|
|
10
|
+
markdown file internally from the provided fields. The AI no longer needs
|
|
11
|
+
to hand-craft YAML frontmatter or reverse-engineer format from validation
|
|
12
|
+
errors. Tool descriptions include field names and types.
|
|
13
|
+
|
|
14
|
+
| Tool | New args |
|
|
15
|
+
|------|----------|
|
|
16
|
+
| `foundry_config_create_artefact_type` | `id`, `name`, `filePatterns`, `description`, `appraisers?` |
|
|
17
|
+
| `foundry_config_create_appraiser` | `id`, `name`, `description`, `model?` |
|
|
18
|
+
| `foundry_config_create_flow` | `id`, `name`, `startingCycles`, `description` |
|
|
19
|
+
| `foundry_config_create_cycle` | `id`, `name`, `outputType` + 10 optional fields |
|
|
20
|
+
| `foundry_config_add_law` | `id`, `name`, `description`, `passing`, `failing`, `target`, `validators?` |
|
|
21
|
+
| `foundry_config_edit_law` | `id` + per-field optional updates |
|
|
22
|
+
|
|
23
|
+
All five authoring skills updated to use the new signatures. Validate tools
|
|
24
|
+
unchanged — they still accept `{ name, body }`.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **`tool-paths.js` resolves command paths from `PATH`.** `git` and `opencode`
|
|
29
|
+
paths are resolved lazily and overridable via `FOUNDRY_GIT_PATH` /
|
|
30
|
+
`FOUNDRY_OPENCODE_PATH` env vars. Removed `sonarjs/no-os-command-from-path`
|
|
31
|
+
overrides for `foundry.js` and `agent-refresh.js`.
|
|
32
|
+
|
|
3
33
|
## [3.2.7] - 2026-05-14
|
|
4
34
|
|
|
5
35
|
### Added
|
|
@@ -2,8 +2,35 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { validate } from '../config-validators/appraiser.js';
|
|
3
3
|
import { makeCreator } from './factory.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Assemble the markdown body for an appraiser definition from structured arguments.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} args
|
|
9
|
+
* @param {string} args.id Slugged identifier; becomes frontmatter.id.
|
|
10
|
+
* @param {string} args.name Human-readable display name; becomes frontmatter.name.
|
|
11
|
+
* @param {string} args.description Prose personality description after frontmatter.
|
|
12
|
+
* @param {string} [args.model] Optional model override.
|
|
13
|
+
* @returns {string} Assembled markdown body.
|
|
14
|
+
*/
|
|
15
|
+
export function assembleAppraiserMarkdown(args) {
|
|
16
|
+
const { id, name, description } = args;
|
|
17
|
+
let body = `---\nid: ${id}\nname: ${name}\n`;
|
|
18
|
+
|
|
19
|
+
if (args.model) {
|
|
20
|
+
body += `model: ${args.model}\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
body += `---\n\n${description}\n`;
|
|
24
|
+
return body;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const _create = makeCreator({
|
|
6
28
|
kind: { human: 'appraiser', underscored: 'appraiser' },
|
|
7
|
-
pathFor: (args) => join('foundry', 'appraisers', `${args.
|
|
29
|
+
pathFor: (args) => join('foundry', 'appraisers', `${args.id}.md`),
|
|
8
30
|
validator: validate,
|
|
9
31
|
});
|
|
32
|
+
|
|
33
|
+
export async function create(args) {
|
|
34
|
+
const body = assembleAppraiserMarkdown(args);
|
|
35
|
+
return _create({ ...args, name: args.id, body });
|
|
36
|
+
}
|
|
@@ -2,8 +2,55 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { validate } from '../config-validators/artefact-type.js';
|
|
3
3
|
import { makeCreator } from './factory.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Render the optional appraisers block as YAML lines.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ count?: number, allowed?: string[] } | undefined} appraisers
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
function renderAppraisers(appraisers) {
|
|
12
|
+
if (!appraisers) return '';
|
|
13
|
+
let block = 'appraisers:\n';
|
|
14
|
+
if (appraisers.count !== undefined) {
|
|
15
|
+
block += ` count: ${appraisers.count}\n`;
|
|
16
|
+
}
|
|
17
|
+
if (appraisers.allowed && appraisers.allowed.length > 0) {
|
|
18
|
+
block += ' allowed:\n';
|
|
19
|
+
block += appraisers.allowed.map((a) => ` - ${a}`).join('\n') + '\n';
|
|
20
|
+
}
|
|
21
|
+
return block;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Assemble the markdown body for an artefact-type definition from structured arguments.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} args
|
|
28
|
+
* @param {string} args.id Slugged identifier; becomes frontmatter.name.
|
|
29
|
+
* @param {string} args.name Human-readable display name (not persisted).
|
|
30
|
+
* @param {string[]} args.filePatterns Glob patterns for write scope.
|
|
31
|
+
* @param {string} args.description Prose under ## Definition.
|
|
32
|
+
* @param {{ count?: number, allowed?: string[] }} [args.appraisers] Optional appraiser config.
|
|
33
|
+
* @returns {string} Assembled markdown body.
|
|
34
|
+
*/
|
|
35
|
+
export function assembleArtefactTypeMarkdown(args) {
|
|
36
|
+
const { id, filePatterns, description } = args;
|
|
37
|
+
let body = `---\nname: ${id}\nfile-patterns:\n`;
|
|
38
|
+
for (const p of filePatterns) {
|
|
39
|
+
body += ` - ${p}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body += renderAppraisers(args.appraisers);
|
|
43
|
+
body += `---\n\n## Definition\n\n${description}\n`;
|
|
44
|
+
return body;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const _create = makeCreator({
|
|
6
48
|
kind: { human: 'artefact-type', underscored: 'artefact_type' },
|
|
7
|
-
pathFor: (args) => join('foundry', 'artefacts', args.
|
|
49
|
+
pathFor: (args) => join('foundry', 'artefacts', args.id, 'definition.md'),
|
|
8
50
|
validator: validate,
|
|
9
51
|
});
|
|
52
|
+
|
|
53
|
+
export async function create(args) {
|
|
54
|
+
const body = assembleArtefactTypeMarkdown(args);
|
|
55
|
+
return _create({ ...args, name: args.id, body });
|
|
56
|
+
}
|