@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.
@@ -70,13 +70,17 @@ async function executeFeedbackAdd(args, context) {
70
70
 
71
71
  try {
72
72
  const store = openFeedbackStore('WORK.feedback.yaml', io);
73
- const { id, deduped } = store.add({
73
+ const params = {
74
74
  file: args.file,
75
75
  tag: args.tag,
76
76
  text: args.text,
77
77
  source: activeStage,
78
78
  cycle,
79
- });
79
+ };
80
+ if (args.artefact_version !== undefined) {
81
+ params.artefact_version = args.artefact_version;
82
+ }
83
+ const { id, deduped } = store.add(params);
80
84
  return JSON.stringify({ ok: true, id, deduped });
81
85
  } catch (err) {
82
86
  return JSON.stringify({ error: `foundry_feedback_add: ${err.message}` });
@@ -201,6 +205,7 @@ export function createFeedbackTools({ tool }) {
201
205
  file: tool.schema.string().describe('Artefact file path'),
202
206
  text: tool.schema.string().describe('Feedback text'),
203
207
  tag: tool.schema.string().describe('Tag for the feedback item'),
208
+ artefact_version: tool.schema.string().optional().describe('SHA-256 hash for version-aware sorting'),
204
209
  },
205
210
  execute: guarded('foundry_feedback_add', [flowBranchGuard, gateNotFailed], executeFeedbackAdd, { branchIo: branchIoFactory, io: asyncIoFactory }),
206
211
  }),
@@ -218,7 +223,7 @@ export function createFeedbackTools({ tool }) {
218
223
  execute: guarded('foundry_feedback_wontfix', [flowBranchGuard, gateNotFailed], executeFeedbackWontfix, { branchIo: branchIoFactory, io: asyncIoFactory }),
219
224
  }),
220
225
  foundry_feedback_resolve: tool({
221
- description: 'Resolve a feedback item (approved or rejected). In human-appraise stages, this tool can override deadlocked items by providing a reason.',
226
+ description: 'Resolve a feedback item (approved or rejected). Human-appraise stages can override deadlock with a reason.',
222
227
  args: {
223
228
  id: tool.schema.string().describe('Feedback item id (ULID)'),
224
229
  resolution: tool.schema.enum(['approved', 'rejected']).describe('Resolution type'),
@@ -230,7 +235,6 @@ export function createFeedbackTools({ tool }) {
230
235
  args: {
231
236
  file: tool.schema.string().optional().describe('Filter by artefact file path'),
232
237
  },
233
- execute: executeFeedbackList,
234
- }),
238
+ execute: executeFeedbackList }),
235
239
  };
236
240
  }
@@ -38,7 +38,7 @@ function createGitBridge(cwd) {
38
38
  }
39
39
 
