faberun 0.14.0 → 0.16.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.
@@ -5,26 +5,44 @@
5
5
  * resolves a *contract's* already-declared runtimes; a plan's draft node
6
6
  * never names one (the constitution reserves model choice to runtimes,
7
7
  * runtimeDefaults, or an explicit node override) — this module is what turns
8
- * a taskKind/riskTier classification into one of those three.
8
+ * a taskKind/riskTier classification into one of those three. Each rule names
9
+ * the strategy that consumes its `prefer` list, and every assignment records
10
+ * the strategy that was applied and the reason for the choice — `declared`
11
+ * when an operator instruction prevailed, since that outranks every strategy.
9
12
  */
10
13
 
11
- import { cheapest, strongest } from "../engine/runtime-discovery.mjs";
14
+ import { ROUTING_STRATEGIES } from "../contract/runtime.mjs";
15
+ import { cheapest, isRuntimeAvailable, strongest } from "../engine/runtime-discovery.mjs";
12
16
 
13
- /** @typedef {{available: boolean, exhaustedUntil: string|null, [key: string]: unknown}} RoutingAvailability */
17
+ /** @typedef {import("../contract/runtime.mjs").RoutingStrategy} RoutingStrategy */
18
+ /** @typedef {RoutingStrategy|"declared"} AppliedStrategy */
19
+
20
+ /**
21
+ * The plan's copy of the discovery catalogue record: only what the harness de
22
+ * facto reports, with the moment each datum was observed. The observables are
23
+ * null where the harness exposes nothing -- never zero and never full
24
+ * allowance, so a runtime that reports nothing cannot look rested -- and an
25
+ * observation older than its own window reads as unknown at the one shared
26
+ * reader, `isRuntimeAvailable` in runtime-discovery.mjs.
27
+ * @typedef {{available: boolean, exhaustedUntil: string|null, observedAt?: string|null, window?: string|null, remaining?: number|null, [key: string]: unknown}} RoutingAvailability
28
+ */
14
29
  /** @typedef {{vendor: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
15
30
  /** @typedef {{id: string, taskKind?: string, riskTier?: string}} RoutingNode */
16
31
  /** @typedef {{taskKind?: string, riskTier?: string}} RoutingWhen */
17
- /** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge"}} RoutingRule */
32
+ /** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge", strategy?: RoutingStrategy}} RoutingRule */
18
33
  /** @typedef {{worker?: string, judge?: string}} RoutingRoleMap */
19
34
  /** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>}} RoutingConfig */
20
- /** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}}} RoutingAssignment */
35
+ /** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}, strategy: {worker: AppliedStrategy|null, judge: AppliedStrategy|null}, reason: {worker: string, judge: string}}} RoutingAssignment */
21
36
  /** @typedef {{nodeId: string, role: "worker"|"judge", rule: string}} RoutingUnmet */
22
37
  /** @typedef {{assignments: Record<string, RoutingAssignment>, unmet: RoutingUnmet[]}} RoutingResult */
23
38
 
24
39
  /**
25
40
  * @param {RoutingNode[]} nodes
26
41
  * @param {RoutingConfig} config
27
- * @param {{partial?: boolean}} [options]
42
+ * @param {{partial?: boolean, previous?: Record<string, RoutingRoleMap>}} [options]
43
+ * `previous` carries the per-node role assignment the last routing pass made,
44
+ * the datum the `attempt-affinity` strategy prefers; absent, that strategy is
45
+ * inert and the remaining rules decide.
28
46
  * @returns {RoutingResult}
29
47
  */
