@really-knows-ai/foundry 3.5.8 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +2 -3
- package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +9 -5
- package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +3 -1
- package/dist/CHANGELOG.md +38 -0
- package/dist/README.md +16 -10
- package/dist/docs/README.md +6 -6
- package/dist/docs/architecture.md +59 -19
- package/dist/docs/concepts.md +55 -19
- package/dist/docs/getting-started.md +37 -15
- package/dist/docs/memory-maintenance.md +3 -3
- package/dist/docs/tools.md +131 -70
- package/dist/docs/work-spec.md +38 -52
- package/dist/scripts/appraise-module.js +69 -7
- package/dist/scripts/lib/artefacts.js +43 -1
- package/dist/scripts/lib/config-creators/cycle.js +6 -10
- package/dist/scripts/lib/config-validators/cycle.js +1 -9
- package/dist/scripts/lib/feedback-store.js +26 -51
- package/dist/scripts/lib/finalize.js +10 -2
- package/dist/scripts/lib/forge-contract.js +93 -0
- package/dist/scripts/lib/history.js +2 -1
- package/dist/scripts/lib/sort-reason.js +11 -8
- package/dist/scripts/lib/sort-routing.js +185 -63
- package/dist/scripts/lib/workfile.js +28 -0
- package/dist/scripts/orchestrate-cycle.js +3 -13
- package/dist/scripts/orchestrate-phases.js +51 -45
- package/dist/scripts/orchestrate-terminals.js +37 -2
- package/dist/scripts/orchestrate.js +62 -5
- package/dist/scripts/quench-module.js +54 -12
- package/dist/scripts/sort.js +42 -62
- package/dist/skills/add-cycle/SKILL.md +4 -4
- package/dist/skills/add-flow/SKILL.md +1 -1
- package/dist/skills/human-appraise/SKILL.md +12 -40
- package/package.json +1 -1
package/dist/docs/work-spec.md
CHANGED
|
@@ -32,9 +32,8 @@ flow: <flow-id>
|
|
|
32
32
|
cycle: <current-cycle-id>
|
|
33
33
|
stages: [forge:write-haiku, quench:check-syllables, appraise:evaluate-quality]
|
|
34
34
|
max-iterations: 3
|
|
35
|
-
human-appraise: false
|
|
36
|
-
deadlock-appraise: true
|
|
37
|
-
deadlock-iterations: 5
|
|
35
|
+
always-human-appraise: false
|
|
36
|
+
deadlock-human-appraise: true
|
|
38
37
|
models:
|
|
39
38
|
forge: anthropic/claude-opus-4.7
|
|
40
39
|
appraise: openai/gpt-5
|
|
@@ -46,21 +45,21 @@ assay:
|
|
|
46
45
|
Fields:
|
|
47
46
|
- `flow` — the foundry flow being executed.
|
|
48
47
|
- `cycle` — the current cycle id.
|
|
49
|
-
- `stages` — the ordered route for this cycle. Each entry uses `base:alias` format where `base` is the stage type (`forge`, `quench`, `appraise`, `human-appraise`, or `assay`) and `alias` is a human-readable name for what that stage does in this cycle. The list is derived from the cycle and artefact type: `forge` and `appraise` are always included; `quench` is included iff any applicable law declares validators; `human-appraise` is included iff the cycle sets `human-appraise: true`; and `assay` is included iff the cycle declares an `assay.extractors` block.
|
|
48
|
+
- `stages` — the ordered route for this cycle. Each entry uses `base:alias` format where `base` is the stage type (`forge`, `quench`, `appraise`, `human-appraise`, or `assay`) and `alias` is a human-readable name for what that stage does in this cycle. The list is derived from the cycle and artefact type: `forge` and `appraise` are always included; `quench` is included iff any applicable law declares validators; `human-appraise` is included iff the cycle sets `always-human-appraise: true`; and `assay` is included iff the cycle declares an `assay.extractors` block.
|
|
50
49
|
- `max-iterations` — how many forge passes before the cycle is blocked (default: 3).
|
|
51
|
-
- `human-appraise` — run human-appraise every iteration (default: `false`).
|
|
52
|
-
- `deadlock-appraise` — route to human-appraise when
|
|
53
|
-
- `deadlock-iterations` — deadlock threshold (default: 5).
|
|
50
|
+
- `always-human-appraise` — run human-appraise every iteration (default: `false`).
|
|
51
|
+
- `deadlock-human-appraise` — route to human-appraise when the iteration count reaches `max-iterations` (default: `true`).
|
|
54
52
|
- `models` — optional per-stage model overrides; individual appraisers may further override via their own `model` field.
|
|
55
53
|
- `assay.extractors` — optional list of extractor names (defined under `foundry/memory/extractors/`) to run at iteration 0 before the first forge. Requires `foundry/memory/` to be initialized; cycle fails to load otherwise.
|
|
56
54
|
|
|
57
|
-
The `stages` list is the happy path. Sort follows it, loops back to `forge` when unresolved feedback demands it, and
|
|
55
|
+
The `stages` list is the happy path. Sort follows it, loops back to `forge` when unresolved feedback demands it, and routes to `human-appraise` when the iteration count reaches `max-iterations` (provided `deadlock-human-appraise` is `true`). If `assay` is configured, it runs once at iteration 0 before the route begins.
|
|
58
56
|
|
|
59
57
|
### Who sets what
|
|
60
58
|
|
|
61
59
|
- `flow`, `cycle` — set by the `flow` skill via `foundry_workfile_create` at flow start and updated as the flow advances between cycles.
|
|
62
60
|
- `goal` — written once by the `flow` skill when `WORK.md` is created.
|
|
63
|
-
- `stages`, `max-iterations`, `human-appraise`, `deadlock-appraise`, `
|
|
61
|
+
- `stages`, `max-iterations`, `always-human-appraise`, `deadlock-human-appraise`, `models`, `assay` — set by `foundry_orchestrate` on the first call of each cycle (via internal `workfile_configure_from_cycle`, reading the cycle definition).
|
|
62
|
+
- The frontmatter may also contain `status: failed` with a `reason` when the flow enters a failed state, locking all mutation tools.
|
|
64
63
|
|
|
65
64
|
## Sections
|
|
66
65
|
|
|
@@ -68,22 +67,6 @@ The `stages` list is the happy path. Sort follows it, loops back to `forge` when
|
|
|
68
67
|
|
|
69
68
|
Free text describing what the foundry flow is producing and any context the human provided. Written once at foundry flow start, not modified after.
|
|
70
69
|
|
|
71
|
-
### Artefacts
|
|
72
|
-
|
|
73
|
-
A table tracking every artefact produced by the foundry flow. The generator (`createWorkfile` in `src/scripts/lib/workfile.js`) writes the table immediately after the `# Goal` body — there is no `# Artefacts` heading. The orchestrator's internal finalise step appends rows for matching output files; authoring tools should not edit the artefacts table directly.
|
|
74
|
-
|
|
75
|
-
```markdown
|
|
76
|
-
| File | Type | Cycle | Status |
|
|
77
|
-
|------|------|-------|--------|
|
|
78
|
-
| petitions/login-change.md | petition | write-petition | draft |
|
|
79
|
-
| features/login-change.feature | gherkin | petition-to-gherkin | draft |
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Statuses:
|
|
83
|
-
- `draft` — artefact exists but has not cleared all stages
|
|
84
|
-
- `done` — artefact has cleared all stages
|
|
85
|
-
- `blocked` — artefact hit iteration limit or a violation
|
|
86
|
-
|
|
87
70
|
## WORK.feedback.yaml
|
|
88
71
|
|
|
89
72
|
The flow run owns one `WORK.feedback.yaml` file alongside `WORK.md` and
|
|
@@ -113,40 +96,48 @@ Each history snapshot:
|
|
|
113
96
|
|
|
114
97
|
| Field | Type | Required | Notes |
|
|
115
98
|
|-------|------|----------|-------|
|
|
116
|
-
| `state` | enum | yes | `open \| actioned \| wont-fix \| rejected \|
|
|
99
|
+
| `state` | enum | yes | `open \| actioned \| wont-fix \| rejected \| resolved` |
|
|
117
100
|
| `stage` | string (`base:alias`) or literal `sort` | yes | Who performed the transition |
|
|
118
101
|
| `cycle` | string | yes | Cycle id at the time of the transition |
|
|
119
102
|
| `timestamp` | ISO-8601 UTC with ms | yes | |
|
|
120
|
-
| `reason` | string | conditional | Required on `rejected`, `wont-fix
|
|
103
|
+
| `reason` | string | conditional | Required on `rejected`, `wont-fix`; forbidden on `open`; optional on `actioned`, `resolved` |
|
|
121
104
|
|
|
122
105
|
`history[0]` is always the current state; new snapshots are prepended.
|
|
123
106
|
`resolved` is terminal.
|
|
124
107
|
|
|
125
108
|
### State machine
|
|
126
109
|
|
|
127
|
-
Feedback items flow through a
|
|
110
|
+
Feedback items flow through a five-state lifecycle: `open` (newly raised), `actioned` (forge has addressed it), `wont-fix` (forge declined subjective feedback with justification), `rejected` (appraiser or human overruled the wont-fix), and `resolved` (approved by the item's originating stage or human override). Transitions are source-based: the legal moves depend on what stage created the item and who is trying to transition it. The feedback state machine is the engine that routes work between cycles.
|
|
128
111
|
|
|
129
|
-
The
|
|
112
|
+
The five states and the legal transitions are:
|
|
130
113
|
|
|
131
|
-
| From \ Caller | forge (any source) | source-stage (quench / appraise / human-appraise where stageId === item.source) |
|
|
132
|
-
|
|
133
|
-
| `open` | -> `actioned` always; -> `wont-fix` only if `item.source` base is `appraise` | — |
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
| `resolved` | — | — | — | — (terminal) |
|
|
114
|
+
| From \ Caller | forge (any source) | source-stage (quench / appraise / human-appraise where stageId === item.source) | human-appraise (override authority, any source) |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| `open` | -> `actioned` always; -> `wont-fix` only if `item.source` base is `appraise` | — | — |
|
|
117
|
+
| `actioned` | — | -> `{resolved, rejected}` | -> `{resolved, rejected}` |
|
|
118
|
+
| `wont-fix` | — | -> `{resolved, rejected}` | -> `{resolved, rejected}` |
|
|
119
|
+
| `rejected` | -> `actioned` always; -> `wont-fix` only if `item.source` base is `appraise` | — | — |
|
|
120
|
+
| `resolved` | — | — | — (terminal) |
|
|
139
121
|
|
|
140
122
|
Notes:
|
|
141
123
|
|
|
142
|
-
- `source-stage` column applies when the caller's stage id exactly matches `item.source` (e.g. `appraise:write-check` resolving an item it created). `human-appraise` override authority (last column) applies regardless of `item.source` and
|
|
124
|
+
- `source-stage` column applies when the caller's stage id exactly matches `item.source` (e.g. `appraise:write-check` resolving an item it created). `human-appraise` override authority (last column) applies regardless of `item.source` and operates only on items in `actioned` or `wont-fix` state (or `deadlocked` for backward compatibility with existing feedback files).
|
|
143
125
|
- **Forge `wont-fix` scope.** When `item.source` base is `quench` (objective validation failure) or `human-appraise` (direct user instruction), forge may not `wont-fix` — it must `actioned`. Only `appraise`-sourced items are wont-fix-able by forge. This replaces the earlier tag-based restriction on `validation` / `human` tags.
|
|
144
|
-
- `tag` is categorical and display-only. The state machine consults `source`, not tags
|
|
145
|
-
- **Reason required on** `rejected`, `wont-fix
|
|
146
|
-
- Sort
|
|
126
|
+
- `tag` is categorical and display-only. The state machine consults `source`, not tags.
|
|
127
|
+
- **Reason required on** `rejected`, `wont-fix`. **Forbidden on** `open`. **Optional on** `actioned`, `resolved`.
|
|
128
|
+
- Sort does not write `state: deadlocked`. Deadlock detection in sort uses iteration count comparison against `max-iterations` and `deadlock-human-appraise` to decide whether to route to human-appraise, not per-item deadlock state. The feedback-transition validator still recognises `deadlocked` state for backward compatibility with existing feedback files.
|
|
147
129
|
|
|
148
130
|
This section is the authoritative specification of the feedback state machine.
|
|
149
131
|
|
|
132
|
+
### Feedback tag gates
|
|
133
|
+
|
|
134
|
+
`foundry_feedback_add` enforces per-stage tag rules:
|
|
135
|
+
|
|
136
|
+
- **forge** and **assay** stages cannot add feedback; the tool returns an error.
|
|
137
|
+
- **quench** and **appraise** stages require tags starting with `law:`.
|
|
138
|
+
- **human-appraise** requires the tag `human`.
|
|
139
|
+
- Quench validator feedback uses the tag format `law:<law-id>:<validator-id>`.
|
|
140
|
+
|
|
150
141
|
### Transitions are made via the plugin API
|
|
151
142
|
|
|
152
143
|
No direct yaml editing. Every state change goes through one of:
|
|
@@ -166,10 +157,10 @@ A crash between the two steps leaves the live file untouched.
|
|
|
166
157
|
| Section | Written by | Updated by |
|
|
167
158
|
|---------|-----------|------------|
|
|
168
159
|
| Frontmatter (`flow`, `cycle`) | `foundry_workfile_create` (flow skill) | updated in place as the flow advances between cycles |
|
|
169
|
-
| Frontmatter (`stages`, `max-iterations`, `human-appraise`, `deadlock-appraise`, `
|
|
160
|
+
| Frontmatter (`stages`, `max-iterations`, `always-human-appraise`, `deadlock-human-appraise`, `models`, `assay`) | `foundry_orchestrate` (first call of each cycle, internally) | reset on each new cycle |
|
|
170
161
|
| Goal | `foundry_workfile_create` (flow skill) | nobody |
|
|
171
162
|
| Artefact files | forge stage writes files on disk | git tracks file changes; cycle-level state records completion or failure |
|
|
172
|
-
| `WORK.feedback.yaml` | `foundry_feedback_add` (`quench` / `appraise` / `human-appraise`) | `foundry_feedback_action` / `foundry_feedback_wontfix` (forge), `foundry_feedback_resolve` (source stage / human-appraise override)
|
|
163
|
+
| `WORK.feedback.yaml` | `foundry_feedback_add` (`quench` / `appraise` / `human-appraise`) | `foundry_feedback_action` / `foundry_feedback_wontfix` (forge), `foundry_feedback_resolve` (source stage / human-appraise override) |
|
|
173
164
|
| `WORK.history.yaml` | `foundry_orchestrate` | `foundry_orchestrate` |
|
|
174
165
|
|
|
175
166
|
Artefact files are discovered from branch changes matching the cycle output type's `file-patterns`.
|
|
@@ -231,7 +222,8 @@ A separate file (`WORK.history.yaml`) alongside WORK.md. Append-only log of ever
|
|
|
231
222
|
| `timestamp` | ISO-8601 UTC with ms | yes | |
|
|
232
223
|
| `seq` | integer | yes on write | Monotonic per file; sort tiebreaker for same-ms entries |
|
|
233
224
|
| `route` | string | conditional | Only on `stage: sort` entries; records the route decision. Throws if set on a non-sort entry |
|
|
234
|
-
| `open_feedback` | integer | yes on write | Count of non-resolved items in `WORK.feedback.yaml` at the time of write
|
|
225
|
+
| `open_feedback` | integer | yes on write | Count of non-resolved items in `WORK.feedback.yaml` at the time of write |
|
|
226
|
+
| `changed_files` | array of strings | conditional | Only on stage entries; records the list of files detected as changed by the finalise step for that stage |
|
|
235
227
|
|
|
236
228
|
### Rules
|
|
237
229
|
|
|
@@ -265,9 +257,8 @@ flow: make-haiku
|
|
|
265
257
|
cycle: haiku-creation
|
|
266
258
|
stages: [forge:write-haiku, quench:check-syllables, appraise:evaluate-quality]
|
|
267
259
|
max-iterations: 3
|
|
268
|
-
human-appraise: false
|
|
269
|
-
deadlock-appraise: true
|
|
270
|
-
deadlock-iterations: 5
|
|
260
|
+
always-human-appraise: false
|
|
261
|
+
deadlock-human-appraise: true
|
|
271
262
|
---
|
|
272
263
|
|
|
273
264
|
# Goal
|
|
@@ -275,9 +266,4 @@ deadlock-iterations: 5
|
|
|
275
266
|
Write a haiku about autumn rain. Should evoke loneliness
|
|
276
267
|
and the sound of rain on leaves.
|
|
277
268
|
|
|
278
|
-
| File | Type | Cycle | Status |
|
|
279
|
-
|------|------|-------|--------|
|
|
280
|
-
| petitions/autumn-rain-haiku.md | petition | haiku-ideation | done |
|
|
281
|
-
| haiku/autumn-rain.md | haiku | haiku-creation | draft |
|
|
282
|
-
|
|
283
269
|
```
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -52,14 +52,11 @@ function renderModels(models) {
|
|
|
52
52
|
/** Render boolean and numeric flags (camelCase to kebab-case). */
|
|
53
53
|
function renderFlags(args) {
|
|
54
54
|
let fm = '';
|
|
55
|
-
if (args.
|
|
56
|
-
fm += `human-appraise: ${args.
|
|
55
|
+
if (args.alwaysHumanAppraise !== undefined) {
|
|
56
|
+
fm += `always-human-appraise: ${args.alwaysHumanAppraise}\n`;
|
|
57
57
|
}
|
|
58
|
-
if (args.
|
|
59
|
-
fm += `deadlock-appraise: ${args.
|
|
60
|
-
}
|
|
61
|
-
if (args.deadlockIterations !== undefined) {
|
|
62
|
-
fm += `deadlock-iterations: ${args.deadlockIterations}\n`;
|
|
58
|
+
if (args.deadlockHumanAppraise !== undefined) {
|
|
59
|
+
fm += `deadlock-human-appraise: ${args.deadlockHumanAppraise}\n`;
|
|
63
60
|
}
|
|
64
61
|
if (args.maxIterations !== undefined) {
|
|
65
62
|
fm += `max-iterations: ${args.maxIterations}\n`;
|
|
@@ -76,9 +73,8 @@ function renderFlags(args) {
|
|
|
76
73
|
* @param {string} args.outputType Artefact type ID this cycle produces.
|
|
77
74
|
* @param {{ type: 'any-of'|'all-of', artefacts: string[] }} [args.inputs] Input contract.
|
|
78
75
|
* @param {string[]} [args.targets] Downstream cycle IDs.
|
|
79
|
-
* @param {boolean} [args.
|
|
80
|
-
* @param {boolean} [args.
|
|
81
|
-
* @param {number} [args.deadlockIterations] Iteration threshold for deadlock detection.
|
|
76
|
+
* @param {boolean} [args.alwaysHumanAppraise] Include human-appraise in every iteration.
|
|
77
|
+
* @param {boolean} [args.deadlockHumanAppraise] Route to human-appraise when max-iterations is reached.
|
|
82
78
|
* @param {number} [args.maxIterations] Maximum forge iterations.
|
|
83
79
|
* @param {{ extractors: string[] }} [args.assay] Assay stage config.
|
|
84
80
|
* @param {{ read: string[], write: string[] }} [args.memory] Flow memory permissions.
|
|
@@ -29,7 +29,6 @@ export async function validate({ name, body, io }) {
|
|
|
29
29
|
await checkOutputType(fm, io),
|
|
30
30
|
...await checkInputs(fm, io),
|
|
31
31
|
...await checkTargets(fm, io),
|
|
32
|
-
checkIterationLimits(fm),
|
|
33
32
|
].filter(Boolean);
|
|
34
33
|
|
|
35
34
|
return errors.length ? { ok: false, errors } : { ok: true };
|
|
@@ -131,11 +130,4 @@ async function validateCycleRefs(targets, io) {
|
|
|
131
130
|
return errors;
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
|
|
135
|
-
const maxIt = fm['max-iterations'];
|
|
136
|
-
const dlIt = fm['deadlock-iterations'];
|
|
137
|
-
if (maxIt !== undefined && dlIt !== undefined && dlIt > maxIt) {
|
|
138
|
-
return `deadlock-iterations (${dlIt}) must be <= max-iterations (${maxIt}); deadlock would never trigger before the cycle blocks`;
|
|
139
|
-
}
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
133
|
+
|
|
@@ -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,11 +96,11 @@ export function openFeedbackStore(path, io) {
|
|
|
96
96
|
timestamp: nowIso, persist,
|
|
97
97
|
});
|
|
98
98
|
},
|
|
99
|
-
|
|
100
|
-
return
|
|
99
|
+
autoResolve({ id, reason, cycle }) {
|
|
100
|
+
return storeAutoResolve({ id, reason, cycle, items, persist, timestamp: nowIso });
|
|
101
101
|
},
|
|
102
|
-
|
|
103
|
-
return
|
|
102
|
+
forceState(id, state, cycle) {
|
|
103
|
+
return storeForceState({ id, state, cycle, items, persist });
|
|
104
104
|
},
|
|
105
105
|
resolveSystemItems(stage, cycle) {
|
|
106
106
|
resolveSystemItemsImpl({ items, stage, cycle, timestamp: nowIso, persist });
|
|
@@ -115,20 +115,21 @@ function isDuplicate(item, file, tag, textHash, stateOf) {
|
|
|
115
115
|
stateOf(item) !== 'resolved';
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
function assertAddParams(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
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');
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
function storeAdd(params, items, deps) {
|
|
125
|
-
const { file, tag, text, source, cycle } = params;
|
|
124
|
+
const { file, tag, text, source, cycle, artefact_version } = params;
|
|
126
125
|
const { hashFn, stateOf, validateSrc, persist, makeUlid, timestamp } = deps;
|
|
127
126
|
assertAddParams(file, tag, text, source, cycle);
|
|
128
127
|
validateSrc(source);
|
|
129
128
|
|
|
130
129
|
const textHash = hashFn(text);
|
|
131
|
-
const existing = items.find(it =>
|
|
130
|
+
const existing = items.find(it =>
|
|
131
|
+
it.artefact_version === artefact_version && isDuplicate(it, file, tag, textHash, stateOf)
|
|
132
|
+
);
|
|
132
133
|
if (existing) return { id: existing.id, deduped: true };
|
|
133
134
|
|
|
134
135
|
const id = makeUlid();
|
|
@@ -136,6 +137,9 @@ function storeAdd(params, items, deps) {
|
|
|
136
137
|
id, file, tag, text, source,
|
|
137
138
|
history: [{ state: 'open', stage: source, cycle, timestamp: timestamp() }],
|
|
138
139
|
};
|
|
140
|
+
if (artefact_version !== undefined) {
|
|
141
|
+
item.artefact_version = artefact_version;
|
|
142
|
+
}
|
|
139
143
|
persist([...items, item]);
|
|
140
144
|
return { id, deduped: false };
|
|
141
145
|
}
|
|
@@ -153,7 +157,7 @@ function forgeWontFixError(item) {
|
|
|
153
157
|
|
|
154
158
|
function reasonRequiredForTransition(target, current, reason) {
|
|
155
159
|
const REASON_REQUIRED_TARGETS = new Set(['rejected', 'wont-fix']);
|
|
156
|
-
return
|
|
160
|
+
return REASON_REQUIRED_TARGETS.has(target)
|
|
157
161
|
&& (!reason || !reason.trim());
|
|
158
162
|
}
|
|
159
163
|
|
|
@@ -208,49 +212,20 @@ function storeTransition(params, items, deps) {
|
|
|
208
212
|
return { ok: true };
|
|
209
213
|
}
|
|
210
214
|
|
|
211
|
-
function
|
|
212
|
-
const { id, cycle, reason } = params;
|
|
213
|
-
const { timestamp, persist } = deps;
|
|
214
|
-
|
|
215
|
+
function storeForceState({ id, state, cycle, items, persist }) {
|
|
215
216
|
const item = items.find(x => x.id === id);
|
|
216
217
|
if (!item) return { ok: false, error: `feedback item not found: ${id}` };
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const snapshot = { state: 'deadlocked', stage: 'sort', cycle, timestamp: timestamp(), reason };
|
|
222
|
-
persist(items.map(it =>
|
|
223
|
-
it.id === id ? { ...it, history: [snapshot, ...it.history] } : it
|
|
224
|
-
));
|
|
218
|
+
const snapshot = { state, stage: 'system:forge-contract-mismatch', cycle, timestamp: nowIso() };
|
|
219
|
+
persist(applyTransition(items, id, snapshot));
|
|
225
220
|
return { ok: true };
|
|
226
221
|
}
|
|
227
222
|
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
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 };
|
|
233
229
|
}
|
|
234
230
|
|
|
235
|
-
function storeWriteDeadlockedSnapshots(params, items, deps) {
|
|
236
|
-
const { ids, reason, stage, cycle } = params;
|
|
237
|
-
const { timestamp, persist } = deps;
|
|
238
231
|
|
|
239
|
-
const precheck = assertDeadlockedSnapshotArgs(ids, reason);
|
|
240
|
-
if (precheck) return precheck;
|
|
241
|
-
|
|
242
|
-
const ts = timestamp();
|
|
243
|
-
const idSet = new Set(ids);
|
|
244
|
-
const nextItems = items.map(it => {
|
|
245
|
-
if (!idSet.has(it.id)) return it;
|
|
246
|
-
return { ...it, history: [{ state: 'deadlocked', stage, cycle, timestamp: ts, reason }, ...it.history] };
|
|
247
|
-
});
|
|
248
|
-
for (const id of ids) {
|
|
249
|
-
if (!items.some(it => it.id === id)) {
|
|
250
|
-
return { ok: false, error: `feedback item(s) not found: ${id}` };
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
persist(nextItems);
|
|
255
|
-
return { ok: true };
|
|
256
|
-
}
|
|
@@ -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
|
|
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
|
+
}
|