@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.
@@ -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 transformFeedbackItem(text, file, index, (line) =>
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 transformFeedbackItem(text, file, index, (line) =>
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 transformFeedbackItem(text, file, index, (line) => {
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
+ }
@@ -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
- existing.push({
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
+ }
@@ -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
- return yaml.load(match[1]) || {};
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
- // Check for deadlock escalation
108
- const deadlocked = detectDeadlocks(feedback, history);
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 humanAppraise = findFirst(stages, 'human-appraise');
111
- if (humanAppraise && baseStage(current) !== 'human-appraise') {
112
- return humanAppraise;
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
- // Human-appraise not available or we're already in it — blocked
115
- if (forgeCount >= maxIterations) return 'blocked';
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
- export function runSort({ workPath = 'WORK.md', historyPath = 'WORK.history.yaml', foundryDir = 'foundry', cycleDef, agentsDir = '.opencode/agents' } = {}, io = defaultIO) {
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
- return { route, ...(model ? { model } : {}) };
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
- > Do you want a human quality gate on this cycle? If enabled, a human reviewer will check the artefact after LLM appraisers pass, and can break deadlocks between forge and appraisers.
57
+ > Human-appraise has two independent knobs:
58
58
  >
59
- > - Enable human-appraise? (yes/no)
60
- > - If yes, deadlock threshold (default: 3 number of forge/appraise iterations before escalating to human)
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
- enabled: <true|false>
113
- deadlock-threshold: <number>
116
+ human-appraise: <true|false>
117
+ deadlock-appraise: <true|false>
118
+ deadlock-iterations: <number>
114
119
  models:
115
120
  appraise: <model-id>
116
121
  ---