30
48
  export function resolveRuntimes(nodes, config, options = {}) {
@@ -33,6 +51,15 @@ export function resolveRuntimes(nodes, config, options = {}) {
33
51
  const availability = config.availability ?? {};
34
52
  const runtimeDefaults = config.runtimeDefaults ?? {};
35
53
  const overrides = config.overrides ?? {};
54
+ const previous = options.previous ?? {};
55
+
56
+ // A strategy name outside the vocabulary is an authored-table defect, not an
57
+ // unobservable datum: it fails fast, before any node is resolved.
58
+ for (const row of table) {
59
+ if (row.strategy !== undefined && !ROUTING_STRATEGIES.has(row.strategy)) {
60
+ throw new TypeError(`routing rule ${ruleLabel(row)} names unknown strategy ${row.strategy}`);
61
+ }
62
+ }
36
63
 
37
64
  /** @type {Record<string, RoutingAssignment>} */
38
65
  const assignments = {};
@@ -41,15 +68,21 @@ export function resolveRuntimes(nodes, config, options = {}) {
41
68
 
42
69
  for (const node of nodes) {
43
70
  const override = overrides[node.id] ?? {};
44
- const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS });
71
+ const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS, previousId: previous[node.id]?.worker });
45
72
  if (worker.runtimeId === null) unmet.push({ nodeId: node.id, role: "worker", rule: worker.rule });
46
73
  // The judge's forbidden vendors follow the worker runtime that was
47
74
  // actually chosen, never the row that named it — a worker unmet leaves
48
75
  // nothing to conflict with, so the judge resolves without restriction.
49
76
  const forbiddenVendors = worker.runtimeId ? forbiddenJudgeVendors(worker.runtimeId, runtimes) : EMPTY_VENDORS;
50
- const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors });
77
+ const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors, previousId: previous[node.id]?.judge });
51
78
  if (judge.runtimeId === null) unmet.push({ nodeId: node.id, role: "judge", rule: judge.rule });
52
- assignments[node.id] = { worker: worker.runtimeId, judge: judge.runtimeId, rule: { worker: worker.rule, judge: judge.rule } };
79
+ assignments[node.id] = {
80
+ worker: worker.runtimeId,
81
+ judge: judge.runtimeId,
82
+ rule: { worker: worker.rule, judge: judge.rule },
83
+ strategy: { worker: worker.strategy, judge: judge.strategy },
84
+ reason: { worker: worker.reason, judge: judge.reason },
85
+ };
53
86
  }
54
87
 
55
88
  if (unmet.length && options.partial !== true) {
@@ -66,44 +99,136 @@ const EMPTY_VENDORS = Object.freeze(new Set());
66
99
  /**
67
100
  * Precedence for one role on one node: an explicit node override, then the
68
101
  * operator's runtimeDefaults, then the first table row whose `when` matches
69
- * this node's classification, then plain discovery. A row or default that
70
- * names an unavailable or vendor-forbidden runtime is unmet by that rule —
71
- * it does not fall through to a lower-precedence source, since falling
72
- * through would silently discard an explicit declaration; only the
73
- * candidates *within* a row's `prefer` list, and within discovery, are
74
- * skipped for exhaustion or vendor conflict.
102
+ * this node's classification, then plain discovery. A declaration outranks
103
+ * every strategy — it is recorded as `declared`, never as a strategy outcome —
104
+ * and a row or default that names an unavailable or vendor-forbidden runtime
105
+ * is unmet by that rule — it does not fall through to a lower-precedence
106
+ * source, since falling through would silently discard an explicit
107
+ * declaration; only the candidates *within* a row's `prefer` list, and within
108
+ * discovery, are skipped for exhaustion or vendor conflict.
75
109
  *
76
110
  * @param {RoutingNode} node
77
111
  * @param {"worker"|"judge"} role
78
- * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string>}} context
79
- * @returns {{runtimeId: string|null, rule: string}}
112
+ * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string>, previousId: string|undefined}} context
113
+ * @returns {{runtimeId: string|null, rule: string, strategy: AppliedStrategy|null, reason: string}}
80
114
  */
81
115
  function resolveRole(node, role, context) {
82
- const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors } = context;
116
+ const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors, previousId } = context;
83
117
 
84
118
  if (override[role] !== undefined) {
85
119
  const id = override[role];
86
- return { runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null, rule: "override" };
120
+ return {
121
+ runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null,
122
+ rule: "override",
123
+ strategy: "declared",
124
+ reason: `operator override on node ${node.id}`,
125
+ };
87
126
  }
