the-high-council 0.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 (78) hide show
  1. package/.env.example +19 -0
  2. package/CONTRIBUTING.md +41 -0
  3. package/LICENSE +21 -0
  4. package/PERSONAS.md +51 -0
  5. package/README.md +499 -0
  6. package/TROUBLESHOOTING.md +45 -0
  7. package/chains/cheap.json +35 -0
  8. package/chains/gp-judge-v1.json +80 -0
  9. package/chains/idea-open-c2.json +106 -0
  10. package/chains/mock-budget.json +15 -0
  11. package/chains/mock-debate.json +59 -0
  12. package/chains/mock-external.json +59 -0
  13. package/chains/mock-open.json +60 -0
  14. package/chains/mock-proposals.json +57 -0
  15. package/chains/mock-questions-wait.json +63 -0
  16. package/chains/mock-questions.json +63 -0
  17. package/chains/mock-relay.json +42 -0
  18. package/chains/mock-unanimous.json +42 -0
  19. package/chains/mock.json +17 -0
  20. package/chains/plan-auto-mistral.json +101 -0
  21. package/chains/plan-auto.json +131 -0
  22. package/chains/plan-cheap.json +82 -0
  23. package/chains/plan-debate-c2.json +86 -0
  24. package/chains/plan-debate-open-c2.json +98 -0
  25. package/chains/plan-debate-roles-c1.json +149 -0
  26. package/chains/plan-debate.json +116 -0
  27. package/chains/plan-fast.json +66 -0
  28. package/chains/plan-proposals-best-of-5.json +98 -0
  29. package/chains/plan-proposals.json +96 -0
  30. package/chains/plan-relay.json +83 -0
  31. package/chains/plan-thorough.json +82 -0
  32. package/chains/plan-unanimous.json +82 -0
  33. package/chains/seven-cheap.json +67 -0
  34. package/chains/seven.json +60 -0
  35. package/chains/smo-v2-cheap.json +67 -0
  36. package/chains/smo.json +50 -0
  37. package/chains/verify.json +40 -0
  38. package/package.json +55 -0
  39. package/src/board-export.js +77 -0
  40. package/src/budget.js +126 -0
  41. package/src/buys.js +44 -0
  42. package/src/cache-integrity.js +38 -0
  43. package/src/chain-lint.js +107 -0
  44. package/src/chain.js +880 -0
  45. package/src/cli.js +1031 -0
  46. package/src/cost-forecast.js +70 -0
  47. package/src/cost.js +139 -0
  48. package/src/errors.js +82 -0
  49. package/src/http-server.js +349 -0
  50. package/src/integrity.js +48 -0
  51. package/src/key-redaction.js +97 -0
  52. package/src/mcp/server.js +319 -0
  53. package/src/name-lint.js +186 -0
  54. package/src/partial-deliverable.js +38 -0
  55. package/src/peer-claim.js +98 -0
  56. package/src/personas.js +54 -0
  57. package/src/preflight.js +84 -0
  58. package/src/pricing.json +120 -0
  59. package/src/providers.js +268 -0
  60. package/src/replay.js +40 -0
  61. package/src/resume-brief.js +114 -0
  62. package/src/role-diagnostics.js +181 -0
  63. package/src/roles.js +520 -0
  64. package/src/schema-version.js +39 -0
  65. package/src/scope-freeze.js +35 -0
  66. package/src/seat-role.js +96 -0
  67. package/src/shape-rounds.js +58 -0
  68. package/src/spend.js +193 -0
  69. package/src/stage-contract.js +223 -0
  70. package/src/stage-submission.js +53 -0
  71. package/src/tie-break.js +94 -0
  72. package/src/ui/app.js +139 -0
  73. package/src/ui/index.html +43 -0
  74. package/src/ui/parse.js +101 -0
  75. package/src/ui/server.js +117 -0
  76. package/src/ui/style.css +37 -0
  77. package/src/verdict-stats.js +267 -0
  78. package/src/withdrawal-ledger.js +47 -0
