@really-knows-ai/foundry 3.6.3 → 3.7.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.
@@ -16,7 +16,6 @@ const FORGE_REQUIRED_TOOLS = [
16
16
  'foundry_workfile_get',
17
17
  'foundry_config_artefact_type',
18
18
  'foundry_config_laws',
19
- 'foundry_feedback_list',
20
19
  ];
21
20
 
22
21
  function stageBase(stage) { return stage.split(':')[0]; }
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.1] - 2026-05-27
4
+
5
+ ### Fixed
6
+
7
+ - `mock.module` calls in `quench-module.test.js` use the canonical `namedExports` key instead of the deprecated `exports` alias. Node 22 does not recognise the old key, causing tests to fail with "does not provide an export named `computeArtefactVersion`".
8
+
9
+ ## [3.7.0] - 2026-05-27
10
+
11
+ ### Added
12
+
13
+ - Single-item forge dispatch: the forge stage dispatches one artefact at a time, producing focused, incremental edits instead of batch rewrites.
14
+
15
+ - Single-item contract enforcement: forge contract checks validate per-item responses and artefact version consistency at the single-artefact level, matching the new dispatch model.
16
+
17
+ ### Fixed
18
+
19
+ - Max-iterations cap now triggers `alwaysHumanAppraise` when the cycle exceeds its limit, and quench no longer files redundant duplicate feedback items for the same violation.
20
+
21
+ - Forge dispatch review findings addressed: tightened guard logic and cleaned up stale inline comments from the initial single-item implementation.
22
+
23
+ ### Changed
24
+
25
+ - Forge and orchestrate skill guidance aligned with the single-item dispatch model.
26
+
3
27
  ## [3.6.3] - 2026-05-26
4
28
 
5
29
  ### Fixed
