@really-knows-ai/foundry 3.6.2 → 3.7.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.
- package/dist/.opencode/plugins/foundry-tools/stage-tools.js +0 -1
- package/dist/CHANGELOG.md +24 -0
- package/dist/scripts/appraise-module.js +6 -0
- package/dist/scripts/lib/feedback-store.js +4 -4
- package/dist/scripts/lib/forge-contract.js +70 -67
- package/dist/scripts/lib/sort-reason.js +7 -7
- package/dist/scripts/lib/sort-routing.js +25 -11
- package/dist/scripts/lib/validation.js +23 -2
- package/dist/scripts/orchestrate-cycle.js +38 -5
- package/dist/scripts/orchestrate-finalise.js +127 -0
- package/dist/scripts/orchestrate-phases.js +24 -130
- package/dist/scripts/orchestrate-terminals.js +1 -1
- package/dist/scripts/orchestrate.js +35 -6
- package/dist/scripts/quench-module.js +65 -9
- package/dist/scripts/sort.js +22 -0
- package/dist/skills/forge/SKILL.md +25 -30
- package/dist/skills/orchestrate/SKILL.md +9 -9
- package/package.json +1 -1
package/dist/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.7.0] - 2026-05-27
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Single-item forge dispatch: the forge stage dispatches one artefact at a time, producing focused, incremental edits instead of batch rewrites.
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
- Forge dispatch review findings addressed: tightened guard logic and cleaned up stale inline comments from the initial single-item implementation.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Forge and orchestrate skill guidance aligned with the single-item dispatch model.
|
|
20
|
+
|
|
21
|
+
## [3.6.3] - 2026-05-26
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Validator script crashes now surface a single clear error ("Validator produced no valid output — check the script for syntax errors") instead of 20+ individual "Invalid JSON" parse errors from the stack trace.
|
|
26
|
+
|
|
3
27
|
## [3.6.2] - 2026-05-25
|
|
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
|
|
3
|
-
* presented feedback item
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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 {{
|
|
88
|
-
*
|
|
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({
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
-
|
|
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)) {
|
|
@@ -197,6 +197,23 @@ export function collectValidatorResult(parseResult, lawId, validatorId, results)
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* If every line of output was unparseable, the validator script itself is
|
|
202
|
+
* broken (syntax error, runtime crash, etc.). Replace the noise of 20+
|
|
203
|
+
* individual "Invalid JSON" errors with a single actionable message.
|
|
204
|
+
*/
|
|
205
|
+
export function checkForValidatorCrash(result) {
|
|
206
|
+
if (result.items.length === 0 && result.parseErrors.length > 0) {
|
|
207
|
+
return {
|
|
208
|
+
...result,
|
|
209
|
+
parseErrors: [
|
|
210
|
+
`Validator produced no valid output (${result.parseErrors.length} unparseable lines). Check the validator script for syntax errors.`,
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
200
217
|
/**
|
|
201
218
|
* Execute a validator command and parse its JSONL output.
|
|
202
219
|
*/
|
|
@@ -209,14 +226,18 @@ export async function executeValidator(expanded, worktree, patterns) {
|
|
|
209
226
|
});
|
|
210
227
|
const { Readable } = await import('stream');
|
|
211
228
|
const stream = Readable.from([output]);
|
|
212
|
-
return
|
|
229
|
+
return checkForValidatorCrash(
|
|
230
|
+
await parseValidatorJsonl(stream, patterns),
|
|
231
|
+
);
|
|
213
232
|
} catch (err) {
|
|
214
233
|
// Validator command failed — prefer stdout for JSONL
|
|
215
234
|
// (tools like rg exit 1 with results on stdout)
|
|
216
235
|
const output = (err.stdout || err.stderr || err.message || '').trim();
|
|
217
236
|
const { Readable } = await import('stream');
|
|
218
237
|
const stream = Readable.from([output]);
|
|
219
|
-
return
|
|
238
|
+
return checkForValidatorCrash(
|
|
239
|
+
await parseValidatorJsonl(stream, patterns),
|
|
240
|
+
);
|
|
220
241
|
}
|
|
221
242
|
}
|
|
222
243
|
|
|
@@ -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
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
190
|
+
item,
|
|
191
|
+
preVersion: forgeCtx.forgePreVersion,
|
|
192
|
+
postVersion,
|
|
193
|
+
summary,
|
|
194
|
+
feedbackStore,
|
|
195
|
+
cycleId,
|
|
167
196
|
});
|
|
168
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
39
|
-
*
|
|
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,
|
|
52
|
+
async function resolveStaleQuenchFeedback(ctx, currentVersion, store) {
|
|
42
53
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/dist/scripts/sort.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
58
|
-
3.
|
|
59
|
-
4.
|
|
60
|
-
5.
|
|
61
|
-
6.
|
|
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
|
-
|
|
67
|
-
Each
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
3
|
+
"version": "3.7.0",
|
|
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",
|