@@ -0,0 +1,96 @@
1
+ import { DEFAULT_PERSONAS as PERSONAS_DEFAULT, resolvePersona } from './personas.js';
2
+
3
+ // v6 §1: an optional per-seat `role` field, alongside `provider`/`model`/
4
+ // `maxTokens`/`lab`. Absent, prompt assembly is byte-identical to today -
5
+ // proven by test/seat-role.test.js's golden-hash comparison against every
6
+ // shipped chain config. Present, it augments the debate-stage system
7
+ // prompt only (wired in src/chain.js at the debate-stage invoke call);
8
+ // the panel stage never sees it (that structural guarantee is v6 §3,
9
+ // phase 2 - this module only defines the field and the augmentation, it
10
+ // does not itself enforce stage isolation).
11
+ //
12
+ // §2's argued position: `lens` and `persona` are separable because a lens
13
+ // is what can plausibly change what a seat catches, and a persona only
14
+ // changes how it talks - coupling them would make it impossible to
15
+ // attribute a measured difference (v6 §5) to either one.
16
+
17
+ // Fixed critique-function instruction blocks. Wording is a placeholder
18
+ // pending author review (v6 plan, Decisions left to the author) - the
19
+ // enum itself, and the fact that it is closed, is not.
20
+ export const LENSES = {
21
+ adversary: 'Argue against this proposal as its strongest opponent would. Find the case where it fails, not the case where it usually works.',
22
+ integrator: 'Judge this proposal by how it fits with everything else in the plan, not in isolation. Name what it would break or duplicate elsewhere.',
23
+ 'long-horizon': 'Judge this proposal by what it costs to maintain a year from now, not by how it looks today. Name the maintenance burden it adds.',
24
+ 'user-advocate': 'Judge this proposal by what the person actually using this experiences, not by what is convenient to build. Name where it makes their life harder.',
25
+ 'security-and-legal': 'Judge this proposal for what it exposes, leaks, or obligates - data, keys, liability. Name the concrete exposure, not a general risk.',
26
+ };
27
+
28
+ // v6 phase 7 integration fix: this used to be a second, independently
29
+ // maintained literal of the same five names src/personas.js (phase 6,
30
+ // built in parallel off master, unaware of this module) already owns
31
+ // with full name/voice data - exactly the "two places quietly agree
32
+ // today, nothing stops them drifting" class this project has been
33
+ // bitten by before (v5 Phase 2's report.json writers). Derived from
34
+ // personas.js's own keys now, so there is one source of the five names,
35
+ // not two.
36
+ export const DEFAULT_PERSONAS = Object.keys(PERSONAS_DEFAULT);
37
+
38
+ /**
39
+ * Validate a seat's `role` field. Returns an array of problem strings -
40
+ * empty means valid. Never throws; a chain-config lint (v5's own
41
+ * lintChain, v6 §1) is expected to call this and surface problems the
42
+ * same fail-loud way it already surfaces a missing seats.critics.
43
+ */
44
+ export function validateSeatRole(role) {
45
+ if (role === undefined || role === null) return [];
46
+ const problems = [];
47
+ if (typeof role !== 'object' || Array.isArray(role)) {
48
+ return [`role must be an object with an optional "lens" and/or "persona" field, got ${Array.isArray(role) ? 'an array' : typeof role}`];
49
+ }
50
+ const { lens, persona, ...rest } = role;
51
+ if (lens === undefined && persona === undefined) {
52
+ problems.push('role is set but has neither "lens" nor "persona" - at least one must be present');
53
+ }
54
+ if (lens !== undefined && !(lens in LENSES)) {
55
+ problems.push(`role.lens "${lens}" is not one of: ${Object.keys(LENSES).join(', ')}`);
56
+ }
57
+ if (persona !== undefined && typeof persona !== 'string') {
58
+ problems.push(`role.persona must be a string persona name, got ${typeof persona}`);
59
+ }
60
+ for (const key of Object.keys(rest)) {
61
+ problems.push(`role.${key} is not a recognized field - only "lens" and "persona" are`);
62
+ }
63
+ return problems;
64
+ }
65
+
66
+ /**
67
+ * The debate-stage system prompt for a seat, with its role (if any)
68
+ * appended after the chain's own instructions. A seat with no role
69
+ * produces `basePrompt` unchanged - not a copy with an empty suffix,
70
+ * the exact same string, so a golden-hash comparison is a real
71
+ * byte-identity check and not merely a "looks the same" one.
72
+ *
73
+ * v6 phase 7 bug-audit fix: `role.persona` used to be embedded as a
74
+ * bare, unresolved string, entirely disconnected from personas.js's
75
+ * curated name/voice data (built by phase 6, in parallel, unaware
76
+ * this module existed) - the exact safety net phase 6 exists for
77
+ * (keeping third-party names out of a public MIT repo) had nothing
78
+ * to do with what actually reached a live model prompt. Now resolved
79
+ * against `personas` (defaulting to the shipped set) when the key
80
+ * matches; an unresolved key still falls back to the raw string
81
+ * unchanged, since phase 6 also explicitly allows an operator to
82
+ * replace the whole set, and this module has no way to know which
83
+ * operator-supplied personas.json (if any) was loaded for this run.
84
+ */
85
+ export function applySeatRole(basePrompt, role, personas = PERSONAS_DEFAULT) {
86
+ if (role === undefined || role === null) return basePrompt;
87
+ const blocks = [];
88
+ if (role.lens && LENSES[role.lens]) blocks.push(LENSES[role.lens]);
89
+ if (role.persona) {
90
+ const resolved = resolvePersona(role.persona, personas);
91
+ const detail = resolved ? ` (${resolved.name}). ${resolved.voice}` : '.';
92
+ blocks.push(`You are arguing as ${role.persona}${detail} Let that voice and point of view shape how you argue, without changing what you are actually judging.`);
93
+ }
94
+ if (!blocks.length) return basePrompt;
95
+ return `${basePrompt}\n\n[SEAT ROLE]\n${blocks.join('\n\n')}\n[END SEAT ROLE]`;
96
+ }
@@ -0,0 +1,58 @@
1
+ // v5 §1 candidate 1: which critique rounds in a run were spent entirely on
2
+ // document shape rather than substance. Reads each round's critique
3
+ // file(s) already written to disk (panel-N-<lab>.md for a panel chain,
4
+ // critique-N.md for a single-critic chain) - no new state, no ledger,
5
+ // derived from what a run already writes.
6
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const readJson = p => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
10
+
11
+ // The project has no existing format/substance objection classifier to
12
+ // reuse - this is a fresh, deliberately conservative keyword heuristic
13
+ // over a failure's criterion+problem text. A substance objection that
14
+ // happens to mention one of these words could misclassify; the trade is
15
+ // toward not flagging a round shape_only unless it's clearly shape talk.
16
+ const FORMAT_PATTERN = /\b(format|formatting|structure|heading|header|section order|numbering|numbered|whitespace|indent(?:ation)?|markdown|bullet|list style|capitali[sz]ation|typo|wording|title case|json shape|schema shape)\b/i;
17
+
18
+ export function classifyFailure(failure) {
19
+ const text = `${failure?.criterion || ''} ${failure?.problem || ''}`;
20
+ return FORMAT_PATTERN.test(text) ? 'format' : 'substance';
21
+ }
22
+
23
+ // Round number from a stage filename, e.g. "panel-2-glm.md" -> 2,
24
+ // "critique-3.md" -> 3. Other stage files (criteria.md, build.md, ...)
25
+ // return null.
26
+ function roundOfFile(name) {
27
+ const m = name.match(/^(?:panel|critique)-(\d+)(?:-[a-z0-9]+)?\.md$/);
28
+ return m ? Number(m[1]) : null;
29
+ }
30
+
31
+ /**
32
+ * Which critique rounds in run folder `dir` spent entirely on document
33
+ * shape. A round counts as shape_only when it has at least one failure and
34
+ * every failure in it classifies as format-class; a round with zero
35
+ * failures (a clean pass) is not counted - nothing was spent on it.
36
+ * Never throws - a missing or unreadable dir means no rounds, not an error.
37
+ */
38
+ export function shapeRounds(dir) {
39
+ let files = [];
40
+ try { files = existsSync(dir) ? readdirSync(dir) : []; } catch { return { rounds: [], shapeOnlyRounds: 0 }; }
41
+
42
+ const byRound = new Map();
43
+ for (const f of files) {
44
+ const round = roundOfFile(f);
45
+ if (round === null) continue;
46
+ const parsed = readJson(join(dir, f));
47
+ const failures = Array.isArray(parsed?.failures) ? parsed.failures : [];
48
+ if (!byRound.has(round)) byRound.set(round, []);
49
+ byRound.get(round).push(...failures);
50
+ }
51
+
52
+ const rounds = [...byRound.entries()].sort((a, b) => a[0] - b[0]).map(([round, failures]) => {
53
+ const shapeOnly = failures.length > 0 && failures.every(f => classifyFailure(f) === 'format');
54
+ return { round, failureCount: failures.length, shapeOnly };
55
+ });
56
+
57
+ return { rounds, shapeOnlyRounds: rounds.filter(r => r.shapeOnly).length };
58
+ }
package/src/spend.js ADDED
@@ -0,0 +1,193 @@
1
+ // Cross-run spend accounting.
2
+ //
3
+ // "What have I spent today, across every run" is not answerable from
4
+ // run_status, which reports one run, or from the per-run cap, which governs
5
+ // one run. This answers it.
6
+ //
7
+ // It is DERIVED, not recorded. There is no ledger file and nothing to append
8
+ // to: every figure here is read back out of runs/<id>/ on disk, the same
9
+ // disk-is-the-source-of-truth convention the rest of the project uses. That
10
+ // choice buys four things a separate ledger cannot:
11
+ //
12
+ // - It cannot drift. A ledger is a second copy of the truth, and a second
13
+ // copy that is written on a path that can fail is a copy that will
14
+ // eventually disagree with the run folders.
15
+ // - It has no failure mode to degrade from. Nothing is written, so a run
16
+ // can never fail, stall or warn because bookkeeping failed.
17
+ // - It is retroactive. Runs that completed before this file existed are
18
+ // already accounted for.
19
+ // - It respects deletion. Delete a run folder because the work was
20
+ // sensitive and its cost goes with it. An append-only ledger outside the
21
+ // project would keep a record of work the user deliberately destroyed.
22
+ //
23
+ // Privacy: chain name, cost and state only. Never the task path or any run
24
+ // content - what a run was about is not spend data.
25
+
26
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ const readJson = p => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
30
+
31
+ // Run ids are ISO timestamps with : and . replaced, e.g.
32
+ // 2026-09-11T10-06-06-899Z. Anything else in runs/ is not ours; skip it.
33
+ export function runIdToDate(runId) {
34
+ const d = new Date(runId.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, 'T$1:$2:$3.$4Z'));
35
+ return Number.isNaN(d.getTime()) ? null : d;
36
+ }
37
+
38
+ // A finished run's cost is in report.json. A run that is still going, or that
39
+ // the cap stopped, has no report - but every stage it paid for left a
40
+ // <label>.usage.json behind, so the spend is still on disk.
41
+ function costOfRun(dir) {
42
+ const report = readJson(join(dir, 'report.json'));
43
+ if (report?.totals?.usd !== undefined && report?.totals?.usd !== null) {
44
+ return { usd: report.totals.usd, chain: report.chain ?? null, complete: true };
45
+ }
46
+ let usd = 0;
47
+ let stages = 0;
48
+ for (const f of readdirSync(dir)) {
49
+ if (!f.endsWith('.usage.json')) continue;
50
+ usd += readJson(join(dir, f))?.usd ?? 0;
51
+ stages += 1;
52
+ }
53
+ return { usd, chain: readJson(join(dir, 'run.json'))?.chain ?? null, complete: false, stages };
54
+ }
55
+
56
+ // v2 plan §8 (~/Projects/relay/runs/2026-09-11T12-19-34-184Z/deliverable.md), narrowed per
57
+ // decision in DECISIONS.md: §8 proposed a new ledger file to get per-call cost granularity
58
+ // and a calendar-day "cost-today" view. Both are already derivable from what's on disk -
59
+ // report.json's `stages` array for a finished run, individual `<label>.usage.json` files for
60
+ // one still going - so this extends the existing derivation instead of adding a ledger.
61
+ //
62
+ // Per-call/per-stage cost breakdown for one run: [{ label, provider, model, usd }, ...].
63
+ function stagesOfRun(dir) {
64
+ const report = readJson(join(dir, 'report.json'));
65
+ if (Array.isArray(report?.stages)) {
66
+ return report.stages.map(s => ({ label: s.label, provider: s.provider ?? null, model: s.model ?? null, usd: s.usd ?? 0 }));
67
+ }
68
+ const stages = [];
69
+ for (const f of readdirSync(dir)) {
70
+ if (!f.endsWith('.usage.json')) continue;
71
+ const u = readJson(join(dir, f));
72
+ if (!u) continue;
73
+ stages.push({ label: f.replace(/\.usage\.json$/, ''), provider: u.provider ?? null, model: u.model ?? null, usd: u.usd ?? 0 });
74
+ }
75
+ return stages;
76
+ }
77
+
78
+ // Aggregate per-model spend across a list of run directories - the "per-call granularity"
79
+ // §8 wanted, without a ledger: sum every stage's cost by provider/model across every run
80
+ // given, in one pass over files already being read for their totals anyway.
81
+ function perModelBreakdown(dirs) {
82
+ const byModel = new Map();
83
+ for (const dir of dirs) {
84
+ for (const s of stagesOfRun(dir)) {
85
+ const key = `${s.provider ?? '?'}/${s.model ?? '?'}`;
86
+ byModel.set(key, (byModel.get(key) ?? 0) + s.usd);
87
+ }
88
+ }
89
+ return [...byModel.entries()]
90
+ .map(([model, usd]) => ({ model, usd }))
91
+ .sort((a, b) => b.usd - a.usd);
92
+ }
93
+
94
+ function stateOf(dir) {
95
+ if (existsSync(join(dir, 'report.json'))) return 'complete';
96
+ if (existsSync(join(dir, 'STOPPED-budget.json'))) return 'stopped: spend cap';
97
+ if (readdirSync(dir).some(f => f.startsWith('NEEDS-'))) return 'paused';
98
+ return 'incomplete';
99
+ }
100
+
101
+ /**
102
+ * Spend across every run in `runsDir` within the last `days`.
103
+ *
104
+ * Never throws. A missing or unreadable runs/ is not an error - it means no
105
+ * runs, which is a true and useful answer.
106
+ */
107
+ export function spendReport(runsDir, { days = 1, now = Date.now() } = {}) {
108
+ const cutoff = now - days * 24 * 3600 * 1000;
109
+ const runs = [];
110
+ let unreadable = 0;
111
+
112
+ let ids = [];
113
+ try {
114
+ ids = existsSync(runsDir) ? readdirSync(runsDir) : [];
115
+ } catch {
116
+ return { runsDir, days, since: new Date(cutoff), runs: [], totalUsd: 0, count: 0,
117
+ unreadable: 0, note: 'runs directory could not be read' };
118
+ }
119
+
120
+ for (const id of ids) {
121
+ const when = runIdToDate(id);
122
+ if (!when || when.getTime() < cutoff) continue;
123
+ const dir = join(runsDir, id);
124
+ try {
125
+ if (!statSync(dir).isDirectory()) continue;
126
+ const { usd, chain, complete } = costOfRun(dir);
127
+ runs.push({ id, when, chain, usd, state: stateOf(dir), complete });
128
+ } catch {
129
+ unreadable += 1; // a run folder we cannot read is reported, not fatal
130
+ }
131
+ }
132
+
133
+ runs.sort((a, b) => b.when - a.when);
134
+ return {
135
+ runsDir, days,
136
+ since: new Date(cutoff),
137
+ runs,
138
+ totalUsd: runs.reduce((s, r) => s + r.usd, 0),
139
+ count: runs.length,
140
+ unreadable,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * What has been spent today, by the local calendar day (midnight to midnight), not a
146
+ * rolling 24h window like spendReport's default - the distinction §8 asked for by name.
147
+ * Adds a per-model breakdown across every run counted, which is the per-call granularity
148
+ * §8 wanted a ledger for; both are derived from the same on-disk files spendReport already
149
+ * reads, so no new file is written anywhere.
150
+ *
151
+ * Never throws, same degradation contract as spendReport.
152
+ */
153
+ export function costToday(runsDir, { date = new Date(), now = Date.now() } = {}) {
154
+ const day = new Date(date);
155
+ const startOfDay = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
156
+ const endOfDay = startOfDay + 24 * 3600 * 1000;
157
+ const runs = [];
158
+ const countedDirs = [];
159
+ let unreadable = 0;
160
+
161
+ let ids = [];
162
+ try {
163
+ ids = existsSync(runsDir) ? readdirSync(runsDir) : [];
164
+ } catch {
165
+ return { runsDir, date: new Date(startOfDay), runs: [], totalUsd: 0, count: 0,
166
+ unreadable: 0, perModel: [], note: 'runs directory could not be read' };
167
+ }
168
+
169
+ for (const id of ids) {
170
+ const when = runIdToDate(id);
171
+ if (!when || when.getTime() < startOfDay || when.getTime() >= endOfDay) continue;
172
+ const dir = join(runsDir, id);
173
+ try {
174
+ if (!statSync(dir).isDirectory()) continue;
175
+ const { usd, chain, complete } = costOfRun(dir);
176
+ runs.push({ id, when, chain, usd, state: stateOf(dir), complete });
177
+ countedDirs.push(dir);
178
+ } catch {
179
+ unreadable += 1;
180
+ }
181
+ }
182
+
183
+ runs.sort((a, b) => b.when - a.when);
184
+ return {
185
+ runsDir,
186
+ date: new Date(startOfDay),
187
+ runs,
188
+ totalUsd: runs.reduce((s, r) => s + r.usd, 0),
189
+ count: runs.length,
190
+ perModel: perModelBreakdown(countedDirs),
191
+ unreadable,
192
+ };
193
+ }
@@ -0,0 +1,223 @@
1
+ // Stage contract schema (v2 plan §2.2, ~/Projects/relay/runs/2026-09-11T12-19-34-184Z/deliverable.md).
2
+ //
3
+ // A stage contract is a small, structured, self-contained description of
4
+ // what a stage needs and must return, derived from a chain config alone -
5
+ // no run state, no prior conversation. It is the one shared substrate that
6
+ // prepare_stage_prompt (§3), the resume brief (§5), the pre-flight check
7
+ // (§6) and partial-deliverable detection (§7.1) all read or write.
8
+ //
9
+ // This module only defines and derives the schema (Phase 1, items 1-2). It
10
+ // does not yet drive prompt assembly, resume, pre-flight or completeness
11
+ // checks - those are later phases and read this contract, not the other
12
+ // way around.
13
+
14
+ // Canonical stage kinds. chain.js emits per-lab/per-round labels
15
+ // (`propose-glm`, `panel-2-mistral`, `revise-3`, ...); STAGE_KIND_PATTERNS
16
+ // maps any such label back to one of these fixed kinds so the contract
17
+ // vocabulary stays small and stable even as labs and round counts vary.
18
+ const STAGE_DEFS = {
19
+ questions: {
20
+ role: 'Ask up to N clarifying questions that would change the plan, each with a stated default.',
21
+ required_sections: ['questions'],
22
+ return_instructions: 'Return JSON: { "questions": [{ "question": string, "default": string }] }.',
23
+ },
24
+ criteria: {
25
+ role: 'Write the acceptance criteria the eventual deliverable will be checked against, before the deliverable exists.',
26
+ required_sections: ['criteria'],
27
+ return_instructions: 'Return JSON: { "criteria": [string, ...] }.',
28
+ },
29
+ skeleton: {
30
+ role: 'Sketch the buildable shape of the deliverable that proposers will each independently fill in, blind to one another.',
31
+ required_sections: ['skeleton'],
32
+ return_instructions: 'Return the skeleton as plain text.',
33
+ },
34
+ propose: {
35
+ role: 'Blind proposal: offer up to N buildable parts against the skeleton and criteria, without seeing any other seat\'s proposal.',
36
+ required_sections: ['proposals'],
37
+ return_instructions: 'Return JSON: { "proposals": [{ "title": string, ... }] }.',
38
+ },
39
+ judge: {
40
+ role: 'Score a pool of proposals from one lab and keep the strongest, distinct subset.',
41
+ required_sections: ['kept'],
42
+ return_instructions: 'Return JSON naming which proposals to keep.',
43
+ },
44
+ debate: {
45
+ role: 'Post an anonymised objection, support, or merge suggestion against another seat\'s proposal, without knowing which lab wrote it.',
46
+ required_sections: ['posts'],
47
+ return_instructions: 'Return JSON: { "posts": [{ "on": string, "stance": string, "text": string }] }.',
48
+ },
49
+ reply: {
50
+ role: 'As the proposal\'s own author, decide whether to keep, amend, or withdraw it in light of the debate posts against it.',
51
+ required_sections: ['replies'],
52
+ return_instructions: 'Return JSON: { "replies": [{ "proposal": string, "action": "keep"|"amend"|"withdraw", "text": string }] }.',
53
+ },
54
+ build: {
55
+ role: 'Integrate the surviving proposals into one coherent first draft of the deliverable.',
56
+ required_sections: ['draft'],
57
+ return_instructions: 'Return the draft as plain text/markdown.',
58
+ },
59
+ panel: {
60
+ role: 'Critique the current draft against the acceptance criteria; pass or fail it, naming every objection if failing.',
61
+ required_sections: ['verdict', 'objections'],
62
+ return_instructions: 'Return JSON: { "verdict": "pass"|"fail", "objections": [string, ...] }.',
63
+ },
64
+ critique: {
65
+ role: 'Critique the current draft against the acceptance criteria; pass or fail it, naming every objection if failing.',
66
+ required_sections: ['verdict', 'objections'],
67
+ return_instructions: 'Return JSON: { "verdict": "pass"|"fail", "objections": [string, ...] }.',
68
+ },
69
+ revise: {
70
+ role: 'Revise the draft to address the union of every critic\'s objections from the prior round.',
71
+ required_sections: ['draft'],
72
+ return_instructions: 'Return the revised draft as plain text/markdown.',
73
+ },
74
+ final: {
75
+ role: 'Make one last editorial pass over the signed-off draft before handoff.',
76
+ required_sections: ['draft'],
77
+ return_instructions: 'Return the final draft as plain text/markdown.',
78
+ },
79
+ handoff: {
80
+ role: 'Turn the finished deliverable into a build-ready handoff: an ordered task list with acceptance tests per item.',
81
+ required_sections: ['tasks'],
82
+ return_instructions: 'Return the handoff document as plain text/markdown.',
83
+ },
84
+ };
85
+
86
+ // Order matters only for readability; STAGE_KIND_PATTERNS is checked in
87
+ // order and the first match wins, so put more specific patterns first.
88
+ const STAGE_KIND_PATTERNS = [
89
+ [/^questions$/, 'questions'],
90
+ [/^criteria$/, 'criteria'],
91
+ [/^skeleton$/, 'skeleton'],
92
+ [/^propose-.+-retry$/, 'propose'],
93
+ [/^propose-/, 'propose'],
94
+ [/^judge-/, 'judge'],
95
+ [/^debate-/, 'debate'],
96
+ [/^reply-/, 'reply'],
97
+ [/^build$/, 'build'],
98
+ [/^panel-/, 'panel'],
99
+ [/^critique-/, 'critique'],
100
+ [/^revise-/, 'revise'],
101
+ [/^final$/, 'final'],
102
+ [/^handoff$/, 'handoff'],
103
+ ];
104
+
105
+ /** Map a chain.js stage label (e.g. "propose-glm", "panel-2-mistral") to a canonical stage kind, or null if unrecognised. */
106
+ export function stageKindOf(label) {
107
+ const hit = STAGE_KIND_PATTERNS.find(([pattern]) => pattern.test(label));
108
+ return hit ? hit[1] : null;
109
+ }
110
+
111
+ /**
112
+ * List the canonical stage kinds a given chain config will actually run,
113
+ * in the order chain.js runs them. Mirrors the flag checks in runChain().
114
+ */
115
+ export function stageKindsFor(config) {
116
+ const kinds = [];
117
+ if (config.questions) kinds.push('questions');
118
+ kinds.push('criteria');
119
+ if (config.proposals) {
120
+ kinds.push('skeleton', 'propose');
121
+ if ((config.proposals.samples ?? 1) > 1) kinds.push('judge');
122
+ }
123
+ if (config.debate) kinds.push('debate', 'reply');
124
+ kinds.push('build');
125
+ kinds.push(config.signoff === 'unanimous' ? 'panel' : 'critique');
126
+ kinds.push('revise');
127
+ kinds.push('final');
128
+ if (config.handoff) kinds.push('handoff');
129
+ return kinds;
130
+ }
131
+
132
+ /** Required section names for a stage kind alone, with no chain config needed. */
133
+ export function requiredSectionsFor(stageKind) {
134
+ const def = STAGE_DEFS[stageKind];
135
+ if (!def) throw new Error(`requiredSectionsFor: unknown stage kind "${stageKind}"`);
136
+ return [...def.required_sections];
137
+ }
138
+
139
+ // Stage kinds whose return_instructions ask for JSON (required_sections names JSON keys to
140
+ // check for) vs. freeform markdown/text (required_sections names a single symbolic slot,
141
+ // satisfied by any non-empty text). Used by partial-deliverable detection (§7.1) to know how
142
+ // to validate a stage's raw output without re-deriving it from return_instructions text.
143
+ const STRUCTURED_STAGE_KINDS = new Set(['questions', 'criteria', 'propose', 'judge', 'debate', 'reply', 'panel', 'critique']);
144
+ export function isStructuredStage(stageKind) {
145
+ return STRUCTURED_STAGE_KINDS.has(stageKind);
146
+ }
147
+
148
+ /**
149
+ * Build the stage contract for one stage kind of one chain config.
150
+ * Self-contained and derived purely from `config` - no run state - so it
151
+ * can be regenerated identically at any point in a run's life.
152
+ */
153
+ export function buildStageContract(config, stageKind) {
154
+ const def = STAGE_DEFS[stageKind];
155
+ if (!def) throw new Error(`buildStageContract: unknown stage kind "${stageKind}"`);
156
+ const approxLength = stageKind === 'build' || stageKind === 'revise' || stageKind === 'final'
157
+ ? config.estimate?.draftTokens ?? null
158
+ : config.estimate?.promptTokens ?? null;
159
+ return {
160
+ stage_id: stageKind,
161
+ role: def.role,
162
+ no_prior_context: true,
163
+ deliverable_format: {
164
+ required_sections: [...def.required_sections],
165
+ approx_length: approxLength,
166
+ },
167
+ return_instructions: def.return_instructions,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Render the self-contained stage_prompt.md bundle (§3) for a paused external stage.
173
+ * Pure string assembly - no file IO - so it can be unit-tested without a run folder.
174
+ * `references` must be paths, never inlined content: that is the property that keeps
175
+ * the bundle small enough for a fresh, context-empty reader to hold comfortably.
176
+ */
177
+ export function renderStagePromptBundle({ contract, taskText, chainName, label, run, references }) {
178
+ return `# STAGE CONTRACT
179
+ stage_id: ${contract.stage_id}
180
+ role: ${contract.role}
181
+ no_prior_context: true
182
+ deliverable_format:
183
+ required_sections: ${JSON.stringify(contract.deliverable_format.required_sections)}
184
+ approx_length: ${contract.deliverable_format.approx_length ?? 'not estimated'}
185
+ return_instructions: ${contract.return_instructions}
186
+
187
+ # TASK
188
+ ${taskText}
189
+
190
+ # WHAT'S ALREADY DECIDED
191
+ Chain: ${chainName ?? 'unknown'}. This run is paused waiting on stage "${label}".
192
+ (A fuller resume brief is planned - runs/<id>/RESUME.md, not yet built. Until then, read
193
+ run.log in the run folder for the full stage history if you need more than this line.)
194
+
195
+ # CONTEXT REFERENCES
196
+ The following documents are relevant background. Do not assume you have read them -
197
+ you have not. If your deliverable needs their content, read them:
198
+ ${references.map(r => ` - ${r}`).join('\n')}
199
+
200
+ # WHAT NOT TO ASSUME
201
+ No static contract/criteria conflict check has run yet for this stage (planned, not
202
+ yet built). If the task's acceptance criteria seem to conflict with this stage's
203
+ required_sections above, say so in your deliverable rather than silently picking one.
204
+
205
+ # RETURN
206
+ Write your deliverable per return_instructions above. Give it back to the driving
207
+ session, which will call submit_stage(${JSON.stringify(run)}, ${JSON.stringify(label)}, <your deliverable text>).
208
+ `;
209
+ }
210
+
211
+ /** True iff every field a stage contract is required to carry is present and non-empty. */
212
+ export function isValidStageContract(contract) {
213
+ return Boolean(
214
+ contract &&
215
+ typeof contract.stage_id === 'string' && contract.stage_id.length > 0 &&
216
+ typeof contract.role === 'string' && contract.role.length > 0 &&
217
+ contract.no_prior_context === true &&
218
+ contract.deliverable_format &&
219
+ Array.isArray(contract.deliverable_format.required_sections) &&
220
+ contract.deliverable_format.required_sections.length > 0 &&
221
+ typeof contract.return_instructions === 'string' && contract.return_instructions.length > 0
222
+ );
223
+ }
@@ -0,0 +1,53 @@
1
+ // v3 plan §1: the three warn-only checks `submit_stage` runs on a peer-submitted stage answer
2
+ // before accepting it as the stage's final one, factored out of src/mcp/server.js's tool handler
3
+ // so they're independently testable without spinning up the MCP transport (see
4
+ // test/peer-claim.test.js). Pure file-based logic, no network, no subprocess.
5
+ import { existsSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { stageKindOf } from './stage-contract.js';
8
+ import { validateDeliverable } from './partial-deliverable.js';
9
+ import { writeClaim, readClaimFor } from './peer-claim.js';
10
+ import { words } from './ui/parse.js';
11
+
12
+ /**
13
+ * Evaluate and write one stage answer. Never rejects: every check below produces a warning,
14
+ * not a refusal, and the existing required-arguments/return-shape contract for a caller that
15
+ * omits `claimedBy` is unchanged (see the caller in src/mcp/server.js).
16
+ *
17
+ * @param {string} dir - the run folder.
18
+ * @param {string} stage - the stage label (e.g. "build", "criteria").
19
+ * @param {string} content - the submitted answer text.
20
+ * @param {{ claimedBy?: string, chainConfig?: object }} opts
21
+ * @returns {{ warnings: Array<object>, writtenFile: string, isDuplicate: boolean }}
22
+ */
23
+ export function submitStageAnswer(dir, stage, content, { claimedBy, chainConfig } = {}) {
24
+ const warnings = [];
25
+
26
+ // (1) claim_mismatch: claimedBy given, differs from an existing claim's claimed_by.
27
+ const existingClaim = readClaimFor(dir, stage);
28
+ if (claimedBy && existingClaim && existingClaim.claimed_by !== claimedBy) {
29
+ warnings.push({ type: 'claim_mismatch', stage, claimed_by: claimedBy, claim_holder: existingClaim.claimed_by });
30
+ }
31
+ if (claimedBy) writeClaim(dir, stage, claimedBy);
32
+
33
+ // (2) stage_validation_failed: reuse v2's own required-sections machinery
34
+ // (stage-contract.js/partial-deliverable.js), not a new validator.
35
+ const kind = stageKindOf(stage);
36
+ if (chainConfig && kind) {
37
+ const check = validateDeliverable(kind, content);
38
+ if (!check.ok) warnings.push({ type: 'stage_validation_failed', stage, reason: check.reason, missing: check.missing });
39
+ }
40
+
41
+ // (3) duplicate_answer: <stage>.md already exists - never silently overwrite (that would
42
+ // silently discard whichever peer answered first). Keep both; the harness picks neither.
43
+ const answerFile = join(dir, `${stage}.md`);
44
+ const isDuplicate = existsSync(answerFile);
45
+ const writtenFile = isDuplicate ? join(dir, `${stage}.late.md`) : answerFile;
46
+ const usageFile = isDuplicate ? join(dir, `${stage}.late.usage.json`) : join(dir, `${stage}.usage.json`);
47
+ if (isDuplicate) warnings.push({ type: 'duplicate_answer', stage, kept: [`${stage}.md`, `${stage}.late.md`] });
48
+
49
+ writeFileSync(writtenFile, content);
50
+ writeFileSync(usageFile, JSON.stringify({ provider: 'external', model: 'claude-code-session', usage: { input: 0, output: words(content) }, usd: 0, ms: 0 }));
51
+
52
+ return { warnings, writtenFile, isDuplicate };
53
+ }