@really-knows-ai/foundry 3.5.8 → 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.
Files changed (34) hide show
  1. package/README.md +16 -10
  2. package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +2 -3
  3. package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +9 -5
  4. package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +3 -1
  5. package/dist/CHANGELOG.md +38 -0
  6. package/dist/README.md +16 -10
  7. package/dist/docs/README.md +6 -6
  8. package/dist/docs/architecture.md +59 -19
  9. package/dist/docs/concepts.md +55 -19
  10. package/dist/docs/getting-started.md +37 -15
  11. package/dist/docs/memory-maintenance.md +3 -3
  12. package/dist/docs/tools.md +131 -70
  13. package/dist/docs/work-spec.md +38 -52
  14. package/dist/scripts/appraise-module.js +69 -7
  15. package/dist/scripts/lib/artefacts.js +43 -1
  16. package/dist/scripts/lib/config-creators/cycle.js +6 -10
  17. package/dist/scripts/lib/config-validators/cycle.js +1 -9
  18. package/dist/scripts/lib/feedback-store.js +26 -51
  19. package/dist/scripts/lib/finalize.js +10 -2
  20. package/dist/scripts/lib/forge-contract.js +93 -0
  21. package/dist/scripts/lib/history.js +2 -1
  22. package/dist/scripts/lib/sort-reason.js +11 -8
  23. package/dist/scripts/lib/sort-routing.js +185 -63
  24. package/dist/scripts/lib/workfile.js +28 -0
  25. package/dist/scripts/orchestrate-cycle.js +3 -13
  26. package/dist/scripts/orchestrate-phases.js +51 -45
  27. package/dist/scripts/orchestrate-terminals.js +37 -2
  28. package/dist/scripts/orchestrate.js +62 -5
  29. package/dist/scripts/quench-module.js +54 -12
  30. package/dist/scripts/sort.js +42 -62
  31. package/dist/skills/add-cycle/SKILL.md +4 -4
  32. package/dist/skills/add-flow/SKILL.md +1 -1
  33. package/dist/skills/human-appraise/SKILL.md +12 -40
  34. package/package.json +1 -1
@@ -78,7 +78,7 @@ function validateAppendArgs({ iteration, comment, route, stage }) {
78
78
  }
79
79
  }
80
80
 