88
127
 
89
128
  if (runtimeDefaults[role] !== undefined) {
90
129
  const id = runtimeDefaults[role];
91
- return { runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null, rule: "runtimeDefaults" };
130
+ return {
131
+ runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null,
132
+ rule: "runtimeDefaults",
133
+ strategy: "declared",
134
+ reason: "operator runtimeDefaults",
135
+ };
92
136
  }
93
137
 
94
138
  const row = table.find((candidate) => candidate.role === role
95
139
  && (candidate.when.taskKind === undefined || candidate.when.taskKind === node.taskKind)
96
140
  && (candidate.when.riskTier === undefined || candidate.when.riskTier === node.riskTier));
97
141
  if (row) {
98
- const rule = ruleLabel(row);
99
- const id = row.prefer.find((candidate) => admits(candidate, runtimes, availability, forbiddenVendors)) ?? null;
100
- return { runtimeId: id, rule };
142
+ const strategy = row.strategy ?? "priority";
143
+ const decision = chooseByStrategy(strategy, row.prefer, { runtimes, availability, forbiddenVendors, previousId });
144
+ return { runtimeId: decision.runtimeId, rule: ruleLabel(row), strategy, reason: decision.reason };
101
145
  }
102
146
 
103
147
  const discovered = role === "worker"
104
148
  ? cheapestAvailable(runtimes, availability, forbiddenVendors)
105
149
  : strongestAvailable(runtimes, availability, forbiddenVendors);
106
- return { runtimeId: discovered, rule: "discovery" };
150
+ return {
151
+ runtimeId: discovered,
152
+ rule: "discovery",
153
+ strategy: role === "worker" ? "cost" : "priority",
154
+ reason: role === "worker" ? "discovery: cheapest available runtime" : "discovery: strongest available runtime",
155
+ };
156
+ }
157
+
158
+ /**
159
+ * One strategy's choice over a row's `prefer` list -- attempt-affinity's
160
+ * candidate is the previous attempt's runtime, which the list need not name.
161
+ * A strategy whose datum a
162
+ * candidate does not expose is inert for that candidate — it cannot choose it,
163
+ * and it never raises; inert for the whole row when no candidate exposes it,
164
+ * at which point the prefer list's own order decides. The reason says which
165
+ * of the two happened, because a recorded strategy that fell back must read
166
+ * differently from one that decided.
167
+ *
168
+ * @param {RoutingStrategy} strategy
169
+ * @param {string[]} prefer
170
+ * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, forbiddenVendors: ReadonlySet<string>, previousId: string|undefined}} context
171
+ * @returns {{runtimeId: string|null, reason: string}}
172
+ */
173
+ function chooseByStrategy(strategy, prefer, context) {
174
+ const { runtimes, availability, forbiddenVendors, previousId } = context;
175
+ const candidates = prefer.map((id) => ({ id, admissible: admits(id, runtimes, availability, forbiddenVendors) }));
176
+ const admissible = candidates.filter((candidate) => candidate.admissible);
177
+ if (admissible.length === 0) return { runtimeId: null, reason: "no admissible candidate in the prefer list" };
178
+
179
+ if (strategy === "attempt-affinity") {
180
+ if (previousId === undefined) {
181
+ return { runtimeId: admissible[0].id, reason: "attempt-affinity inert: no previous attempt recorded; prefer order decided" };
182
+ }
183
+ // Affinity's candidate is the previous attempt's runtime itself, even
184
+ // where the prefer list does not name it -- reusing it is the strategy's
185
+ // whole point. It yields by name whenever it would violate availability
186
+ // or vendor distinction, the yields `admits` already speaks.
187
+ if (admits(previousId, runtimes, availability, forbiddenVendors)) {
188
+ return { runtimeId: previousId, reason: `attempt-affinity: previous attempt's runtime ${previousId} still admissible` };
189
+ }
190
+ return { runtimeId: admissible[0].id, reason: `attempt-affinity yielded: ${previousId} ${blockedWhy(previousId, runtimes, availability, forbiddenVendors)}; prefer order decided` };
191
+ }
192
+
193
+ if (strategy === "cost") {
194
+ const ranked = admissible.filter((candidate) => typeof runtimes[candidate.id]?.costRank === "number");
195
+ if (ranked.length === 0) {
196
+ return { runtimeId: admissible[0].id, reason: "cost inert: no admissible candidate exposes costRank; prefer order decided" };
197
+ }
198
+ /** @param {{id: string, admissible: boolean}} candidate */
199
+ const costRank = (candidate) => /** @type {number} */ (runtimes[candidate.id]?.costRank);
200
+ const winner = ranked.reduce((best, candidate) => (costRank(candidate) < costRank(best) ? candidate : best));
201
+ return { runtimeId: winner.id, reason: `cost: lowest costRank ${costRank(winner)} among ranked candidates` };
202
+ }
203
+
204
+ if (strategy === "reset-proximity") {
205
+ const measured = admissible.filter((candidate) => typeof availability[candidate.id]?.remaining === "number");
206
+ if (measured.length === 0) {
207
+ return { runtimeId: admissible[0].id, reason: "reset-proximity inert: no admissible candidate exposes remaining; prefer order decided" };
208
+ }
209
+ // The window nearest its reset is spent first, so a reset never wastes
210
+ // allowance; a runtime exposing nothing is unrankable, not free.
211
+ const winner = measured.reduce((best, candidate) => (/** @type {number} */ (availability[candidate.id].remaining) < /** @type {number} */ (availability[best.id].remaining) ? candidate : best));
212
+ return { runtimeId: winner.id, reason: `reset-proximity: least remaining allowance (${/** @type {number} */ (availability[winner.id].remaining)})` };
213
+ }
214
+
215
+ return { runtimeId: admissible[0].id, reason: "priority: first admissible of the prefer list" };
216
+ }
217
+
218
+ /**
219
+ * Why one runtime fails `admits`, in words a recorded reason can quote.
220
+ *
221
+ * @param {string} id
222
+ * @param {Record<string, RoutingRuntime>} runtimes
223
+ * @param {Record<string, RoutingAvailability>} availability
224
+ * @param {ReadonlySet<string>} forbiddenVendors
225
+ * @returns {string}
226
+ */
227
+ function blockedWhy(id, runtimes, availability, forbiddenVendors) {
228
+ const runtime = runtimes[id];
229
+ if (!runtime) return "is not declared in runtimes";
230
+ if (forbiddenVendors.has(runtime.vendor)) return `carries forbidden vendor ${runtime.vendor}`;
231
+ return "is unavailable";
107
232
  }
