@really-knows-ai/foundry 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/plugins/foundry.js +408 -1
- package/README.md +31 -5
- package/docs/concepts.md +5 -1
- package/docs/work-spec.md +7 -7
- package/package.json +2 -2
- package/scripts/lib/artefacts.js +118 -0
- package/scripts/lib/config.js +154 -0
- package/scripts/lib/feedback.js +285 -0
- package/scripts/lib/history.js +47 -0
- package/scripts/lib/workfile.js +53 -0
- package/scripts/sort.js +54 -196
- package/skills/appraise/SKILL.md +24 -83
- package/skills/cycle/SKILL.md +25 -62
- package/skills/flow/SKILL.md +12 -38
- package/skills/forge/SKILL.md +25 -41
- package/skills/hitl/SKILL.md +18 -41
- package/skills/quench/SKILL.md +15 -44
- package/skills/sort/SKILL.md +20 -53
|
@@ -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
|
+
}
|