@really-knows-ai/foundry 3.5.8 → 3.5.9

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.
@@ -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
  ```
@@ -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
+
@@ -96,12 +96,6 @@ 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 });
101
- },
102
- writeDeadlockedSnapshots(ids, reason, stage, cycle) {
103
- return storeWriteDeadlockedSnapshots({ ids, reason, stage, cycle }, items, { timestamp: nowIso, persist });
104
- },
105
99
  resolveSystemItems(stage, cycle) {
106
100
  resolveSystemItemsImpl({ items, stage, cycle, timestamp: nowIso, persist });
107
101
  },
@@ -153,7 +147,7 @@ function forgeWontFixError(item) {
153
147
 
154
148
  function reasonRequiredForTransition(target, current, reason) {
155
149
  const REASON_REQUIRED_TARGETS = new Set(['rejected', 'wont-fix']);
156
- return (REASON_REQUIRED_TARGETS.has(target) || current === 'deadlocked')
150
+ return REASON_REQUIRED_TARGETS.has(target)
157
151
  && (!reason || !reason.trim());
158
152
  }
159
153
 
@@ -208,49 +202,4 @@ function storeTransition(params, items, deps) {
208
202
  return { ok: true };
209
203
  }
210
204
 
211
- function storeWriteDeadlockedSnapshot(params, items, deps) {
212
- const { id, cycle, reason } = params;
213
- const { timestamp, persist } = deps;
214
-
215
- const item = items.find(x => x.id === id);
216
- 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
- ));
225
- return { ok: true };
226
- }
227
-
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;
233
- }
234
-
235
- function storeWriteDeadlockedSnapshots(params, items, deps) {
236
- const { ids, reason, stage, cycle } = params;
237
- const { timestamp, persist } = deps;
238
-
239
- const precheck = assertDeadlockedSnapshotArgs(ids, reason);
240
- if (precheck) return precheck;
241
205
 
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
- }
@@ -21,15 +21,14 @@ function buildReasonData(route, prep) {
21
21
  const forgeCount = prep.history.filter(e => baseStage(e.stage || '') === 'forge').length;
22
22
  const maxIt = prep.defaults.maxIterations;
23
23
  const feedback = prep.feedback || [];
24
- const openCount = feedback.filter(
25
- f => f.state !== 'resolved' && f.state !== 'deadlocked',
26
- ).length;
27
- const dlCount = feedback.filter(f => f.state === 'deadlocked').length;
24
+ const openCount = feedback.filter(f => f.state !== 'resolved').length;
28
25
  const needingForge = feedback.filter(
29
26
  f => f.state === 'open' || f.state === 'rejected',
30
27
  ).length;
28
+ const alwaysHumanAppraise = prep.defaults.alwaysHumanAppraise;
29
+ const deadlockHumanAppraise = prep.defaults.deadlockHumanAppraise;
31
30
 
32
- return { base, route, forgeCount, maxIt, openCount, dlCount, needingForge, anyDeadlocked: prep.anyDeadlocked };
31
+ return { base, route, forgeCount, maxIt, openCount, needingForge, alwaysHumanAppraise, deadlockHumanAppraise };
33
32
  }
34
33
 
35
34
  function forgeReason(d) {
@@ -38,12 +37,14 @@ function forgeReason(d) {
38
37
  }
39
38
 
40
39
  function appraiseReason(d) {
41
- if (d.anyDeadlocked) return `${d.dlCount} feedback item(s) deadlocked — routing to appraise for re-evaluation`;
42
40
  return `quench passed with ${d.openCount} open feedback item(s) — routing to appraise`;
43
41
  }
44
42
 
45
43
  function humanAppraiseReason(d) {
46
- return `${d.dlCount} feedback item(s) deadlocked after ${d.forgeCount} forge iteration(s) — routing to human for override`;
44
+ if (d.alwaysHumanAppraise) {
45
+ return `always-human-appraise enabled — routing to human after ${d.forgeCount} forge iteration(s)`;
46
+ }
47
+ return `max iterations (${d.maxIt}) reached after ${d.forgeCount} forge iteration(s) — routing to human for review`;
47
48
  }
48
49
 
49
50
  function blockedReason(d) {
@@ -6,9 +6,9 @@
6
6
  * configured `max-lines` limit and to lower per-function complexity.
7
7
  */
8
8
 
9
- // Spec §6.1: an item is "open" (still in flight) when its head state is
10
- // 'open', 'actioned', 'rejected', or 'wont-fix' equivalently, when the
11
- // state is neither 'resolved' nor 'deadlocked'.
9
+ // An item is "open" (still in flight) when its head state is not
10
+ // 'resolved' or 'deadlocked'. The deadlocked check is retained for
11
+ // backward compatibility with existing feedback files.
12
12
  const isOpenItem = (f) => f.state !== 'resolved' && f.state !== 'deadlocked';
13
13
 
14
14
  export { isOpenItem };
@@ -40,36 +40,90 @@ function hasItemsPendingApproval(openItems) {
40
40
  return openItems.some(f => f.state === 'actioned' || f.state === 'wont-fix');
41
41
  }
42
42
 
43
- function routeForgeIfNeeded(stages, forgeCount, maxIterations) {
44
- if (forgeCount >= maxIterations) return 'blocked';
45
- return findFirst(stages, 'forge') ?? 'blocked';
43
+ function firstForgeOrBlocked(stages) {
44
+ const stage = findFirst(stages, 'forge');
45
+ return stage !== null ? stage : 'blocked';
46
46
  }
47
47
 
48
- function appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations) {
48
+ function nextRouteOrDone(stages, current) {
49
+ const nextStage = nextInRoute(stages, current);
50
+ return nextStage !== null ? nextStage : 'done';
51
+ }
52
+
53
+ function firstHumanOrSuffixed(stages, cycle) {
54
+ const stage = findFirst(stages, 'human-appraise');
55
+ return stage !== null ? stage : `human-appraise:${cycle}`;
56
+ }
57
+
58
+ function isBelowIterationCap(forgeCount, maxIterations, alwaysHumanAppraise) {
59
+ return alwaysHumanAppraise || forgeCount < maxIterations;
60
+ }
61
+
62
+ function callHandlerOrBlocked(handler) {
63
+ return handler ? handler() : 'blocked';
64
+ }
65
+
66
+ function routeForgeIfNeeded(stages, forgeCount, maxIterations, opts = {}) {
67
+ const { alwaysHumanAppraise, deadlockHumanAppraise, cycle } = opts;
68
+ if (isBelowIterationCap(forgeCount, maxIterations, alwaysHumanAppraise)) {
69
+ return firstForgeOrBlocked(stages);
70
+ }
71
+ if (!deadlockHumanAppraise) return 'blocked';
72
+ if (!findFirst(stages, 'human-appraise')) return 'blocked';
73
+ return `human-appraise:${cycle}`;
74
+ }
75
+
76
+ function appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations, opts = {}) {
49
77
  if (hasItemsNeedingForge(openItems)) {
50
- return routeForgeIfNeeded(stages, forgeCount, maxIterations);
78
+ return routeForgeIfNeeded(stages, forgeCount, maxIterations, opts);
51
79
  }
52
80
  if (hasItemsPendingApproval(openItems)) {
53
- return findFirst(stages, 'appraise') ?? 'blocked';
81
+ const stage = findFirst(stages, 'appraise');
82
+ return stage !== null ? stage : 'blocked';
54
83
  }
55
84
  return null;
56
85
  }
57
86
 
58
- export function nextAfterAppraise({ stages, current, feedback, forgeCount, maxIterations }) {
59
- // Note: deadlock detection is handled by runDeadlockPass at the top of
60
- // runSort (spec §6.1). This helper assumes routing has already been allowed
61
- // to fall through (i.e., no item qualifies as deadlocked).
62
- const openItems = feedback.filter(isOpenItem);
63
- const decided = appraiseForgeOrApproval(stages, openItems, forgeCount, maxIterations);
87
+ function routeAlwaysHumanAppraise(stages, current, openItems, cycle) {
88
+ if (openItems.length > 0) {
89
+ return firstHumanOrSuffixed(stages, cycle);
90
+ }
91
+ return nextRouteOrDone(stages, current);
92
+ }
93
+
94
+ function decideAppraiseRoute(opts) {
95
+ const {
96
+ stages, current, openItems, forgeCount, maxIterations,
97
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
98
+ } = opts;
99
+ const decided = appraiseForgeOrApproval(
100
+ stages, openItems, forgeCount, maxIterations,
101
+ { alwaysHumanAppraise, deadlockHumanAppraise, cycle },
102
+ );
64
103
  if (decided !== null) return decided;
65
- return nextInRoute(stages, current) ?? 'done';
104
+ return nextRouteOrDone(stages, current);
66
105
  }
67
106
 
68
- export function nextAfterQuench(stages, current, feedback, forgeCount, maxIterations) {
107
+ export function nextAfterAppraise({
108
+ stages, current, feedback, forgeCount, maxIterations,
109
+ alwaysHumanAppraise = false, deadlockHumanAppraise = false, cycle = 'default',
110
+ }) {
69
111
  const openItems = feedback.filter(isOpenItem);
70
- const needsForge = openItems.some(f => f.state === 'open' || f.state === 'rejected');
71
- if (needsForge) return routeForgeIfNeeded(stages, forgeCount, maxIterations);
72
- return nextInRoute(stages, current) ?? 'done';
112
+ if (alwaysHumanAppraise) {
113
+ return routeAlwaysHumanAppraise(stages, current, openItems, cycle);
114
+ }
115
+ return decideAppraiseRoute({
116
+ stages, current, openItems, forgeCount, maxIterations,
117
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
118
+ });
119
+ }
120
+
121
+ export function nextAfterQuench(stages, current, feedback, opts = {}) {
122
+ const { forgeCount = 0, maxIterations = 100 } = opts;
123
+ const openItems = feedback.filter(isOpenItem);
124
+ const needsForge = hasItemsNeedingForge(openItems);
125
+ if (needsForge) return routeForgeIfNeeded(stages, forgeCount, maxIterations, opts);
126
+ return nextRouteOrDone(stages, current);
73
127
  }
74
128
 
75
129
  function lastNonSortStage(history) {
@@ -78,26 +132,50 @@ function lastNonSortStage(history) {
78
132
  return nonSort[nonSort.length - 1].stage;
79
133
  }
80
134
 
81
- function buildRouteHandlers({ stages, lastEntry, feedback, forgeCount, maxIterations }) {
135
+ function buildRouteHandlers({
136
+ stages, lastEntry, feedback, forgeCount, maxIterations,
137
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
138
+ }) {
82
139
  const appraiseRoute = () => nextAfterAppraise({
83
140
  stages, current: lastEntry, feedback, forgeCount, maxIterations,
141
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
84
142
  });
85
143
  return {
86
- 'assay': () => findFirst(stages, 'forge') ?? 'blocked',
87
- 'forge': () => nextInRoute(stages, lastEntry) ?? 'done',
88
- 'quench': () => nextAfterQuench(stages, lastEntry, feedback, forgeCount, maxIterations),
144
+ 'assay': () => firstForgeOrBlocked(stages),
145
+ 'forge': () => nextRouteOrDone(stages, lastEntry),
146
+ 'quench': () => nextAfterQuench(
147
+ stages, lastEntry, feedback,
148
+ { forgeCount, maxIterations, alwaysHumanAppraise, deadlockHumanAppraise, cycle },
149
+ ),
89
150
  'appraise': appraiseRoute,
90
151
  'human-appraise': appraiseRoute,
91
152
  };
92
153
  }
93
154
 
94
- export function determineRoute(stages, history, feedback, maxIterations) {
95
- const forgeCount = history.filter(e => baseStage(e.stage || '') === 'forge').length;
96
- const lastEntry = lastNonSortStage(history);
155
+ function countForgeIterations(history) {
156
+ return history.filter(e => baseStage(e.stage || '') === 'forge').length;
157
+ }
158
+
159
+ function routeFromLastEntry(opts) {
160
+ const {
161
+ stages, lastEntry, feedback, forgeCount, maxIterations,
162
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
163
+ } = opts;
97
164
  if (lastEntry === null) return stages[0];
98
165
  const handlers = buildRouteHandlers({
99
166
  stages, lastEntry, feedback, forgeCount, maxIterations,
167
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
100
168
  });
101
169
  const handler = handlers[baseStage(lastEntry)];
102
- return handler ? handler() : 'blocked';
170
+ return callHandlerOrBlocked(handler);
171
+ }
172
+
173
+ export function determineRoute(stages, history, feedback, maxIterations, opts = {}) {
174
+ const { alwaysHumanAppraise = false, deadlockHumanAppraise = false, cycle = 'default' } = opts;
175
+ const forgeCount = countForgeIterations(history);
176
+ const lastEntry = lastNonSortStage(history);
177
+ return routeFromLastEntry({
178
+ stages, lastEntry, feedback, forgeCount, maxIterations,
179
+ alwaysHumanAppraise, deadlockHumanAppraise, cycle,
180
+ });
103
181
  }
@@ -150,13 +150,13 @@ export function tryCommit(git, message, allowedPatterns, phase) {
150
150
  // Stage synthesis (pure utility, used by setupWorkfile and exported publicly).
151
151
  // ---------------------------------------------------------------------------
152
152
 
153
- export function synthesizeStages({ cycleId, hasValidation, humanAppraise, assay = false }) {
153
+ export function synthesizeStages({ cycleId, hasValidation, alwaysHumanAppraise, assay = false }) {
154
154
  const stages = [];
155
155
  if (assay) stages.push(`assay:${cycleId}`);
156
156
  stages.push(`forge:${cycleId}`);
157
157
  if (hasValidation) stages.push(`quench:${cycleId}`);
158
158
  stages.push(`appraise:${cycleId}`);
159
- if (humanAppraise) stages.push(`human-appraise:${cycleId}`);
159
+ if (alwaysHumanAppraise) stages.push(`human-appraise:${cycleId}`);
160
160
  return stages;
161
161
  }
162
162
 
@@ -273,14 +273,4 @@ export function renderDispatchPrompt({ stage, cycle, token, cwd, filePatterns, o
273
273
  return lines.join('\n');
274
274
  }
275
275
 
276
- export function checkIterationLimits(cfm, cycleId) {
277
- const maxIt = cfm['max-iterations'];
278
- const dlIt = cfm['deadlock-iterations'];
279
- if (maxIt !== undefined && dlIt !== undefined && dlIt > maxIt) {
280
- return violation(
281
- `cycle ${cycleId}: deadlock-iterations (${dlIt}) cannot exceed max-iterations (${maxIt})`,
282
- ['WORK.md'],
283
- );
284
- }
285
- return null;
286
- }
276
+
@@ -21,7 +21,6 @@ import {
21
21
  tryCommit,
22
22
  synthesizeStages,
23
23
  renderDispatchPrompt,
24
- checkIterationLimits,
25
24
  } from './orchestrate-cycle.js';
26
25
  import {
27
26
  doneAction,
@@ -157,7 +156,7 @@ function stageTag(s, cycleId) {
157
156
 
158
157
  function resolveStages(cfm, cycleId, hasValidation, assayExtractors) {
159
158
  if (!Array.isArray(cfm.stages)) {
160
- return synthesizeStages({ cycleId, hasValidation, humanAppraise: cfm['human-appraise'] === true, assay: !!assayExtractors });
159
+ return synthesizeStages({ cycleId, hasValidation, alwaysHumanAppraise: cfm['always-human-appraise'] === true, assay: !!assayExtractors });
161
160
  }
162
161
  if (cfm.stages.length === 0) {
163
162
  return { error: violation(`cycle ${cycleId} has no stages declared in cycle definition`, ['WORK.md']) };
@@ -168,9 +167,8 @@ function resolveStages(cfm, cycleId, hasValidation, assayExtractors) {
168
167
  function applyFmDefaults(newFm, cfm, assayExtractors) {
169
168
  const maxIt = cfm['max-iterations'] ?? 3;
170
169
  newFm['max-iterations'] = maxIt;
171
- newFm['human-appraise'] = cfm['human-appraise'] === true;
172
- newFm['deadlock-appraise'] = cfm['deadlock-appraise'] !== false;
173
- newFm['deadlock-iterations'] = cfm['deadlock-iterations'] ?? maxIt;
170
+ newFm['always-human-appraise'] = cfm['always-human-appraise'] === true;
171
+ newFm['deadlock-human-appraise'] = cfm['deadlock-human-appraise'] !== false;
174
172
  if (cfm.models) newFm.models = cfm.models;
175
173
  if (assayExtractors) newFm.assay = { extractors: assayExtractors };
176
174
  }
@@ -226,8 +224,6 @@ async function completeSetup(ctx) {
226
224
  const hasValidation = ctx.lawsWithValidators && ctx.lawsWithValidators.length > 0;
227
225
  const stagesResult = resolveStages(ctx.cfm, ctx.cycleId, hasValidation, ctx.assayResult.extractors);
228
226
  if (stagesResult.error) return stagesResult.error;
229
- const validityErr = checkIterationLimits(ctx.cfm, ctx.cycleId);
230
- if (validityErr) return validityErr;
231
227
  const newWork = buildNewFrontmatter(ctx.workContent, stagesResult, ctx.cfm, ctx.assayResult.extractors);
232
228
  ctx.io.writeFile('WORK.md', newWork);
233
229
  return trySetupCommit(ctx);