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.
- package/.env.example +19 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/PERSONAS.md +51 -0
- package/README.md +499 -0
- package/TROUBLESHOOTING.md +45 -0
- package/chains/cheap.json +35 -0
- package/chains/gp-judge-v1.json +80 -0
- package/chains/idea-open-c2.json +106 -0
- package/chains/mock-budget.json +15 -0
- package/chains/mock-debate.json +59 -0
- package/chains/mock-external.json +59 -0
- package/chains/mock-open.json +60 -0
- package/chains/mock-proposals.json +57 -0
- package/chains/mock-questions-wait.json +63 -0
- package/chains/mock-questions.json +63 -0
- package/chains/mock-relay.json +42 -0
- package/chains/mock-unanimous.json +42 -0
- package/chains/mock.json +17 -0
- package/chains/plan-auto-mistral.json +101 -0
- package/chains/plan-auto.json +131 -0
- package/chains/plan-cheap.json +82 -0
- package/chains/plan-debate-c2.json +86 -0
- package/chains/plan-debate-open-c2.json +98 -0
- package/chains/plan-debate-roles-c1.json +149 -0
- package/chains/plan-debate.json +116 -0
- package/chains/plan-fast.json +66 -0
- package/chains/plan-proposals-best-of-5.json +98 -0
- package/chains/plan-proposals.json +96 -0
- package/chains/plan-relay.json +83 -0
- package/chains/plan-thorough.json +82 -0
- package/chains/plan-unanimous.json +82 -0
- package/chains/seven-cheap.json +67 -0
- package/chains/seven.json +60 -0
- package/chains/smo-v2-cheap.json +67 -0
- package/chains/smo.json +50 -0
- package/chains/verify.json +40 -0
- package/package.json +55 -0
- package/src/board-export.js +77 -0
- package/src/budget.js +126 -0
- package/src/buys.js +44 -0
- package/src/cache-integrity.js +38 -0
- package/src/chain-lint.js +107 -0
- package/src/chain.js +880 -0
- package/src/cli.js +1031 -0
- package/src/cost-forecast.js +70 -0
- package/src/cost.js +139 -0
- package/src/errors.js +82 -0
- package/src/http-server.js +349 -0
- package/src/integrity.js +48 -0
- package/src/key-redaction.js +97 -0
- package/src/mcp/server.js +319 -0
- package/src/name-lint.js +186 -0
- package/src/partial-deliverable.js +38 -0
- package/src/peer-claim.js +98 -0
- package/src/personas.js +54 -0
- package/src/preflight.js +84 -0
- package/src/pricing.json +120 -0
- package/src/providers.js +268 -0
- package/src/replay.js +40 -0
- package/src/resume-brief.js +114 -0
- package/src/role-diagnostics.js +181 -0
- package/src/roles.js +520 -0
- package/src/schema-version.js +39 -0
- package/src/scope-freeze.js +35 -0
- package/src/seat-role.js +96 -0
- package/src/shape-rounds.js +58 -0
- package/src/spend.js +193 -0
- package/src/stage-contract.js +223 -0
- package/src/stage-submission.js +53 -0
- package/src/tie-break.js +94 -0
- package/src/ui/app.js +139 -0
- package/src/ui/index.html +43 -0
- package/src/ui/parse.js +101 -0
- package/src/ui/server.js +117 -0
- package/src/ui/style.css +37 -0
- package/src/verdict-stats.js +267 -0
- package/src/withdrawal-ledger.js +47 -0
package/src/seat-role.js
ADDED
|
@@ -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
|
+
}
|