@really-knows-ai/foundry 3.5.9 → 3.6.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.
@@ -2,17 +2,10 @@
2
2
  * Sort routing helpers — pure functions that decide the next stage given
3
3
  * the current stage list, history, feedback, and iteration counters.
4
4
  *
5
- * Extracted from `src/scripts/sort.js` to keep that file under the
6
- * configured `max-lines` limit and to lower per-function complexity.
5
+ * Replaced the handler-dispatch pattern with a state-driven decision tree
6
+ * that routes based on feedback item state, history, and the stage list.
7
7
  */
8
8
 
9
- // An item is "open" (still in flight) when its head state is not
10
- // 'resolved' or 'deadlocked'. The deadlocked check is retained for
11
- // backward compatibility with existing feedback files.
12
- const isOpenItem = (f) => f.state !== 'resolved' && f.state !== 'deadlocked';
13
-
14
- export { isOpenItem };
15
-
16
9
  export function baseStage(stage) {
17
10
  return stage.split(':')[0];
18
11
  }
@@ -24,7 +17,10 @@ export function findFirst(stages, base) {
24
17
  return null;
25
18
  }
26
19
 
27
- export function nextInRoute(stages, current) {
20
+ /**
21
+ * Returns the next stage in `stages` after `current`, or null if at the end.
22
+ */
23
+ export function nextStageInChain(stages, current) {
28
24
  const idx = stages.indexOf(current);
29
25
  if (idx !== -1 && idx + 1 < stages.length) {
30
26
  return stages[idx + 1];
@@ -32,150 +28,198 @@ export function nextInRoute(stages, current) {
32
28
  return null;
33
29
  }
34
30
 
35
- function hasItemsNeedingForge(openItems) {
36
- return openItems.some(f => f.state === 'open' || f.state === 'rejected');
31
+ /**
32
+ * Returns the first stage in `stages` whose base matches `base`, with
33
+ * a fallback for `human-appraise`. For `human-appraise`, if no exact
34
+ * match is found, falls back to `human-appraise:<cycle>`.
35
+ */
36
+ export function first(base, stages, cycle) {
37
+ if (base === 'human-appraise') {
38
+ const stage = findFirst(stages, 'human-appraise');
39
+ return stage !== null ? stage : `human-appraise:${cycle}`;
40
+ }
41
+ return findFirst(stages, base);
37
42
  }
38
43
 
39
- function hasItemsPendingApproval(openItems) {
40
- return openItems.some(f => f.state === 'actioned' || f.state === 'wont-fix');
44
+ /**
45
+ * Returns the first stage in `stages` whose base appears after `base`
46
+ * in the canonical chain order [forge, quench, appraise, human-appraise].
47
+ * Returns null if base is not in the canonical order or no subsequent
48
+ * stage is configured.
49
+ */
50
+ export function firstAfter(stages, base) {
51
+ const canon = ['forge', 'quench', 'appraise', 'human-appraise'];
52
+ const baseIdx = canon.indexOf(base);
53
+ if (baseIdx === -1) return null;
54
+ for (let i = baseIdx + 1; i < canon.length; i++) {
55
+ const found = findFirst(stages, canon[i]);
56
+ if (found !== null) return found;
57
+ }
58
+ return null;
41
59
  }
42
60
 
43
- function firstForgeOrBlocked(stages) {
44
- const stage = findFirst(stages, 'forge');
45
- return stage !== null ? stage : 'blocked';
61
+ /**
62
+ * Returns true when a stage with the given base exists in `stages`.
63
+ */
64
+ export function hasStage(stages, base) {
65
+ return findFirst(stages, base) !== null;
46
66
  }
47
67
 
48
- function nextRouteOrDone(stages, current) {
49
- const nextStage = nextInRoute(stages, current);
50
- return nextStage !== null ? nextStage : 'done';
68
+ /**
69
+ * Called after all unresolved and addressed items have been exhausted.
70
+ * Finds the next stage after `lastStage` via `nextStageInChain`.
71
+ * Returns 'done' if no next stage exists.
72
+ * Returns 'done' if the next stage is human-appraise and
73
+ * opts.alwaysHumanAppraise is false.
74
+ * Otherwise returns the next stage.
75
+ */
76
+ export function forwardClean(stages, lastStage, opts = {}) {
77
+ const next = nextStageInChain(stages, lastStage);
78
+ if (next === null) return 'done';
79
+ if (baseStage(next) === 'human-appraise' && !opts.alwaysHumanAppraise) {
80
+ return 'done';
81
+ }
82
+ return next;
51
83
  }
52
84
 
53
- function firstHumanOrSuffixed(stages, cycle) {
54
- const stage = findFirst(stages, 'human-appraise');
55
- return stage !== null ? stage : `human-appraise:${cycle}`;
85
+ // ---------------------------------------------------------------------------
86
+ // Helpers extracted to keep determineRoute within complexity limits
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Validates maxIterations, stages list, and history emptiness.
91
+ * Returns a route value when the input is terminal, or null to continue.
92
+ */
93
+ function isValidMaxIterations(maxIterations) {
94
+ return typeof maxIterations === 'number' && Number.isInteger(maxIterations) && maxIterations >= 1;
56
95
  }
57
96
 
58
- function isBelowIterationCap(forgeCount, maxIterations, alwaysHumanAppraise) {
59
- return alwaysHumanAppraise || forgeCount < maxIterations;
97
+ function validateRoute(maxIterations, stages, history) {
98
+ if (!isValidMaxIterations(maxIterations)) return 'blocked';
99
+ if (!stages || stages.length === 0) return 'blocked';
100
+ if (history.length === 0) return stages[0];
101
+ return null;
60
102
  }
61
103
 
62
- function callHandlerOrBlocked(handler) {
63
- return handler ? handler() : 'blocked';
104
+ /**
105
+ * Extracts routing state from history and feedback: the last non-sort
106
+ * stage entry (full alias), its base stage name, forge iteration count,
107
+ * and categorised feedback items.
108
+ */
109
+ function computeRoutingState(history, feedback) {
110
+ const nonSort = history.filter(e => baseStage(e.stage || '') !== 'sort');
111
+ const lastEntry = nonSort.length > 0 ? nonSort[nonSort.length - 1].stage : null;
112
+ const lastStage = lastEntry !== null ? baseStage(lastEntry) : null;
113
+ const forgeCount = history.filter(e =>
114
+ baseStage(e.stage || '') === 'forge' && e.contract_passed !== false,
115
+ ).length;
116
+ const unresolvedItems = feedback.filter(f => f.state === 'open' || f.state === 'rejected');
117
+ const addressedItems = feedback.filter(f => f.state === 'actioned' || f.state === 'wont-fix');
118
+ return { lastEntry, lastStage, forgeCount, unresolvedItems, addressedItems };
64
119
  }
65
120
 
66
- function routeForgeIfNeeded(stages, forgeCount, maxIterations, opts = {}) {
67
- const { alwaysHumanAppraise, deadlockHumanAppraise, cycle } = opts;
68
- if (isBelowIterationCap(forgeCount, maxIterations, alwaysHumanAppraise)) {
69
- return firstForgeOrBlocked(stages);
121
+ /**
122
+ * Checks whether the iteration cap has been reached.
123
+ * When the cap is reached and not bypassed by alwaysHumanAppraise,
124
+ * routes to human-appraise (if deadlockHumanAppraise) or 'blocked'.
125
+ * Otherwise routes to forge via first('forge', stages).
126
+ */
127
+ function checkIterationAndRoute(firstFn, stages, forgeCount, maxIterations, opts) {
128
+ if (forgeCount >= maxIterations && !opts.alwaysHumanAppraise) {
129
+ if (opts.deadlockHumanAppraise) {
130
+ return firstFn('human-appraise', stages, opts.cycle);
131
+ }
132
+ return 'blocked';
70
133
  }
71
- if (!deadlockHumanAppraise) return 'blocked';
72
- if (!findFirst(stages, 'human-appraise')) return 'blocked';
73
- return `human-appraise:${cycle}`;
134
+ if (!hasStage(stages, 'forge')) return 'blocked';
135
+ return firstFn('forge', stages);
74
136
  }
75
137
 
76
- function appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations, opts = {}) {
77
- if (hasItemsNeedingForge(openItems)) {
78
- return routeForgeIfNeeded(stages, forgeCount, maxIterations, opts);
79
- }
80
- if (hasItemsPendingApproval(openItems)) {
81
- const stage = findFirst(stages, 'appraise');
82
- return stage !== null ? stage : 'blocked';
138
+ /**
139
+ * Collects unique source bases from addressed items and finds the
140
+ * earliest stage in the canonical chain that has a matching configured
141
+ * stage. Returns the resolved stage alias when found, or null to
142
+ * fall through to forwardClean.
143
+ */
144
+ function routeAddressedItems(addressedItems, stages, opts) {
145
+ const sourceBases = [...new Set(addressedItems.map(i => baseStage(i.source)))];
146
+ const chain = ['quench', 'appraise', 'human-appraise'];
147
+ for (const base of chain) {
148
+ if (sourceBases.includes(base) && hasStage(stages, base)) {
149
+ return first(base, stages, opts.cycle);
150
+ }
83
151
  }
84
152
  return null;
85
153
  }
86
154
 
87
- function routeAlwaysHumanAppraise(stages, current, openItems, cycle) {
88
- if (openItems.length > 0) {
89
- return firstHumanOrSuffixed(stages, cycle);
90
- }
91
- return nextRouteOrDone(stages, current);
92
- }
93
-
94
- function decideAppraiseRoute(opts) {
95
- const {
96
- stages, current, openItems, forgeCount, maxIterations,
97
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
98
- } = opts;
99
- const decided = appraiseForgeOrApproval(
100
- stages, openItems, forgeCount, maxIterations,
101
- { alwaysHumanAppraise, deadlockHumanAppraise, cycle },
102
- );
103
- if (decided !== null) return decided;
104
- return nextRouteOrDone(stages, current);
105
- }
106
-
107
- export function nextAfterAppraise({
108
- stages, current, feedback, forgeCount, maxIterations,
109
- alwaysHumanAppraise = false, deadlockHumanAppraise = false, cycle = 'default',
110
- }) {
111
- const openItems = feedback.filter(isOpenItem);
112
- if (alwaysHumanAppraise) {
113
- return routeAlwaysHumanAppraise(stages, current, openItems, cycle);
114
- }
115
- return decideAppraiseRoute({
116
- stages, current, openItems, forgeCount, maxIterations,
117
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
118
- });
155
+ /**
156
+ * Routes addressed items to their earliest source stage, falling through
157
+ * to forwardClean when no source matches or no addressed items exist.
158
+ */
159
+ function routeAddressedOrForward(addressedItems, stages, lastEntry, opts) {
160
+ const routed = routeAddressedItems(addressedItems, stages, opts);
161
+ if (routed !== null) return routed;
162
+ return forwardClean(stages, lastEntry, opts);
119
163
  }
120
164
 
121
- export function nextAfterQuench(stages, current, feedback, opts = {}) {
122
- const { forgeCount = 0, maxIterations = 100 } = opts;
123
- const openItems = feedback.filter(isOpenItem);
124
- const needsForge = hasItemsNeedingForge(openItems);
125
- if (needsForge) return routeForgeIfNeeded(stages, forgeCount, maxIterations, opts);
126
- return nextRouteOrDone(stages, current);
165
+ /**
166
+ * Handles the R7 case when the last non-sort history entry is forge.
167
+ * If unresolved items exist, delegates to checkIterationAndRoute.
168
+ * If clean, routes via firstAfter to the first evaluation stage,
169
+ * or 'done' if no stage follows forge.
170
+ */
171
+ function handleForgeJustRan(stages, unresolvedItems, forgeCount, maxIterations, opts) {
172
+ if (unresolvedItems.length > 0) {
173
+ return checkIterationAndRoute(first, stages, forgeCount, maxIterations, opts);
174
+ }
175
+ const next = firstAfter(stages, 'forge');
176
+ return next !== null ? next : 'done';
127
177
  }
128
178
 
129
- function lastNonSortStage(history) {
130
- const nonSort = history.filter(e => baseStage(e.stage || '') !== 'sort');
131
- if (nonSort.length === 0) return null;
132
- return nonSort[nonSort.length - 1].stage;
133
- }
134
-
135
- function buildRouteHandlers({
136
- stages, lastEntry, feedback, forgeCount, maxIterations,
137
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
138
- }) {
139
- const appraiseRoute = () => nextAfterAppraise({
140
- stages, current: lastEntry, feedback, forgeCount, maxIterations,
141
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
142
- });
143
- return {
144
- 'assay': () => firstForgeOrBlocked(stages),
145
- 'forge': () => nextRouteOrDone(stages, lastEntry),
146
- 'quench': () => nextAfterQuench(
147
- stages, lastEntry, feedback,
148
- { forgeCount, maxIterations, alwaysHumanAppraise, deadlockHumanAppraise, cycle },
149
- ),
150
- 'appraise': appraiseRoute,
151
- 'human-appraise': appraiseRoute,
152
- };
153
- }
154
-
155
- function countForgeIterations(history) {
156
- return history.filter(e => baseStage(e.stage || '') === 'forge').length;
157
- }
158
-
159
- function routeFromLastEntry(opts) {
160
- const {
161
- stages, lastEntry, feedback, forgeCount, maxIterations,
162
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
163
- } = opts;
164
- if (lastEntry === null) return stages[0];
165
- const handlers = buildRouteHandlers({
166
- stages, lastEntry, feedback, forgeCount, maxIterations,
167
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
168
- });
169
- const handler = handlers[baseStage(lastEntry)];
170
- return callHandlerOrBlocked(handler);
179
+ /**
180
+ * Handles R1 (unresolved, addressed) and R2 (forward) routing paths.
181
+ * Extracted to keep determineRoute within the complexity limit.
182
+ */
183
+ function routeFeedbackState(state, stages, maxIterations, opts) {
184
+ if (state.unresolvedItems.length > 0) {
185
+ return checkIterationAndRoute(first, stages, state.forgeCount, maxIterations, opts);
186
+ }
187
+ if (state.addressedItems.length > 0) {
188
+ return routeAddressedOrForward(state.addressedItems, stages, state.lastEntry, opts);
189
+ }
190
+ return forwardClean(stages, state.lastEntry, opts);
171
191
  }
172
192
 
193
+ // ---------------------------------------------------------------------------
194
+ // determineRoute — state-driven decision tree
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Determines the next route given the current cycle state.
199
+ *
200
+ * The decision tree reads feedback item state, history, and the stage
201
+ * list to route to forge, an evaluation stage, human-appraise, 'done',
202
+ * or 'blocked'. It never calls computeArtefactVersion and does not
203
+ * read item.artefact_version.
204
+ *
205
+ * @param {string[]} stages - Configured stage aliases in chain order
206
+ * @param {object[]} history - Cycle history entries
207
+ * @param {object[]} feedback - Feedback items with state, source, etc.
208
+ * @param {number} maxIterations - Maximum forge iterations
209
+ * @param {object} [opts] - Options object
210
+ * @param {boolean} [opts.alwaysHumanAppraise=false]
211
+ * @param {boolean} [opts.deadlockHumanAppraise=false]
212
+ * @param {string} [opts.cycle='default']
213
+ * @returns {string} Next route: stage alias, 'done', or 'blocked'
214
+ */
173
215
  export function determineRoute(stages, history, feedback, maxIterations, opts = {}) {
174
- const { alwaysHumanAppraise = false, deadlockHumanAppraise = false, cycle = 'default' } = opts;
175
- const forgeCount = countForgeIterations(history);
176
- const lastEntry = lastNonSortStage(history);
177
- return routeFromLastEntry({
178
- stages, lastEntry, feedback, forgeCount, maxIterations,
179
- alwaysHumanAppraise, deadlockHumanAppraise, cycle,
180
- });
216
+ const validation = validateRoute(maxIterations, stages, history);
217
+ if (validation !== null) return validation;
218
+
219
+ const state = computeRoutingState(history, feedback);
220
+
221
+ if (state.lastStage === null) return stages[0];
222
+ if (state.lastStage === 'forge') return handleForgeJustRan(stages, state.unresolvedItems, state.forgeCount, maxIterations, opts);
223
+
224
+ return routeFeedbackState(state, stages, maxIterations, opts);
181
225
  }
@@ -121,3 +121,31 @@ export function createWorkfile(frontmatter, goal) {
121
121
  ${goal}
122
122
  `;
123
123
  }
124
+
125
+ /**
126
+ * Build a forge history entry options object for appendEntry.
127
+ *
128
+ * The returned object includes changedFiles (camelCase), artefact_version,
129
+ * and contract_passed. appendEntry/buildEntry handle sorting changedFiles,
130
+ * converting to changed_files in YAML, and setting timestamp/seq.
131
+ *
132
+ * @param {{ cycle: string, stage: string, iteration: number, comment: string,
133
+ * artefactVersion: string, contractPassed: boolean,
134
+ * changedFiles: string[] }} params
135
+ * @returns {{ cycle: string, stage: string, iteration: number,
136
+ * comment: string, changedFiles: string[], artefact_version: string,
137
+ * contract_passed: boolean }}
138
+ */
139
+ export function buildForgeHistoryEntry(
140
+ { cycle, stage, iteration, comment, artefactVersion, contractPassed, changedFiles },
141
+ ) {
142
+ return {
143
+ cycle,
144
+ stage,
145
+ iteration,
146
+ comment,
147
+ changedFiles,
148
+ artefact_version: artefactVersion,
149
+ contract_passed: contractPassed,
150
+ };
151
+ }
@@ -6,11 +6,12 @@ import {
6
6
  getCycleDefinition,
7
7
  getLawsForQuench,
8
8
  } from './lib/config.js';
9
- import { parseFrontmatter, writeFrontmatter } from './lib/workfile.js';
9
+ import { parseFrontmatter, writeFrontmatter, buildForgeHistoryEntry } from './lib/workfile.js';
10
10
  import matter from 'gray-matter';
11
11
  import { clearActiveStage, clearLastStage } from './lib/state.js';
12
12
  import { appendEntry, getIteration } from './lib/history.js';
13
13
  import { stageBaseOf } from './lib/stage-guard.js';
14
+ import { baseStage } from './lib/sort-routing.js';
14
15
  import { allowedPatternsForStage } from './lib/git-policy.js';
15
16
  import { loadExtractor } from './lib/assay/loader.js';
16
17
  import { checkExtractorAgainstCycle } from './lib/assay/permissions.js';
@@ -66,13 +67,9 @@ function isTerminalRoute(route) {
66
67
  return route === 'done' || route === 'blocked' || route === 'violation';
67
68
  }
68
69
 
69
- function getRouteBase(route) {
70
- return routeDispatch(route);
71
- }
72
-
73
70
  export async function handleSortResult(sortResult, ctx) {
74
71
  const { route, model, token, reason } = sortResult;
75
- const routeBase = getRouteBase(route);
72
+ const routeBase = routeDispatch(route);
76
73
  const result = await resolveRouteResult({ route, routeBase, model, token, ctx });
77
74
  if (reason !== undefined) result.reason = reason;
78
75
  return result;
@@ -85,10 +82,6 @@ async function resolveRouteResult({ route, routeBase, model, token, ctx }) {
85
82
  return buildDispatchAction(route, model, token, ctx);
86
83
  }
87
84
 
88
- async function fetchCycleDefinition(foundryDir, cycleId, io) {
89
- try { return await getCycleDefinition(foundryDir, cycleId, io); } catch { return null; }
90
- }
91
-
92
85
  function checkOutputType(cfm, cycleId) {
93
86
  const outputType = cfm['output-type'];
94
87
  if (outputType) return { outputType };
@@ -103,7 +96,6 @@ async function checkArtefactType(foundryDir, outputType, io) {
103
96
  catch { return { error: violation(`artefact type not found: ${outputType}`, ['WORK.md']) }; }
104
97
  }
105
98
 
106
- function isAssayAbsent(assayBlock) { return assayBlock === undefined || assayBlock === null; }
107
99
  function isAssayInvalid(assayBlock) { return typeof assayBlock !== 'object' || Array.isArray(assayBlock); }
108
100
 
109
101
  function checkAssayExtractors(list, cycleId) {
@@ -115,7 +107,7 @@ function checkAssayExtractors(list, cycleId) {
115
107
 
116
108
  function checkAssayShape(cfm, cycleId) {
117
109
  const assayBlock = cfm.assay;
118
- if (isAssayAbsent(assayBlock)) return { ok: true, extractors: null };
110
+ if (assayBlock === undefined || assayBlock === null) return { ok: true, extractors: null };
119
111
  if (isAssayInvalid(assayBlock)) {
120
112
  return { error: violation(`cycle ${cycleId}: 'assay' must be a mapping`, ['WORK.md']) };
121
113
  }
@@ -124,13 +116,6 @@ function checkAssayShape(cfm, cycleId) {
124
116
  return { ok: true, extractors: assayBlock.extractors };
125
117
  }
126
118
 
127
- function checkMemoryEnabled(io, cycleId) {
128
- if (!io.exists('foundry/memory/config.md')) {
129
- return violation(`cycle ${cycleId}: 'assay:' requires memory to be enabled (run the init-memory skill first)`, ['WORK.md']);
130
- }
131
- return null;
132
- }
133
-
134
119
  function checkCycleWriteDecl(cfm, cycleId) {
135
120
  const cycleWrite = cfm.memory?.write;
136
121
  if (!Array.isArray(cycleWrite)) {
@@ -150,10 +135,6 @@ async function checkExtractors(foundryDir, cycleId, list, cycleWriteSet, io) {
150
135
  return null;
151
136
  }
152
137
 
153
- function stageTag(s, cycleId) {
154
- return typeof s === 'string' && s.includes(':') ? s : `${s}:${cycleId}`;
155
- }
156
-
157
138
  function resolveStages(cfm, cycleId, hasValidation, assayExtractors) {
158
139
  if (!Array.isArray(cfm.stages)) {
159
140
  return synthesizeStages({ cycleId, hasValidation, alwaysHumanAppraise: cfm['always-human-appraise'] === true, assay: !!assayExtractors });
@@ -161,14 +142,14 @@ function resolveStages(cfm, cycleId, hasValidation, assayExtractors) {
161
142
  if (cfm.stages.length === 0) {
162
143
  return { error: violation(`cycle ${cycleId} has no stages declared in cycle definition`, ['WORK.md']) };
163
144
  }
164
- return cfm.stages.map(s => stageTag(s, cycleId));
145
+ return cfm.stages.map(s => typeof s === 'string' && s.includes(':') ? s : `${s}:${cycleId}`);
165
146
  }
166
147
 
167
- function applyFmDefaults(newFm, cfm, assayExtractors) {
148
+ export function applyFmDefaults(newFm, cfm, assayExtractors) {
168
149
  const maxIt = cfm['max-iterations'] ?? 3;
169
150
  newFm['max-iterations'] = maxIt;
170
151
  newFm['always-human-appraise'] = cfm['always-human-appraise'] === true;
171
- newFm['deadlock-human-appraise'] = cfm['deadlock-human-appraise'] !== false;
152
+ newFm['deadlock-human-appraise'] = cfm['deadlock-human-appraise'] === true;
172
153
  if (cfm.models) newFm.models = cfm.models;
173
154
  if (assayExtractors) newFm.assay = { extractors: assayExtractors };
174
155
  }
@@ -184,8 +165,9 @@ function buildNewFrontmatter(workContent, stages, cfm, assayExtractors) {
184
165
  }
185
166
 
186
167
  async function checkAssayPrereqs(cfm, cycleId, io) {
187
- const memErr = checkMemoryEnabled(io, cycleId);
188
- if (memErr) return memErr;
168
+ if (!io.exists('foundry/memory/config.md')) {
169
+ return violation(`cycle ${cycleId}: 'assay:' requires memory to be enabled (run the init-memory skill first)`, ['WORK.md']);
170
+ }
189
171
  return checkCycleWriteDecl(cfm, cycleId);
190
172
  }
191
173
 
@@ -203,7 +185,7 @@ async function runAssayValidation(cfm, cycleId, io, foundryDir) {
203
185
 
204
186
  export async function setupWorkfile(args) {
205
187
  const { cycleId, workContent, io, git, foundryDir } = args;
206
- const cycleDefDoc = await fetchCycleDefinition(foundryDir, cycleId, io);
188
+ const cycleDefDoc = await getCycleDefinition(foundryDir, cycleId, io).catch(() => null);
207
189
  if (!cycleDefDoc) return violation(`cycle definition not found for id: ${cycleId}`, ['WORK.md']);
208
190
  const cfm = cycleDefDoc.frontmatter || {};
209
191
  return runSetupPipeline({ cfm, cycleId, workContent, io, git, foundryDir });
@@ -238,10 +220,6 @@ async function trySetupCommit(ctx) {
238
220
  return { ok: true, workContent: ctx.io.readFile('WORK.md') };
239
221
  }
240
222
 
241
- function readOriginalState(io) {
242
- return { workMd: io.readFile('WORK.md'), history: io.exists('WORK.history.yaml') ? io.readFile('WORK.history.yaml') : null };
243
- }
244
-
245
223
  function buildFinalizeViolation(finalizeResult) {
246
224
  if (finalizeResult.error === 'unexpected_files') {
247
225
  return violation(`unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}`, finalizeResult.files || []);
@@ -249,9 +227,32 @@ function buildFinalizeViolation(finalizeResult) {
249
227
  return violation(`stage_finalize error: ${finalizeResult.error}`, []);
250
228
  }
251
229
 
230
+ function buildStageEntryBase(ctx) {
231
+ const summary = ctx.lastStage.summary || '(no summary)';
232
+ const changed = ctx.lastStage.changedFiles ?? [];
233
+ return { cycle: ctx.cycleId, stage: ctx.lastStage.stage,
234
+ iteration: ctx.iteration, comment: summary,
235
+ openFeedback: ctx.openFeedback, changedFiles: changed,
236
+ ...(baseStage(ctx.lastStage.stage || '') === 'forge'
237
+ ? buildForgeHistoryEntry({
238
+ cycle: ctx.cycleId, stage: ctx.lastStage.stage,
239
+ iteration: ctx.iteration, comment: summary,
240
+ artefactVersion: ctx.artefactVersion,
241
+ contractPassed: ctx.contractPassed,
242
+ changedFiles: changed,
243
+ })
244
+ : {}),
245
+ };
246
+ }
247
+
252
248
  function writeHistoryEntries(ctx) {
253
- appendEntry(ctx.historyPath, { cycle: ctx.cycleId, stage: 'sort', iteration: ctx.iteration, route: ctx.lastStage.stage, comment: `route ${ctx.lastStage.stage}`, openFeedback: ctx.openFeedback }, ctx.io);
254
- appendEntry(ctx.historyPath, { cycle: ctx.cycleId, stage: ctx.lastStage.stage, iteration: ctx.iteration, comment: ctx.lastStage.summary || '(no summary)', openFeedback: ctx.openFeedback, changedFiles: ctx.lastStage.changedFiles ?? [] }, ctx.io);
249
+ appendEntry(ctx.historyPath, {
250
+ cycle: ctx.cycleId, stage: 'sort', iteration: ctx.iteration,
251
+ route: ctx.lastStage.stage,
252
+ comment: `route ${ctx.lastStage.stage}`,
253
+ openFeedback: ctx.openFeedback,
254
+ }, ctx.io);
255
+ appendEntry(ctx.historyPath, buildStageEntryBase(ctx), ctx.io);
255
256
  }
256
257
 
257
258
  async function computeAllowedPatterns(lastStage, cycleId, io) {
@@ -286,12 +287,18 @@ function clearStageState(activeStage, lastStage, io) {
286
287
  }
287
288
 
288
289
  export async function finaliseStage(args) {
289
- const { lastStage, activeStage, cycleId, io, finalize, git } = args;
290
- const original = readOriginalState(io);
290
+ const { lastStage, activeStage, cycleId, io, finalize, git, postVersion, contractPassed } = args;
291
+ const original = {
292
+ workMd: io.readFile('WORK.md'),
293
+ history: io.exists('WORK.history.yaml') ? io.readFile('WORK.history.yaml') : null,
294
+ };
291
295
  if (typeof finalize !== 'function') {
292
296
  return violation('orchestrate caller must inject a `finalize` function when providing lastResult; the plugin wires lib/finalize.finalizeStage; tests must pass a stub.', []);
293
297
  }
294
- const finalizeResult = await finalize({ cycleId, stage: lastStage.stage, baseSha: lastStage.baseSha, io });
298
+ const finalizeResult = await finalize({
299
+ cycleId, stage: lastStage.stage, baseSha: lastStage.baseSha, io,
300
+ artefact_version: postVersion, contractPassed,
301
+ });
295
302
  if (!finalizeResult.ok) {
296
303
  clearStageState(activeStage, null, io);
297
304
  return buildFinalizeViolation(finalizeResult);
@@ -299,7 +306,10 @@ export async function finaliseStage(args) {
299
306
  const historyPath = 'WORK.history.yaml';
300
307
  const iteration = getIteration(historyPath, cycleId, io);
301
308
  const openFeedback = computeOpenFeedback(io);
302
- writeHistoryEntries({ historyPath, cycleId, lastStage, iteration, openFeedback, io });
309
+ writeHistoryEntries({
310
+ historyPath, cycleId, lastStage, iteration, openFeedback, io,
311
+ artefactVersion: postVersion, contractPassed,
312
+ });
303
313
  const commitErr = await tryStageCommit(git, lastStage, cycleId, io);
304
314
  if (commitErr) {
305
315
  rollbackState(io, original);
@@ -1,6 +1,8 @@
1
1
  import { getCycleDefinition } from './lib/config.js';
2
- import { getArtefactFiles } from './lib/artefacts.js';
2
+ import { getArtefactFiles, computeArtefactVersion } from './lib/artefacts.js';
3
3
  import { readCycleTargets, readRecentFeedback, violation } from './orchestrate-cycle.js';
4
+ import { openFeedbackStore } from './lib/feedback-store.js';
5
+ import { baseStage } from './lib/sort-routing.js';
4
6
 
5
7
  async function findOutputArtefacts(cfm, io, foundryDir, baseBranch) {
6
8
  const outputType = cfm ? cfm['output-type'] : undefined;
@@ -29,15 +31,48 @@ export async function blockedAction(cycleId, io, details, foundryDir, baseBranch
29
31
  }
30
32
 
31
33
  export async function humanAppraiseAction(route, token, ctx) {
32
- const { cycleId, io, baseBranch } = ctx;
34
+ const { cycleId, io, baseBranch, cwd } = ctx;
33
35
  const fd = ctx.foundryDir || 'foundry';
34
36
  const base = baseBranch || 'main';
35
37
  const cfm = (await getCycleDefinition(fd, cycleId, io)).frontmatter;
38
+
39
+ try {
40
+ await resolveStaleHumanAppraiseFeedback(cfm, fd, io, cycleId, cwd);
41
+ } catch (err) {
42
+ return { action: 'violation', details: `version check failed: ${err.message}`, recoverable: false, affected_files: [] };
43
+ }
44
+
36
45
  const artefact = await findOutputArtefacts(cfm, io, fd, base);
37
46
  const artefactFile = artefact ? artefact.file : null;
38
47
  return { action: 'human_appraise', stage: route, token, context: { cycle: cycleId, artefact_file: artefactFile, recent_feedback: readRecentFeedback(io) } };
39
48
  }
40
49
 
50
+ /**
51
+ * Resolve stale human-appraise feedback. Errors propagate to the caller
52
+ * (humanAppraiseAction) which surfaces them as a violation.
53
+ */
54
+ async function resolveStaleHumanAppraiseFeedback(cfm, fd, io, cycleId, cwd) {
55
+ const outputType = cfm['output-type'];
56
+ if (!outputType) return;
57
+ const store = openFeedbackStore('WORK.feedback.yaml', io);
58
+ const currentVersion = await computeArtefactVersion(fd, outputType, io, cwd);
59
+ for (const item of store.list()) {
60
+ if (shouldSkipHumanAppraiseResolve(item, currentVersion)) continue;
61
+ store.autoResolve({
62
+ id: item.id,
63
+ reason: `superseded by forge revision ${currentVersion}`,
64
+ cycle: cycleId,
65
+ });
66
+ }
67
+ }
68
+
69
+ function shouldSkipHumanAppraiseResolve(item, currentVersion) {
70
+ if (item.history[0].state === 'resolved') return true;
71
+ if (baseStage(item.source) !== 'human-appraise') return true;
72
+ if (item.artefact_version === currentVersion) return true;
73
+ return false;
74
+ }
75
+
41
76
  export async function missingModelViolation(cycleId, route, io, foundryDir, baseBranch) {
42
77
  const fd = foundryDir || 'foundry';
43
78
  const base = baseBranch || 'main';