40
40
  async function createFinalize(cwd, io) {
41
- return async ({ cycleId, stage, baseSha }) => {
41
+ return async ({ cycleId, stage, baseSha, artefact_version, contractPassed }) => {
42
42
  let cycleDoc;
43
43
  try {
44
44
  cycleDoc = await getCycleDefinition('foundry', cycleId, io);
@@ -66,6 +66,8 @@ async function createFinalize(cwd, io) {
66
66
  cycleDef,
67
67
  artefactTypes,
68
68
  io,
69
+ artefact_version,
70
+ contractPassed,
69
71
  });
70
72
  return result;
71
73
  };
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.6.0] - 2026-05-25
4
+
5
+ ### Added
6
+
7
+ - State-driven sort routing (R1–R4, R7): sort routes based on feedback item state (`unresolved` → forge, `addressed` → source stage) instead of stage position, with deadlock detection and iteration cap.
8
+ - Artefact version tracking: `computeArtefactVersion` hashes all artefact files after each forge run; the version is recorded on every feedback item and forge history entry.
9
+ - Forge contract enforcement (R8–R9): per-item response check and batch-level version consistency check. Contract violations revert items to `open` and post system feedback. Three consecutive contract failures block the cycle.
10
+ - Stale feedback detection (R6): when quench, appraise, or human-appraise re-enters after a forge run, feedback items with a mismatched artefact version are auto-resolved as superseded.
11
+ - Legacy feedback migration: items without a valid artefact version are auto-resolved on load and excluded from routing.
12
+ - Integration test suite: 40 integration tests covering the routing decision tree, forge contract enforcement, and the spec's 10-step worked example.
13
+
14
+ ### Fixed
15
+
16
+ - Forge contract enforcement wired into the orchestration post-dispatch path (previously it finalised without computing versions or checking the contract).
17
+ - New feedback items from quench, appraise, and the plugin feedback tool now carry the current artefact version, preventing false legacy detection.
18
+ - `computeArtefactVersion` scans the worktree root for file patterns (previously scanned the `foundry/` config directory).
19
+ - Version computation failures during stale resolution are surfaced to the orchestrator instead of being silently swallowed.
20
+ - Appraise stale feedback resolution runs during the gather phase (before appraiser dispatch), not only during consolidation.
21
+
22
+ ### Changed
23
+
24
+ - Orchestration: forge post-dispatch extracts a dedicated `runForgePostDispatch` path that reads the forge context, computes versions, enforces the contract, and passes `contractPassed`/`postVersion` to `finaliseStage`.
25
+ - Quench and appraise stale resolution helpers gracefully degrade when the artefact type is not configured (best-effort, not fatal).
26
+ - Added `worktree` parameter to `computeArtefactVersion` for explicit worktree-root specification.
27
+
3
28
  ## [3.5.9] - 2026-05-24
4
29
 
5
30
  ### Changed
@@ -11,8 +11,9 @@
11
11
  * so the orchestrator can re-sort and determine the next action.
12
12
  */
13
13
 
14
- import { getArtefactFiles } from './lib/artefacts.js';
14
+ import { getArtefactFiles, computeArtefactVersion } from './lib/artefacts.js';
15
15
  import { selectAppraisers, getLaws, getCycleDefinition } from './lib/config.js';
16
+ import { openFeedbackStore } from './lib/feedback-store.js';
16
17
  import yaml from 'js-yaml';
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -39,6 +40,8 @@ export async function gatherAppraiseContext(ctx) {
39
40
  return violation('cycleId is required', []);
40
41
  }
41
42
 
43
+ await resolveStaleAppraiseFeedback(ctx);
44
+
42
45
  const cd = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
43
46
  const outputType = cd.frontmatter['output-type'];
44
47
  if (!outputType) {
@@ -147,11 +150,51 @@ function emptyDispatch(cycleId) {
147
150
  // ---------------------------------------------------------------------------
148
151
 
149
152
  /**
150
- * Consolidate appraiser results after all subagents have run.
153
+ * Resolve stale feedback items whose artefact version does not match the
154
+ * current on-disk version. Items from this stage's source base with a
155
+ * mismatched artefact_version are auto-resolved as superseded.
156
+ */
157
+ export function resolveStaleFeedback(items, currentVersion, stageBase, feedback, cycle) {
158
+ for (const item of items) {
159
+ if (shouldSkipStaleResolve(item, currentVersion, stageBase)) continue;
160
+ const reason = `superseded by forge revision ${currentVersion}`;
161
+ feedback.autoResolve({ id: item.id, reason, cycle });
162
+ }
163
+ }
164
+
165
+ function shouldSkipStaleResolve(item, currentVersion, stageBase) {
166
+ if (item.history[0].state === 'resolved') return true;
167
+ const itemBase = typeof item.source === 'string' ? item.source.split(':')[0] : '';
168
+ if (itemBase !== stageBase) return true;
169
+ if (item.artefact_version === currentVersion) return true;
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Resolve stale appraise-sourced feedback. Errors propagate to the caller
175
+ * which must handle them (e.g. by returning a violation).
176
+ */
177
+ async function resolveStaleAppraiseFeedback(ctx) {
178
+ try {
179
+ const cycleDef = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
180
+ const outputType = cycleDef.frontmatter['output-type'];
181
+ if (outputType) {
182
+ const store = openFeedbackStore('WORK.feedback.yaml', ctx.io);
183
+ const currentVersion = await computeArtefactVersion(ctx.foundryDir, outputType, ctx.io, ctx.cwd);
184
+ resolveStaleFeedback(store.list(), currentVersion, 'appraise', store, ctx.cycleId);
185
+ }
186
+ } catch {
187
+ // Graceful degrade — stale resolution is best-effort.
188
+ // The orchestrator handles IO failures at the cycle level.
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Consolidate appraiser results and finalise the appraise stage.
151
194
  *
152
- * Parses each successful output for structured issues, unions across
153
- * appraisers, de-duplicates by (file, law-id, issue text), posts feedback,
154
- * resolves stale prior appraise feedback, and finalises the stage.
195
+ * Called by orchestrator after all appraisers have completed. Parses results,
196
+ * posts combined feedback, resolves prior appraise feedback, and advances
197
+ * the cycle to the next stage via finalize.
155
198
  *
156
199
  * @param {object} ctx
157
200
  * @param {Array<{ok: boolean, output?: string, error?: string}>} lastResults
@@ -170,10 +213,13 @@ export async function consolidateAppraise(ctx, lastResults) {
170
213
  return violation('All appraisers failed to evaluate the artefact', []);
171
214
  }
172
215
 
216
+ await resolveStaleAppraiseFeedback(ctx);
217
+
173
218
  const consolidated = parseConsolidated(successful);
174
219
  const stageId = `appraise:${ctx.cycleId}`;
175
220
 
176
- postConsolidatedFeedback(ctx, consolidated);
221
+ const artefactVersion = await computeAppraiseArtefactVersion(ctx);
222
+ postConsolidatedFeedback(ctx, consolidated, artefactVersion);
177
223
  resolvePriorAppraise(ctx, consolidated, stageId);
178
224
 
179
225
  const summary = buildConsolidateSummary(consolidated.length);
@@ -219,15 +265,31 @@ function deduplicateIssues(issues) {
219
265
  return result;
220
266
  }
221
267
 
268
+ /**
269
+ * Compute artefact version for the appraise cycle so feedback items carry
270
+ * a version hash and are not auto-resolved by sort as legacy items.
271
+ */
272
+ async function computeAppraiseArtefactVersion(ctx) {
273
+ try {
274
+ const cycleDef = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
275
+ const outputType = cycleDef.frontmatter['output-type'];
276
+ if (outputType) {
277
+ return await computeArtefactVersion(ctx.foundryDir, outputType, ctx.io, ctx.cwd);
278
+ }
279
+ } catch { /* skip */ }
280
+ return undefined;
281
+ }
282
+
222
283
  /**
223
284
  * Post one feedback item per consolidated issue.
224
285
  */
225
- function postConsolidatedFeedback(ctx, consolidated) {
286
+ function postConsolidatedFeedback(ctx, consolidated, artefactVersion) {
226
287
  for (const issue of consolidated) {
227
288
  ctx.feedback.add({
228
289
  file: issue.file,
229
290
  text: issue.issue,
230
291
  tag: `law:${issue.law}`,
292
+ artefact_version: artefactVersion,
231
293
  });
232
294
  }
233
295
  }
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import { minimatch } from 'minimatch';
9
- import { sortPaths } from './attestation/hash.js';
9
+ import { sortPaths, sha256Text } from './attestation/hash.js';
10
10
  import { getArtefactType } from './config.js';
11
+ import { expandPatterns } from './validation.js';
11
12
 
12
13
  // --- Shared branch artefact discovery ---
13
14
 
@@ -164,3 +165,44 @@ export async function getArtefactFiles(foundryDir, typeId, io, options = {}) {
164
165
 
165
166
  return result;
166
167
  }
168
+
169
+ /**
170
+ * Compute the artefact version hash for a given artefact type.
171
+ *
172
+ * Reads the artefact type definition, expands its file patterns across the
173
+ * worktree, and computes a SHA-256 hash over all matching files. Each file
174
+ * contributes `sha256(filePath + ":" + content)` and the per-file hashes are
175
+ * joined with "\n" before the final SHA-256.
176
+ *
177
+ * When no patterns are defined or no files match, returns the SHA-256 of an
178
+ * empty input (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855).
179
+ *
180
+ * @param {string} foundryDir - Path to the foundry config directory
181
+ * @param {string} typeId - Artefact type identifier
182
+ * @param {object} io - IO interface with readFile(path, encoding)
183
+ * @param {string} [worktree] - Worktree root for pattern expansion (defaults to foundryDir)
184
+ * @returns {Promise<string>} SHA-256 hex string (64 characters)
185
+ * @throws {Error} On IO errors (unknown type, file read failure, glob error)
186
+ */
187
+ export async function computeArtefactVersion(foundryDir, typeId, io, worktree = foundryDir) {
188
+ const def = await getArtefactType(foundryDir, typeId, io);
189
+ const patterns = getFilePatterns(def);
190
+
191
+ if (patterns.length === 0) {
192
+ return sha256Text('');
193
+ }
194
+
195
+ const files = await expandPatterns(patterns, worktree);
196
+
197
+ if (files.length === 0) {
198
+ return sha256Text('');
199
+ }
200
+
201
+ const perFileHashes = await Promise.all(files.map(async file => {
202
+ const content = await io.readFile(file, 'utf-8');
203
+ return sha256Text(file + ':' + content);
204
+ }));
205
+
206
+ const joined = perFileHashes.join('\n');
207
+ return sha256Text(joined);
208
+ }
@@ -5,11 +5,11 @@ import { validateTransition, hashText, canForgeWontFix } from './feedback-transi
5
5
 
6
6
  const YAML_OPTS = { lineWidth: -1 };
7
7
 
8
- const VALID_SOURCE_BASES = new Set(['forge', 'quench', 'appraise', 'human-appraise']);
8
+ const VALID_SOURCE_BASES = new Set(['forge', 'quench', 'appraise', 'human-appraise', 'system']);
9
9
 
10
10
  function validateSourceBase(base) {
11
11
  if (!VALID_SOURCE_BASES.has(base)) {
12
- throw new Error(`unknown source base: ${base} (expected one of: forge, quench, appraise, human-appraise)`);
12
+ throw new Error(`unknown source base: ${base} (expected one of: forge, quench, appraise, human-appraise, system)`);
13
13
  }
14
14
  }
15
15
 
@@ -96,6 +96,12 @@ export function openFeedbackStore(path, io) {
96
96
  timestamp: nowIso, persist,
97
97
  });
98
98
  },
99
+ autoResolve({ id, reason, cycle }) {
100
+ return storeAutoResolve({ id, reason, cycle, items, persist, timestamp: nowIso });
101
+ },
102
+ forceState(id, state, cycle) {
103
+ return storeForceState({ id, state, cycle, items, persist });
104
+ },
99
105
  resolveSystemItems(stage, cycle) {
100
106
  resolveSystemItemsImpl({ items, stage, cycle, timestamp: nowIso, persist });
101
107
  },
@@ -109,20 +115,21 @@ function isDuplicate(item, file, tag, textHash, stateOf) {
109
115
  stateOf(item) !== 'resolved';
110
116
  }
111
117
 
112
- function assertAddParams(...params) {
113
- for (const p of params) {
114
- if (!p) throw new Error('add requires file, tag, text, source, cycle');
115
- }
118
+ function assertAddParams(file, tag, text, source, cycle) {
119
+ const missing = typeof file !== 'string' || [tag, text, source, cycle].some(v => !v);
120
+ if (missing) throw new Error('add requires file, tag, text, source, cycle');
116
121
  }
117
122
 
118
123
  function storeAdd(params, items, deps) {
119
- const { file, tag, text, source, cycle } = params;
124
+ const { file, tag, text, source, cycle, artefact_version } = params;
120
125
  const { hashFn, stateOf, validateSrc, persist, makeUlid, timestamp } = deps;
121
126
  assertAddParams(file, tag, text, source, cycle);
122
127
  validateSrc(source);
123
128
 
124
129
  const textHash = hashFn(text);
125
- const existing = items.find(it => isDuplicate(it, file, tag, textHash, stateOf));
130
+ const existing = items.find(it =>
131
+ it.artefact_version === artefact_version && isDuplicate(it, file, tag, textHash, stateOf)
132
+ );
126
133
  if (existing) return { id: existing.id, deduped: true };
127
134
 
128
135
  const id = makeUlid();
@@ -130,6 +137,9 @@ function storeAdd(params, items, deps) {
130
137
  id, file, tag, text, source,
131
138
  history: [{ state: 'open', stage: source, cycle, timestamp: timestamp() }],
132
139
  };
140
+ if (artefact_version !== undefined) {
141
+ item.artefact_version = artefact_version;
142
+ }
133
143
  persist([...items, item]);
134
144
  return { id, deduped: false };
135
145
  }
@@ -202,4 +212,20 @@ function storeTransition(params, items, deps) {
202
212
  return { ok: true };
203
213
  }
204
214
 
215
+ function storeForceState({ id, state, cycle, items, persist }) {
216
+ const item = items.find(x => x.id === id);
217
+ if (!item) return { ok: false, error: `feedback item not found: ${id}` };
218
+ const snapshot = { state, stage: 'system:forge-contract-mismatch', cycle, timestamp: nowIso() };
219
+ persist(applyTransition(items, id, snapshot));
220
+ return { ok: true };
221
+ }
222
+
223
+ function storeAutoResolve({ id, reason, cycle, items, persist, timestamp }) {
224
+ const item = items.find(x => x.id === id);
225
+ if (!item) return { ok: false, error: `feedback item not found: ${id}` };
226
+ const snapshot = { state: 'resolved', stage: 'system', cycle, reason, timestamp: timestamp() };
227
+ persist(applyTransition(items, id, snapshot));
228
+ return { ok: true };
229
+ }
230
+
205
231
 
@@ -49,7 +49,7 @@ function classifyFiles(files, allowedPatterns) {
49
49
  return { matched, unexpected };
50
50
  }
51
51
 
52
- export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes, io }) {
52
+ export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes, io, artefact_version }) {
53
53
  if (!io?.exec) {
54
54
  throw new Error('finalizeStage: io.exec is required');
55
55
  }
@@ -66,5 +66,13 @@ export function finalizeStage({ cwd, baseSha, stageBase, cycleDef, artefactTypes
66
66
  file,
67
67
  type: cycleDef.outputArtefactType,
68
68
  }));
69
- return { ok: true, artefacts, changedFiles: sortedFiles };
69
+ return forgeResult(artefacts, sortedFiles, artefact_version);
70
+ }
71
+
72
+ function forgeResult(artefacts, files, artefact_version) {
73
+ const result = { ok: true, artefacts, changedFiles: files };
74
+ if (artefact_version !== undefined) {
75
+ result.artefact_version = artefact_version;
76
+ }
77
+ return result;
70
78
  }
@@ -0,0 +1,93 @@
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.
5
+ *
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.
9
+ */
10
+
11
+ function currentState(feedbackStore, id) {
12
+ const item = feedbackStore.get(id);
13
+ return item ? item.history[0].state : null;
14
+ }
15
+
16
+ function revertAll(items, feedbackStore, cycleId) {
17
+ for (const item of items) {
18
+ feedbackStore.forceState(item.id, 'open', cycleId);
19
+ }
20
+ }
21
+
22
+ function postSystemFeedback(feedbackStore, cycleId, postVersion, text) {
23
+ feedbackStore.add({
24
+ file: '',
25
+ tag: 'system:forge-contract-mismatch',
26
+ text,
27
+ source: 'system:forge-contract-mismatch',
28
+ artefact_version: postVersion,
29
+ cycle: cycleId,
30
+ });
31
+ }
32
+
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
+ );
61
+ return { contractPassed: false };
62
+ }
63
+
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 };
71
+ }
72
+
73
+ return { contractPassed: true };
74
+ }
75
+
76
+ /**
77
+ * Enforce the forge contract on a batch of items presented to forge.
78
+ *
79
+ * Two-level check:
80
+ * 1. Per-item: every item must end in 'actioned' or 'wont-fix'.
81
+ * 2. Batch-level: artefact version semantics must be consistent.
82
+ *
83
+ * @param {{ items: Array<{id: string}>, preVersion: string, postVersion: string,
84
+ * feedbackStore: object, cycleId: string }} params
85
+ * @returns {{ contractPassed: boolean }}
86
+ */
87
+ export function enforceForgeContract({ items, preVersion, postVersion, feedbackStore, cycleId }) {
88
+ if (!checkPerItemResponse(items, feedbackStore, cycleId, postVersion)) {
89
+ return { contractPassed: false };
90
+ }
91
+
92
+ return checkBatchVersion(items, feedbackStore, cycleId, postVersion, preVersion);
93
+ }
@@ -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,7 +18,9 @@ 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
26
  const openCount = feedback.filter(f => f.state !== 'resolved').length;