@really-knows-ai/foundry 3.3.9 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Appraise module — gathers context for parallel appraiser dispatch and
3
+ * consolidates results after all appraisers have run.
4
+ *
5
+ * Gather phase: reads artefacts, laws, and appraiser personalities, builds
6
+ * subagent prompts, and returns a dispatch_multi action so the orchestrator's
7
+ * LLM dispatches appraisers in parallel.
8
+ *
9
+ * Consolidate phase: receives lastResults from the orchestrator, unions and
10
+ * de-duplicates appraiser issues, posts feedback, and finalises the stage
11
+ * so the orchestrator can re-sort and determine the next action.
12
+ */
13
+
14
+ import { getArtefactFiles } from './lib/artefacts.js';
15
+ import { selectAppraisers, getLaws, getCycleDefinition } from './lib/config.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Public API — gather
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Gather appraise context: read draft artefacts, select appraisers, read laws
23
+ * and artefact content, then build a dispatch_multi action with one task per
24
+ * (artefact, appraiser) pair.
25
+ *
26
+ * @param {object} ctx
27
+ * @param {string} ctx.cycleId
28
+ * @param {object} ctx.io
29
+ * @param {string} ctx.foundryDir
30
+ * @param {string} [ctx.baseBranch] - Git base branch for diff comparison,
31
+ * defaults to 'main'.
32
+ * @param {string} [ctx.defaultModel] - Fallback model when an appraiser has no
33
+ * explicit model.
34
+ * @returns {Promise<{action: string, tasks: Array, stage: string, cycle: string}>}
35
+ */
36
+ export async function gatherAppraiseContext(ctx) {
37
+ if (!ctx.cycleId) {
38
+ return violation('cycleId is required', []);
39
+ }
40
+
41
+ const cd = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
42
+ const outputType = cd.frontmatter['output-type'];
43
+ if (!outputType) {
44
+ return violation(`cycle ${ctx.cycleId} missing output-type field`, []);
45
+ }
46
+ const baseBranch = ctx.baseBranch || 'main';
47
+ const artefacts = await getArtefactFiles(ctx.foundryDir, outputType, ctx.io, { baseBranch });
48
+ if (artefacts.length === 0) {
49
+ return emptyDispatch(ctx.cycleId);
50
+ }
51
+
52
+ const typedArtefacts = artefacts.map(artefact => ({ ...artefact, type: outputType }));
53
+ const tasks = await collectTasks(typedArtefacts, ctx);
54
+
55
+ return {
56
+ action: 'dispatch_multi',
57
+ tasks,
58
+ stage: `appraise:${ctx.cycleId}`,
59
+ cycle: ctx.cycleId,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build all appraiser tasks across artefacts, caching per type.
65
+ */
66
+ async function collectTasks(artefacts, ctx) {
67
+ const tasks = [];
68
+ const typeCache = new Map();
69
+
70
+ for (const artefact of artefacts) {
71
+ const entry = await resolveTypeEntry(artefact.type, typeCache, ctx);
72
+ if (!entry) continue;
73
+
74
+ addTasksForArtefact(tasks, artefact, entry, ctx);
75
+ }
76
+
77
+ return tasks;
78
+ }
79
+
80
+ /**
81
+ * Get or create a cached (appraisers, laws) entry for an artefact type.
82
+ * Returns null when no appraisers are available for the type.
83
+ */
84
+ async function resolveTypeEntry(typeId, cache, ctx) {
85
+ if (cache.has(typeId)) {
86
+ return cache.get(typeId);
87
+ }
88
+
89
+ const [appraisers, laws] = await Promise.all([
90
+ selectAppraisers(ctx.foundryDir, typeId, { io: ctx.io }),
91
+ getLaws(ctx.foundryDir, ctx.io, { typeId }),
92
+ ]);
93
+
94
+ const entry = appraisers.length === 0 ? null : { appraisers, laws };
95
+ cache.set(typeId, entry);
96
+ return entry;
97
+ }
98
+
99
+ /**
100
+ * Build and append appraiser tasks for a single artefact.
101
+ */
102
+ function addTasksForArtefact(tasks, artefact, entry, ctx) {
103
+ let content = '';
104
+ if (artefact.state !== 'deleted') {
105
+ content = ctx.io.readFile(artefact.file);
106
+ }
107
+
108
+ for (const appraiser of entry.appraisers) {
109
+ const prompt = buildAppraiserPrompt({
110
+ appraiser,
111
+ artefact: { file: artefact.file, content },
112
+ laws: entry.laws,
113
+ });
114
+
115
+ tasks.push({
116
+ subagent_type: resolveSubagentType(appraiser, ctx),
117
+ prompt,
118
+ });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Map an appraiser's model to a subagent type string.
124
+ */
125
+ function resolveSubagentType(appraiser, ctx) {
126
+ const name = appraiser.model || ctx.defaultModel || 'general';
127
+ if (name === 'general') return 'general';
128
+
129
+ return `foundry-${name.replace(/[/.]/g, '-')}`;
130
+ }
131
+
132
+ /**
133
+ * Empty dispatch response when there is nothing to appraise.
134
+ */
135
+ function emptyDispatch(cycleId) {
136
+ return {
137
+ action: 'dispatch_multi',
138
+ tasks: [],
139
+ stage: `appraise:${cycleId}`,
140
+ cycle: cycleId,
141
+ };
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Public API — consolidate
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Consolidate appraiser results after all subagents have run.
150
+ *
151
+ * Parses each successful output for structured issues, unions across
152
+ * appraisers, de-duplicates by (file, law-id, issue text), posts feedback,
153
+ * resolves stale prior appraise feedback, and finalises the stage.
154
+ *
155
+ * @param {object} ctx
156
+ * @param {Array<{ok: boolean, output?: string, error?: string}>} lastResults
157
+ * @returns {Promise<{ok: boolean, summary?: string}|violation>}
158
+ */
159
+ export async function consolidateAppraise(ctx, lastResults) {
160
+ const baseSha = ctx.activeStage?.baseSha;
161
+ if (!baseSha) {
162
+ return violation('No active stage found', []);
163
+ }
164
+
165
+ const results = arrayFrom(lastResults);
166
+ const successful = results.filter(r => r.ok === true);
167
+
168
+ if (allAppraisersFailed(results, successful)) {
169
+ return violation('All appraisers failed to evaluate the artefact', []);
170
+ }
171
+
172
+ const consolidated = parseConsolidated(successful);
173
+ const stageId = `appraise:${ctx.cycleId}`;
174
+
175
+ postConsolidatedFeedback(ctx, consolidated);
176
+ resolvePriorAppraise(ctx, consolidated, stageId);
177
+
178
+ const summary = buildConsolidateSummary(consolidated.length);
179
+
180
+ await ctx.finalize({
181
+ lastStage: { stage: stageId, summary, baseSha },
182
+ activeStage: ctx.activeStage,
183
+ });
184
+
185
+ return { ok: true, summary };
186
+ }
187
+
188
+ /**
189
+ * Parse all successful appraiser outputs and de-duplicate the combined issue
190
+ * list by (file, law-id, issue text).
191
+ */
192
+ function parseConsolidated(successful) {
193
+ const all = [];
194
+
195
+ for (const result of successful) {
196
+ const issues = parseAppraiserOutput(result.output || '');
197
+ all.push(...issues);
198
+ }
199
+
200
+ return deduplicateIssues(all);
201
+ }
202
+
203
+ /**
204
+ * De-duplicate an issue array by (file, law, issue text).
205
+ */
206
+ function deduplicateIssues(issues) {
207
+ const seen = new Set();
208
+ const result = [];
209
+
210
+ for (const issue of issues) {
211
+ const key = `${issue.file}:${issue.law}:${issue.issue}`;
212
+ if (!seen.has(key)) {
213
+ seen.add(key);
214
+ result.push(issue);
215
+ }
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ /**
222
+ * Post one feedback item per consolidated issue.
223
+ */
224
+ function postConsolidatedFeedback(ctx, consolidated) {
225
+ for (const issue of consolidated) {
226
+ ctx.feedback.add({
227
+ file: issue.file,
228
+ text: issue.issue,
229
+ tag: `law:${issue.law}`,
230
+ });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Resolve prior appraise feedback: items still present stay rejected, stale
236
+ * items are approved.
237
+ */
238
+ function resolvePriorAppraise(ctx, consolidated, stageId) {
239
+ const current = new Set(
240
+ consolidated.map(i => `${i.file}:law:${i.law}`)
241
+ );
242
+
243
+ const priorItems = ctx.feedback.list({ source: stageId });
244
+
245
+ for (const prior of priorItems) {
246
+ if (prior.state === 'resolved') continue;
247
+ const sig = `${prior.file}:${prior.tag}`;
248
+ const decision = current.has(sig) ? 'rejected' : 'approved';
249
+ ctx.feedback.resolve(prior.id, decision);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Build the summary string for consolidation.
255
+ */
256
+ function buildConsolidateSummary(count) {
257
+ if (count === 0) return 'No issues found by appraisers';
258
+
259
+ return `${count} issue(s) found by appraisers`;
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Prompt builder
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Build a subagent prompt for a single (appraiser, artefact) pair.
268
+ *
269
+ * Follows the template from the appraise skill (src/skills/appraise/SKILL.md)
270
+ * extended to include the file path for deterministic result parsing.
271
+ */
272
+ function buildAppraiserPrompt({ appraiser, artefact, laws }) {
273
+ const lawSections = laws
274
+ .map(law => `## ${law.id}\n\n${law.text}`)
275
+ .join('\n\n');
276
+
277
+ const lines = [
278
+ 'You are an appraiser. Your personality:',
279
+ '',
280
+ appraiser.personality,
281
+ '',
282
+ 'Evaluate the following artefact against each law below. For each law,',
283
+ 'either:',
284
+ '- Note no issues (pass)',
285
+ '- Describe the issue, quoting evidence from the artefact',
286
+ '',
287
+ '## Artefact',
288
+ '',
289
+ artefact.content,
290
+ '',
291
+ '## Laws',
292
+ '',
293
+ lawSections,
294
+ '',
295
+ '## Output',
296
+ '',
297
+ 'Return a list of issues. For each issue:',
298
+ `- file: ${artefact.file}`,
299
+ '- law: <law-id>',
300
+ '- issue: <description>',
301
+ '- evidence: <quote from artefact>',
302
+ '',
303
+ 'If there are no issues, return an empty list.',
304
+ ];
305
+
306
+ return lines.join('\n');
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Output parsing
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /**
314
+ * Parse a structured issue list from an appraiser subagent output.
315
+ *
316
+ * Expected per-issue format (YAML list):
317
+ * - file: <path>
318
+ * law: <law-id>
319
+ * issue: <description>
320
+ * evidence: <quote>
321
+ *
322
+ * Returns an array of { file, law, issue, evidence } objects. Entries that
323
+ * lack a file, law, or issue field are silently skipped.
324
+ */
325
+ function parseAppraiserOutput(output) {
326
+ // Split output into entries on boundaries where a new line starts a
327
+ // list entry ("- "). Avoids regex to prevent sonarjs/slow-regex.
328
+ const entries = [];
329
+ let buffer = '';
330
+
331
+ for (const line of output.split('\n')) {
332
+ if (buffer && isListEntryStart(line)) {
333
+ entries.push(buffer);
334
+ buffer = line;
335
+ continue;
336
+ }
337
+
338
+ buffer = concatLine(buffer, line);
339
+ }
340
+
341
+ if (buffer) entries.push(buffer);
342
+
343
+ return entries
344
+ .map(parseRawEntry)
345
+ .filter(e => e !== null);
346
+ }
347
+
348
+ /**
349
+ * Append a line to the current buffer string.
350
+ */
351
+ function concatLine(buffer, line) {
352
+ if (!buffer) return line;
353
+ return `${buffer}\n${line}`;
354
+ }
355
+
356
+ /**
357
+ * True when a line marks the start of a new YAML list entry (starts with
358
+ * "- " after optional whitespace).
359
+ */
360
+ function isListEntryStart(line) {
361
+ for (let i = 0; i < line.length; i++) {
362
+ const ch = line[i];
363
+ if (ch === ' ' || ch === '\t') continue;
364
+ return ch === '-' && line[i + 1] === ' ';
365
+ }
366
+ return false;
367
+ }
368
+
369
+ /**
370
+ * Parse a single raw entry block into an issue object, or null when
371
+ * required fields are missing.
372
+ */
373
+ function parseRawEntry(raw) {
374
+ const block = raw.trim();
375
+
376
+ const file = extractField(block, 'file');
377
+ const law = extractField(block, 'law');
378
+ const issue = extractField(block, 'issue');
379
+ const evidence = extractField(block, 'evidence');
380
+
381
+ if (!file || !law || !issue) return null;
382
+
383
+ return { file, law, issue, evidence: evidence || '' };
384
+ }
385
+
386
+ /**
387
+ * Extract a YAML list item field value.
388
+ *
389
+ * Matches lines like:
390
+ * - file: value
391
+ * law: value
392
+ *
393
+ * The field name may be preceded by optional whitespace and/or a "- " list
394
+ * marker. Returns the value portion, trimmed.
395
+ */
396
+ function extractField(text, key) {
397
+ // Walk lines to find "key: value" preceded only by whitespace or a "- "
398
+ // list marker. Avoids regex quantifiers that trigger sonarjs/slow-regex.
399
+
400
+ for (const line of text.split('\n')) {
401
+ const trimmed = line.trim();
402
+ const value = tryExtractKey(trimmed, key);
403
+ if (value !== null) return value;
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ /**
410
+ * Given a single trimmed line, try to extract the value for key.
411
+ * Returns null if the pattern is not found.
412
+ */
413
+ function tryExtractKey(line, key) {
414
+ const needle = `${key}:`;
415
+ const idx = line.indexOf(needle);
416
+ if (idx < 0) return null;
417
+
418
+ const before = line.slice(0, idx);
419
+ if (before.length > 0 && !isLegalPrefix(before)) return null;
420
+
421
+ const value = line.slice(idx + needle.length).trim();
422
+ return value || null;
423
+ }
424
+
425
+ /**
426
+ * True when the text before a key: on a line is either all whitespace or
427
+ * the "- " list marker.
428
+ */
429
+ function isLegalPrefix(before) {
430
+ if (before === '- ') return true;
431
+
432
+ for (let i = 0; i < before.length; i++) {
433
+ if (before[i] !== ' ' && before[i] !== '\t') return false;
434
+ }
435
+
436
+ return true;
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Shared helpers
441
+ // ---------------------------------------------------------------------------
442
+
443
+ function violation(details, affectedFiles) {
444
+ return {
445
+ action: 'violation',
446
+ details,
447
+ recoverable: false,
448
+ affected_files: affectedFiles,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Safely coerce a value to an array, defaulting to empty array.
454
+ */
455
+ function arrayFrom(value) {
456
+ return Array.isArray(value) ? value : [];
457
+ }
458
+
459
+ /**
460
+ * True when there were results but none succeeded.
461
+ */
462
+ function allAppraisersFailed(results, successful) {
463
+ return results.length > 0 && successful.length === 0;
464
+ }