81
- function buildEntry({ cycle, stage, iteration, comment, route, openFeedback, changedFiles }, seq) {
81
+ function buildEntry({ cycle, stage, iteration, comment, route, openFeedback, changedFiles, ...rest }, seq) {
82
82
  const entry = {
83
83
  cycle,
84
84
  stage,
@@ -87,6 +87,7 @@ function buildEntry({ cycle, stage, iteration, comment, route, openFeedback, cha
87
87
  timestamp: new Date().toISOString(),
88
88
  seq,
89
89
  open_feedback: openFeedback ?? 0,
90
+ ...rest,
90
91
  };
91
92
  if (route !== undefined) entry.route = route;
92
93
  if (changedFiles !== undefined) {
@@ -18,18 +18,19 @@ export function reasonForRoute(route, prep) {
18
18
 
19
19
  function buildReasonData(route, prep) {
20
20
  const base = baseStage(route);
21
- const forgeCount = prep.history.filter(e => baseStage(e.stage || '') === 'forge').length;
21
+ const forgeCount = prep.history.filter(e =>
22
+ baseStage(e.stage || '') === 'forge' && e.contract_passed !== false,
23
+ ).length;
22
24
  const maxIt = prep.defaults.maxIterations;
23
25
  const feedback = prep.feedback || [];
24
- const openCount = feedback.filter(
25
- f => f.state !== 'resolved' && f.state !== 'deadlocked',
26
- ).length;
27
- const dlCount = feedback.filter(f => f.state === 'deadlocked').length;
26
+ const openCount = feedback.filter(f => f.state !== 'resolved').length;
28
27
  const needingForge = feedback.filter(
29
28
  f => f.state === 'open' || f.state === 'rejected',
30
29
  ).length;
30
+ const alwaysHumanAppraise = prep.defaults.alwaysHumanAppraise;
31
+ const deadlockHumanAppraise = prep.defaults.deadlockHumanAppraise;
31
32
 
32
- return { base, route, forgeCount, maxIt, openCount, dlCount, needingForge, anyDeadlocked: prep.anyDeadlocked };
33
+ return { base, route, forgeCount, maxIt, openCount, needingForge, alwaysHumanAppraise, deadlockHumanAppraise };
33
34
  }
34
35
 
35
36
  function forgeReason(d) {
@@ -38,12 +39,14 @@ function forgeReason(d) {
38
39
  }
39
40
 
40
41
  function appraiseReason(d) {
41
- if (d.anyDeadlocked) return `${d.dlCount} feedback item(s) deadlocked — routing to appraise for re-evaluation`;
42
42
  return `quench passed with ${d.openCount} open feedback item(s) — routing to appraise`;
43
43
  }
44
44
 
45
45
  function humanAppraiseReason(d) {
46
- return `${d.dlCount} feedback item(s) deadlocked after ${d.forgeCount} forge iteration(s) — routing to human for override`;
46
+ if (d.alwaysHumanAppraise) {
47
+ return `always-human-appraise enabled — routing to human after ${d.forgeCount} forge iteration(s)`;
48
+ }
49
+ return `max iterations (${d.maxIt}) reached after ${d.forgeCount} forge iteration(s) — routing to human for review`;
47
50
  }
48
51
 
49
52
  function blockedReason(d) {
@@ -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
- // Spec §6.1: an item is "open" (still in flight) when its head state is
10
- // 'open', 'actioned', 'rejected', or 'wont-fix' — equivalently, when the
11
- // state is neither 'resolved' nor 'deadlocked'.
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,72 +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 routeForgeIfNeeded(stages, forgeCount, maxIterations) {
44
- if (forgeCount >= maxIterations) return 'blocked';
45
- return findFirst(stages, 'forge') ?? '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 appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations) {
49
- if (hasItemsNeedingForge(openItems)) {
50
- return routeForgeIfNeeded(stages, forgeCount, maxIterations);
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';
51
81
  }
52
- if (hasItemsPendingApproval(openItems)) {
53
- return findFirst(stages, 'appraise') ?? 'blocked';
82
+ return next;
83
+ }
84
+
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;
95
+ }
96
+
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;
102
+ }
103
+
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 };
119
+ }
120
+
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';
133
+ }
134
+ if (!hasStage(stages, 'forge')) return 'blocked';
135
+ return firstFn('forge', stages);
136
+ }
137
+
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
+ }
54
151
  }
55
152
  return null;
56
153
  }
57
154
 
58
- export function nextAfterAppraise({ stages, current, feedback, forgeCount, maxIterations }) {
59
- // Note: deadlock detection is handled by runDeadlockPass at the top of
60
- // runSort (spec §6.1). This helper assumes routing has already been allowed
61
- // to fall through (i.e., no item qualifies as deadlocked).
62
- const openItems = feedback.filter(isOpenItem);
63
- const decided = appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations);
64
- if (decided !== null) return decided;
65
- return nextInRoute(stages, current) ?? 'done';
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);
66
163
  }
67
164
 
68
- export function nextAfterQuench(stages, current, feedback, forgeCount, maxIterations) {
69
- const openItems = feedback.filter(isOpenItem);
70
- const needsForge = openItems.some(f => f.state === 'open' || f.state === 'rejected');
71
- if (needsForge) return routeForgeIfNeeded(stages, forgeCount, maxIterations);
72
- return nextInRoute(stages, current) ?? 'done';
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';
73
177
  }
74
178
 
