@really-knows-ai/foundry 3.5.8 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +16 -10
  2. package/dist/.opencode/plugins/foundry-tools/config-create-tools.js +2 -3
  3. package/dist/.opencode/plugins/foundry-tools/feedback-tools.js +9 -5
  4. package/dist/.opencode/plugins/foundry-tools/orchestrate-tool.js +3 -1
  5. package/dist/CHANGELOG.md +38 -0
  6. package/dist/README.md +16 -10
  7. package/dist/docs/README.md +6 -6
  8. package/dist/docs/architecture.md +59 -19
  9. package/dist/docs/concepts.md +55 -19
  10. package/dist/docs/getting-started.md +37 -15
  11. package/dist/docs/memory-maintenance.md +3 -3
  12. package/dist/docs/tools.md +131 -70
  13. package/dist/docs/work-spec.md +38 -52
  14. package/dist/scripts/appraise-module.js +69 -7
  15. package/dist/scripts/lib/artefacts.js +43 -1
  16. package/dist/scripts/lib/config-creators/cycle.js +6 -10
  17. package/dist/scripts/lib/config-validators/cycle.js +1 -9
  18. package/dist/scripts/lib/feedback-store.js +26 -51
  19. package/dist/scripts/lib/finalize.js +10 -2
  20. package/dist/scripts/lib/forge-contract.js +93 -0
  21. package/dist/scripts/lib/history.js +2 -1
  22. package/dist/scripts/lib/sort-reason.js +11 -8
  23. package/dist/scripts/lib/sort-routing.js +185 -63
  24. package/dist/scripts/lib/workfile.js +28 -0
  25. package/dist/scripts/orchestrate-cycle.js +3 -13
  26. package/dist/scripts/orchestrate-phases.js +51 -45
  27. package/dist/scripts/orchestrate-terminals.js +37 -2
  28. package/dist/scripts/orchestrate.js +62 -5
  29. package/dist/scripts/quench-module.js +54 -12
  30. package/dist/scripts/sort.js +42 -62
  31. package/dist/skills/add-cycle/SKILL.md +4 -4
  32. package/dist/skills/add-flow/SKILL.md +1 -1
  33. package/dist/skills/human-appraise/SKILL.md +12 -40
  34. package/package.json +1 -1
