@really-knows-ai/foundry 1.2.2 → 1.3.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,154 @@
1
+ /**
2
+ * Structured reads of foundry/ directory contents.
3
+ */
4
+
5
+ import { join } from 'path';
6
+ import { parseFrontmatter } from './workfile.js';
7
+
8
+ function parseDoc(text) {
9
+ const frontmatter = parseFrontmatter(text);
10
+ const body = text.replace(/^---\n.+?\n---\n?/s, '').trim();
11
+ return { frontmatter, body };
12
+ }
13
+
14
+ export async function getCycleDefinition(foundryDir, cycleId, io) {
15
+ const path = join(foundryDir, 'cycles', `${cycleId}.md`);
16
+ if (!(await io.exists(path))) {
17
+ throw new Error(`Cycle not found: ${cycleId}`);
18
+ }
19
+ const text = await io.readFile(path);
20
+ return parseDoc(text);
21
+ }
22
+
23
+ export async function getArtefactType(foundryDir, typeId, io) {
24
+ const path = join(foundryDir, 'artefacts', typeId, 'definition.md');
25
+ if (!(await io.exists(path))) {
26
+ throw new Error(`Artefact type not found: ${typeId}`);
27
+ }
28
+ const text = await io.readFile(path);
29
+ return parseDoc(text);
30
+ }
31
+
32
+ export async function getLaws(foundryDir, typeId, io) {
33
+ // Handle optional typeId: if typeId is the io object, shift args
34
+ if (typeId && typeof typeId === 'object' && typeof typeId.exists === 'function') {
35
+ io = typeId;
36
+ typeId = null;
37
+ }
38
+
39
+ const laws = [];
40
+
41
+ function parseLaws(text, source) {
42
+ const lines = text.split('\n');
43
+ let currentId = null;
44
+ let currentLines = [];
45
+
46
+ for (const line of lines) {
47
+ const heading = line.match(/^## (.+)/);
48
+ if (heading) {
49
+ if (currentId) {
50
+ laws.push({ id: currentId, text: currentLines.join('\n').trim(), source });
51
+ }
52
+ currentId = heading[1];
53
+ currentLines = [];
54
+ } else if (currentId) {
55
+ currentLines.push(line);
56
+ }
57
+ }
58
+ if (currentId) {
59
+ laws.push({ id: currentId, text: currentLines.join('\n').trim(), source });
60
+ }
61
+ }
62
+
63
+ // Global laws
64
+ const globalDir = join(foundryDir, 'laws');
65
+ if (await io.exists(globalDir)) {
66
+ const files = await io.readDir(globalDir);
67
+ const mdFiles = files.filter(f => f.endsWith('.md')).sort();
68
+ for (const file of mdFiles) {
69
+ const text = await io.readFile(join(globalDir, file));
70
+ parseLaws(text, `laws/${file}`);
71
+ }
72
+ }
73
+
74
+ // Type-specific laws
75
+ if (typeId) {
76
+ const typeLawsPath = join(foundryDir, 'artefacts', typeId, 'laws.md');
77
+ if (await io.exists(typeLawsPath)) {
78
+ const text = await io.readFile(typeLawsPath);
79
+ parseLaws(text, `artefacts/${typeId}/laws.md`);
80
+ }
81
+ }
82
+
83
+ return laws;
84
+ }
85
+
86
+ export async function getValidation(foundryDir, typeId, io) {
87
+ const path = join(foundryDir, 'artefacts', typeId, 'validation.md');
88
+ if (!(await io.exists(path))) {
89
+ return null;
90
+ }
91
+ const text = await io.readFile(path);
92
+ const commands = [];
93
+ const regex = /```(?:bash|sh)\n([\s\S]*?)```/g;
94
+ let match;
95
+ while ((match = regex.exec(text)) !== null) {
96
+ for (const line of match[1].trim().split('\n')) {
97
+ const trimmed = line.trim();
98
+ if (trimmed) commands.push(trimmed);
99
+ }
100
+ }
101
+ return commands;
102
+ }
103
+
104
+ export async function getAppraisers(foundryDir, io) {
105
+ const dir = join(foundryDir, 'appraisers');
106
+ if (!(await io.exists(dir))) return [];
107
+ const files = await io.readDir(dir);
108
+ const mdFiles = files.filter(f => f.endsWith('.md')).sort();
109
+ const result = [];
110
+ for (const file of mdFiles) {
111
+ const text = await io.readFile(join(dir, file));
112
+ const { frontmatter, body } = parseDoc(text);
113
+ const entry = { id: frontmatter.id, personality: body };
114
+ if (frontmatter.model) entry.model = frontmatter.model;
115
+ result.push(entry);
116
+ }
117
+ return result;
118
+ }
119
+
120
+ export async function getFlow(foundryDir, flowId, io) {
121
+ const path = join(foundryDir, 'flows', `${flowId}.md`);
122
+ if (!(await io.exists(path))) {
123
+ throw new Error(`Flow not found: ${flowId}`);
124
+ }
125
+ const text = await io.readFile(path);
126
+ return parseDoc(text);
127
+ }
128
+
129
+ export async function selectAppraisers(foundryDir, typeId, countOverride, io) {
130
+ // Handle optional countOverride
131
+ if (countOverride && typeof countOverride === 'object' && typeof countOverride.exists === 'function') {
132
+ io = countOverride;
133
+ countOverride = null;
134
+ }
135
+
136
+ const { frontmatter } = await getArtefactType(foundryDir, typeId, io);
137
+ const appraiserConfig = frontmatter.appraisers || {};
138
+ const count = countOverride || appraiserConfig.count || 3;
139
+ const allowed = appraiserConfig.allowed || null;
140
+
141
+ const allAppraisers = await getAppraisers(foundryDir, io);
142
+ let pool = allowed
143
+ ? allAppraisers.filter(a => allowed.includes(a.id))
144
+ : allAppraisers;
145
+
146
+ if (pool.length === 0) return [];
147
+
148
+ // Round-robin distribute
149
+ const result = [];
150
+ for (let i = 0; i < count; i++) {
151
+ result.push(pool[i % pool.length]);
152
+ }
153
+ return result;
154
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Feedback parsing and manipulation utilities for WORK.md.
3
+ */
4
+
5
+ import { extractAllTags } from './tags.js';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Parsing
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export function parseFeedbackItem(line) {
12
+ const item = { raw: line, state: 'unknown', tags: [], resolved: false };
13
+
14
+ if (line.startsWith('- [ ]')) {
15
+ item.state = 'open';
16
+ } else if (line.startsWith('- [x]')) {
17
+ item.state = 'actioned';
18
+ } else if (line.startsWith('- [~]')) {
19
+ item.state = 'wont-fix';
20
+ }
21
+
22
+ if (line.includes('| approved')) {
23
+ item.resolved = true;
24
+ } else if (line.includes('| rejected')) {
25
+ item.state = 'rejected';
26
+ item.resolved = false;
27
+ }
28
+
29
+ item.tags = extractAllTags(line);
30
+
31
+ return item;
32
+ }
33
+
34
+ export function parseFeedback(text, cycle, artefacts) {
35
+ const cycleFiles = new Set();
36
+ for (const art of artefacts) {
37
+ if (art.cycle === cycle) {
38
+ cycleFiles.add(art.file || '');
39
+ }
40
+ }
41
+
42
+ const items = [];
43
+ let currentFile = null;
44
+ let inFeedback = false;
45
+ let feedbackLevel = 0;
46
+
47
+ for (const line of text.split('\n')) {
48
+ const stripped = line.trim();
49
+
50
+ if (stripped === '# Feedback' || stripped === '## Feedback') {
51
+ inFeedback = true;
52
+ feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
53
+ continue;
54
+ }
55
+
56
+ // Exit feedback on a heading at the same or higher level
57
+ if (inFeedback && /^#{1,2} /.test(stripped)) {
58
+ const level = stripped.startsWith('## ') ? 2 : 1;
59
+ if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
60
+ inFeedback = false;
61
+ continue;
62
+ }
63
+ }
64
+
65
+ if (!inFeedback) continue;
66
+
67
+ // File sub-headings are one level below the Feedback heading
68
+ const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
69
+ if (stripped.startsWith(fileHeadingPrefix)) {
70
+ currentFile = stripped.slice(fileHeadingPrefix.length).trim();
71
+ continue;
72
+ }
73
+
74
+ if (cycleFiles.has(currentFile) && /^- \[/.test(stripped)) {
75
+ items.push(parseFeedbackItem(stripped));
76
+ }
77
+ }
78
+
79
+ return items;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Manipulation
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export function addFeedbackItem(text, file, itemText, tag) {
87
+ const newItem = `- [ ] ${itemText} #${tag}`;
88
+ const lines = text.split('\n');
89
+
90
+ // Find ## Feedback section
91
+ let feedbackIdx = -1;
92
+ let feedbackLevel = 0;
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const stripped = lines[i].trim();
95
+ if (stripped === '## Feedback') {
96
+ feedbackIdx = i;
97
+ feedbackLevel = 2;
98
+ break;
99
+ }
100
+ if (stripped === '# Feedback') {
101
+ feedbackIdx = i;
102
+ feedbackLevel = 1;
103
+ break;
104
+ }
105
+ }
106
+
107
+ const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
108
+ const fileHeading = `${fileHeadingPrefix}${file}`;
109
+
110
+ if (feedbackIdx === -1) {
111
+ // No Feedback section — append one
112
+ lines.push('', '## Feedback', '', `### ${file}`, newItem);
113
+ return lines.join('\n');
114
+ }
115
+
116
+ // Find the file heading within the feedback section
117
+ let fileIdx = -1;
118
+ let sectionEnd = lines.length; // end of feedback section
119
+ for (let i = feedbackIdx + 1; i < lines.length; i++) {
120
+ const stripped = lines[i].trim();
121
+ // Check if we've left the feedback section
122
+ if (/^#{1,2} /.test(stripped)) {
123
+ const level = stripped.startsWith('## ') ? 2 : 1;
124
+ if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
125
+ sectionEnd = i;
126
+ break;
127
+ }
128
+ }
129
+ if (stripped === fileHeading.trim()) {
130
+ fileIdx = i;
131
+ }
132
+ }
133
+
134
+ if (fileIdx === -1) {
135
+ // File heading doesn't exist — add it before section end
136
+ lines.splice(sectionEnd, 0, '', fileHeading, newItem);
137
+ return lines.join('\n');
138
+ }
139
+
140
+ // Find last item under this file heading
141
+ let insertIdx = fileIdx + 1;
142
+ for (let i = fileIdx + 1; i < sectionEnd; i++) {
143
+ const stripped = lines[i].trim();
144
+ if (stripped.startsWith(fileHeadingPrefix)) break; // next file heading
145
+ if (/^- \[/.test(stripped)) {
146
+ insertIdx = i + 1;
147
+ }
148
+ }
149
+
150
+ lines.splice(insertIdx, 0, newItem);
151
+ return lines.join('\n');
152
+ }
153
+
154
+ export function actionFeedbackItem(text, file, index) {
155
+ return transformFeedbackItem(text, file, index, (line) =>
156
+ line.replace('- [ ]', '- [x]')
157
+ );
158
+ }
159
+
160
+ export function wontfixFeedbackItem(text, file, index, reason) {
161
+ return transformFeedbackItem(text, file, index, (line) =>
162
+ line.replace('- [ ]', '- [~]') + ` | wont-fix: ${reason}`
163
+ );
164
+ }
165
+
166
+ export function resolveFeedbackItem(text, file, index, resolution, reason) {
167
+ return transformFeedbackItem(text, file, index, (line) => {
168
+ if (resolution === 'approved') {
169
+ return line + ' | approved';
170
+ }
171
+ return line + ` | rejected: ${reason}`;
172
+ });
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Listing
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export function listFeedback(text, cycle, artefacts, filterFile) {
180
+ const cycleFiles = new Set();
181
+ for (const art of artefacts) {
182
+ if (art.cycle === cycle) {
183
+ cycleFiles.add(art.file || '');
184
+ }
185
+ }
186
+
187
+ const results = [];
188
+ let currentFile = null;
189
+ let fileIndex = 0;
190
+ let inFeedback = false;
191
+ let feedbackLevel = 0;
192
+
193
+ for (const line of text.split('\n')) {
194
+ const stripped = line.trim();
195
+
196
+ if (stripped === '# Feedback' || stripped === '## Feedback') {
197
+ inFeedback = true;
198
+ feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
199
+ continue;
200
+ }
201
+
202
+ if (inFeedback && /^#{1,2} /.test(stripped)) {
203
+ const level = stripped.startsWith('## ') ? 2 : 1;
204
+ if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
205
+ inFeedback = false;
206
+ continue;
207
+ }
208
+ }
209
+
210
+ if (!inFeedback) continue;
211
+
212
+ const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
213
+ if (stripped.startsWith(fileHeadingPrefix)) {
214
+ currentFile = stripped.slice(fileHeadingPrefix.length).trim();
215
+ fileIndex = 0;
216
+ continue;
217
+ }
218
+
219
+ if (cycleFiles.has(currentFile) && /^- \[/.test(stripped)) {
220
+ if (!filterFile || filterFile === currentFile) {
221
+ const item = parseFeedbackItem(stripped);
222
+ results.push({
223
+ file: currentFile,
224
+ index: fileIndex,
225
+ text: item.raw,
226
+ state: item.state,
227
+ tags: item.tags,
228
+ resolved: item.resolved,
229
+ });
230
+ }
231
+ fileIndex++;
232
+ }
233
+ }
234
+
235
+ return results;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Internal helpers
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function transformFeedbackItem(text, file, index, transform) {
243
+ const lines = text.split('\n');
244
+ let inFeedback = false;
245
+ let feedbackLevel = 0;
246
+ let currentFile = null;
247
+ let fileIndex = 0;
248
+
249
+ for (let i = 0; i < lines.length; i++) {
250
+ const stripped = lines[i].trim();
251
+
252
+ if (stripped === '# Feedback' || stripped === '## Feedback') {
253
+ inFeedback = true;
254
+ feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
255
+ continue;
256
+ }
257
+
258
+ if (inFeedback && /^#{1,2} /.test(stripped)) {
259
+ const level = stripped.startsWith('## ') ? 2 : 1;
260
+ if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
261
+ inFeedback = false;
262
+ continue;
263
+ }
264
+ }
265
+
266
+ if (!inFeedback) continue;
267
+
268
+ const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
269
+ if (stripped.startsWith(fileHeadingPrefix)) {
270
+ currentFile = stripped.slice(fileHeadingPrefix.length).trim();
271
+ fileIndex = 0;
272
+ continue;
273
+ }
274
+
275
+ if (currentFile === file && /^- \[/.test(stripped)) {
276
+ if (fileIndex === index) {
277
+ lines[i] = transform(lines[i]);
278
+ return lines.join('\n');
279
+ }
280
+ fileIndex++;
281
+ }
282
+ }
283
+
284
+ return text;
285
+ }
@@ -0,0 +1,47 @@
1
+ import yaml from 'js-yaml';
2
+
3
+ /**
4
+ * Load history entries for a cycle, sorted by timestamp ascending.
5
+ */
6
+ export function loadHistory(historyPath, cycle, io) {
7
+ if (!io.exists(historyPath)) return [];
8
+ const data = yaml.load(io.readFile(historyPath)) || [];
9
+ const filtered = data.filter(e => e.cycle === cycle);
10
+ filtered.sort((a, b) => {
11
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
12
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
13
+ return ta - tb;
14
+ });
15
+ return filtered;
16
+ }
17
+
18
+ /**
19
+ * Append a history entry with auto-generated ISO timestamp.
20
+ */
21
+ export function appendEntry(historyPath, { cycle, stage, iteration, comment }, io) {
22
+ if (iteration == null) throw new Error('iteration is required');
23
+ if (!comment) throw new Error('comment is required');
24
+
25
+ let existing = [];
26
+ if (io.exists(historyPath)) {
27
+ existing = yaml.load(io.readFile(historyPath)) || [];
28
+ }
29
+
30
+ existing.push({
31
+ cycle,
32
+ stage,
33
+ iteration,
34
+ comment,
35
+ timestamp: new Date().toISOString(),
36
+ });
37
+
38
+ io.writeFile(historyPath, yaml.dump(existing));
39
+ }
40
+
41
+ /**
42
+ * Count forge entries for a cycle.
43
+ */
44
+ export function getIteration(historyPath, cycle, io) {
45
+ const history = loadHistory(historyPath, cycle, io);
46
+ return history.filter(e => (e.stage || '').split(':')[0] === 'forge').length;
47
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shared WORK.md parsing and generation utilities.
3
+ */
4
+
5
+ import yaml from 'js-yaml';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Frontmatter parsing
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export function parseFrontmatter(text) {
12
+ const match = text.match(/^---\n(.+?)\n---/s);
13
+ if (!match) return {};
14
+ return yaml.load(match[1]) || {};
15
+ }
16
+
17
+ export function writeFrontmatter(fields) {
18
+ const body = yaml.dump(fields, { lineWidth: -1 }).trimEnd();
19
+ return `---\n${body}\n---`;
20
+ }
21
+
22
+ export function getFrontmatterField(text, key) {
23
+ const fm = parseFrontmatter(text);
24
+ return fm[key];
25
+ }
26
+
27
+ export function setFrontmatterField(text, key, value) {
28
+ const fm = parseFrontmatter(text);
29
+ fm[key] = value;
30
+ const fmBlock = writeFrontmatter(fm);
31
+
32
+ // Strip existing frontmatter (if any) and prepend new one
33
+ const body = text.replace(/^---\n.+?\n---\n?/s, '');
34
+ return body ? `${fmBlock}\n${body}` : fmBlock;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Workfile creation
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export function createWorkfile(frontmatter, goal) {
42
+ const fm = writeFrontmatter(frontmatter);
43
+ return `${fm}
44
+ # Goal
45
+
46
+ ${goal}
47
+
48
+ | Artefact | Status |
49
+ |----------|--------|
50
+
51
+ ## Feedback
52
+ `;
53
+ }