75
- function lastNonSortStage(history) {
76
- const nonSort = history.filter(e => baseStage(e.stage || '') !== 'sort');
77
- if (nonSort.length === 0) return null;
78
- return nonSort[nonSort.length - 1].stage;
79
- }
80
-
81
- function buildRouteHandlers({ stages, lastEntry, feedback, forgeCount, maxIterations }) {
82
- const appraiseRoute = () => nextAfterAppraise({
83
- stages, current: lastEntry, feedback, forgeCount, maxIterations,
84
- });
85
- return {
86
- 'assay': () => findFirst(stages, 'forge') ?? 'blocked',
87
- 'forge': () => nextInRoute(stages, lastEntry) ?? 'done',
88
- 'quench': () => nextAfterQuench(stages, lastEntry, feedback, forgeCount, maxIterations),
89
- 'appraise': appraiseRoute,
90
- 'human-appraise': appraiseRoute,
91
- };
92
- }
93
-
94
- export function determineRoute(stages, history, feedback, maxIterations) {
95
- const forgeCount = history.filter(e => baseStage(e.stage || '') === 'forge').length;
96
- const lastEntry = lastNonSortStage(history);
97
- if (lastEntry === null) return stages[0];
98
- const handlers = buildRouteHandlers({
99
- stages, lastEntry, feedback, forgeCount, maxIterations,
100
- });
101
- const handler = handlers[baseStage(lastEntry)];
102
- return handler ? handler() : 'blocked';
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);
191
+ }
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
+ */
215
+ export function determineRoute(stages, history, feedback, maxIterations, opts = {}) {
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);
103
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
+ }
@@ -150,13 +150,13 @@ export function tryCommit(git, message, allowedPatterns, phase) {
150
150
  // Stage synthesis (pure utility, used by setupWorkfile and exported publicly).
151
151
  // ---------------------------------------------------------------------------
152
152
 
153
- export function synthesizeStages({ cycleId, hasValidation, humanAppraise, assay = false }) {
153
+ export function synthesizeStages({ cycleId, hasValidation, alwaysHumanAppraise, assay = false }) {
154
154
  const stages = [];
155
155
  if (assay) stages.push(`assay:${cycleId}`);
156
156
  stages.push(`forge:${cycleId}`);
157
157
  if (hasValidation) stages.push(`quench:${cycleId}`);
158
158
  stages.push(`appraise:${cycleId}`);
159
- if (humanAppraise) stages.push(`human-appraise:${cycleId}`);
159
+ if (alwaysHumanAppraise) stages.push(`human-appraise:${cycleId}`);
160
160
  return stages;
161
161
  }
162
162
 
@@ -273,14 +273,4 @@ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, o
273
273
  return lines.join('\n');
274
274
  }
275
275
 
276
- export function checkIterationLimits(cfm, cycleId) {
277
- const maxIt = cfm['max-iterations'];
278
- const dlIt = cfm['deadlock-iterations'];
279
- if (maxIt !== undefined && dlIt !== undefined && dlIt > maxIt) {
280
- return violation(
281
- `cycle ${cycleId}: deadlock-iterations (${dlIt}) cannot exceed max-iterations (${maxIt})`,
282
- ['WORK.md'],
283
- );
284
- }
285
- return null;
286
- }
276
+
@@ -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';
@@ -21,7 +22,6 @@ import {
21
22
  tryCommit,
22
23
  synthesizeStages,
23
24
  renderDispatchPrompt,
24
- checkIterationLimits,
25
25
  } from './orchestrate-cycle.js';
26
26
  import {
27
27
  doneAction,
@@ -67,13 +67,9 @@ function isTerminalRoute(route) {
67
67
  return route === 'done' || route === 'blocked' || route === 'violation';
68
68
  }
69
69
 
70
- function getRouteBase(route) {
71
- return routeDispatch(route);
72
- }
73
-
74
70
  export async function handleSortResult(sortResult, ctx) {
75
71
  const { route, model, token, reason } = sortResult;
76
- const routeBase = getRouteBase(route);
72
+ const routeBase = routeDispatch(route);
77
73
  const result = await resolveRouteResult({ route, routeBase, model, token, ctx });
78
74
  if (reason !== undefined) result.reason = reason;
79
75
  return result;
@@ -86,10 +82,6 @@ async function resolveRouteResult({ route, routeBase, model, token, ctx }) {
86
82
  return buildDispatchAction(route, model, token, ctx);
87
83
  }
88
84
 
89
- async function fetchCycleDefinition(foundryDir, cycleId, io) {
90
- try { return await getCycleDefinition(foundryDir, cycleId, io); } catch { return null; }
91
- }
92
-
93
85
  function checkOutputType(cfm, cycleId) {
94
86
  const outputType = cfm['output-type'];
95
87
  if (outputType) return { outputType };
@@ -104,7 +96,6 @@ async function checkArtefactType(foundryDir, outputType, io) {
104
96
  catch { return { error: violation(`artefact type not found: ${outputType}`, ['WORK.md']) }; }
105
97
  }
106
98
 
107
- function isAssayAbsent(assayBlock) { return assayBlock === undefined || assayBlock === null; }
108
99
  function isAssayInvalid(assayBlock) { return typeof assayBlock !== 'object' || Array.isArray(assayBlock); }
109
100
 
110
101
  function checkAssayExtractors(list, cycleId) {
@@ -116,7 +107,7 @@ function checkAssayExtractors(list, cycleId) {
116
107
 
117
108
  function checkAssayShape(cfm, cycleId) {
118
109
  const assayBlock = cfm.assay;
119
- if (isAssayAbsent(assayBlock)) return { ok: true, extractors: null };
110
+ if (assayBlock === undefined || assayBlock === null) return { ok: true, extractors: null };
120
111
  if (isAssayInvalid(assayBlock)) {
121
112
  return { error: violation(`cycle ${cycleId}: 'assay' must be a mapping`, ['WORK.md']) };
122
113
  }
@@ -125,13 +116,6 @@ function checkAssayShape(cfm, cycleId) {
125
116
  return { ok: true, extractors: assayBlock.extractors };
126
117
  }
127
118
 
128
- function checkMemoryEnabled(io, cycleId) {
129
- if (!io.exists('foundry/memory/config.md')) {
130
- return violation(`cycle ${cycleId}: 'assay:' requires memory to be enabled (run the init-memory skill first)`, ['WORK.md']);
131
- }
132
- return null;
133
- }
134
-
135
119
  function checkCycleWriteDecl(cfm, cycleId) {
136
120
  const cycleWrite = cfm.memory?.write;
137
121
  if (!Array.isArray(cycleWrite)) {
@@ -151,26 +135,21 @@ async function checkExtractors(foundryDir, cycleId, list, cycleWriteSet, io) {
151
135
  return null;
152
136
  }
153
137
 
154
- function stageTag(s, cycleId) {
155
- return typeof s === 'string' && s.includes(':') ? s : `${s}:${cycleId}`;
156
- }
157
-
158
138
  function resolveStages(cfm, cycleId, hasValidation, assayExtractors) {
159
139
  if (!Array.isArray(cfm.stages)) {
160
- return synthesizeStages({ cycleId, hasValidation, humanAppraise: cfm['human-appraise'] === true, assay: !!assayExtractors });
140
+ return synthesizeStages({ cycleId, hasValidation, alwaysHumanAppraise: cfm['always-human-appraise'] === true, assay: !!assayExtractors });
161
141
  }
162
142
  if (cfm.stages.length === 0) {
163
143
  return { error: violation(`cycle ${cycleId} has no stages declared in cycle definition`, ['WORK.md']) };
164
144
  }
165
- return cfm.stages.map(s => stageTag(s, cycleId));
145
+ return cfm.stages.map(s => typeof s === 'string' && s.includes(':') ? s : `${s}:${cycleId}`);
166
146
  }
167
147
 
168
- function applyFmDefaults(newFm, cfm, assayExtractors) {
148
+ export function applyFmDefaults(newFm, cfm, assayExtractors) {
169
149
  const maxIt = cfm['max-iterations'] ?? 3;
170
150
  newFm['max-iterations'] = maxIt;
171
- newFm['human-appraise'] = cfm['human-appraise'] === true;
172
- newFm['deadlock-appraise'] = cfm['deadlock-appraise'] !== false;
173
- newFm['deadlock-iterations'] = cfm['deadlock-iterations'] ?? maxIt;
151
+ newFm['always-human-appraise'] = cfm['always-human-appraise'] === true;
152
+ newFm['deadlock-human-appraise'] = cfm['deadlock-human-appraise'] === true;
174
153
  if (cfm.models) newFm.models = cfm.models;
175
154
  if (assayExtractors) newFm.assay = { extractors: assayExtractors };
176
155
  }
@@ -186,8 +165,9 @@ function buildNewFrontmatter(workContent, stages, cfm, assayExtractors) {
186
165
  }
187
166
 
188
167
  async function checkAssayPrereqs(cfm, cycleId, io) {
189
- const memErr = checkMemoryEnabled(io, cycleId);
190
- 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
+ }
191
171
  return checkCycleWriteDecl(cfm, cycleId);
192
172
  }
193
173
 
@@ -205,7 +185,7 @@ async function runAssayValidation(cfm, cycleId, io, foundryDir) {
205
185
 
206
186
  export async function setupWorkfile(args) {
207
187
  const { cycleId, workContent, io, git, foundryDir } = args;
208
- const cycleDefDoc = await fetchCycleDefinition(foundryDir, cycleId, io);
188
+ const cycleDefDoc = await getCycleDefinition(foundryDir, cycleId, io).catch(() => null);
209
189
  if (!cycleDefDoc) return violation(`cycle definition not found for id: ${cycleId}`, ['WORK.md']);
210
190
  const cfm = cycleDefDoc.frontmatter || {};
211
191
  return runSetupPipeline({ cfm, cycleId, workContent, io, git, foundryDir });
@@ -226,8 +206,6 @@ async function completeSetup(ctx) {
226
206
  const hasValidation = ctx.lawsWithValidators && ctx.lawsWithValidators.length > 0;
227
207
  const stagesResult = resolveStages(ctx.cfm, ctx.cycleId, hasValidation, ctx.assayResult.extractors);
228
208
  if (stagesResult.error) return stagesResult.error;
229
- const validityErr = checkIterationLimits(ctx.cfm, ctx.cycleId);
230
- if (validityErr) return validityErr;
231
209
  const newWork = buildNewFrontmatter(ctx.workContent, stagesResult, ctx.cfm, ctx.assayResult.extractors);
232
210
  ctx.io.writeFile('WORK.md', newWork);
233
211
  return trySetupCommit(ctx);
@@ -242,10 +220,6 @@ async function trySetupCommit(ctx) {
242
220
  return { ok: true, workContent: ctx.io.readFile('WORK.md') };
243
221
  }
244
222
 
245
- function readOriginalState(io) {
246
- return { workMd: io.readFile('WORK.md'), history: io.exists('WORK.history.yaml') ? io.readFile('WORK.history.yaml') : null };
247
- }
248
-
249
223
  function buildFinalizeViolation(finalizeResult) {
250
224
  if (finalizeResult.error === 'unexpected_files') {
251
225
  return violation(`unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}`, finalizeResult.files || []);
@@ -253,9 +227,32 @@ function buildFinalizeViolation(finalizeResult) {
253
227
  return violation(`stage_finalize error: ${finalizeResult.error}`, []);
254
228
  }
255
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
+
256
248
  function writeHistoryEntries(ctx) {
257
- 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);
258
- 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);
259
256
  }
260
257
 
261
258
  async function computeAllowedPatterns(lastStage, cycleId, io) {
@@ -290,12 +287,18 @@ function clearStageState(activeStage, lastStage, io) {
290
287
  }
291
288
 
292
289
  export async function finaliseStage(args) {
293
- const { lastStage, activeStage, cycleId, io, finalize, git } = args;
294
- 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
+ };
295
295
  if (typeof finalize !== 'function') {
296
296
  return violation('orchestrate caller must inject a `finalize` function when providing lastResult; the plugin wires lib/finalize.finalizeStage; tests must pass a stub.', []);
297
297
  }
298
- 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
+ });
299
302
  if (!finalizeResult.ok) {
300
303
  clearStageState(activeStage, null, io);
301
304
  return buildFinalizeViolation(finalizeResult);
@@ -303,7 +306,10 @@ export async function finaliseStage(args) {
303
306
  const historyPath = 'WORK.history.yaml';
304
307
  const iteration = getIteration(historyPath, cycleId, io);
305
308
  const openFeedback = computeOpenFeedback(io);
306
- writeHistoryEntries({ historyPath, cycleId, lastStage, iteration, openFeedback, io });
309
+ writeHistoryEntries({
310
+ historyPath, cycleId, lastStage, iteration, openFeedback, io,
311
+ artefactVersion: postVersion, contractPassed,
312
+ });
307
313
  const commitErr = await tryStageCommit(git, lastStage, cycleId, io);
308
314
  if (commitErr) {
309
315
  rollbackState(io, original);