@@ -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 LLM appraisers deadlock (default: `true`).
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 may insert `human-appraise` on deadlock. If `assay` is configured, it runs once at iteration 0 before the route begins.
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`, `deadlock-iterations`, `models`, `assay` — set by `foundry_orchestrate` on the first call of each cycle (via internal `workfile_configure_from_cycle`, reading the cycle definition).
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 \| deadlocked \| resolved` |
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`, `deadlocked`, `resolved`; forbidden on `open`; optional on `actioned` |
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 six-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), `deadlocked` (sort detected repeated forge/appraise iterations on the same item), 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.
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 six states and the legal transitions are:
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) | sort | human-appraise (override authority, any source) |
132
- |---|---|---|---|---|
133
- | `open` | -> `actioned` always; -> `wont-fix` only if `item.source` base is `appraise` | — | -> `deadlocked` (if depth >= threshold) | — |
134
- | `rejected` | -> `actioned` always; -> `wont-fix` only if `item.source` base is `appraise` | — | -> `deadlocked` (if depth >= threshold) | — |
135
- | `actioned` | — | -> `{resolved, rejected}` | -> `deadlocked` (if depth >= threshold) | -> `{resolved, rejected}` |
136
- | `wont-fix` | — | -> `{resolved, rejected}` | -> `deadlocked` (if depth >= threshold) | -> `{resolved, rejected}` |
137
- | `deadlocked` | — | — | — | -> `{resolved, rejected}` |
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 is the only path that can transition out of `deadlocked`.
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; `validation` / `human` tag-based restrictions are legacy and do not apply.
145
- - **Reason required on** `rejected`, `wont-fix`, `deadlocked`, `resolved`. **Forbidden on** `open`. **Optional on** `actioned` (the code change is the reason).
146
- - Sort is the only writer of `state: deadlocked`; it writes these via its internal pass, not through the plugin API.
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`, `deadlock-iterations`, `models`) | `foundry_orchestrate` (first call of each cycle, internally) | reset on each new cycle |
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); sort writes only deadlocked snapshots |
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; deadlocked items are counted |
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
- * Consolidate appraiser results after all subagents have run.
153
+ * Resolve stale feedback items whose artefact version does not match the
154
+ * current on-disk version. Items from this stage's source base with a
155
+ * mismatched artefact_version are auto-resolved as superseded.
156
+ */
157
+ export function resolveStaleFeedback(items, currentVersion, stageBase, feedback, cycle) {
158
+ for (const item of items) {
159
+ if (shouldSkipStaleResolve(item, currentVersion, stageBase)) continue;
160
+ const reason = `superseded by forge revision ${currentVersion}`;
161
+ feedback.autoResolve({ id: item.id, reason, cycle });
162
+ }
163
+ }
164
+
165
+ function shouldSkipStaleResolve(item, currentVersion, stageBase) {
166
+ if (item.history[0].state === 'resolved') return true;
167
+ const itemBase = typeof item.source === 'string' ? item.source.split(':')[0] : '';
168
+ if (itemBase !== stageBase) return true;
169
+ if (item.artefact_version === currentVersion) return true;
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Resolve stale appraise-sourced feedback. Errors propagate to the caller
175
+ * which must handle them (e.g. by returning a violation).
176
+ */
177
+ async function resolveStaleAppraiseFeedback(ctx) {
178
+ try {
179
+ const cycleDef = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
180
+ const outputType = cycleDef.frontmatter['output-type'];
181
+ if (outputType) {
182
+ const store = openFeedbackStore('WORK.feedback.yaml', ctx.io);
183
+ const currentVersion = await computeArtefactVersion(ctx.foundryDir, outputType, ctx.io, ctx.cwd);
184
+ resolveStaleFeedback(store.list(), currentVersion, 'appraise', store, ctx.cycleId);
185
+ }
186
+ } catch {
187
+ // Graceful degrade — stale resolution is best-effort.
188
+ // The orchestrator handles IO failures at the cycle level.
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Consolidate appraiser results and finalise the appraise stage.
151
194
  *
152
- * Parses each successful output for structured issues, unions across
153
- * appraisers, de-duplicates by (file, law-id, issue text), posts feedback,
154
- * resolves stale prior appraise feedback, and finalises the stage.
195
+ * Called by orchestrator after all appraisers have completed. Parses results,
196
+ * posts combined feedback, resolves prior appraise feedback, and advances
197
+ * the cycle to the next stage via finalize.
155
198
  *
156
199
  * @param {object} ctx
157
200
  * @param {Array<{ok: boolean, output?: string, error?: string}>} lastResults
@@ -170,10 +213,13 @@ export async function consolidateAppraise(ctx, lastResults) {
170
213
  return violation('All appraisers failed to evaluate the artefact', []);
171
214
  }
172
215
 
216
+ await resolveStaleAppraiseFeedback(ctx);
217
+
173
218
  const consolidated = parseConsolidated(successful);
174
219
  const stageId = `appraise:${ctx.cycleId}`;
175
220
 
176
- postConsolidatedFeedback(ctx, consolidated);
221
+ const artefactVersion = await computeAppraiseArtefactVersion(ctx);
222
+ postConsolidatedFeedback(ctx, consolidated, artefactVersion);
177
223
  resolvePriorAppraise(ctx, consolidated, stageId);
178
224
 
179
225
  const summary = buildConsolidateSummary(consolidated.length);
@@ -219,15 +265,31 @@ function deduplicateIssues(issues) {
219
265
  return result;
220
266
  }
221
267
 
268
+ /**
269
+ * Compute artefact version for the appraise cycle so feedback items carry
270
+ * a version hash and are not auto-resolved by sort as legacy items.
271
+ */
272
+ async function computeAppraiseArtefactVersion(ctx) {
273
+ try {
274
+ const cycleDef = await getCycleDefinition(ctx.foundryDir, ctx.cycleId, ctx.io);
275
+ const outputType = cycleDef.frontmatter['output-type'];
276
+ if (outputType) {
277
+ return await computeArtefactVersion(ctx.foundryDir, outputType, ctx.io, ctx.cwd);
278
+ }
279
+ } catch { /* skip */ }
280
+ return undefined;
281
+ }
282
+
222
283
  /**
223
284
  * Post one feedback item per consolidated issue.
224
285
  */
225
- function postConsolidatedFeedback(ctx, consolidated) {
286
+ function postConsolidatedFeedback(ctx, consolidated, artefactVersion) {
226
287
  for (const issue of consolidated) {
227
288
  ctx.feedback.add({
228
289
  file: issue.file,
229
290
  text: issue.issue,
230
291
  tag: `law:${issue.law}`,
292
+ artefact_version: artefactVersion,
231
293
  });
232
294
  }
233
295
  }
@@ -6,8 +6,9 @@
6
6
  */
7
7
 
8
8
  import { minimatch } from 'minimatch';
9
- import { sortPaths } from './attestation/hash.js';
9
+ import { sortPaths, sha256Text } from './attestation/hash.js';
10
10
  import { getArtefactType } from './config.js';
11
+ import { expandPatterns } from './validation.js';
11
12
 
12
13
  // --- Shared branch artefact discovery ---
13
14
 
@@ -164,3 +165,44 @@ export async function getArtefactFiles(foundryDir, typeId, io, options = {}) {
164
165
 
165
166
  return result;
166
167
  }
168
+
169
+ /**
170
+ * Compute the artefact version hash for a given artefact type.
171
+ *
172
+ * Reads the artefact type definition, expands its file patterns across the
173
+ * worktree, and computes a SHA-256 hash over all matching files. Each file
174
+ * contributes `sha256(filePath + ":" + content)` and the per-file hashes are
175
+ * joined with "\n" before the final SHA-256.
176
+ *
177
+ * When no patterns are defined or no files match, returns the SHA-256 of an
178
+ * empty input (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855).
179
+ *
180
+ * @param {string} foundryDir - Path to the foundry config directory
181
+ * @param {string} typeId - Artefact type identifier
182
+ * @param {object} io - IO interface with readFile(path, encoding)
183
+ * @param {string} [worktree] - Worktree root for pattern expansion (defaults to foundryDir)
184
+ * @returns {Promise<string>} SHA-256 hex string (64 characters)
185
+ * @throws {Error} On IO errors (unknown type, file read failure, glob error)
186
+ */
187
+ export async function computeArtefactVersion(foundryDir, typeId, io, worktree = foundryDir) {
188
+ const def = await getArtefactType(foundryDir, typeId, io);
189
+ const patterns = getFilePatterns(def);
190
+
191
+ if (patterns.length === 0) {
192
+ return sha256Text('');
193
+ }
194
+
195
+ const files = await expandPatterns(patterns, worktree);
196
+
197
+ if (files.length === 0) {
198
+ return sha256Text('');
199
+ }
200
+
201
+ const perFileHashes = await Promise.all(files.map(async file => {
202
+ const content = await io.readFile(file, 'utf-8');
203
+ return sha256Text(file + ':' + content);
204
+ }));
205
+
206
+ const joined = perFileHashes.join('\n');
207
+ return sha256Text(joined);
208
+ }
@@ -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.humanAppraise !== undefined) {
56
- fm += `human-appraise: ${args.humanAppraise}\n`;
55
+ if (args.alwaysHumanAppraise !== undefined) {
56
+ fm += `always-human-appraise: ${args.alwaysHumanAppraise}\n`;
57
57
  }
58
- if (args.deadlockAppraise !== undefined) {
59
- fm += `deadlock-appraise: ${args.deadlockAppraise}\n`;
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.humanAppraise] Include human-appraise in every iteration.
80
- * @param {boolean} [args.deadlockAppraise] Route to human-appraise on deadlock.
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
- function checkIterationLimits(fm) {
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
- writeDeadlockedSnapshotForTest(params) {
100
- return storeWriteDeadlockedSnapshot(params, items, { timestamp: nowIso, persist });
99
+ autoResolve({ id, reason, cycle }) {
100
+ return storeAutoResolve({ id, reason, cycle, items, persist, timestamp: nowIso });
101
101
  },
102
- writeDeadlockedSnapshots(ids, reason, stage, cycle) {
103
- return storeWriteDeadlockedSnapshots({ ids, reason, stage, cycle }, items, { timestamp: nowIso, persist });
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(...params) {
119
- for (const p of params) {
120
- if (!p) throw new Error('add requires file, tag, text, source, cycle');
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 => isDuplicate(it, file, tag, textHash, stateOf));
130
+ const existing = items.find(it =>
131
+ it.artefact_version === artefact_version && isDuplicate(it, file, tag, textHash, stateOf)
132
+ );
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 (REASON_REQUIRED_TARGETS.has(target) || current === 'deadlocked')
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 storeWriteDeadlockedSnapshot(params, items, deps) {
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
- if (!reason || !reason.trim()) {
218
- return { ok: false, error: 'reason is required for deadlocked snapshot' };
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 assertDeadlockedSnapshotArgs(ids, reason) {
229
- if (!Array.isArray(ids)) return { ok: false, error: 'ids must be an array' };
230
- if (ids.length === 0) return { ok: true };
231
- if (!reason) return { ok: false, error: 'reason is required for deadlocked snapshot' };
232
- return null;
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 { ok: true, artefacts, changedFiles: sortedFiles };
69
+ return forgeResult(artefacts, sortedFiles, artefact_version);
70
+ }
71
+
72
+ function forgeResult(artefacts, files, artefact_version) {
73
+ const result = { ok: true, artefacts, changedFiles: files };
74
+ if (artefact_version !== undefined) {
75
+ result.artefact_version = artefact_version;
76
+ }
77
+ return result;
70
78
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Forge contract enforcement — validates that forge responded to every
3
+ * presented feedback item and that the batch-level artefact version
4
+ * semantics are satisfied.
5
+ *
6
+ * Per-item check: every item must end in 'actioned' or 'wont-fix'.
7
+ * Batch-level check: if any item is actioned, version must change.
8
+ * If no item is actioned, version must be unchanged.
9
+ */
10
+
11
+ function currentState(feedbackStore, id) {
12
+ const item = feedbackStore.get(id);
13
+ return item ? item.history[0].state : null;
14
+ }
15
+
16
+ function revertAll(items, feedbackStore, cycleId) {
17
+ for (const item of items) {
18
+ feedbackStore.forceState(item.id, 'open', cycleId);
19
+ }
20
+ }
21
+
22
+ function postSystemFeedback(feedbackStore, cycleId, postVersion, text) {
23
+ feedbackStore.add({
24
+ file: '',
25
+ tag: 'system:forge-contract-mismatch',
26
+ text,
27
+ source: 'system:forge-contract-mismatch',
28
+ artefact_version: postVersion,
29
+ cycle: cycleId,
30
+ });
31
+ }
32
+
33
+ function checkPerItemResponse(items, feedbackStore, cycleId, postVersion) {
34
+ for (const item of items) {
35
+ const state = currentState(feedbackStore, item.id);
36
+ if (state !== 'actioned' && state !== 'wont-fix') {
37
+ revertAll(items, feedbackStore, cycleId);
38
+ postSystemFeedback(
39
+ feedbackStore, cycleId, postVersion,
40
+ 'forge did not respond to every presented feedback item',
41
+ );
42
+ return false;
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+
48
+ function hasActionedItem(items, feedbackStore) {
49
+ return items.some(item => currentState(feedbackStore, item.id) === 'actioned');
50
+ }
51
+
52
+ function checkBatchVersion(items, feedbackStore, cycleId, postVersion, preVersion) {
53
+ const hasActioned = hasActionedItem(items, feedbackStore);
54
+
55
+ if (hasActioned && preVersion === postVersion) {
56
+ revertAll(items, feedbackStore, cycleId);
57
+ postSystemFeedback(
58
+ feedbackStore, cycleId, postVersion,
59
+ 'forge marked feedback as actioned without changing artefacts',
60
+ );
61
+ return { contractPassed: false };
62
+ }
63
+
64
+ if (!hasActioned && preVersion !== postVersion) {
65
+ revertAll(items, feedbackStore, cycleId);
66
+ postSystemFeedback(
67
+ feedbackStore, cycleId, postVersion,
68
+ 'forge changed artefacts but did not mark any feedback as actioned',
69
+ );
70
+ return { contractPassed: false };
71
+ }
72
+
73
+ return { contractPassed: true };
74
+ }
75
+
76
+ /**
77
+ * Enforce the forge contract on a batch of items presented to forge.
78
+ *
79
+ * Two-level check:
80
+ * 1. Per-item: every item must end in 'actioned' or 'wont-fix'.
81
+ * 2. Batch-level: artefact version semantics must be consistent.
82
+ *
83
+ * @param {{ items: Array<{id: string}>, preVersion: string, postVersion: string,
84
+ * feedbackStore: object, cycleId: string }} params
85
+ * @returns {{ contractPassed: boolean }}
86
+ */
87
+ export function enforceForgeContract({ items, preVersion, postVersion, feedbackStore, cycleId }) {
88
+ if (!checkPerItemResponse(items, feedbackStore, cycleId, postVersion)) {
89
+ return { contractPassed: false };
90
+ }
91
+
92
+ return checkBatchVersion(items, feedbackStore, cycleId, postVersion, preVersion);
93
+ }