@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.
@@ -53,19 +53,42 @@ const VALIDATE_GUARDS = [gitRepoGuard, foundryRootGuard];
53
53
 
54
54
  // --- tool factories --------------------------------------------------------
55
55
 
56
- // Module-level helper: returns a `makeCreate` function bound to `tool` and `baseArgs`.
57
- function createMakeCreate(tool, baseArgs) {
58
- return function makeCreate(toolName, creator, extraArgs = {}) {
59
- const kind = toolName.replace('foundry_config_create_', '');
60
- let desc = `Create a new ${kind} definition (config-tier; requires a config/* branch).`;
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
- if (kind === 'law') {
63
- desc += ' target must be {kind:"global", file:"<name>.md"} or {kind:"type-specific", typeId:"<id>"}.';
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: desc,
68
- args: { ...baseArgs, ...extraArgs },
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 baseArgs = {
181
+ const makeValidate = createMakeValidate(tool, {
109
182
  name: tool.schema.string(),
110
183
  body: tool.schema.string(),
111
- };
112
-
113
- const makeCreate = createMakeCreate(tool, baseArgs);
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') + '\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(_args, context) {
88
- return requireGitRepo(makeIO(context.worktree));
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(GIT_COMMAND, argv, { cwd, encoding: 'utf8', stdio: 'pipe' });
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 = [gitRepoGuard, foundryRootGuard, configBranchGuard, gateNotFailed];
109
- const EDIT_GUARDS = [gitRepoGuard, foundryRootGuard, configBranchGuard, gateNotFailed];
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
- return JSON.stringify({
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, args);
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, args.body);
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
- if (path) await rollbackAddLaw(io, path, existedBefore, priorContent);
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
- const result = await findLawByID(io, 'foundry', args.id);
279
- if (!result.found) {
280
- return JSON.stringify({
281
- ok: false,
282
- errors: [`Law "${args.id}" not found`],
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 instanceof UnexpectedFilesError) {
304
- return JSON.stringify({ error: err.message, affected_files: err.files });
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
- id: tool.schema.string().describe('Law ID to read'),
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). Target must be {kind:"global", file:"<name>.md"} or {kind:"type-specific", typeId:"<id>"}.',
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
- name: tool.schema.string(),
330
- body: tool.schema.string(),
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.string(),
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. Validates the new body, updates the file, and commits on the current config/* branch.',
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
- body: tool.schema.string().describe('Full new markdown body for the law'),
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
- export const create = makeCreator({
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.name}.md`),
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
- export const create = makeCreator({
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.name, 'definition.md'),
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
+ }