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,70 @@
1
+ // v5 §1 candidate 7: a realistic-case cost estimate before picking a chain,
2
+ // extending the project's existing "price before you run" discipline from
3
+ // `estimateChainRows`'s worst-case (the chain's own declared token
4
+ // assumptions, no history) to what this chain has actually cost on this
5
+ // machine. DERIVED, same convention as spend.js/verdict-stats.js: no
6
+ // ledger, read back from runs/*/report.json, repriced at CURRENT
7
+ // pricing.json rather than trusting a historical run's own possibly-stale
8
+ // `usd` field (a model's price can change between the run and today).
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { runIdToDate } from './spend.js';
12
+ import { costOf } from './cost.js';
13
+
14
+ const readJson = p => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
15
+
16
+ /**
17
+ * A cost range for `chainName`, built from every historical run of that
18
+ * exact chain in `runsDir` within the last `days`. The range is the
19
+ * observed min/max of what those runs actually cost, repriced at today's
20
+ * pricing.json - a real spread from real runs, not a fabricated tolerance
21
+ * formula. A single historical run gives a zero-width range (low === high)
22
+ * rather than inventing variance that was never observed.
23
+ * Never throws - a missing runs/ dir or no matching history is a note, not
24
+ * an error.
25
+ */
26
+ export function forecastCost(chainName, runsDir, { days = 90, now = Date.now() } = {}) {
27
+ const cutoff = now - days * 24 * 3600 * 1000;
28
+
29
+ let ids = [];
30
+ try {
31
+ ids = existsSync(runsDir) ? readdirSync(runsDir) : [];
32
+ } catch {
33
+ return { chain: chainName, days, runsUsed: 0, low: null, high: null, mean: null, note: 'runs directory could not be read' };
34
+ }
35
+
36
+ const perRunUsd = [];
37
+ const unpriced = new Set();
38
+ for (const id of ids) {
39
+ const when = runIdToDate(id);
40
+ if (!when || when.getTime() < cutoff) continue;
41
+ const report = readJson(join(runsDir, id, 'report.json'));
42
+ if (!report || report.chain !== chainName) continue;
43
+
44
+ let total = 0;
45
+ for (const s of report.stages || []) {
46
+ if (!s.usage) continue;
47
+ const c = costOf(s.provider, s.model, s.usage);
48
+ total += c.usd;
49
+ if (!c.priced) unpriced.add(`${s.provider}/${s.model}`);
50
+ }
51
+ perRunUsd.push(total);
52
+ }
53
+
54
+ if (!perRunUsd.length) {
55
+ return { chain: chainName, days, runsUsed: 0, low: null, high: null, mean: null, note: 'no historical runs of this chain in the window' };
56
+ }
57
+
58
+ const low = Math.min(...perRunUsd);
59
+ const high = Math.max(...perRunUsd);
60
+ const mean = perRunUsd.reduce((a, b) => a + b, 0) / perRunUsd.length;
61
+
62
+ // A stage whose provider/model isn't in pricing.json prices as $0 - not
63
+ // "free", just unpriced. Flagging this rather than letting it silently
64
+ // understate the range, same discipline summarise() (cost.js) already
65
+ // applies to a single run's own total.
66
+ return {
67
+ chain: chainName, days, runsUsed: perRunUsd.length, low, high, mean,
68
+ ...(unpriced.size ? { partial: true, unpriced: [...unpriced], note: `${unpriced.size} model(s) in this history have no entry in pricing.json and were counted as $0: ${[...unpriced].join(', ')}` } : {}),
69
+ };
70
+ }
package/src/cost.js ADDED
@@ -0,0 +1,139 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const PRICES = JSON.parse(readFileSync(join(here, 'pricing.json'), 'utf8'));
7
+
8
+ export function priceOf(provider, model) {
9
+ return PRICES[`${provider}/${model}`] || null;
10
+ }
11
+
12
+ export function costOf(provider, model, usage) {
13
+ const p = priceOf(provider, model);
14
+ if (!p) return { usd: 0, priced: false };
15
+ const usd = (usage.input / 1e6) * p.in + (usage.output / 1e6) * p.out;
16
+ return { usd, priced: true };
17
+ }
18
+
19
+ export function summarise(stages) {
20
+ let input = 0, output = 0, usd = 0;
21
+ const unpriced = new Set();
22
+ for (const s of stages) {
23
+ if (!s.usage) continue;
24
+ input += s.usage.input;
25
+ output += s.usage.output;
26
+ const c = costOf(s.provider, s.model, s.usage);
27
+ usd += c.usd;
28
+ if (!c.priced) unpriced.add(`${s.provider}/${s.model}`);
29
+ }
30
+ return { input, output, total: input + output, usd, unpriced: [...unpriced] };
31
+ }
32
+
33
+ export function formatUsd(n) {
34
+ return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Per-chain worst-case estimate, from the config's own declared token
39
+ // assumptions. Calls nothing, so it costs nothing. This is the one place that
40
+ // walks a chain's stage shape (questions/criteria/skeleton/proposals/debate/
41
+ // panel rounds/handoff) to price it - `--dry-run` and `council doctor` both
42
+ // call this rather than each re-deriving the shape themselves.
43
+ export function estimateChainRows(config, { fromRun = false } = {}) {
44
+ const a = config.estimate || { promptTokens: 4000, draftTokens: 6000, critiqueTokens: 1200 };
45
+ const rows = [];
46
+ const push = (label, seat, input, output) => {
47
+ if (!seat) return;
48
+ const p = seat.provider === 'external' ? { in: 0, out: 0 } : priceOf(seat.provider, seat.model);
49
+ const usd = p ? (input / 1e6) * p.in + (output / 1e6) * p.out : 0;
50
+ rows.push({ label, seat: `${seat.provider}/${seat.model}`, input, output, usd, priced: !!p });
51
+ };
52
+ if (config.questions && !fromRun) push('questions', config.seats.questions || config.seats.criteria, a.promptTokens, 800);
53
+ // A chain with hand-written criteria skips that stage entirely.
54
+ if (!config.criteria?.length) push('criteria', config.seats.criteria, a.promptTokens, 400);
55
+ let proposalTokens = 0;
56
+ if (config.proposals && !fromRun) {
57
+ const parts = config.proposals.parts ?? 3, per = config.proposals.maxTokens ?? 1500;
58
+ push('skeleton', config.seats.skeleton || config.seats.builder, a.promptTokens + 400, 1200);
59
+ const samples = config.proposals.samples ?? 1, keep = config.proposals.keep ?? parts;
60
+ for (const seat of config.seats.proposers || config.seats.critics) {
61
+ for (let k = 0; k < samples; k++) push(`propose-${seat.lab || seat.provider}${samples > 1 ? `-${k + 1}` : ''}`, seat, a.promptTokens + 1600, parts * per);
62
+ if (samples > 1) push(`judge-${seat.lab || seat.provider}`, config.seats.judge || seat, a.promptTokens + 1600 + samples * parts * per, 300);
63
+ proposalTokens += (samples > 1 ? keep : parts) * per;
64
+ }
65
+ }
66
+ if (config.debate && !fromRun) {
67
+ for (const seat of config.seats.proposers || config.seats.critics) {
68
+ push(`debate-${seat.lab || seat.provider}`, seat, a.promptTokens + 1600 + proposalTokens, 1500);
69
+ push(`reply-${seat.lab || seat.provider}`, seat, a.promptTokens + 3000, 800);
70
+ }
71
+ proposalTokens += proposalTokens; // the board roughly doubles what the builder reads
72
+ }
73
+ if (!fromRun) push('build', config.seats.builder, a.promptTokens + 400 + proposalTokens, a.draftTokens);
74
+ const unanimous = config.signoff === 'unanimous';
75
+ for (let r = 1; r <= config.maxRounds; r++) {
76
+ if (unanimous) {
77
+ for (const critic of config.seats.critics) {
78
+ push(`panel-${r}-${critic.lab || critic.provider}`, critic, a.promptTokens + a.draftTokens, a.critiqueTokens);
79
+ }
80
+ } else {
81
+ const critic = config.seats.critics[(r - 1) % config.seats.critics.length];
82
+ push(`critique-${r}`, critic, a.promptTokens + a.draftTokens, a.critiqueTokens);
83
+ }
84
+ if (r < config.maxRounds) push(`revise-${r}`, config.seats.reviser || config.seats.builder, a.promptTokens + a.draftTokens + a.critiqueTokens + proposalTokens, a.draftTokens);
85
+ }
86
+ push('final', config.seats.finalist, a.promptTokens + a.draftTokens, a.draftTokens);
87
+ if (config.handoff) push('handoff', config.seats.handoff || config.seats.builder, a.promptTokens + a.draftTokens, 1200);
88
+ return rows;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Per-run spend cap.
93
+ //
94
+ // costOf() above is measurement: what a stage cost once it had already been
95
+ // paid for. A cap that only measures is not a cap, so enforcement needs a
96
+ // number BEFORE the call goes out - hence a worst case rather than an actual.
97
+ //
98
+ // Worst case for one stage is: every prompt character billed as input, plus
99
+ // the seat's entire maxTokens budget billed as output. A stage essentially
100
+ // never spends its whole output budget, so this over-estimates on purpose - a
101
+ // cap that occasionally stops a run slightly early is a working cap, and one
102
+ // that lets a run slip past the ceiling is not.
103
+
104
+ // Characters per token. Real tokenisers vary by model and language; 4 is the
105
+ // usual English rule of thumb and errs low (i.e. estimates MORE tokens) on
106
+ // the code and JSON these prompts are full of.
107
+ const CHARS_PER_TOKEN = 4;
108
+
109
+ export function estimateTokens(text) {
110
+ return Math.ceil((text || '').length / CHARS_PER_TOKEN);
111
+ }
112
+
113
+ /**
114
+ * The most this stage could cost. Returns 0 for a seat with no price entry -
115
+ * an unpriced model cannot be accounted for, so it cannot be capped either
116
+ * (this is why the $0 mock chains run freely under any ceiling).
117
+ *
118
+ * `retries` covers the thinking-disabled retry in chain.js invoke(), which
119
+ * can pay for the same stage twice.
120
+ */
121
+ export function worstCaseOf(provider, model, { promptChars = 0, maxTokens = 8000, retries = 1 } = {}) {
122
+ const p = priceOf(provider, model);
123
+ if (!p) return { usd: 0, priced: false };
124
+ const input = Math.ceil(promptChars / CHARS_PER_TOKEN);
125
+ const usd = ((input / 1e6) * p.in + (maxTokens / 1e6) * p.out) * retries;
126
+ return { usd, priced: true };
127
+ }
128
+
129
+ /**
130
+ * Would running this stage risk breaching the ceiling? Pure, so it is
131
+ * testable without a provider.
132
+ *
133
+ * `cap` of null means no ceiling and always returns ok.
134
+ */
135
+ export function wouldBreach({ spent, cap, projected }) {
136
+ if (cap === null || cap === undefined) return { breach: false };
137
+ const after = spent + projected;
138
+ return { breach: after > cap, after, remaining: Math.max(0, cap - spent) };
139
+ }
package/src/errors.js ADDED
@@ -0,0 +1,82 @@
1
+ // v5 §1 candidate 4: a small, stable catalog of the codes this project's
2
+ // hard-fail paths actually hit, each with a one-line cause, a one-line
3
+ // fix naming a real file, and a doc pointer - documented in
4
+ // TROUBLESHOOTING.md, which every code here must also appear in (checked
5
+ // by test/errors.test.js).
6
+ //
7
+ // Tone, fixed by Muad from a concrete preview (2026-09-13): name what's
8
+ // wrong in plain words, explain what the missing concept IS in one line
9
+ // (not just that it's absent), blank line, then the concrete fix with a
10
+ // real path, then a doc pointer. No jargon the reader hasn't met yet. No
11
+ // apology, no personality, no exclamation marks - warm is not chatty.
12
+ // `formatCouncilError` enforces this shape mechanically so no call site
13
+ // can drift from it by writing its own message.
14
+
15
+ export const ERROR_CATALOG = {
16
+ 'COUNCIL-E001': {
17
+ kind: 'degradable',
18
+ title: 'Missing API key',
19
+ render: ctx => ({
20
+ what: `The "${ctx.provider}" API key isn't set, so chain "${ctx.chain}" can't call that lab.`,
21
+ concept: `An API key is what proves to ${ctx.provider} that a request is yours to pay for - without it, every call to that lab fails before it starts.`,
22
+ fix: `Set ${ctx.envVar} in your .env file.`,
23
+ doc: 'README.md#setup',
24
+ }),
25
+ },
26
+ 'COUNCIL-E002': {
27
+ kind: 'degradable',
28
+ title: 'Unpriced model',
29
+ render: ctx => ({
30
+ what: `"${ctx.provider}/${ctx.model}" has no listed price, so its cost can't be shown or added to your spend total.`,
31
+ concept: `A price is the per-token rate this program uses to add up what a run will cost before you pay for it.`,
32
+ fix: `Add an entry for "${ctx.provider}/${ctx.model}" to src/pricing.json.`,
33
+ doc: 'README.md#the-spend-cap',
34
+ }),
35
+ },
36
+ 'COUNCIL-E003': {
37
+ kind: 'fatal',
38
+ title: 'Malformed chain file',
39
+ render: ctx => ({
40
+ what: `The chain file "${ctx.path}" isn't valid JSON, so it can't be read at all.`,
41
+ concept: `A chain file is the plain JSON config that says which models fill which seat - a syntax error anywhere in it stops the whole file from parsing.`,
42
+ fix: `Check ${ctx.path} for a missing comma, bracket, or quote.`,
43
+ doc: 'README.md#chains',
44
+ }),
45
+ },
46
+ 'COUNCIL-E004': {
47
+ kind: 'degradable',
48
+ title: 'Unreadable stage reply',
49
+ render: ctx => ({
50
+ what: `The reply from "${ctx.lab}" for stage "${ctx.label}" couldn't be read as the JSON this stage expects.`,
51
+ concept: `A stage reply is one lab's structured answer for one step of the chain - an unreadable one is treated as an abstention, not a failure, so the run continues without it.`,
52
+ fix: `Read the saved reply at ${ctx.file || 'runs/<id>/<label>.md'} to see what that lab actually returned.`,
53
+ doc: 'TROUBLESHOOTING.md#council-e004',
54
+ }),
55
+ },
56
+ };
57
+
58
+ /**
59
+ * The full, tone-shaped message for `code`, filled with `ctx`. Throws if
60
+ * `code` isn't in the catalog - a call site using an unknown code is a
61
+ * bug in this codebase, not a user-facing condition.
62
+ */
63
+ export function formatCouncilError(code, ctx = {}) {
64
+ const entry = ERROR_CATALOG[code];
65
+ if (!entry) throw new Error(`formatCouncilError: unknown code "${code}"`);
66
+ const { what, concept, fix, doc } = entry.render(ctx);
67
+ return `${code} [${entry.kind}] ${entry.title}\n${what} ${concept}\n\n${fix}\nSee: ${doc}`;
68
+ }
69
+
70
+ /**
71
+ * An Error carrying a catalog `code` and its `kind` (degradable|fatal), so
72
+ * a top-level catch can print the tone-shaped message and choose an exit
73
+ * code without re-deriving either.
74
+ */
75
+ export class CouncilError extends Error {
76
+ constructor(code, ctx = {}) {
77
+ super(formatCouncilError(code, ctx));
78
+ this.code = code;
79
+ this.kind = ERROR_CATALOG[code]?.kind;
80
+ this.ctx = ctx;
81
+ }
82
+ }
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ // relay as a network service: trigger-service (sower-industries.de's order pipeline) calling a
3
+ // deployed relay instance over HTTPS, since Fly.io machines don't share a filesystem the way a
4
+ // local sibling-checkout does. Blocking request/response, not detached-and-polled like
5
+ // src/mcp/server.js's tools - this mirrors trigger-service's own existing jobs/start-run.js and
6
+ // jobs/resume-harness.js exactly (a real chain run/resume already blocks the buyer's own HTTP
7
+ // request today; this doesn't change that shape, only moves where the child process runs).
8
+ //
9
+ // 2026-09-09: built under an explicit author override of the 2026-09-08 scope-gate KILL-DESCOPE
10
+ // verdict on relay productionization (sower-industries/Docs/working-agreements-scars.md carries
11
+ // the original verdict; sower-industries/PLAN.md's Parking lot section names the four defects this
12
+ // build addresses: wrong dependency facts (fixed below - see requiredEnvForChain(), derived from
13
+ // a chain's own seats table rather than guessed), no crash-recovery/idempotency (the idempotency
14
+ // store below - disk-persisted at idemDir, its own volume in production, same as runs/), no budget
15
+ // guard on the endpoint (dailyUsage() below, a backstop behind trigger-service's own
16
+ // MAX_USD_PER_DAY check - this is defense in depth, not the primary guard), and a missed reuse of
17
+ // relay's own MCP server (src/mcp/server.js's run-state functions - runSummary/waiting/safeRun -
18
+ // are the same disk-as-source-of-truth model this file uses; not literally shared code, since the
19
+ // MCP server's detached-and-polled execution model and this file's blocking one are genuinely
20
+ // different shapes, but the same conventions: a run's true state always comes from its runs/<id>/
21
+ // folder on disk, never from in-memory state that a restart would lose).
22
+ import express from 'express';
23
+ import crypto from 'node:crypto';
24
+ import { spawn as nodeSpawn } from 'node:child_process';
25
+ import {
26
+ existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, mkdtempSync, copyFileSync,
27
+ } from 'node:fs';
28
+ import { join, dirname, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { tmpdir } from 'node:os';
31
+
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ const root = resolve(here, '..');
34
+
35
+ // The only chain trigger-service actually starts (jobs/start-run.js's own DEFAULT_CHAIN,
36
+ // 'plan-auto') - read directly from that chain's own seats table rather than assumed, so this list
37
+ // can't drift from the chain config the way the original scope-gated spec's provider list did.
38
+ // Every other chain this repo can run is out of scope for this endpoint on purpose - trigger-
39
+ // service's own RELAY_CHAIN env var (start-run.js) could in principle name a different chain, in
40
+ // which case its provider keys would also need to be Fly secrets here; this constant is a
41
+ // documented default, not a hard runtime restriction.
42
+ export function requiredEnvForChain(chainName, chainsDir = join(root, 'chains')) {
43
+ const configPath = join(chainsDir, `${chainName}.json`);
44
+ if (!existsSync(configPath)) return [];
45
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
46
+ const providers = new Set();
47
+ const seats = config.seats || {};
48
+ for (const [key, seat] of Object.entries(seats)) {
49
+ if (key === 'critics') {
50
+ for (const critic of seat) providers.add(critic.provider);
51
+ } else if (seat?.provider) {
52
+ providers.add(seat.provider);
53
+ }
54
+ }
55
+ const KEY_BY_PROVIDER = {
56
+ anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', google: 'GOOGLE_API_KEY',
57
+ xai: 'XAI_API_KEY', mistral: 'MISTRAL_API_KEY', deepseek: 'DEEPSEEK_API_KEY',
58
+ groq: 'GROQ_API_KEY', cohere: 'COHERE_API_KEY', openrouter: 'OPENROUTER_API_KEY',
59
+ together: 'TOGETHER_API_KEY', zai: 'ZAI_API_KEY',
60
+ };
61
+ return [...providers].map((p) => KEY_BY_PROVIDER[p]).filter(Boolean).sort();
62
+ }
63
+
64
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
65
+ const safeRunId = (id) => /^[0-9TZ-]+$/.test(id) && existsSync(join(root, 'runs', id));
66
+
67
+ // Run ids are relay's own timestamp format (src/cli.js: `new Date().toISOString().replace(...)`),
68
+ // e.g. `2026-09-08T10-44-43-969Z` - reversible back to a real Date without needing a second,
69
+ // separately-maintained timestamp store.
70
+ function runIdToDate(runId) {
71
+ const iso = runId.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, 'T$1:$2:$3.$4Z');
72
+ const d = new Date(iso);
73
+ return Number.isNaN(d.getTime()) ? null : d;
74
+ }
75
+
76
+ /**
77
+ * Defense-in-depth spend guard at this endpoint's own layer - trigger-service's own
78
+ * spend.js/budgetOk() (checked before it ever calls this service) is the primary guard; this one
79
+ * exists because Docs/working-agreements-scars.md's recorded scope-gate verdict named "no budget
80
+ * guard on the newly-exposed endpoint" as a real defect, and an endpoint reachable by anything
81
+ * holding the bearer token should not trust a caller's own pre-check as its only protection.
82
+ * Source of truth is runs/ on disk (each run's own report.json.totals.usd), same
83
+ * disk-not-memory convention as the rest of this file - nothing here is lost on a restart.
84
+ */
85
+ export function dailyUsage(runsDir, now = Date.now()) {
86
+ const cutoff = now - 24 * 3600 * 1000;
87
+ let usd = 0;
88
+ let count = 0;
89
+ for (const id of existsSync(runsDir) ? readdirSync(runsDir) : []) {
90
+ const d = runIdToDate(id);
91
+ if (!d || d.getTime() < cutoff) continue;
92
+ count += 1;
93
+ usd += readJson(join(runsDir, id, 'report.json'))?.totals?.usd ?? 0;
94
+ }
95
+ return { usd, count };
96
+ }
97
+
98
+ function runChildForeground({ spawnFn, args, cwd }) {
99
+ return new Promise((resolvePromise) => {
100
+ let child;
101
+ try {
102
+ child = spawnFn('node', args, { cwd });
103
+ } catch (err) {
104
+ resolvePromise({ code: null, stderr: String(err?.message ?? err) });
105
+ return;
106
+ }
107
+ let stderr = '';
108
+ child.stderr?.on('data', (d) => { stderr += d.toString(); });
109
+ child.on('error', (err) => resolvePromise({ code: null, stderr: stderr + String(err?.message ?? err) }));
110
+ child.on('close', (code) => resolvePromise({ code, stderr }));
111
+ });
112
+ }
113
+
114
+ // Filtered to relay's own run-id shape (src/cli.js: an ISO-8601 timestamp with `:`/`.` swapped for
115
+ // `-`, e.g. `2026-09-08T10-44-43-969Z`) - the same pattern src/mcp/server.js's own safeRun() already
116
+ // checks. Without this filter, any other new entry appearing under runsDir in the same before/after
117
+ // window (this file's own idempotency store, if it ever shared this directory; a future unrelated
118
+ // tool writing here) could be mistaken for the run this request just started.
119
+ const RUN_ID_PATTERN = /^[0-9TZ-]+$/;
120
+ function findNewRunDir(runsDir, before) {
121
+ if (!existsSync(runsDir)) return null;
122
+ return readdirSync(runsDir).find((d) => !before.has(d) && RUN_ID_PATTERN.test(d)) ?? null;
123
+ }
124
+
125
+ // Idempotency store: one JSON file per idempotency key (trigger-service passes its own order id).
126
+ // {status:'pending'} while a spawn is in progress; {status:'done', statusCode, body} once resolved,
127
+ // replayed verbatim to a retried request instead of spawning a second run for the same order.
128
+ // Atomic create via the 'wx' flag - a second concurrent request for the same key gets EEXIST and is
129
+ // told to poll, never silently spawns a duplicate.
130
+ //
131
+ // 2026-09-09: moved off relayRoot's own ephemeral local disk onto its own persistent path
132
+ // (idemDir, independent of runsDir - see createRelayHttpServer's own comment on why it isn't
133
+ // nested under runs/ itself) once the author asked for the "lost on restart" tradeoff closed -
134
+ // same disk-as-source-of-truth durability runs/ already gets from its own Fly volume.
135
+ function idempotencyPath(idemDir, key) {
136
+ return join(idemDir, `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
137
+ }
138
+
139
+ function claimIdempotencyKey(idemDir, key) {
140
+ mkdirSync(idemDir, { recursive: true });
141
+ const path = idempotencyPath(idemDir, key);
142
+ if (existsSync(path)) {
143
+ const existing = readJson(path);
144
+ return { claimed: false, existing };
145
+ }
146
+ try {
147
+ writeFileSync(path, JSON.stringify({ status: 'pending', startedAt: new Date().toISOString() }), { flag: 'wx' });
148
+ return { claimed: true, path };
149
+ } catch (err) {
150
+ if (err.code === 'EEXIST') return { claimed: false, existing: readJson(path) };
151
+ throw err;
152
+ }
153
+ }
154
+
155
+ function resolveIdempotencyKey(path, statusCode, body) {
156
+ writeFileSync(path, JSON.stringify({ status: 'done', statusCode, body, finishedAt: new Date().toISOString() }));
157
+ }
158
+
159
+ /**
160
+ * createRelayHttpServer({ root, token, spawnFn, maxUsdPerDay, maxRunsPerDay, idemDir }) - factory
161
+ * shape matching trigger-service's own createXRouter()/createXRouter() dependency-injection
162
+ * convention (webhooks/stripe.js, jobs/checkpoint.js) so this is testable against a stub spawnFn,
163
+ * no real child process, no real API keys, same pattern as that repo's own 40-test suite.
164
+ *
165
+ * `idemDir` defaults to `<relayRoot>/.idempotency` (local disk, matching every prior default) but
166
+ * is independently overridable - production points it at its own persistent volume, deliberately
167
+ * NOT nested under `runsDir` (`runs/.idempotency/` would durability-wise be simpler - one mount
168
+ * instead of two - but every directory under runs/ is treated as a run by src/mcp/server.js's and
169
+ * src/ui/server.js's own `list_runs`/UI listings, neither of which this file's own idempotency
170
+ * bookkeeping should ever show up in).
171
+ */
172
+ export function createRelayHttpServer({
173
+ relayRoot = root,
174
+ token = process.env.RELAY_SERVICE_TOKEN,
175
+ spawnFn = nodeSpawn,
176
+ maxUsdPerDay = Number(process.env.MAX_USD_PER_DAY || 20),
177
+ maxRunsPerDay = Number(process.env.MAX_RUNS_PER_DAY || 5),
178
+ idemDir = process.env.RELAY_IDEMPOTENCY_DIR || join(relayRoot, '.idempotency'),
179
+ } = {}) {
180
+ const runsDir = join(relayRoot, 'runs');
181
+ const cliPath = join(relayRoot, 'src', 'cli.js');
182
+ const resumingNow = new Set();
183
+
184
+ const app = express();
185
+ app.use(express.json({ limit: '256kb' }));
186
+
187
+ app.get('/health', (req, res) => {
188
+ res.json({ status: 'ok', spendLast24h: dailyUsage(runsDir) });
189
+ });
190
+
191
+ app.use((req, res, next) => {
192
+ const header = req.headers.authorization || '';
193
+ const provided = header.startsWith('Bearer ') ? header.slice(7) : '';
194
+ const expected = token || '';
195
+ const providedBuf = Buffer.from(provided);
196
+ const expectedBuf = Buffer.from(expected);
197
+ const ok = expected.length > 0
198
+ && providedBuf.length === expectedBuf.length
199
+ && crypto.timingSafeEqual(providedBuf, expectedBuf);
200
+ if (!ok) {
201
+ res.status(401).json({ error: 'unauthorized' });
202
+ return;
203
+ }
204
+ next();
205
+ });
206
+
207
+ app.post('/runs', async (req, res) => {
208
+ const { chain, taskContent, idempotencyKey } = req.body ?? {};
209
+ if (typeof chain !== 'string' || typeof taskContent !== 'string' || typeof idempotencyKey !== 'string' || !chain || !taskContent || !idempotencyKey) {
210
+ res.status(400).json({ error: 'malformed_request' });
211
+ return;
212
+ }
213
+
214
+ const usage = dailyUsage(runsDir);
215
+ if (usage.usd >= maxUsdPerDay || usage.count >= maxRunsPerDay) {
216
+ res.status(503).json({ error: 'budget_exceeded', reason: usage.usd >= maxUsdPerDay ? 'usd_cap' : 'run_cap', usage });
217
+ return;
218
+ }
219
+
220
+ const claim = claimIdempotencyKey(idemDir, idempotencyKey);
221
+ if (!claim.claimed) {
222
+ if (claim.existing?.status === 'done') {
223
+ res.status(claim.existing.statusCode).json(claim.existing.body);
224
+ return;
225
+ }
226
+ res.status(409).json({ error: 'in_progress' });
227
+ return;
228
+ }
229
+
230
+ const taskDir = mkdtempSync(join(tmpdir(), 'relay-service-task-'));
231
+ const taskPath = join(taskDir, `${idempotencyKey}.md`);
232
+ writeFileSync(taskPath, taskContent);
233
+
234
+ const before = new Set(existsSync(runsDir) ? readdirSync(runsDir) : []);
235
+ const { code, stderr } = await runChildForeground({
236
+ spawnFn, args: [cliPath, '--chain', chain, '--task', taskPath], cwd: relayRoot,
237
+ });
238
+
239
+ let statusCode;
240
+ let body;
241
+
242
+ if (code === 3) {
243
+ const runId = findNewRunDir(runsDir, before);
244
+ const needsPath = runId ? join(runsDir, runId, 'NEEDS-answers.md') : null;
245
+ if (!runId || !existsSync(needsPath)) {
246
+ statusCode = 500;
247
+ body = { outcome: 'error', reason: 'run_start_failed', runId };
248
+ } else {
249
+ const questionsRaw = readFileSync(join(runsDir, runId, 'questions.md'), 'utf8');
250
+ const parsed = JSON.parse(questionsRaw.slice(questionsRaw.indexOf('{'), questionsRaw.lastIndexOf('}') + 1));
251
+ const questions = (Array.isArray(parsed?.questions) ? parsed.questions : []).map((q) => q.question).filter(Boolean);
252
+ statusCode = 200;
253
+ body = { outcome: 'paused', runId, questions };
254
+ }
255
+ } else if (code === 0) {
256
+ const runId = findNewRunDir(runsDir, before);
257
+ statusCode = 200;
258
+ body = { outcome: 'completed', runId };
259
+ } else {
260
+ statusCode = 500;
261
+ body = { outcome: 'error', reason: 'nonzero_exit', exitCode: code, stderrTail: stderr.slice(-500) };
262
+ }
263
+
264
+ resolveIdempotencyKey(claim.path, statusCode, body);
265
+ res.status(statusCode).json(body);
266
+ });
267
+
268
+ app.post('/runs/:id/resume', async (req, res) => {
269
+ const { id } = req.params;
270
+ const { answersText } = req.body ?? {};
271
+ if (!safeRunId(id)) {
272
+ res.status(404).json({ error: 'no_such_run' });
273
+ return;
274
+ }
275
+ if (typeof answersText !== 'string' || !answersText) {
276
+ res.status(400).json({ error: 'malformed_request' });
277
+ return;
278
+ }
279
+ if (resumingNow.has(id)) {
280
+ res.status(409).json({ error: 'resume_in_progress' });
281
+ return;
282
+ }
283
+ resumingNow.add(id);
284
+
285
+ try {
286
+ const runDir = join(runsDir, id);
287
+ writeFileSync(join(runDir, 'answers.md'), answersText);
288
+
289
+ const { code, stderr } = await runChildForeground({
290
+ spawnFn, args: [cliPath, '--resume', join('runs', id)], cwd: relayRoot,
291
+ });
292
+
293
+ if (code !== 0) {
294
+ res.status(500).json({ success: false, error: 'nonzero_exit', exitCode: code, stderrTail: stderr.slice(-500) });
295
+ return;
296
+ }
297
+
298
+ // Relay never writes a file literally named PLAN.md - it writes deliverable.md. Same
299
+ // copy-if-missing step trigger-service's own resume-harness.js already does today; moved
300
+ // here since this is now where the run folder actually lives.
301
+ const deliverablePath = join(runDir, 'deliverable.md');
302
+ const planPath = join(runDir, 'PLAN.md');
303
+ if (existsSync(deliverablePath) && !existsSync(planPath)) {
304
+ copyFileSync(deliverablePath, planPath);
305
+ }
306
+
307
+ const missing = ['PLAN.md', 'BOARD.md', 'HANDOFF.md'].filter((f) => !existsSync(join(runDir, f)));
308
+ if (missing.length > 0) {
309
+ res.status(500).json({ success: false, error: 'missing_output_files', missing });
310
+ return;
311
+ }
312
+ res.status(200).json({ success: true });
313
+ } finally {
314
+ resumingNow.delete(id);
315
+ }
316
+ });
317
+
318
+ app.get('/runs/:id/attachments', (req, res) => {
319
+ const { id } = req.params;
320
+ if (!safeRunId(id)) {
321
+ res.status(404).json({ error: 'no_such_run' });
322
+ return;
323
+ }
324
+ const runDir = join(runsDir, id);
325
+ const files = ['PLAN.md', 'BOARD.md', 'HANDOFF.md']
326
+ .filter((f) => existsSync(join(runDir, f)))
327
+ .map((f) => ({ filename: f, content: readFileSync(join(runDir, f), 'utf8') }));
328
+ res.status(200).json({ files });
329
+ });
330
+
331
+ app.use((req, res) => {
332
+ res.status(404).json({ error: 'not_found' });
333
+ });
334
+
335
+ return app;
336
+ }
337
+
338
+ if (import.meta.url === `file://${process.argv[1]}`) {
339
+ const PORT = Number(process.env.PORT || 8090);
340
+ if (!process.env.RELAY_SERVICE_TOKEN) {
341
+ console.error('RELAY_SERVICE_TOKEN not set - refusing to start (this endpoint holds live LLM provider keys).');
342
+ process.exit(1);
343
+ }
344
+ const app = createRelayHttpServer({});
345
+ app.listen(PORT, () => {
346
+ console.log(`relay http-server listening on :${PORT}`);
347
+ console.log(`required env for plan-auto: ${requiredEnvForChain('plan-auto').join(', ')}`);
348
+ });
349
+ }