@really-knows-ai/foundry 2.0.1 → 2.2.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.
- package/.opencode/plugins/foundry.js +343 -51
- package/CHANGELOG.md +55 -0
- package/package.json +4 -3
- package/scripts/lib/artefacts.js +6 -0
- package/scripts/lib/feedback-transitions.js +25 -0
- package/scripts/lib/feedback.js +146 -9
- package/scripts/lib/finalize.js +41 -0
- package/scripts/lib/history.js +15 -3
- package/scripts/lib/pending.js +18 -0
- package/scripts/lib/secret.js +23 -0
- package/scripts/lib/slug.js +33 -0
- package/scripts/lib/stage-guard.js +25 -0
- package/scripts/lib/state.js +31 -0
- package/scripts/lib/token.js +26 -0
- package/scripts/lib/workfile.js +12 -1
- package/scripts/sort.js +99 -15
- package/skills/add-cycle/SKILL.md +11 -6
- package/skills/appraise/SKILL.md +33 -17
- package/skills/cycle/SKILL.md +25 -19
- package/skills/flow/SKILL.md +9 -2
- package/skills/forge/SKILL.md +38 -26
- package/skills/human-appraise/SKILL.md +29 -17
- package/skills/quench/SKILL.md +31 -15
- package/skills/refresh-agents/SKILL.md +6 -3
- package/skills/sort/SKILL.md +86 -16
- package/skills/upgrade-foundry/SKILL.md +52 -6
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Matrix: [current][target] => set of allowed stageBases
|
|
4
|
+
const MATRIX = {
|
|
5
|
+
open: { actioned: ['forge'], 'wont-fix': ['forge'] },
|
|
6
|
+
actioned: { approved: ['quench', 'appraise', 'human-appraise'], rejected: ['quench', 'appraise', 'human-appraise'] },
|
|
7
|
+
'wont-fix': { approved: ['appraise', 'human-appraise'], rejected: ['appraise', 'human-appraise'] },
|
|
8
|
+
rejected: { actioned: ['forge'], 'wont-fix': ['forge'] },
|
|
9
|
+
approved: {}, // terminal
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function validateTransition(current, target, stageBase) {
|
|
13
|
+
const row = MATRIX[current];
|
|
14
|
+
if (!row) return { ok: false, reason: `unknown state: ${current}` };
|
|
15
|
+
const allowedStages = row[target];
|
|
16
|
+
if (!allowedStages) return { ok: false, reason: `invalid transition ${current} → ${target}` };
|
|
17
|
+
if (!allowedStages.includes(stageBase)) {
|
|
18
|
+
return { ok: false, reason: `stage ${stageBase} cannot transition ${current} → ${target}` };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hashText(text) {
|
|
24
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
package/scripts/lib/feedback.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { extractAllTags } from './tags.js';
|
|
6
|
+
import { validateTransition, hashText } from './feedback-transitions.js';
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Parsing
|
|
@@ -85,6 +86,16 @@ export function parseFeedback(text, cycle, artefacts) {
|
|
|
85
86
|
// ---------------------------------------------------------------------------
|
|
86
87
|
|
|
87
88
|
export function addFeedbackItem(text, file, itemText, tag) {
|
|
89
|
+
// Dedup by (file, tag, text hash): if any existing item under this file
|
|
90
|
+
// heading has the same tag and the same itemText, return without mutating.
|
|
91
|
+
const existing = collectItemsForFile(text, file);
|
|
92
|
+
const h = hashText(itemText);
|
|
93
|
+
for (const ex of existing) {
|
|
94
|
+
if (ex.tags.includes(`#${tag}`) && hashText(ex.coreText) === h) {
|
|
95
|
+
return { text, deduped: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
const newItem = `- [ ] ${itemText} #${tag}`;
|
|
89
100
|
const lines = text.split('\n');
|
|
90
101
|
|
|
@@ -111,7 +122,7 @@ export function addFeedbackItem(text, file, itemText, tag) {
|
|
|
111
122
|
if (feedbackIdx === -1) {
|
|
112
123
|
// No Feedback section — append one
|
|
113
124
|
lines.push('', '## Feedback', '', `### ${file}`, newItem);
|
|
114
|
-
return lines.join('\n');
|
|
125
|
+
return { text: lines.join('\n'), deduped: false };
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
// Find the file heading within the feedback section
|
|
@@ -135,7 +146,7 @@ export function addFeedbackItem(text, file, itemText, tag) {
|
|
|
135
146
|
if (fileIdx === -1) {
|
|
136
147
|
// File heading doesn't exist — add it before section end
|
|
137
148
|
lines.splice(sectionEnd, 0, '', fileHeading, newItem);
|
|
138
|
-
return lines.join('\n');
|
|
149
|
+
return { text: lines.join('\n'), deduped: false };
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
// Find last item under this file heading
|
|
@@ -149,23 +160,23 @@ export function addFeedbackItem(text, file, itemText, tag) {
|
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
lines.splice(insertIdx, 0, newItem);
|
|
152
|
-
return lines.join('\n');
|
|
163
|
+
return { text: lines.join('\n'), deduped: false };
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
export function actionFeedbackItem(text, file, index) {
|
|
156
|
-
return
|
|
166
|
+
export function actionFeedbackItem(text, file, index, stageBase) {
|
|
167
|
+
return transformFeedbackItemWithValidation(text, file, index, 'actioned', stageBase, (line) =>
|
|
157
168
|
line.replace('- [ ]', '- [x]')
|
|
158
169
|
);
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
export function wontfixFeedbackItem(text, file, index, reason) {
|
|
162
|
-
return
|
|
172
|
+
export function wontfixFeedbackItem(text, file, index, reason, stageBase) {
|
|
173
|
+
return transformFeedbackItemWithValidation(text, file, index, 'wont-fix', stageBase, (line) =>
|
|
163
174
|
line.replace('- [ ]', '- [~]') + ` | wont-fix: ${reason}`
|
|
164
175
|
);
|
|
165
176
|
}
|
|
166
177
|
|
|
167
|
-
export function resolveFeedbackItem(text, file, index, resolution, reason) {
|
|
168
|
-
return
|
|
178
|
+
export function resolveFeedbackItem(text, file, index, resolution, reason, stageBase) {
|
|
179
|
+
return transformFeedbackItemWithValidation(text, file, index, resolution, stageBase, (line) => {
|
|
169
180
|
if (resolution === 'approved') {
|
|
170
181
|
return line + ' | approved';
|
|
171
182
|
}
|
|
@@ -257,6 +268,132 @@ export function detectDeadlocks(feedback, history, threshold = 3) {
|
|
|
257
268
|
// Internal helpers
|
|
258
269
|
// ---------------------------------------------------------------------------
|
|
259
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Collect feedback items under a specific file heading, returning the parsed
|
|
273
|
+
* representation plus the "core text" (item body with tag and trailing resolution
|
|
274
|
+
* stripped) for dedup hashing.
|
|
275
|
+
*/
|
|
276
|
+
function collectItemsForFile(text, file) {
|
|
277
|
+
const items = [];
|
|
278
|
+
const lines = text.split('\n');
|
|
279
|
+
let inFeedback = false;
|
|
280
|
+
let feedbackLevel = 0;
|
|
281
|
+
let currentFile = null;
|
|
282
|
+
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
const stripped = line.trim();
|
|
285
|
+
|
|
286
|
+
if (stripped === '# Feedback' || stripped === '## Feedback') {
|
|
287
|
+
inFeedback = true;
|
|
288
|
+
feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (inFeedback && /^#{1,2} /.test(stripped)) {
|
|
293
|
+
const level = stripped.startsWith('## ') ? 2 : 1;
|
|
294
|
+
if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
|
|
295
|
+
inFeedback = false;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!inFeedback) continue;
|
|
301
|
+
|
|
302
|
+
const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
|
|
303
|
+
if (stripped.startsWith(fileHeadingPrefix)) {
|
|
304
|
+
currentFile = stripped.slice(fileHeadingPrefix.length).trim();
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (currentFile === file && /^- \[/.test(stripped)) {
|
|
309
|
+
const parsed = parseFeedbackItem(stripped);
|
|
310
|
+
// Strip checkbox, tags, and trailing `| approved` / `| rejected: ...` /
|
|
311
|
+
// `| wont-fix: ...` to get the core author-supplied text for dedup.
|
|
312
|
+
let core = stripped.replace(/^- \[[ x~]\]\s*/, '');
|
|
313
|
+
core = core.replace(/\s*\|\s*(approved|rejected[^|]*|wont-fix[^|]*)\s*$/, '');
|
|
314
|
+
for (const t of parsed.tags) {
|
|
315
|
+
core = core.replace(t, '');
|
|
316
|
+
}
|
|
317
|
+
core = core.trim();
|
|
318
|
+
items.push({ line: stripped, state: parsed.state, tags: parsed.tags, coreText: core });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return items;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Read the line at (file, index) and return its current feedback state
|
|
327
|
+
* (or null if not found).
|
|
328
|
+
*/
|
|
329
|
+
function readItemState(text, file, index) {
|
|
330
|
+
const lines = text.split('\n');
|
|
331
|
+
let inFeedback = false;
|
|
332
|
+
let feedbackLevel = 0;
|
|
333
|
+
let currentFile = null;
|
|
334
|
+
let fileIndex = 0;
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
337
|
+
const stripped = lines[i].trim();
|
|
338
|
+
|
|
339
|
+
if (stripped === '# Feedback' || stripped === '## Feedback') {
|
|
340
|
+
inFeedback = true;
|
|
341
|
+
feedbackLevel = stripped.startsWith('## ') ? 2 : 1;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (inFeedback && /^#{1,2} /.test(stripped)) {
|
|
346
|
+
const level = stripped.startsWith('## ') ? 2 : 1;
|
|
347
|
+
if (level <= feedbackLevel && stripped !== '# Feedback' && stripped !== '## Feedback') {
|
|
348
|
+
inFeedback = false;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!inFeedback) continue;
|
|
354
|
+
|
|
355
|
+
const fileHeadingPrefix = feedbackLevel === 1 ? '## ' : '### ';
|
|
356
|
+
if (stripped.startsWith(fileHeadingPrefix)) {
|
|
357
|
+
currentFile = stripped.slice(fileHeadingPrefix.length).trim();
|
|
358
|
+
fileIndex = 0;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (currentFile === file && /^- \[/.test(stripped)) {
|
|
363
|
+
if (fileIndex === index) {
|
|
364
|
+
const parsed = parseFeedbackItem(stripped);
|
|
365
|
+
// Map parseFeedbackItem's (state, resolved) pair onto state-machine states:
|
|
366
|
+
// - `| approved` → terminal "approved"
|
|
367
|
+
// - `| rejected` → "rejected" (parseFeedbackItem already sets this)
|
|
368
|
+
// - bare `[x]` → "actioned"
|
|
369
|
+
// - bare `[~]` → "wont-fix"
|
|
370
|
+
// - bare `[ ]` → "open"
|
|
371
|
+
if (parsed.resolved) return 'approved';
|
|
372
|
+
return parsed.state;
|
|
373
|
+
}
|
|
374
|
+
fileIndex++;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function transformFeedbackItemWithValidation(text, file, index, target, stageBase, transform) {
|
|
381
|
+
if (stageBase !== undefined) {
|
|
382
|
+
const current = readItemState(text, file, index);
|
|
383
|
+
if (!current) {
|
|
384
|
+
return { ok: false, error: `feedback item not found: file=${file} index=${index}` };
|
|
385
|
+
}
|
|
386
|
+
const v = validateTransition(current, target, stageBase);
|
|
387
|
+
if (!v.ok) {
|
|
388
|
+
return { ok: false, error: v.reason };
|
|
389
|
+
}
|
|
390
|
+
const updated = transformFeedbackItem(text, file, index, transform);
|
|
391
|
+
return { ok: true, text: updated };
|
|
392
|
+
}
|
|
393
|
+
// Backward-compatible path: return plain string.
|
|
394
|
+
return transformFeedbackItem(text, file, index, transform);
|
|
395
|
+
}
|
|
396
|
+
|
|
260
397
|
function transformFeedbackItem(text, file, index, transform) {
|
|
261
398
|
const lines = text.split('\n');
|
|
262
399
|
let inFeedback = false;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// scripts/lib/finalize.js
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { minimatch } from 'minimatch';
|
|
4
|
+
|
|
5
|
+
const TOOL_MANAGED = [
|
|
6
|
+
'WORK.md',
|
|
7
|
+
'WORK.history.yaml',
|
|
8
|
+
];
|
|
9
|
+
const TOOL_MANAGED_PREFIX = ['.foundry/'];
|
|
10
|
+
|
|
11
|
+
function changedFiles(cwd, baseSha) {
|
|
12
|
+
const tracked = execSync(`git diff --name-only ${baseSha} HEAD`, { cwd }).toString().split('\n').filter(Boolean);
|
|
13
|
+
const diffUnstaged = execSync('git diff --name-only', { cwd }).toString().split('\n').filter(Boolean);
|
|
14
|
+
const untracked = execSync('git ls-files --others --exclude-standard', { cwd }).toString().split('\n').filter(Boolean);
|
|
15
|
+
return [...new Set([...tracked, ...diffUnstaged, ...untracked])];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isToolManaged(f) {
|
|
19
|
+
if (TOOL_MANAGED.includes(f)) return true;
|
|
20
|
+
return TOOL_MANAGED_PREFIX.some(p => f.startsWith(p));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes, registerArtefact }) {
|
|
24
|
+
const files = changedFiles(cwd, baseSha).filter(f => !isToolManaged(f));
|
|
25
|
+
const allowedPatterns = stageBase === 'forge'
|
|
26
|
+
? (artefactTypes[cycleDef.outputArtefactType]?.filePatterns ?? [])
|
|
27
|
+
: [];
|
|
28
|
+
const unexpected = [];
|
|
29
|
+
const matched = [];
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
const hit = allowedPatterns.find(p => minimatch(f, p));
|
|
32
|
+
if (hit) matched.push(f);
|
|
33
|
+
else unexpected.push(f);
|
|
34
|
+
}
|
|
35
|
+
if (unexpected.length) return { ok: false, error: 'unexpected_files', files: unexpected };
|
|
36
|
+
const artefacts = matched.map(file => {
|
|
37
|
+
registerArtefact({ file, type: cycleDef.outputArtefactType, status: 'draft' });
|
|
38
|
+
return { file, type: cycleDef.outputArtefactType, status: 'draft' };
|
|
39
|
+
});
|
|
40
|
+
return { ok: true, artefacts };
|
|
41
|
+
}
|
package/scripts/lib/history.js
CHANGED
|
@@ -18,7 +18,7 @@ export function loadHistory(historyPath, cycle, io) {
|
|
|
18
18
|
/**
|
|
19
19
|
* Append a history entry with auto-generated ISO timestamp.
|
|
20
20
|
*/
|
|
21
|
-
export function appendEntry(historyPath, { cycle, stage, iteration, comment }, io) {
|
|
21
|
+
export function appendEntry(historyPath, { cycle, stage, iteration, comment, route }, io) {
|
|
22
22
|
if (iteration == null) throw new Error('iteration is required');
|
|
23
23
|
if (!comment) throw new Error('comment is required');
|
|
24
24
|
|
|
@@ -27,13 +27,15 @@ export function appendEntry(historyPath, { cycle, stage, iteration, comment }, i
|
|
|
27
27
|
existing = yaml.load(io.readFile(historyPath)) || [];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const entry = {
|
|
31
31
|
cycle,
|
|
32
32
|
stage,
|
|
33
33
|
iteration,
|
|
34
34
|
comment,
|
|
35
35
|
timestamp: new Date().toISOString(),
|
|
36
|
-
}
|
|
36
|
+
};
|
|
37
|
+
if (route !== undefined) entry.route = route;
|
|
38
|
+
existing.push(entry);
|
|
37
39
|
|
|
38
40
|
io.writeFile(historyPath, yaml.dump(existing));
|
|
39
41
|
}
|
|
@@ -45,3 +47,13 @@ export function getIteration(historyPath, cycle, io) {
|
|
|
45
47
|
const history = loadHistory(historyPath, cycle, io);
|
|
46
48
|
return history.filter(e => (e.stage || '').split(':')[0] === 'forge').length;
|
|
47
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return the `route` field from the most recent `sort` history entry for a
|
|
53
|
+
* given cycle, or null if none exists.
|
|
54
|
+
*/
|
|
55
|
+
export function readLastSortRoute(historyPath, cycle, io) {
|
|
56
|
+
const entries = loadHistory(historyPath, cycle, io).filter(e => e.stage === 'sort');
|
|
57
|
+
if (!entries.length) return null;
|
|
58
|
+
return entries[entries.length - 1].route ?? null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function createPendingStore() {
|
|
2
|
+
const map = new Map();
|
|
3
|
+
return {
|
|
4
|
+
add(nonce, meta) { map.set(nonce, meta); },
|
|
5
|
+
consume(nonce) {
|
|
6
|
+
const meta = map.get(nonce);
|
|
7
|
+
if (!meta) return null;
|
|
8
|
+
map.delete(nonce);
|
|
9
|
+
if (meta.exp < Date.now()) return null;
|
|
10
|
+
return meta;
|
|
11
|
+
},
|
|
12
|
+
size() {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [k, v] of map) if (v.exp < now) map.delete(k);
|
|
15
|
+
return map.size;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, openSync, writeSync, closeSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function readOrCreateSecret(directory) {
|
|
6
|
+
const dir = join(directory, '.foundry');
|
|
7
|
+
const file = join(dir, '.secret');
|
|
8
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
9
|
+
const bytes = randomBytes(32);
|
|
10
|
+
let fd;
|
|
11
|
+
try {
|
|
12
|
+
fd = openSync(file, 'wx', 0o600);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
if (err.code === 'EEXIST') return readFileSync(file);
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
writeSync(fd, bytes);
|
|
19
|
+
} finally {
|
|
20
|
+
closeSync(fd);
|
|
21
|
+
}
|
|
22
|
+
return bytes;
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slug utilities for generating shell-safe, git-ref-safe identifiers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert an arbitrary string into a URL/git-branch-friendly slug.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* - Strips diacritics (e.g. "café" → "cafe")
|
|
10
|
+
* - Lowercases
|
|
11
|
+
* - Replaces any run of non-[a-z0-9] characters with a single dash
|
|
12
|
+
* - Trims leading/trailing dashes
|
|
13
|
+
*
|
|
14
|
+
* Throws if the input is not a string or if the resulting slug is empty.
|
|
15
|
+
*/
|
|
16
|
+
export function slugify(input) {
|
|
17
|
+
if (typeof input !== 'string') {
|
|
18
|
+
throw new TypeError(`slugify: expected string, got ${typeof input}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const slug = input
|
|
22
|
+
.normalize('NFD')
|
|
23
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
26
|
+
.replace(/^-+|-+$/g, '');
|
|
27
|
+
|
|
28
|
+
if (slug.length === 0) {
|
|
29
|
+
throw new Error(`slugify: input produced empty slug (input: ${JSON.stringify(input)})`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return slug;
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// scripts/lib/stage-guard.js
|
|
2
|
+
import { readActiveStage } from './state.js';
|
|
3
|
+
|
|
4
|
+
export function stageBaseOf(stage) {
|
|
5
|
+
const i = stage.indexOf(':');
|
|
6
|
+
return i === -1 ? stage : stage.slice(0, i);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function requireNoActiveStage(io) {
|
|
10
|
+
const a = readActiveStage(io);
|
|
11
|
+
if (!a) return { ok: true };
|
|
12
|
+
return { ok: false, error: `tool requires no active stage; current: ${a.stage}` };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function requireActiveStage(io, { stageBase, cycle } = {}) {
|
|
16
|
+
const a = readActiveStage(io);
|
|
17
|
+
if (!a) return { ok: false, error: `tool requires active stage; current: none` };
|
|
18
|
+
if (stageBase && stageBaseOf(a.stage) !== stageBase) {
|
|
19
|
+
return { ok: false, error: `tool requires active ${stageBase} stage; current: ${a.stage}` };
|
|
20
|
+
}
|
|
21
|
+
if (cycle && a.cycle !== cycle) {
|
|
22
|
+
return { ok: false, error: `tool requires active stage in cycle ${cycle}; current cycle: ${a.cycle}` };
|
|
23
|
+
}
|
|
24
|
+
return { ok: true, active: a };
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const ACTIVE = '.foundry/active-stage.json';
|
|
2
|
+
const LAST = '.foundry/last-stage.json';
|
|
3
|
+
const DIR = '.foundry';
|
|
4
|
+
|
|
5
|
+
export function ensureFoundryDir(io) {
|
|
6
|
+
if (!io.exists(DIR)) io.mkdir(DIR);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readActiveStage(io) {
|
|
10
|
+
if (!io.exists(ACTIVE)) return null;
|
|
11
|
+
return JSON.parse(io.readFile(ACTIVE));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function writeActiveStage(io, payload) {
|
|
15
|
+
ensureFoundryDir(io);
|
|
16
|
+
io.writeFile(ACTIVE, JSON.stringify(payload, null, 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function clearActiveStage(io) {
|
|
20
|
+
io.unlink(ACTIVE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readLastStage(io) {
|
|
24
|
+
if (!io.exists(LAST)) return null;
|
|
25
|
+
return JSON.parse(io.readFile(LAST));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeLastStage(io, payload) {
|
|
29
|
+
ensureFoundryDir(io);
|
|
30
|
+
io.writeFile(LAST, JSON.stringify(payload, null, 2));
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function signToken(payload, secret) {
|
|
4
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
5
|
+
const mac = createHmac('sha256', secret).update(body).digest('base64url');
|
|
6
|
+
return `${body}.${mac}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function verifyToken(token, secret) {
|
|
10
|
+
if (typeof token !== 'string' || !token.includes('.')) return { ok: false, reason: 'malformed' };
|
|
11
|
+
const [body, mac] = token.split('.');
|
|
12
|
+
if (!body || !mac) return { ok: false, reason: 'malformed' };
|
|
13
|
+
const expected = createHmac('sha256', secret).update(body).digest();
|
|
14
|
+
let given;
|
|
15
|
+
try { given = Buffer.from(mac, 'base64url'); } catch { return { ok: false, reason: 'malformed' }; }
|
|
16
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
|
|
17
|
+
return { ok: false, reason: 'bad_signature' };
|
|
18
|
+
}
|
|
19
|
+
let payload;
|
|
20
|
+
try { payload = JSON.parse(Buffer.from(body, 'base64url').toString()); }
|
|
21
|
+
catch { return { ok: false, reason: 'malformed' }; }
|
|
22
|
+
if (typeof payload.exp !== 'number' || payload.exp < Date.now()) {
|
|
23
|
+
return { ok: false, reason: 'expired' };
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, payload };
|
|
26
|
+
}
|
package/scripts/lib/workfile.js
CHANGED
|
@@ -11,7 +11,16 @@ import yaml from 'js-yaml';
|
|
|
11
11
|
export function parseFrontmatter(text) {
|
|
12
12
|
const match = text.match(/^---\n(.+?)\n---/s);
|
|
13
13
|
if (!match) return {};
|
|
14
|
-
|
|
14
|
+
const fm = yaml.load(match[1]) || {};
|
|
15
|
+
// Normalize: on-disk canonical key is `max-iterations` (kebab).
|
|
16
|
+
// Tolerate legacy `maxIterations` (camel) by rewriting on read.
|
|
17
|
+
if (fm.maxIterations !== undefined) {
|
|
18
|
+
if (fm['max-iterations'] === undefined) {
|
|
19
|
+
fm['max-iterations'] = fm.maxIterations;
|
|
20
|
+
}
|
|
21
|
+
delete fm.maxIterations;
|
|
22
|
+
}
|
|
23
|
+
return fm;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export function writeFrontmatter(fields) {
|
|
@@ -25,6 +34,8 @@ export function getFrontmatterField(text, key) {
|
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
export function setFrontmatterField(text, key, value) {
|
|
37
|
+
// Coerce legacy camelCase key to canonical kebab form on write.
|
|
38
|
+
if (key === 'maxIterations') key = 'max-iterations';
|
|
28
39
|
const fm = parseFrontmatter(text);
|
|
29
40
|
fm[key] = value;
|
|
30
41
|
const fmBlock = writeFrontmatter(fm);
|