@really-knows-ai/foundry 2.1.0 → 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 +329 -46
- package/CHANGELOG.md +55 -0
- package/package.json +3 -2
- 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/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 +89 -14
- 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/sort/SKILL.md +60 -28
- package/skills/upgrade-foundry/SKILL.md +33 -1
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,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);
|
package/scripts/sort.js
CHANGED
|
@@ -64,7 +64,7 @@ const defaultIO = {
|
|
|
64
64
|
// Routing logic
|
|
65
65
|
// ---------------------------------------------------------------------------
|
|
66
66
|
|
|
67
|
-
function determineRoute(stages, history, feedback, maxIterations) {
|
|
67
|
+
function determineRoute(stages, history, feedback, maxIterations, opts = {}) {
|
|
68
68
|
const forgeCount = history.filter(e => baseStage(e.stage || '') === 'forge').length;
|
|
69
69
|
|
|
70
70
|
const nonSortHistory = history.filter(e => baseStage(e.stage || '') !== 'sort');
|
|
@@ -83,11 +83,11 @@ function determineRoute(stages, history, feedback, maxIterations) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if (lastBase === 'appraise') {
|
|
86
|
-
return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory);
|
|
86
|
+
return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory, opts);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
if (lastBase === 'human-appraise') {
|
|
90
|
-
return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory);
|
|
90
|
+
return nextAfterAppraise(stages, lastEntry, feedback, forgeCount, maxIterations, nonSortHistory, opts);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
return 'blocked';
|
|
@@ -103,16 +103,31 @@ function nextAfterQuench(stages, current, feedback, forgeCount, maxIterations) {
|
|
|
103
103
|
return nextInRoute(stages, current) ?? 'done';
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function nextAfterAppraise(stages, current, feedback, forgeCount, maxIterations, history = []) {
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
function nextAfterAppraise(stages, current, feedback, forgeCount, maxIterations, history = [], opts = {}) {
|
|
107
|
+
const {
|
|
108
|
+
humanAppraise: humanAppraiseEnabled = false,
|
|
109
|
+
deadlockAppraise = true,
|
|
110
|
+
deadlockIterations = 5,
|
|
111
|
+
cycle = null,
|
|
112
|
+
} = opts;
|
|
113
|
+
|
|
114
|
+
// Check for deadlock escalation using configured threshold
|
|
115
|
+
const deadlocked = detectDeadlocks(feedback, history, deadlockIterations);
|
|
109
116
|
if (deadlocked.length > 0) {
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
117
|
+
const alreadyInHumanAppraise = baseStage(current) === 'human-appraise';
|
|
118
|
+
if (alreadyInHumanAppraise) {
|
|
119
|
+
// Human-appraise ran and deadlock still present — give up.
|
|
120
|
+
return 'blocked';
|
|
113
121
|
}
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
if (deadlockAppraise) {
|
|
123
|
+
// Route to human-appraise. Prefer one in `stages`; else synthesize via cycle id.
|
|
124
|
+
const inStages = findFirst(stages, 'human-appraise');
|
|
125
|
+
if (inStages) return inStages;
|
|
126
|
+
if (cycle) return `human-appraise:${cycle}`;
|
|
127
|
+
return 'blocked';
|
|
128
|
+
}
|
|
129
|
+
// deadlock-appraise disabled — block the cycle.
|
|
130
|
+
return 'blocked';
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
const needsForge = feedback.some(f => f.state === 'open' || f.state === 'rejected');
|
|
@@ -205,11 +220,43 @@ function checkModifiedFiles(lastBase, foundryDir, cycleDef, cycle, io = defaultI
|
|
|
205
220
|
return { ok: violations.length === 0, violations };
|
|
206
221
|
}
|
|
207
222
|
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Micro-commit enforcement
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Return a list of tool-managed files that have uncommitted changes
|
|
229
|
+
* (modified, staged, or untracked) in the working tree.
|
|
230
|
+
*
|
|
231
|
+
* Tool-managed files are WORK.md, WORK.history.yaml, and anything under
|
|
232
|
+
* .foundry/. The sort skill is the sole writer of these between stages,
|
|
233
|
+
* and every stage must end with `foundry_git_commit`. If this function
|
|
234
|
+
* returns a non-empty list at the start of a sort invocation, a prior
|
|
235
|
+
* stage skipped the commit step.
|
|
236
|
+
*/
|
|
237
|
+
function getDirtyToolManagedFiles(io = defaultIO) {
|
|
238
|
+
try {
|
|
239
|
+
const output = io.exec('git status --porcelain -- WORK.md WORK.history.yaml .foundry');
|
|
240
|
+
return output
|
|
241
|
+
.split('\n')
|
|
242
|
+
.map(line => line.trim())
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map(line => line.replace(/^[\sMADRCU?!]+/, '').trim())
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
208
251
|
// ---------------------------------------------------------------------------
|
|
209
252
|
// Exported runSort — structured result for programmatic use
|
|
210
253
|
// ---------------------------------------------------------------------------
|
|
211
254
|
|
|
212
|
-
|
|
255
|
+
function isDispatchableRoute(route) {
|
|
256
|
+
return typeof route === 'string' && /^(forge|quench|appraise|human-appraise):/.test(route);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml', foundryDir = 'foundry', cycleDef, agentsDir = '.opencode/agents', mint, now = Date.now() } = {}, io = defaultIO) {
|
|
213
260
|
if (!io.exists(workPath)) {
|
|
214
261
|
return { route: 'blocked', details: 'WORK.md not found' };
|
|
215
262
|
}
|
|
@@ -220,6 +267,9 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
|
|
|
220
267
|
const cycle = frontmatter.cycle;
|
|
221
268
|
const stages = frontmatter.stages;
|
|
222
269
|
const maxIterations = frontmatter['max-iterations'] ?? 3;
|
|
270
|
+
const humanAppraiseEnabled = frontmatter['human-appraise'] === true;
|
|
271
|
+
const deadlockAppraise = frontmatter['deadlock-appraise'] !== false; // default true
|
|
272
|
+
const deadlockIterations = frontmatter['deadlock-iterations'] ?? 5;
|
|
223
273
|
|
|
224
274
|
if (!cycle) return { route: 'blocked', details: 'No cycle in WORK.md frontmatter' };
|
|
225
275
|
if (!stages || !Array.isArray(stages)) return { route: 'blocked', details: 'No stages in WORK.md frontmatter' };
|
|
@@ -229,6 +279,20 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
|
|
|
229
279
|
const history = loadHistory(historyPath, cycle, io);
|
|
230
280
|
const feedback = parseFeedback(workText, cycle, artefacts);
|
|
231
281
|
|
|
282
|
+
// Micro-commit enforcement: if any prior stage ran (history non-empty),
|
|
283
|
+
// all tool-managed files must be committed before the next sort call.
|
|
284
|
+
// The first sort of a cycle has empty history — WORK.md may be untracked
|
|
285
|
+
// or dirty at that point, which is fine.
|
|
286
|
+
if (history.length > 0) {
|
|
287
|
+
const dirty = getDirtyToolManagedFiles(io);
|
|
288
|
+
if (dirty.length > 0) {
|
|
289
|
+
return {
|
|
290
|
+
route: 'violation',
|
|
291
|
+
details: `Uncommitted tool-managed files since last sort: ${dirty.join(', ')}. Call foundry_git_commit for the prior stage before invoking sort again.`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
232
296
|
// File modification enforcement
|
|
233
297
|
const nonSortHistory = history.filter(e => baseStage(e.stage || '') !== 'sort');
|
|
234
298
|
if (nonSortHistory.length > 0) {
|
|
@@ -248,7 +312,12 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
|
|
|
248
312
|
return { route: 'violation', details: `Feedback tag validation failed: ${details}` };
|
|
249
313
|
}
|
|
250
314
|
|
|
251
|
-
const route = determineRoute(stages, history, feedback, maxIterations
|
|
315
|
+
const route = determineRoute(stages, history, feedback, maxIterations, {
|
|
316
|
+
humanAppraise: humanAppraiseEnabled,
|
|
317
|
+
deadlockAppraise,
|
|
318
|
+
deadlockIterations,
|
|
319
|
+
cycle,
|
|
320
|
+
});
|
|
252
321
|
|
|
253
322
|
// Model resolution
|
|
254
323
|
let model = null;
|
|
@@ -267,7 +336,12 @@ export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml
|
|
|
267
336
|
}
|
|
268
337
|
}
|
|
269
338
|
|
|
270
|
-
|
|
339
|
+
const result = { route, ...(model ? { model } : {}) };
|
|
340
|
+
if (mint && isDispatchableRoute(route)) {
|
|
341
|
+
const token = mint({ route, cycle, exp: now + 10 * 60 * 1000 });
|
|
342
|
+
if (token) result.token = token;
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
271
345
|
}
|
|
272
346
|
|
|
273
347
|
// ---------------------------------------------------------------------------
|
|
@@ -290,6 +364,7 @@ export {
|
|
|
290
364
|
getModifiedFiles,
|
|
291
365
|
getAllowedPatterns,
|
|
292
366
|
checkModifiedFiles,
|
|
367
|
+
getDirtyToolManagedFiles,
|
|
293
368
|
};
|
|
294
369
|
|
|
295
370
|
|
|
@@ -54,10 +54,15 @@ Only stages with an explicitly specified model are included in the `models` fron
|
|
|
54
54
|
|
|
55
55
|
Ask the user:
|
|
56
56
|
|
|
57
|
-
>
|
|
57
|
+
> Human-appraise has two independent knobs:
|
|
58
58
|
>
|
|
59
|
-
> -
|
|
60
|
-
> -
|
|
59
|
+
> 1. `human-appraise` — should a human review the artefact every iteration? Default: no.
|
|
60
|
+
> 2. `deadlock-appraise` — should a human be pulled in only when LLM appraisers deadlock? Default: yes.
|
|
61
|
+
> 3. If either is enabled, `deadlock-iterations` sets the deadlock threshold (default: 5).
|
|
62
|
+
>
|
|
63
|
+
> - human-appraise: yes/no (default no)
|
|
64
|
+
> - deadlock-appraise: yes/no (default yes)
|
|
65
|
+
> - deadlock-iterations: number (default 5)
|
|
61
66
|
|
|
62
67
|
### 5. Validate artefact types
|
|
63
68
|
|
|
@@ -108,9 +113,9 @@ inputs:
|
|
|
108
113
|
- <artefact-type-id>
|
|
109
114
|
targets:
|
|
110
115
|
- <cycle-id>
|
|
111
|
-
human-appraise:
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
human-appraise: <true|false>
|
|
117
|
+
deadlock-appraise: <true|false>
|
|
118
|
+
deadlock-iterations: <number>
|
|
114
119
|
models:
|
|
115
120
|
appraise: <model-id>
|
|
116
121
|
---
|