@@ -153,6 +153,12 @@ function emptyDispatch(cycleId) {
153
153
  * Resolve stale feedback items whose artefact version does not match the
154
154
  * current on-disk version. Items from this stage's source base with a
155
155
  * mismatched artefact_version are auto-resolved as superseded.
156
+ *
157
+ * @param {object[]} items - Feedback items to check
158
+ * @param {string} currentVersion - Current on-disk artefact version
159
+ * @param {string} stageBase - Stage base name (e.g. 'appraise') to filter by source
160
+ * @param {object} feedback - Feedback store instance with autoResolve method
161
+ * @param {string} cycle - Current cycle identifier
156
162
  */
157
163
  export function resolveStaleFeedback(items, currentVersion, stageBase, feedback, cycle) {
158
164
  for (const item of items) {
@@ -99,8 +99,8 @@ export function openFeedbackStore(path, io) {
99
99
  autoResolve({ id, reason, cycle }) {
100
100
  return storeAutoResolve({ id, reason, cycle, items, persist, timestamp: nowIso });
101
101
  },
102
- forceState(id, state, cycle) {
103
- return storeForceState({ id, state, cycle, items, persist });
102
+ forceState(id, state, cycle, stage) {
103
+ return storeForceState({ id, state, cycle, items, persist, stage });
104
104
  },
105
105
  resolveSystemItems(stage, cycle) {
106
106
  resolveSystemItemsImpl({ items, stage, cycle, timestamp: nowIso, persist });
@@ -212,10 +212,10 @@ function storeTransition(params, items, deps) {
212
212
  return { ok: true };
213
213
  }
214
214
 
215
- function storeForceState({ id, state, cycle, items, persist }) {
215
+ function storeForceState({ id, state, cycle, items, persist, stage }) {
216
216
  const item = items.find(x => x.id === id);
217
217
  if (!item) return { ok: false, error: `feedback item not found: ${id}` };
218
- const snapshot = { state, stage: 'system:forge-contract-mismatch', cycle, timestamp: nowIso() };
218
+ const snapshot = { state, stage: stage || 'system:forge-contract-mismatch', cycle, timestamp: nowIso() };
219
219
  persist(applyTransition(items, id, snapshot));
220
220
  return { ok: true };
221
221
  }
@@ -1,11 +1,16 @@
1
1
  /**
2
- * Forge contract enforcement — validates that forge responded to every
3
- * presented feedback item and that the batch-level artefact version
4
- * semantics are satisfied.
2
+ * Forge contract enforcement — validates that forge addressed the single
3
+ * presented feedback item according to the single-item dispatch contract.
5
4
  *
6
- * Per-item check: every item must end in 'actioned' or 'wont-fix'.
7
- * Batch-level check: if any item is actioned, version must change.
8
- * If no item is actioned, version must be unchanged.
5
+ * Rules (per spec R4):
6
+ * - Version changed transition item to `actioned`.
7
+ * - Version unchanged + summary contains `WONT-FIX:` + source base is
8
+ * `appraise` → transition item to `wont-fix` with the justification
9
+ * as the reason.
10
+ * - Version unchanged + summary contains `WONT-FIX:` + source base is
11
+ * NOT `appraise` → contract violation.
12
+ * - Version unchanged + no `WONT-FIX:` in summary → contract violation.
13
+ * - No item (null/undefined) → no-op, contract passes.
9
14
  */
10
15
 
11
16
  function currentState(feedbackStore, id) {
@@ -13,12 +18,6 @@ function currentState(feedbackStore, id) {
13
18
  return item ? item.history[0].state : null;
14
19
  }
15
20
 
16
- function revertAll(items, feedbackStore, cycleId) {
17
- for (const item of items) {
18
- feedbackStore.forceState(item.id, 'open', cycleId);
19
- }
20
- }
21
-
22
21
  function postSystemFeedback(feedbackStore, cycleId, postVersion, text) {
23
22
  feedbackStore.add({
24
23
  file: '',
@@ -30,73 +29,77 @@ function postSystemFeedback(feedbackStore, cycleId, postVersion, text) {
30
29
  });
31
30
  }
32
31
 
33
- function checkPerItemResponse(items, feedbackStore, cycleId, postVersion) {
34
- for (const item of items) {
35
- const state = currentState(feedbackStore, item.id);
36
- if (state !== 'actioned' && state !== 'wont-fix') {
37
- revertAll(items, feedbackStore, cycleId);
38
- postSystemFeedback(
39
- feedbackStore, cycleId, postVersion,
40
- 'forge did not respond to every presented feedback item',
41
- );
42
- return false;
43
- }
44
- }
45
- return true;
46
- }
47
-
48
- function hasActionedItem(items, feedbackStore) {
49
- return items.some(item => currentState(feedbackStore, item.id) === 'actioned');
50
- }
51
-
52
- function checkBatchVersion(items, feedbackStore, cycleId, postVersion, preVersion) {
53
- const hasActioned = hasActionedItem(items, feedbackStore);
54
-
55
- if (hasActioned && preVersion === postVersion) {
56
- revertAll(items, feedbackStore, cycleId);
57
- postSystemFeedback(
58
- feedbackStore, cycleId, postVersion,
59
- 'forge marked feedback as actioned without changing artefacts',
60
- );
32
+ function handleVersionChanged(item, feedbackStore, cycleId, postVersion) {
33
+ const result = feedbackStore.transition({
34
+ id: item.id,
35
+ target: 'actioned',
36
+ stage: 'forge:' + cycleId,
37
+ cycle: cycleId,
38
+ });
39
+ if (!result.ok) {
40
+ postSystemFeedback(feedbackStore, cycleId, postVersion, result.error || 'store transition failed');
41
+ feedbackStore.forceState(item.id, 'open', cycleId, `forge:${cycleId}`);
61
42
  return { contractPassed: false };
62
43
  }
44
+ return { contractPassed: true };
45
+ }
63
46
 
64
- if (!hasActioned && preVersion !== postVersion) {
65
- revertAll(items, feedbackStore, cycleId);
66
- postSystemFeedback(
67
- feedbackStore, cycleId, postVersion,
68
- 'forge changed artefacts but did not mark any feedback as actioned',
69
- );
70
- return { contractPassed: false };
47
+ function handleWontFixWithReason(item, feedbackStore, cycleId, postVersion, reason) {
48
+ const sourceBase = typeof item.source === 'string' ? item.source.split(':')[0] : '';
49
+ if (sourceBase === 'appraise') {
50
+ const result = feedbackStore.transition({
51
+ id: item.id,
52
+ target: 'wont-fix',
53
+ stage: 'forge:' + cycleId,
54
+ cycle: cycleId,
55
+ reason,
56
+ });
57
+ if (!result.ok) {
58
+ postSystemFeedback(feedbackStore, cycleId, postVersion, result.error || 'store transition failed');
59
+ feedbackStore.forceState(item.id, 'open', cycleId, `forge:${cycleId}`);
60
+ }
61
+ return { contractPassed: result.ok };
71
62
  }
72
-
73
- return { contractPassed: true };
63
+ // quench or human-appraise — wont-fix not allowed
64
+ postSystemFeedback(
65
+ feedbackStore, cycleId, postVersion,
66
+ `wont-fix not allowed on ${sourceBase}-sourced item; wont-fix is only allowed for appraise-sourced items`,
67
+ );
68
+ feedbackStore.forceState(item.id, 'open', cycleId, `forge:${cycleId}`);
69
+ return { contractPassed: false };
74
70
  }
75
71
 
76
72
  /**
77
- * Enforce the forge contract on a batch of items presented to forge.
78
- *
79
- * When `items` is not a non-empty array (null, undefined, or []), the
80
- * contract passes immediately without side-effects. This covers the
81
- * initial forge run where no feedback exists yet.
73
+ * Enforce the forge contract on a single feedback item.
82
74
  *
83
- * Two-level check when items are present:
84
- * 1. Per-item: every item must end in 'actioned' or 'wont-fix'.
85
- * 2. Batch-level: artefact version semantics must be consistent.
75
+ * When no item is provided (null/undefined), the contract passes without
76
+ * side-effects. This covers the initial forge run where no feedback
77
+ * exists yet and subsequent runs where all items were already resolved.
86
78
  *
87
- * @param {{ items?: Array<{id: string}> | null, preVersion: string,
88
- * postVersion: string, feedbackStore: object, cycleId: string }} params
79
+ * @param {{ item: object|null, preVersion: string, postVersion: string,
80
+ * summary: string, feedbackStore: object, cycleId: string }} params
89
81
  * @returns {{ contractPassed: boolean }}
90
82
  */
91
- export function enforceForgeContract({ items, preVersion, postVersion, feedbackStore, cycleId }) {
92
- if (!Array.isArray(items) || items.length === 0) {
93
- // Empty batch means forge had no prior feedback to respond to.
94
- // This is the first forge run or all items were already resolved.
95
- return { contractPassed: true };
83
+ export function enforceForgeContract({ item, preVersion, postVersion, summary, feedbackStore, cycleId }) {
84
+ // No item means forge had no prior feedback to respond to.
85
+ if (!item) return { contractPassed: true };
86
+
87
+ // Version changed forge fixed the issue
88
+ if (preVersion !== postVersion) {
89
+ return handleVersionChanged(item, feedbackStore, cycleId, postVersion);
96
90
  }
97
- if (!checkPerItemResponse(items, feedbackStore, cycleId, postVersion)) {
98
- return { contractPassed: false };
91
+
92
+ // Version unchanged check for WONT-FIX justification
93
+ const wontFixMatch = summary.match(/WONT-FIX:\s*(.+)/);
94
+ if (wontFixMatch) {
95
+ return handleWontFixWithReason(item, feedbackStore, cycleId, postVersion, wontFixMatch[1]);
99
96
  }
100
97
 
101
- return checkBatchVersion(items, feedbackStore, cycleId, postVersion, preVersion);
98
+ // Version unchanged with no WONT-FIX — neither fix nor justification
99
+ postSystemFeedback(
100
+ feedbackStore, cycleId, postVersion,
101
+ 'forge did not change artefacts and did not provide WONT-FIX justification',
102
+ );
103
+ feedbackStore.forceState(item.id, 'open', cycleId, `forge:${cycleId}`);
104
+ return { contractPassed: false };
102
105
  }
@@ -18,15 +18,13 @@ 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 =>
22
- baseStage(e.stage || '') === 'forge' && e.contract_passed !== false,
23
- ).length;
24
21
  const maxIt = prep.defaults.maxIterations;
25
22
  const feedback = prep.feedback || [];
23
+ const needingForgeItems = feedback.filter(f => f.state === 'open' || f.state === 'rejected');
24
+ // Per-item forge count: use the maximum count across all unresolved items
25
+ const forgeCount = needingForgeItems.length > 0 ? Math.max(...needingForgeItems.map(i => i.forge_count || 0)) : 0;
26
+ const needingForge = needingForgeItems.length;
26
27
  const openCount = feedback.filter(f => f.state !== 'resolved').length;
27
- const needingForge = feedback.filter(
28
- f => f.state === 'open' || f.state === 'rejected',
29
- ).length;
30
28
  const alwaysHumanAppraise = prep.defaults.alwaysHumanAppraise;
31
29
  const deadlockHumanAppraise = prep.defaults.deadlockHumanAppraise;
32
30
 
@@ -34,7 +32,9 @@ function buildReasonData(route, prep) {
34
32
  }
35
33
 
36
34
  function forgeReason(d) {
37
- if (d.forgeCount === 0) return `starting cycle routing to forge (iteration 1 of ${d.maxIt})`;
35
+ if (d.forgeCount === 0 && d.needingForge === 0) {
36
+ return `starting cycle — routing to forge (iteration 1 of ${d.maxIt})`;
37
+ }
38
38
  return `found ${d.needingForge} unresolved feedback item(s) — routing to forge for revision (iteration ${d.forgeCount + 1} of ${d.maxIt})`;
39
39
  }
40
40
 
@@ -103,18 +103,28 @@ function validateRoute(maxIterations, stages, history) {
103
103
 
104
104
  /**
105
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.
106
+ * stage entry (full alias), its base stage name, forge iteration count
107
+ * per the first unresolved item, and categorised feedback items.
108
+ *
109
+ * The forge count tracks attempts against the current (first) unresolved
110
+ * item only. Each feedback item carries its own `forge_count` (set by
111
+ * `loadFeedback` in sort.js) that records how many forge runs it has
112
+ * actually consumed. This satisfies SPEC R7: each item gets at most
113
+ * `max-iterations` attempts before the cycle deadlocks.
108
114
  */
109
115
  function computeRoutingState(history, feedback) {
110
116
  const nonSort = history.filter(e => baseStage(e.stage || '') !== 'sort');
111
117
  const lastEntry = nonSort.length > 0 ? nonSort[nonSort.length - 1].stage : null;
112
118
  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
119
  const unresolvedItems = feedback.filter(f => f.state === 'open' || f.state === 'rejected');
117
120
  const addressedItems = feedback.filter(f => f.state === 'actioned' || f.state === 'wont-fix');
121
+ // Per-item forge count: use the maximum forge_count across all unresolved
122
+ // items. This ensures that if any item has exhausted its iteration budget,
123
+ // the route blocks — no single item with remaining budget can mask an
124
+ // item that has hit the cap.
125
+ const forgeCount = unresolvedItems.length > 0
126
+ ? Math.max(...unresolvedItems.map(i => i.forge_count || 0))
127
+ : 0;
118
128
  return { lastEntry, lastStage, forgeCount, unresolvedItems, addressedItems };
119
129
  }
120
130
 
@@ -124,12 +134,16 @@ function computeRoutingState(history, feedback) {
124
134
  * routes to human-appraise (if deadlockHumanAppraise) or 'blocked'.
125
135
  * Otherwise routes to forge via first('forge', stages).
126
136
  */
137
+ function routeToHumanOrBlock(firstFn, stages, opts) {
138
+ if ((opts.deadlockHumanAppraise || opts.alwaysHumanAppraise) && hasStage(stages, 'human-appraise')) {
139
+ return firstFn('human-appraise', stages, opts.cycle);
140
+ }
141
+ return 'blocked';
142
+ }
143
+
127
144
  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';
145
+ if (forgeCount >= maxIterations) {
146
+ return routeToHumanOrBlock(firstFn, stages, opts);
133
147
  }
134
148
  if (!hasStage(stages, 'forge')) return 'blocked';
135
149
  return firstFn('forge', stages);
@@ -142,7 +156,7 @@ function checkIterationAndRoute(firstFn, stages, forgeCount, maxIterations, opts
142
156
  * fall through to forwardClean.
143
157
  */
144
158
  function routeAddressedItems(addressedItems, stages, opts) {
145
- const sourceBases = [...new Set(addressedItems.map(i => baseStage(i.source)))];
159
+ const sourceBases = [...new Set(addressedItems.map(i => baseStage(i.source || '')))];
146
160
  const chain = ['quench', 'appraise', 'human-appraise'];
147
161
  for (const base of chain) {
148
162
  if (sourceBases.includes(base) && hasStage(stages, base)) {
@@ -6,6 +6,7 @@ import {
6
6
  getArtefactType,
7
7
  } from './lib/config.js';
8
8
  import { openFeedbackStore } from './lib/feedback-store.js';
9
+ import { baseStage } from './lib/sort-routing.js';
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Public helpers (re-exported by orchestrate.js for tests).
@@ -229,8 +230,20 @@ export function buildDispatchMultiResponse(tasks, stage, cycle) {
229
230
  // Dispatch prompt rendering (pure utility, used by handleSortResult and exported publicly).
230
231
  // ---------------------------------------------------------------------------
231
232
 
232
- function buildForgePromptLines({ cycle, outputType }) {
233
- return [
233
+ /**
234
+ * Extract the base part of a source alias string (everything before the
235
+ * first colon). Returns an empty string when the source is not a string.
236
+ * Example: 'quench:abc123' -> 'quench'
237
+ *
238
+ * Reuses the exported `baseStage` from sort-routing.js to avoid
239
+ * duplicating the split logic.
240
+ */
241
+ function sourceBase(source) {
242
+ return typeof source === 'string' ? baseStage(source) : '';
243
+ }
244
+
245
+ function buildForgePromptLines({ cycle, outputType, forgeItem }) {
246
+ const lines = [
234
247
  ``,
235
248
  `Before producing output you MUST call these tools to understand the context:`,
236
249
  outputType
@@ -243,11 +256,31 @@ function buildForgePromptLines({ cycle, outputType }) {
243
256
  ? ` - foundry_config_laws({ typeId: "${outputType}" }) — to learn all applicable quality laws`
244
257
  : ` - foundry_config_laws({ typeId: "<output type>" }) — to learn all applicable quality laws`,
245
258
  ` - foundry_workfile_get({}) — to learn the goal`,
246
- ` - foundry_feedback_list({}) — to check for existing feedback from prior iterations`,
247
259
  ];
260
+ if (forgeItem) {
261
+ lines.push(
262
+ ``,
263
+ `FEEDBACK ITEM TO ADDRESS:`,
264
+ ``,
265
+ `Source: ${sourceBase(forgeItem.source)}`,
266
+ `File: ${forgeItem.file}`,
267
+ `Issue: ${forgeItem.text}`,
268
+ ``,
269
+ `You MUST either:`,
270
+ ` a) Fix the issue by changing the artefact file. The orchestrator`,
271
+ ` will record this as ACTIONED.`,
272
+ ` b) If this is an appraise-sourced item (subjective quality`,
273
+ ` feedback), you may respond with:`,
274
+ ` WONT-FIX: <justification for why you disagree>`,
275
+ ``,
276
+ `Quench-sourced items are deterministic validation failures —`,
277
+ `you MUST fix them. There is no wont-fix option.`,
278
+ );
279
+ }
280
+ return lines;
248
281
  }
249
282
 
250
- export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, outputType }) {
283
+ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, outputType, forgeItem }) {
251
284
  const base = stage.split(':')[0];
252
285
  const lines = [
253
286
  `You are a Foundry stage agent. Invoke the ${base} skill and follow its instructions exactly.`,
@@ -261,7 +294,7 @@ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, o
261
294
  lines.push(`File patterns (forge only): ${JSON.stringify(filePatterns)}`);
262
295
  }
263
296
  if (base === 'forge') {
264
- lines.push(...buildForgePromptLines({ cycle, outputType }));
297
+ lines.push(...buildForgePromptLines({ cycle, outputType, forgeItem }));
265
298
  }
266
299
  lines.push(
267
300
  ``,
@@ -0,0 +1,127 @@
1
+ // Foundry v3.x orchestrate: finalise stage and violation handlers.
2
+
3
+ import { buildForgeHistoryEntry } from './lib/workfile.js';
4
+ import { baseStage } from './lib/sort-routing.js';
5
+ import { clearActiveStage, clearLastStage } from './lib/state.js';
6
+ import { allowedPatternsForStage } from './lib/git-policy.js';
7
+ import { stageBaseOf } from './lib/stage-guard.js';
8
+ import { appendEntry, getIteration } from './lib/history.js';
9
+ import {
10
+ tryCommit,
11
+ violation,
12
+ computeOpenFeedback,
13
+ readForgeFilePatterns,
14
+ } from './orchestrate-cycle.js';
15
+
16
+ function buildFinalizeViolation(finalizeResult) {
17
+ if (finalizeResult.error === 'unexpected_files') {
18
+ return violation(`unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}`, finalizeResult.files || []);
19
+ }
20
+ return violation(`stage_finalize error: ${finalizeResult.error}`, []);
21
+ }
22
+
23
+ function buildStageEntryBase(ctx) {
24
+ const summary = ctx.lastStage.summary || '(no summary)';
25
+ const changed = ctx.lastStage.changedFiles ?? [];
26
+ return { cycle: ctx.cycleId, stage: ctx.lastStage.stage,
27
+ iteration: ctx.iteration, comment: summary,
28
+ openFeedback: ctx.openFeedback, changedFiles: changed,
29
+ ...(baseStage(ctx.lastStage.stage || '') === 'forge'
30
+ ? buildForgeHistoryEntry({
31
+ cycle: ctx.cycleId, stage: ctx.lastStage.stage,
32
+ iteration: ctx.iteration, comment: summary,
33
+ artefactVersion: ctx.artefactVersion,
34
+ contractPassed: ctx.contractPassed,
35
+ changedFiles: changed,
36
+ })
37
+ : {}),
38
+ };
39
+ }
40
+
41
+ function writeHistoryEntries(ctx) {
42
+ appendEntry(ctx.historyPath, {
43
+ cycle: ctx.cycleId, stage: 'sort', iteration: ctx.iteration,
44
+ route: ctx.lastStage.stage,
45
+ comment: `route ${ctx.lastStage.stage}`,
46
+ openFeedback: ctx.openFeedback,
47
+ }, ctx.io);
48
+ appendEntry(ctx.historyPath, buildStageEntryBase(ctx), ctx.io);
49
+ }
50
+
51
+ async function computeAllowedPatterns(lastStage, cycleId, io) {
52
+ const stageB = stageBaseOf(lastStage.stage);
53
+ let forgeFilePatterns = [];
54
+ if (stageB === 'forge') {
55
+ const result = await readForgeFilePatterns(cycleId, io);
56
+ forgeFilePatterns = result ? result.patterns : [];
57
+ }
58
+ return allowedPatternsForStage({ stageBase: stageB, forgeFilePatterns });
59
+ }
60
+
61
+ function buildCommitMessage(cycleId, lastStage) {
62
+ return `[${cycleId}] ${lastStage.stage}: ${lastStage.summary || '(no summary)'}`;
63
+ }
64
+
65
+ function rollbackState(io, original) {
66
+ io.writeFile('WORK.md', original.workMd);
67
+ if (original.history !== null) { io.writeFile('WORK.history.yaml', original.history); }
68
+ else if (io.exists('WORK.history.yaml')) { io.unlink('WORK.history.yaml'); }
69
+ }
70
+
71
+ async function tryStageCommit(git, lastStage, cycleId, io) {
72
+ if (!git || typeof git.commit !== 'function') return null;
73
+ const allowedPatterns = await computeAllowedPatterns(lastStage, cycleId, io);
74
+ return tryCommit(git, buildCommitMessage(cycleId, lastStage), allowedPatterns, lastStage.stage);
75
+ }
76
+
77
+ function clearStageState(activeStage, lastStage, io) {
78
+ if (activeStage) clearActiveStage(io);
79
+ if (lastStage) clearLastStage(io);
80
+ }
81
+
82
+ export async function finaliseStage(args) {
83
+ const { lastStage, activeStage, cycleId, io, finalize, git, postVersion, contractPassed } = args;
84
+ const original = {
85
+ workMd: io.readFile('WORK.md'),
86
+ history: io.exists('WORK.history.yaml') ? io.readFile('WORK.history.yaml') : null,
87
+ };
88
+ if (typeof finalize !== 'function') {
89
+ return violation('orchestrate caller must inject a `finalize` function when providing lastResult; the plugin wires lib/finalize.finalizeStage; tests must pass a stub.', []);
90
+ }
91
+ const finalizeResult = await finalize({
92
+ cycleId, stage: lastStage.stage, baseSha: lastStage.baseSha, io,
93
+ artefact_version: postVersion, contractPassed,
94
+ });
95
+ if (!finalizeResult.ok) {
96
+ clearStageState(activeStage, null, io);
97
+ return buildFinalizeViolation(finalizeResult);
98
+ }
99
+ const historyPath = 'WORK.history.yaml';
100
+ const iteration = getIteration(historyPath, cycleId, io);
101
+ const openFeedback = computeOpenFeedback(io);
102
+ writeHistoryEntries({
103
+ historyPath, cycleId,
104
+ lastStage: { ...lastStage, changedFiles: finalizeResult.changedFiles },
105
+ iteration, openFeedback, io,
106
+ artefactVersion: postVersion, contractPassed,
107
+ });
108
+ const commitErr = await tryStageCommit(git, lastStage, cycleId, io);
109
+ if (commitErr) {
110
+ rollbackState(io, original);
111
+ clearStageState(activeStage, null, io);
112
+ return commitErr;
113
+ }
114
+ clearStageState(activeStage, lastStage, io);
115
+ return null;
116
+ }
117
+
118
+ export function handleViolation(args) {
119
+ const { lastResult, activeStage, lastStage } = args;
120
+ const failedStage = activeStage || lastStage;
121
+ if (!failedStage) { return violation('lastResult.ok=false but no stage recorded — orphaned state'); }
122
+ clearStageState(activeStage, lastStage, args.io);
123
+ return violation(
124
+ `subagent dispatch failed: ${lastResult.error || 'unknown error'}`,
125
+ lastResult.affected_files || [],
126
+ );
127
+ }
@@ -6,18 +6,12 @@ import {
6
6
  getCycleDefinition,
7
7
  getLawsForQuench,
8
8
  } from './lib/config.js';
9
- import { parseFrontmatter, writeFrontmatter, buildForgeHistoryEntry } from './lib/workfile.js';
9
+ import { parseFrontmatter, writeFrontmatter } from './lib/workfile.js';
10
10
  import matter from 'gray-matter';
11
- import { clearActiveStage, clearLastStage } from './lib/state.js';
12
- import { appendEntry, getIteration } from './lib/history.js';
13
- import { stageBaseOf } from './lib/stage-guard.js';
14
- import { baseStage } from './lib/sort-routing.js';
15
- import { allowedPatternsForStage } from './lib/git-policy.js';
16
11
  import { loadExtractor } from './lib/assay/loader.js';
17
12
  import { checkExtractorAgainstCycle } from './lib/assay/permissions.js';
18
13
  import {
19
14
  readForgeFilePatterns,
20
- computeOpenFeedback,
21
15
  violation,
22
16
  tryCommit,
23
17
  synthesizeStages,
@@ -29,24 +23,35 @@ import {
29
23
  humanAppraiseAction,
30
24
  missingModelViolation,
31
25
  } from './orchestrate-terminals.js';
26
+ import { finaliseStage, handleViolation } from './orchestrate-finalise.js';
27
+ export { finaliseStage, handleViolation };
32
28
 
33
- function makeDispatchPayload({ route, cycleId, token, cwd, filePatterns, outputType }) {
34
- return { stage: route, cycle: cycleId, token, cwd, filePatterns, outputType };
29
+ function makeDispatchPayload({ route, cycleId, token, cwd, filePatterns, outputType, forgeItem }) {
30
+ return { stage: route, cycle: cycleId, token, cwd, filePatterns, outputType, forgeItem };
31
+ }
32
+
33
+ async function prepareForgePayload(cycleId, io) {
34
+ const payload = { filePatterns: null, outputType: null, forgeItem: null };
35
+ const result = await readForgeFilePatterns(cycleId, io);
36
+ if (result) {
37
+ payload.filePatterns = result.patterns;
38
+ payload.outputType = result.outputType;
39
+ }
40
+ const forgeCtxPath = '.foundry/forge-context.json';
41
+ if (io.exists(forgeCtxPath)) {
42
+ try {
43
+ const parsed = JSON.parse(io.readFile(forgeCtxPath));
44
+ payload.forgeItem = parsed.forgeItem ?? null;
45
+ } catch { /* malformed or missing — defaults to null */ }
46
+ }
47
+ return payload;
35
48
  }
36
49
 
37
50
  async function buildDispatchAction(route, model, token, ctx) {
38
51
  if (!model) return missingModelViolation(ctx.cycleId, route, ctx.io, ctx.foundryDir, ctx.baseBranch ?? 'main');
39
52
  const base = route.split(':')[0];
40
- let filePatterns = null;
41
- let outputType = null;
42
- if (base === 'forge') {
43
- const result = await readForgeFilePatterns(ctx.cycleId, ctx.io);
44
- if (result) {
45
- filePatterns = result.patterns;
46
- outputType = result.outputType;
47
- }
48
- }
49
- const payload = { route, cycleId: ctx.cycleId, token, cwd: ctx.cwd, filePatterns, outputType };
53
+ const forgePayload = base === 'forge' ? await prepareForgePayload(ctx.cycleId, ctx.io) : { filePatterns: null, outputType: null, forgeItem: null };
54
+ const payload = { route, cycleId: ctx.cycleId, token, cwd: ctx.cwd, ...forgePayload };
50
55
  return { action: 'dispatch', stage: route, subagent_type: model,
51
56
  prompt: renderDispatchPrompt(makeDispatchPayload(payload)) };
52
57
  }
@@ -220,115 +225,4 @@ async function trySetupCommit(ctx) {
220
225
  return { ok: true, workContent: ctx.io.readFile('WORK.md') };
221
226
  }
222
227
 
223
- function buildFinalizeViolation(finalizeResult) {
224
- if (finalizeResult.error === 'unexpected_files') {
225
- return violation(`unexpected files written by subagent: ${(finalizeResult.files || []).join(', ')}`, finalizeResult.files || []);
226
- }
227
- return violation(`stage_finalize error: ${finalizeResult.error}`, []);
228
- }
229
228
 
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
-
248
- function writeHistoryEntries(ctx) {
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);
256
- }
257
-
258
- async function computeAllowedPatterns(lastStage, cycleId, io) {
259
- const stageBase = stageBaseOf(lastStage.stage);
260
- let forgeFilePatterns = [];
261
- if (stageBase === 'forge') {
262
- const result = await readForgeFilePatterns(cycleId, io);
263
- forgeFilePatterns = result ? result.patterns : [];
264
- }
265
- return allowedPatternsForStage({ stageBase, forgeFilePatterns });
266
- }
267
-
268
- function buildCommitMessage(cycleId, lastStage) {
269
- return `[${cycleId}] ${lastStage.stage}: ${lastStage.summary || '(no summary)'}`;
270
- }
271
-
272
- function rollbackState(io, original) {
273
- io.writeFile('WORK.md', original.workMd);
274
- if (original.history !== null) { io.writeFile('WORK.history.yaml', original.history); }
275
- else if (io.exists('WORK.history.yaml')) { io.unlink('WORK.history.yaml'); }
276
- }
277
-
278
- async function tryStageCommit(git, lastStage, cycleId, io) {
279
- if (!git || typeof git.commit !== 'function') return null;
280
- const allowedPatterns = await computeAllowedPatterns(lastStage, cycleId, io);
281
- return tryCommit(git, buildCommitMessage(cycleId, lastStage), allowedPatterns, lastStage.stage);
282
- }
283
-
284
- function clearStageState(activeStage, lastStage, io) {
285
- if (activeStage) clearActiveStage(io);
286
- if (lastStage) clearLastStage(io);
287
- }
288
-
289
- export async function finaliseStage(args) {
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
- if (typeof finalize !== 'function') {
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
- }
298
- const finalizeResult = await finalize({
299
- cycleId, stage: lastStage.stage, baseSha: lastStage.baseSha, io,
300
- artefact_version: postVersion, contractPassed,
301
- });
302
- if (!finalizeResult.ok) {
303
- clearStageState(activeStage, null, io);
304
- return buildFinalizeViolation(finalizeResult);
305
- }
306
- const historyPath = 'WORK.history.yaml';
307
- const iteration = getIteration(historyPath, cycleId, io);
308
- const openFeedback = computeOpenFeedback(io);
309
- writeHistoryEntries({
310
- historyPath, cycleId,
311
- lastStage: { ...lastStage, changedFiles: finalizeResult.changedFiles },
312
- iteration, openFeedback, io,
313
- artefactVersion: postVersion, contractPassed,
314
- });
315
- const commitErr = await tryStageCommit(git, lastStage, cycleId, io);
316
- if (commitErr) {
317
- rollbackState(io, original);
318
- clearStageState(activeStage, null, io);
319
- return commitErr;
320
- }
321
- clearStageState(activeStage, lastStage, io);
322
- return null;
323
- }
324
-
325
- export function handleViolation(args) {
326
- const { lastResult, activeStage, lastStage } = args;
327
- const failedStage = activeStage || lastStage;
328
- if (!failedStage) { return violation('lastResult.ok=false but no stage recorded — orphaned state'); }
329
- clearStageState(activeStage, lastStage, args.io);
330
- return violation(
331
- `subagent dispatch failed: ${lastResult.error || 'unknown error'}`,
332
- lastResult.affected_files || [],
333
- );
334
- }
@@ -68,7 +68,7 @@ async function resolveStaleHumanAppraiseFeedback(cfm, fd, io, cycleId, cwd) {
68
68
 
69
69
  function shouldSkipHumanAppraiseResolve(item, currentVersion) {
70
70
  if (item.history[0].state === 'resolved') return true;
71
- if (baseStage(item.source) !== 'human-appraise') return true;
71
+ if (typeof item.source !== 'string' || baseStage(item.source) !== 'human-appraise') return true;
72
72
  if (item.artefact_version === currentVersion) return true;
73
73
  return false;
74
74
  }
@@ -40,6 +40,8 @@ export {
40
40
  export { gatherAppraiseContext, consolidateAppraise };
41
41
  export { readCycleTargets, readForgeFilePatterns };
42
42
  export { handleSortResult as __handleSortResultForTest };
43
+ export { captureForgeContext as __captureForgeContextForTest };
44
+ export { enforceForgeStage as __enforceForgeStageForTest };
43
45
 
44
46
  export function needsSetup(workMdContent) {
45
47
  const { data } = matter(workMdContent);
@@ -142,7 +144,19 @@ async function captureForgeContext(sortResult, args, preCheck, io) {
142
144
  return state === 'open' || state === 'rejected';
143
145
  });
144
146
  if (!io.exists('.foundry')) io.mkdir('.foundry');
145
- const ctx = { forgePreVersion: preVersion, forgeItems: unresolvedItems.map(i => ({ id: i.id })) };
147
+ const forgeItem = unresolvedItems.length > 0
148
+ ? ({
149
+ id: unresolvedItems[0].id,
150
+ file: unresolvedItems[0].file,
151
+ tag: unresolvedItems[0].tag,
152
+ text: unresolvedItems[0].text,
153
+ source: (typeof unresolvedItems[0].source === 'string'
154
+ ? unresolvedItems[0].source.split(':')[0]
155
+ : unresolvedItems[0].source),
156
+ sourceAlias: unresolvedItems[0].source,
157
+ })
158
+ : null;
159
+ const ctx = { forgePreVersion: preVersion, forgeItem };
146
160
  io.writeFile(FORGE_CTX, JSON.stringify(ctx));
147
161
  }
148
162
 
@@ -158,14 +172,30 @@ function countConsecutiveForgeFailures(io, cycleId) {
158
172
  return count;
159
173
  }
160
174
 
161
- async function enforceForgeStage(activeStage, fgResult, cycleId, io, cwd) {
175
+ function checkConsecutiveFailures(contractPassed, io, cycleId) {
176
+ if (!contractPassed) {
177
+ return countConsecutiveForgeFailures(io, cycleId) + 1 >= 3;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ async function enforceForgeStage(forgeCtx, fgResult, cycleId, io, cwd) {
162
183
  const postVersion = await computeArtefactVersion('foundry', fgResult.outputType, io, cwd);
163
184
  const feedbackStore = openFeedbackStore('WORK.feedback.yaml', io);
185
+ const lastStage = readLastStage(io);
186
+ const summary = (lastStage && lastStage.summary) || '';
187
+ const item = forgeCtx.forgeItem || null;
188
+
164
189
  const { contractPassed } = enforceForgeContract({
165
- items: activeStage.forgeItems, preVersion: activeStage.forgePreVersion,
166
- postVersion, feedbackStore, cycleId,
190
+ item,
191
+ preVersion: forgeCtx.forgePreVersion,
192
+ postVersion,
193
+ summary,
194
+ feedbackStore,
195
+ cycleId,
167
196
  });
168
- if (!contractPassed && countConsecutiveForgeFailures(io, cycleId) + 1 >= 3) {
197
+
198
+ if (checkConsecutiveFailures(contractPassed, io, cycleId)) {
169
199
  return { violation: 'forge contract failed 3 consecutive times — unable to satisfy feedback requirements' };
170
200
  }
171
201
  return { postVersion, contractPassed };
@@ -273,7 +303,6 @@ async function runForgePostDispatch(args, activeStage, lastStage, cycleId, io) {
273
303
  if (!io.exists(FORGE_CTX)) return finaliseStage(base);
274
304
  const forgeCtx = JSON.parse(io.readFile(FORGE_CTX));
275
305
  io.unlink(FORGE_CTX);
276
- if (!forgeCtx.forgePreVersion) return finaliseStage(base);
277
306
  const result = await enforceForgeStage(forgeCtx, fgResult, cycleId, io, args.cwd);
278
307
  if (result.violation) return violation(result.violation, []);
279
308
  return finaliseStage({ ...base, postVersion: result.postVersion, contractPassed: result.contractPassed });
@@ -12,17 +12,24 @@ import { getArtefactFiles, computeArtefactVersion } from './lib/artefacts.js';
12
12
  import { getCycleDefinition } from './lib/config.js';
13
13
  import { performValidation } from './lib/validation.js';
14
14
  import { openFeedbackStore } from './lib/feedback-store.js';
15
+ import { hashText } from './lib/feedback-transitions.js';
15
16
 
16
17
  /**
17
18
  * Resolve stale feedback items whose artefact version does not match the
18
19
  * current on-disk version. Items from this stage's source base with a
19
20
  * mismatched artefact_version are auto-resolved as superseded.
21
+ *
22
+ * @param {object[]} items - Feedback items to check
23
+ * @param {string} currentVersion - Current on-disk artefact version
24
+ * @param {string} stageBase - Stage base name (e.g. 'quench') to filter by source
25
+ * @param {object} store - Feedback store instance with autoResolve method
26
+ * @param {string} cycle - Current cycle identifier
20
27
  */
21
- export async function resolveStaleFeedback(items, currentVersion, stageBase, feedback, cycle) {
28
+ export async function resolveStaleFeedback(items, currentVersion, stageBase, store, cycle) {
22
29
  for (const item of items) {
23
30
  if (shouldSkipStaleResolve(item, currentVersion, stageBase)) continue;
24
31
  const reason = `superseded by forge revision ${currentVersion}`;
25
- feedback.autoResolve({ id: item.id, reason, cycle });
32
+ store.autoResolve({ id: item.id, reason, cycle });
26
33
  }
27
34
  }
28
35
 
@@ -35,14 +42,17 @@ function shouldSkipStaleResolve(item, currentVersion, stageBase) {
35
42
  }
36
43
 
37
44
  /**
38
- * Resolve stale quench-sourced feedback. Errors propagate to the caller
39
- * (runQuench) which surfaces them as a failure.
45
+ * Resolve stale quench-sourced feedback using the given store.
46
+ * Errors are silently caught stale resolution is best-effort.
47
+ *
48
+ * @param {object} ctx - Orchestration context
49
+ * @param {string|undefined} currentVersion - Current artefact version, or undefined
50
+ * @param {object} store - Feedback store instance
40
51
  */
41
- async function resolveStaleQuenchFeedback(ctx, outputType) {
52
+ async function resolveStaleQuenchFeedback(ctx, currentVersion, store) {
42
53
  try {
43
- const store = openFeedbackStore('WORK.feedback.yaml', ctx.io);
44
- const currentVersion = await computeArtefactVersion(ctx.foundryDir, outputType, ctx.io, ctx.cwd);
45
- resolveStaleFeedback(store.list(), currentVersion, 'quench', store, ctx.cycleId);
54
+ if (currentVersion === undefined || currentVersion === null) return;
55
+ await resolveStaleFeedback(store.list(), currentVersion, 'quench', store, ctx.cycleId);
46
56
  } catch {
47
57
  // Graceful degrade — stale resolution is best-effort.
48
58
  // The orchestrator handles IO failures at the cycle level.
@@ -71,7 +81,6 @@ export async function runQuench(ctx) {
71
81
  }
72
82
 
73
83
  async function runQuenchWithStale(ctx, activeStageRecord, outputType) {
74
- await resolveStaleQuenchFeedback(ctx, outputType);
75
84
  const artefactVersion = await computeArtefactVersion(
76
85
  ctx.foundryDir, outputType, ctx.io, ctx.cwd,
77
86
  ).catch(() => undefined);
@@ -111,6 +120,8 @@ async function processArtefacts(ctx, artefacts, activeStageRecord, outputType, a
111
120
  const perArtefact = [];
112
121
  const currentFeedback = [];
113
122
  let allOk = true;
123
+ ctx.store = openFeedbackStore('WORK.feedback.yaml', ctx.io);
124
+ await resolveStaleQuenchFeedback(ctx, artefactVersion, ctx.store);
114
125
 
115
126
  for (const artefact of artefacts) {
116
127
  const result = await performValidation({
@@ -177,12 +188,57 @@ function isAllErrors(result) {
177
188
  return result.items.length === 0 && result.errors.length > 0;
178
189
  }
179
190
 
191
+ /**
192
+ * True when the candidate feedback item is a duplicate of an existing item
193
+ * that has already been addressed in the current or prior cycle iteration.
194
+ *
195
+ * actioned and wont-fix items are always treated as duplicates. resolved
196
+ * items are duplicates only when their artefact version matches the current
197
+ * version — a resolved item from a prior version should generate fresh
198
+ * feedback.
199
+ *
200
+ * NOTE: Uses `history[0]` as the most recent state. The feedback store
201
+ * must prepend new entries (not append) so that index 0 always holds the
202
+ * latest state. If the store implementation changes to append, this check
203
+ * and all consumers of `history[0]` will break.
204
+ */
205
+ function isDuplicateFeedback(existing, artefactVersion) {
206
+ const state = existing.history[0].state;
207
+ if (state === 'actioned' || state === 'wont-fix') return true;
208
+ if (state === 'resolved' && existing.artefact_version === artefactVersion) return true;
209
+ return false;
210
+ }
211
+
180
212
  /**
181
213
  * Post feedback items for validation results and track for resolution.
214
+ *
215
+ * Skips items whose file:tag:text already exists in actioned, wont-fix,
216
+ * or resolved state (with matching artefact version). This covers both
217
+ * the direct case (items the user has actioned or wont-fixed) and the
218
+ * stale-resolution case where items were advanced to resolved before
219
+ * validation runs. Prevents the quench → forge feedback accumulation
220
+ * loop when validators produce the same message across forge revisions.
182
221
  */
183
222
  function postFeedbackItems(ctx, artefact, result, currentFeedback, artefactVersion) {
223
+ const store = ctx.store;
224
+ const allItems = store.list();
225
+
184
226
  for (const item of result.items) {
185
227
  const tag = `law:${item.lawId}:${item.validatorId}`;
228
+ const textHash = hashText(item.text);
229
+
230
+ const existing = allItems.find(it =>
231
+ it.file === artefact.file &&
232
+ it.tag === tag &&
233
+ hashText(it.text) === textHash &&
234
+ isDuplicateFeedback(it, artefactVersion)
235
+ );
236
+
237
+ if (existing) {
238
+ currentFeedback.push({ file: artefact.file, tag });
239
+ continue;
240
+ }
241
+
186
242
  ctx.feedback.add({ file: artefact.file, text: item.text, tag, artefact_version: artefactVersion });
187
243
  currentFeedback.push({ file: artefact.file, tag });
188
244
  }
@@ -78,6 +78,25 @@ function isLegacyItem(item) {
78
78
  return !item.artefact_version || !SHA256_RE.test(item.artefact_version);
79
79
  }
80
80
 
81
+ /**
82
+ * Counts how many forge runs this feedback item has actually consumed
83
+ * by scanning its history for forge-stage entries.
84
+ *
85
+ * Only entries whose stage base is 'forge' are counted — this covers
86
+ * successful forge transitions (actioned/wont-fix) as well as contract
87
+ * failures, which are now recorded with a `forge:<cycle>` stage.
88
+ *
89
+ * History entries that lack a `stage` field are silently ignored. Such
90
+ * entries originate from a different tracking subsystem and do not
91
+ * represent forge attempts against this item.
92
+ */
93
+ function countForgeAttempts(item) {
94
+ return item.history.filter(e => {
95
+ const entryStage = e.stage || '';
96
+ return baseStage(entryStage) === 'forge';
97
+ }).length;
98
+ }
99
+
81
100
  function loadFeedback(io, cycle) {
82
101
  const store = openFeedbackStore('WORK.feedback.yaml', io);
83
102
  const allItems = store.list();
@@ -95,6 +114,9 @@ function loadFeedback(io, cycle) {
95
114
  depth: item.history.length,
96
115
  source: item.source,
97
116
  artefact_version: item.artefact_version,
117
+ /** Number of forge runs this item has already consumed. Used by the
118
+ * routing decision to enforce the max-iterations cap per item (SPEC R7). */
119
+ forge_count: countForgeAttempts(item),
98
120
  });
99
121
  }
100
122
 
@@ -6,7 +6,7 @@ description: Produces or revises an artefact, guided by WORK.md and the foundry
6
6
 
7
7
  # Forge
8
8
 
9
- You produce or revise artefacts. You read the work file to understand the goal, call `foundry_feedback_list` to understand feedback, and read the foundry cycle definition to understand what you're producing and what inputs you can read.
9
+ You produce or revise artefacts. You read the work file to understand the goal and follow the feedback item in the dispatch prompt, and read the foundry cycle definition to understand what you're producing and what inputs you can read.
10
10
 
11
11
  ## Prerequisites
12
12
 
@@ -19,7 +19,7 @@ Before running this skill, verify that the `foundry/` directory exists in the pr
19
19
  Forge runs inside an enforced stage. Your **first** and **last** tool calls are fixed:
20
20
 
21
21
  1. **First:** `foundry_stage_begin({stage, cycle, token})` — the orchestrator hands you `stage`, `cycle`, and an opaque `token` string in the dispatch prompt. Copy the token verbatim; never invent, edit, or re-sign it. No other tool call is permitted before this one. Any writes before `stage_begin` will be blocked by preconditions.
22
- 2. **Last:** `foundry_stage_end({summary})` — return control to the orchestrator. After `stage_end`, the orchestrator's internal finalize step scans the disk and registers your output artefact. **You do not register artefacts yourself.**
22
+ 2. **Last:** `foundry_stage_end({summary})` — return control to the orchestrator. After `stage_end`, the orchestrator's internal finalise step scans the disk and registers your output artefact. **You do not register artefacts yourself.**
23
23
 
24
24
  ## Protocol
25
25
 
@@ -54,31 +54,28 @@ Forge runs inside an enforced stage. Your **first** and **last** tool calls are
54
54
  ### Revision (feedback exists)
55
55
 
56
56
  1. `foundry_stage_begin(...)`.
57
- 2. `foundry_feedback_list` — find feedback whose state is `open` or `rejected` for the current cycle.
58
- 3. Read the artefact file.
59
- 4. If the cycle declares `inputs`, discover them via filesystem scan against each input type's `file-patterns` (same protocol as first-generation step 6). Re-read the relevant files they may have changed on disk since the previous iteration (nothing in this cycle wrote to them, but the user may have modified them between iterations).
60
- 5. For each item whose state is `open` or `rejected`, follow the feedback handling rules below.
61
- 6. Update the artefact file.
62
- 7. `foundry_stage_end({summary})`.
57
+ 2. Read the artefact file.
58
+ 3. If the cycle declares `inputs`, discover them via filesystem scan against each input type's `file-patterns` (same protocol as first-generation step 6). Re-read the relevant files — they may have changed on disk since the previous iteration (nothing in this cycle wrote to them, but the user may have modified them between iterations).
59
+ 4. Address the single feedback item from the dispatch prompt following the feedback handling rules below either fix the artefact, or for appraise-sourced items write a WONT-FIX justification in the summary.
60
+ 5. Update the artefact file.
61
+ 6. `foundry_stage_end({summary})`.
63
62
 
64
63
  ## Feedback handling
65
64
 
66
- Call `foundry_feedback_list` to see feedback items for the current cycle.
67
- Each entry has shape `{ id, file, tag, text, source, state, depth, reason? }`.
68
- Action every item whose `state` is `open` or `rejected`:
69
-
70
- - If you address the feedback in the artefact: call `foundry_feedback_action`
71
- with `{ id }`. This marks the item `actioned`. The tool returns
72
- `{ ok: true }` on success; keep using the original list entry's `id` for
73
- any follow-up.
74
- - If you decide not to address the feedback: call `foundry_feedback_wontfix`
75
- with `{ id, reason }`. The reason is required. **You may only mark
76
- `wont-fix` on items whose `source` stage base is `appraise`.** If the
77
- item's source base is `quench` (objective validation failure) or
78
- `human-appraise` (direct user instruction), you must action it — the
79
- tool will return an error if you attempt `wont-fix`. This replaces the
80
- old tag-based restriction (`#validation`/`#human` tag check); tags are
81
- now categorical/display-only and not consulted by the state machine.
65
+ The dispatch prompt already contains the single feedback item for this
66
+ iteration. Each item has the shape `{ id, file, tag, text, source, state,
67
+ depth, reason? }`.
68
+
69
+ Fix the issue by changing the artefact the orchestrator records the item
70
+ as actioned when it detects your changes on disk.
71
+
72
+ For items whose `source` stage base is `appraise` only, you may instead
73
+ respond with `WONT-FIX: <justification>` in the `foundry_stage_end`
74
+ summary. The orchestrator records the item as wont-fix.
75
+
76
+ Items whose source base is `quench` (objective validation failure) or
77
+ `human-appraise` (direct user instruction) are deterministic failures that
78
+ **must** be fixed. There is no wont-fix option for these.
82
79
 
83
80
  `foundry_feedback_add` (if you ever call it — forge normally does not)
84
81
  returns `{ ok, id, deduped }`. `deduped: true` means an existing
@@ -88,8 +85,7 @@ new item was written; the returned `id` is the existing item's id.
88
85
 
89
86
  You cannot resolve or reject items — only the stage that created the item
90
87
  (the `source` on each list entry) can do that, with the exception that
91
- human-appraise can override any non-resolved item. You also cannot action
92
- items whose state is `actioned`, `wont-fix`, `deadlocked`, or `resolved`.
88
+ human-appraise can override any non-resolved item.
93
89
 
94
90
  ## Write invariant
95
91
 
@@ -97,7 +93,7 @@ Forge may only write to:
97
93
  - Files matching the output artefact type's `file-patterns`.
98
94
  - `WORK.md`, `WORK.feedback.yaml`, and `WORK.history.yaml` (tool-managed).
99
95
 
100
- Everything else on disk — including files of the cycle's input types, files of unrelated artefact types, and files outside any artefact type — is read-only for this stage. This rule is tool-enforced: the orchestrator's internal finalize step returns `{error: 'unexpected_files'}` and the orchestrator's modified-file check routes a violation on the next call. Either outcome marks the cycle's target artefact `blocked` and you do not get a retry.
96
+ Everything else on disk — including files of the cycle's input types, files of unrelated artefact types, and files outside any artefact type — is read-only for this stage. This rule is tool-enforced: the orchestrator's internal finalise step returns `{error: 'unexpected_files'}` and the orchestrator's modified-file check routes a violation on the next call. Either outcome marks the cycle's target artefact `blocked` and you do not get a retry.
101
97
 
102
98
  When a cycle's output type overlaps with one of its input types (e.g. a `refine-haiku` cycle with input `haiku` and output `haiku`), the overlap is intentional: the cycle's job is to modify existing files of that type. The write invariant still holds — you may only touch files matching the output type's patterns, which in this case includes the files you read as inputs.
103
99
 
@@ -113,9 +109,8 @@ items in the list output.
113
109
 
114
110
  - You normally do not add feedback — that is the quench and appraise skills' job.
115
111
  - You do not `foundry_feedback_resolve` — that belongs to quench/appraise/human-appraise.
116
- - You do not register artefacts — the orchestrator's internal finalize step handles that automatically.
112
+ - You do not register artefacts — the orchestrator's internal finalise step handles that automatically.
117
113
  - You do not call `foundry_history_append` or `foundry_git_commit` — `foundry_orchestrate` does (those tools are not registered publicly).
118
114
  - You do not evaluate or score the artefact.
119
- - You do not mark feedback as actioned unless you actually changed the artefact to address it.
120
- - You do not wont-fix items whose `source` stage base is `quench` or `human-appraise`.
115
+ - You do not mark feedback as actioned or wont-fix via tool calls — the orchestrator handles feedback transitions based on your artefact changes and `stage_end` summary.
121
116
  - You do not write to any file outside the output artefact type's `file-patterns` (plus `WORK.md` / `WORK.feedback.yaml` / `WORK.history.yaml`). Input files are read-only unless the output type's patterns happen to cover them.
@@ -111,12 +111,12 @@ Report to the user: "Cycle halted (violation): `<details>`. Affected files: `<af
111
111
 
112
112
  ## Feedback visibility
113
113
 
114
- Orchestrate's loop does not read, parse, or write feedback directly.
115
- Subagents invoked via `dispatch` use the `foundry_feedback_list` /
116
- `foundry_feedback_add` / `foundry_feedback_action` / `foundry_feedback_wontfix`
117
- / `foundry_feedback_resolve` tools themselves; orchestrate does not stage
118
- feedback state between iterations. If you want to inspect feedback state
119
- between iterations for diagnostic purposes, call `foundry_feedback_list`
120
- the response shape is `[{ id, file, tag, text, source, state, depth,
121
- reason? }]`. This is read-only and does not affect the loop's dispatch
122
- decisions.
114
+ The orchestrator manages forge feedback transitions directly. After each
115
+ forge subagent completes, `enforceForgeStage` inspects the outcome — a
116
+ version change or a WONT-FIX in the stage-end summary — and transitions the
117
+ feedback item to `actioned` or `wont-fix`. Forge subagents do not call
118
+ `foundry_feedback_action` or `foundry_feedback_wontfix`; those are the
119
+ orchestrator's responsibility. If you want to inspect feedback state for
120
+ diagnostic purposes, call `foundry_feedback_list` the response shape is
121
+ `[{ id, file, tag, text, source, state, depth, reason? }]`. This is
122
+ read-only and does not affect the loop's dispatch decisions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@really-knows-ai/foundry",
3
- "version": "3.6.3",
3
+ "version": "3.7.1",
4
4
  "description": "A skill-driven framework for governed artefact generation with AI coding tools. Define your own artefact types, laws, and flows — Foundry handles the forge → quench → appraise pipeline with deterministic routing, quality gates, and iterative refinement.",
5
5
  "type": "module",
6
6
  "main": "dist/.opencode/plugins/foundry.js",