108
233
 
109
234
  /**
@@ -117,14 +242,7 @@ function admits(id, runtimes, availability, forbiddenVendors) {
117
242
  const runtime = runtimes[id];
118
243
  if (!runtime) return false;
119
244
  if (forbiddenVendors.has(runtime.vendor)) return false;
120
- return isAvailable(availability[id]);
121
- }
122
-
123
- /** @param {RoutingAvailability|undefined} entry @returns {boolean} */
124
- function isAvailable(entry) {
125
- if (!entry) return false;
126
- if (entry.available === true) return !entry.exhaustedUntil || Date.parse(entry.exhaustedUntil) <= Date.now();
127
- return Boolean(entry.exhaustedUntil && Date.parse(entry.exhaustedUntil) <= Date.now());
245
+ return isRuntimeAvailable(availability[id]);
128
246
  }
129
247
 
130
248
  /**
@@ -165,7 +283,7 @@ function ruleLabel(row) {
165
283
  function candidateEntries(runtimes, availability, forbiddenVendors) {
166
284
  return Object.entries(runtimes)
167
285
  .map(([id, runtime], order) => ({ id, runtime, order }))
168
- .filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) && isAvailable(availability[id]));
286
+ .filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) && isRuntimeAvailable(availability[id]));
169
287
  }
170
288
 
171
289
  /**
@@ -432,6 +432,39 @@ function markParallelisable(nodes, transformations) {
432
432
  });
433
433
  }
434
434
 
435
+ /**
436
+ * Ceiling on the concurrency a frozen plan may declare. Not a measurement: no
437
+ * one has measured how many concurrent workers this or any host survives, and
438
+ * the only evidence on hand is the wrong direction — three OOM kills on the
439
+ * planning host on 2026-09-21. So the ceiling is the largest value a contract
440
+ * in this repository's own record has ever run with: of the 58 stored
441
+ * contracts, 44 ran at 1 and 14 at 2, none higher. Raise it when someone
442
+ * measures a host surviving more, not before.
443
+ */
444
+ const MAX_PROVEN_PARALLELISM = 2;
445
+
446
+ /**
447
+ * How many nodes a sized plan may have in flight at once: the nodes
448
+ * `markParallelisable` marked, floored at 1 and capped at
449
+ * `MAX_PROVEN_PARALLELISM`. The mark is transitive by construction — a marked
450
+ * node depends on nothing, nothing depends on it, and its writeFiles is
451
+ * disjoint from *every* other node's, so any two marked nodes are independent
452
+ * of each other too and can all run together. The count is therefore the
453
+ * concurrency sizing proved the plan has work for: above it a slot only ever
454
+ * idles, and a plan with one marked node, or none, is 1 — the serial number a
455
+ * contract carries when it says nothing.
456
+ *
457
+ * `parallel` is a node-level mark and no contract node accepts it, so this
458
+ * number is the only way sizing's conclusion survives into a frozen contract
459
+ * (as `maxParallel`).
460
+ *
461
+ * @param {Plan} plan
462
+ * @returns {number}
463
+ */
464
+ export function provenParallelism(plan) {
465
+ return Math.min(MAX_PROVEN_PARALLELISM, Math.max(1, plan.nodes.filter((node) => node.parallel === true).length));
466
+ }
467
+
435
468
  /**
436
469
  * The longest dependsOn chain in the graph, counted in nodes. Sizing runs on
437
470
  * a model-drafted, not-yet-validated plan, so a dependsOn cycle is possible
@@ -104,6 +104,11 @@ export function candidateRefName(runId) {
104
104
  return `refs/faberun/${runId}/candidate`;
105
105
  }
106
106
 
107
+ /** @param {string} runId @param {string} nodeId @returns {string} */
108
+ export function preservedRefName(runId, nodeId) {
109
+ return `refs/faberun/${runId}/preserved/${nodeId}`;
110
+ }
111
+
107
112
  /** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
108
113
  function attemptBranchName(runId, nodeId, attempt) {
109
114
  return `faberun/${runId}/${nodeId}/${attempt}`;
@@ -168,6 +173,29 @@ export function createRunRef(repo, runId, head) {
168
173
  return ref;
169
174
  }
170
175
 
176
+ /**
177
+ * Keep one node's integrated commit reachable after cancel releases the run
178
+ * ref and the attempt branches: without a ref of its own the commit is
179
+ * garbage the next `git gc` collects, and the persisted snapshot alone cannot
180
+ * bring a pruned object back.
181
+ *
182
+ * Idempotent by the same contract `deleteRef` and `removeWorktree` honour:
183
+ * cancel can legitimately run twice (a retry after a partial first pass, or
184
+ * the operator repeating it), so a preserved ref that already names this
185
+ * exact commit is done, not a conflict — the second creation neither fails
186
+ * nor moves the target. A ref left at a different sha moves to the commit
187
+ * handed in now.
188
+ *
189
+ * @param {string} repo @param {string} runId @param {string} nodeId @param {string|null|undefined} sha @returns {string}
190
+ */
191
+ export function createPreservedRef(repo, runId, nodeId, sha) {
192
+ if (!sha) throw Object.assign(new Error("a preserved ref needs an integrated commit"), { code: "git_head_required" });
193
+ const ref = preservedRefName(runId, nodeId);
194
+ if (gitHead(repo, ref) === sha) return ref;
195
+ runGit(["-C", repo, "update-ref", ref, sha]);
196
+ return ref;
197
+ }
198
+
171
199
  /**
172
200
  * @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string, declaredReads?: string[]}} args
173
201
  * `base` cuts the new branch from a sealed sha instead of the run ref tip —
@@ -11,6 +11,7 @@ import { basename, join } from "node:path";
11
11
  import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
12
12
  import { scopeFindingsNote } from "../contract/scope-findings.mjs";
13
13
  import { MARK, fit, roleCosts, statusNote, writeStatusArtifacts } from "./render.mjs";
14
+ import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
14
15
  import { unlinkSync } from "node:fs";
15
16
 
16
17
  /** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
@@ -130,7 +131,7 @@ export function renderFinalReport(runDir, contract, states) {
130
131
  ]));
131
132
  }
132
133
  const roles = roleCosts(nodes);
133
- lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)} · read ${compactTokens(totals.declaredReadBytes)}`);
134
+ lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)} · read ${compactTokens(totals.declaredReadBytes)}${packetRepetitionNote(packetRepetitionByNode(runDir, nodes))}`);
134
135
  return `${lines.join("\n")}\n`;
135
136
  }
136
137
  /**
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The language of a notification, and the labels it is written in.
3
+ *
4
+ * The controller never sees the operator's conversation with a harness; it
5
+ * sees what the operator wrote for faberun: the campaign goal, the journal
6
+ * notes and the objectives the plan gave each node. Those are the operator's
7
+ * prompts to this product, and a message that answers them in the same
8
+ * language reads as a reply rather than a log line. Detection is a count of
9
+ * function words in the two languages this product's operators write in;
10
+ * anything else, or nothing to read, is English. Quoted content -- the
11
+ * worker's own words, a judge finding -- keeps the language it was written
12
+ * in, because translating a quotation would make it stop being one.
13
+ *
14
+ * It is a module apart from `message.mjs` so the layout and the wording can
15
+ * change independently: a new label is one line here, a new line is one
16
+ * function there.
17
+ */
18
+
19
+ /** @typedef {"en"|"pt"} Language */
20
+
21
+ // `a`, `do` and `no` are left out on purpose: each is also an English word,
22
+ // and one article in an English sentence must not read as Portuguese.
23
+ const PORTUGUESE = new Set(["o", "e", "de", "da", "na", "que", "não", "nao", "para", "com", "uma", "um", "em", "os", "as", "dos", "das", "é", "se", "por", "mais", "como", "mas", "ao", "ou", "quando", "já", "também", "ser", "sem", "até", "cada", "nunca", "está", "são", "foi", "pelo", "pela", "nos", "nas", "seu", "sua", "isso", "esse", "essa", "ele", "ela"]);
24
+ const ENGLISH = new Set(["the", "and", "of", "to", "in", "is", "for", "with", "that", "on", "as", "are", "this", "it", "be", "by", "from", "or", "an", "not", "at", "when", "every", "never", "before", "after", "its", "into", "than", "which", "while", "each", "was", "has", "have"]);
25
+
26
+ /**
27
+ * The language of the first group of samples that carries any function word
28
+ * at all, English when none does. Groups are passed in order of authorship:
29
+ * the campaign goal is the operator's own sentence, the journal notes are the
30
+ * orchestrating session's, the node objectives are the planner's -- and a
31
+ * plan written by a model in English must not outvote the person who typed
32
+ * the goal in Portuguese (measured 2026-09-21: two node objectives held 40
33
+ * English function words against a Portuguese goal's 19).
34
+ *
35
+ * @param {...readonly (string|null|undefined)[]} groups
36
+ * @returns {Language}
37
+ */
38
+ export function detectLanguage(...groups) {
39
+ for (const samples of groups) {
40
+ let portuguese = 0;
41
+ let english = 0;
42
+ for (const sample of samples) {
43
+ if (typeof sample !== "string") continue;
44
+ for (const word of sample.toLowerCase().split(/[^\p{L}]+/u)) {
45
+ if (PORTUGUESE.has(word)) portuguese += 1;
46
+ else if (ENGLISH.has(word)) english += 1;
47
+ }
48
+ }
49
+ if (portuguese + english > 0) return portuguese > english ? "pt" : "en";
50
+ }
51
+ return "en";
52
+ }
53
+
54
+ /**
55
+ * Every phrase the message renders, per language. Node ids, run ids, model
56
+ * names and quoted text are never here: they are data, not wording.
57
+ *
58
+ * @type {Record<Language, Record<string, string>>}
59
+ */
60
+ const LABELS = {
61
+ en: {
62
+ doneIn: "done in",
63
+ running: "running",
64
+ pending: "pending",
65
+ state: "state",
66
+ nothingDelivered: "nothing delivered",
67
+ "no-op": "no-op after",
68
+ failedAfter: "failed after",
69
+ blockedAfter: "blocked after",
70
+ exhaustedAfter: "exhausted after",
71
+ stalledAfter: "stalled after",
72
+ canceledAfter: "canceled after",
73
+ needsYou: "needs you",
74
+ attempt: "attempt",
75
+ run: "run",
76
+ done: "done",
77
+ asked: "asked",
78
+ did: "done",
79
+ proof: "proof",
80
+ delivered: "delivered",
81
+ why: "why",
82
+ do: "do",
83
+ judge: "judge",
84
+ missing: "missing",
85
+ error: "error",
86
+ checksGreen: "checks green",
87
+ checksOf: "checks passed",
88
+ judgePass: "judge pass",
89
+ judgePasses: "judge passes",
90
+ judgeRejected: "judge rejected",
91
+ noJudge: "no judge",
92
+ revisions: "revisions",
93
+ nodes: "nodes",
94
+ next: "next",
95
+ waitingOnYou: "waiting on you",
96
+ phases: "phases",
97
+ cache: "cache",
98
+ in: "in",
99
+ out: "out",
100
+ nextCommand: "next",
101
+ available: "available",
102
+ runUpdate: "run",
103
+ noSummary: "(no summary recorded)",
104
+ noObjective: "(no objective recorded)",
105
+ more: "more",
106
+ complete: "complete",
107
+ },
108
+ pt: {
109
+ doneIn: "concluído em",
110
+ running: "rodando",
111
+ pending: "pendente",
112
+ state: "estado",
113
+ nothingDelivered: "nada entregue",
114
+ "no-op": "sem mudança após",
115
+ failedAfter: "falhou após",
116
+ blockedAfter: "bloqueado após",
117
+ exhaustedAfter: "esgotado após",
118
+ stalledAfter: "travou após",
119
+ canceledAfter: "cancelado após",
120
+ needsYou: "precisa de você",
121
+ attempt: "tentativa",
122
+ run: "run",
123
+ done: "concluídos",
124
+ asked: "pedido",
125
+ did: "feito",
126
+ proof: "prova",
127
+ delivered: "entregue",
128
+ why: "motivo",
129
+ do: "ação",
130
+ judge: "juiz",
131
+ missing: "faltou",
132
+ error: "erro",
133
+ checksGreen: "checagens verdes",
134
+ checksOf: "checagens passaram",
135
+ judgePass: "juiz aprovou",
136
+ judgePasses: "aprovações do juiz",
137
+ judgeRejected: "juiz rejeitou",
138
+ noJudge: "sem juiz",
139
+ revisions: "revisões",
140
+ nodes: "nós",
141
+ next: "próximo",
142
+ waitingOnYou: "esperando você",
143
+ phases: "fases",
144
+ cache: "cache",
145
+ in: "entrada",
146
+ out: "saída",
147
+ nextCommand: "próximo passo",
148
+ available: "disponível",
149
+ runUpdate: "rode",
150
+ noSummary: "(sem resumo registrado)",
151
+ noObjective: "(sem objetivo registrado)",
152
+ more: "mais",
153
+ complete: "completa",
154
+ },
155
+ };
156
+
157
+ /**
158
+ * @param {Language} language
159
+ * @returns {(key: string) => string}
160
+ */
161
+ export function labelsFor(language) {
162
+ const table = LABELS[language];
163
+ return (key) => table[key] ?? LABELS.en[key] ?? key;
164
+ }