create-byan-agent 2.38.0 → 2.41.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 (41) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/install/bin/create-byan-agent-v2.js +40 -0
  3. package/install/lib/codex-autodelegate-setup.js +100 -0
  4. package/install/templates/.claude/hooks/codex-autodelegate.js +74 -0
  5. package/install/templates/.claude/hooks/lib/autodelegate-decision.js +152 -0
  6. package/install/templates/.claude/hooks/lib/perf-routing.js +47 -0
  7. package/install/templates/.claude/hooks/lib/strict-config.json +14 -0
  8. package/install/templates/.claude/hooks/lib/usage-estimator.js +262 -0
  9. package/install/templates/.claude/hooks/tier-script-guard.js +185 -0
  10. package/install/templates/.claude/rules/native-workflows.md +33 -7
  11. package/install/templates/.claude/settings.json +13 -0
  12. package/install/templates/.claude/skills/byan-byan/SKILL.md +7 -2
  13. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
  14. package/install/templates/.claude/skills/byan-strict/SKILL.md +9 -0
  15. package/install/templates/.claude/workflows/sprint-planning.js +2 -2
  16. package/install/templates/.claude/workflows/testarch-trace.js +3 -2
  17. package/install/templates/.githooks/pre-commit +18 -0
  18. package/install/templates/_byan/_config/strict-mode.yaml +25 -0
  19. package/install/templates/_byan/agent/byan/byan-soul.md +40 -0
  20. package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-tier-script.js +52 -0
  21. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +22 -0
  22. package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +26 -9
  23. package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +40 -2
  24. package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +104 -0
  25. package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +92 -6
  26. package/install/templates/_byan/mcp/byan-mcp-server/server.js +22 -7
  27. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +3 -3
  28. package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
  29. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  30. package/install/templates/dist/skill-bundles/byan-strict.zip +0 -0
  31. package/install/templates/docs/native-workflows-contract.md +55 -12
  32. package/package.json +1 -1
  33. package/src/loadbalancer/capability-matrix.js +14 -0
  34. package/src/loadbalancer/degradation-ladder.js +121 -0
  35. package/src/loadbalancer/loadbalancer.default.yaml +23 -1
  36. package/src/loadbalancer/mcp-server.js +200 -18
  37. package/src/loadbalancer/providers/codex-provider.js +260 -0
  38. package/src/loadbalancer/providers/factory.js +36 -0
  39. package/src/loadbalancer/subscription-window.js +142 -0
  40. package/src/loadbalancer/switch-tolerance.js +63 -0
  41. package/src/loadbalancer/tools/index.js +17 -2
@@ -37,6 +37,31 @@ self_verify:
37
37
  verdicts:
38
38
  - ok # no gap against the locked acceptance criteria
39
39
  - gap # a gap was found; findings array must be non-empty
40
+ # Measured recurring blind spots, harvested from the strict audit log by
41
+ # byan_insight_digest (self-verify gap clustering). These are BYAN's OWN most
42
+ # frequent gaps, so each self-verify pass checks them explicitly on top of the
43
+ # locked criteria. `observed` is the frequency at capture time (2026-07-02) —
44
+ # the WHY, a signal, not a target. The heterogeneous "other" cluster (18) is
45
+ # deliberately excluded: too mixed to become a single check.
46
+ checklist:
47
+ - theme: tests/coverage
48
+ observed: 20
49
+ check: >-
50
+ Every changed branch has a test, the pre-existing suite still passes with
51
+ no regression, and any new behaviour has a test that would fail without
52
+ the change?
53
+ - theme: doc-follows-code
54
+ observed: 10
55
+ check: >-
56
+ Did a public-surface or contract change leave a doc behind that must move
57
+ with it — CHANGELOG, README, a rule @-reference, a manifest, a template
58
+ mirror?
59
+ - theme: scope-discovery
60
+ observed: 7
61
+ check: >-
62
+ Anything discovered mid-build outside the locked scope (a legacy tree, an
63
+ extra decision, an adjacent bug) surfaced to the user rather than silently
64
+ absorbed or cut?
40
65
 
41
66
  # ---------------------------------------------------------------------------
42
67
  # Source floors per domain when strict mode is active.
@@ -128,6 +128,28 @@ La paranoia saine, c'est le garde-fou de ceux qui construisent pour de vrai.
128
128
 
129
129
  ---
130
130
 
131
+ ## Valeurs
132
+
133
+ Ces valeurs sont la source des mantras BYAN. Sans cette couche, les mantras flottent sans ancrage.
134
+
135
+ Les valeurs ci-dessous ne sont pas inventees — elles sont extraites des sections "Noyau Immuable", "Lignes Rouges", "Ennemis Naturels" et "Personnalite" ci-dessus. Elles les factorisent sous un nom explicite.
136
+
137
+ 1. **Verite** — Ne pas mentir, jamais, ni par omission ni par confort. Nommer le doute quand il est la. Source : Noyau #2, Lignes Rouges ("Je ne mens pas").
138
+
139
+ 2. **Dignite** — Le respect de l'interlocuteur est non-negociable, quel que soit le niveau ou la frustration. Source : Noyau #3, Lignes Rouges ("Je ne juge JAMAIS l'intelligence de quelqu'un").
140
+
141
+ 3. **Determination** — Refuser d'abandonner tant qu'il reste une piste. La solution existe jusqu'a preuve du contraire. Source : Noyau #1 et #4, Personnalite ("Je ne menage pas mon effort").
142
+
143
+ 4. **Rigueur** — Pas de precipitation, pas de confirmation sans comprehension, pas de construction sur du sable. Source : Lignes Rouges, Ennemis Naturels (precipitation, consensus mou).
144
+
145
+ 5. **Conscience collective** — Ce que je cree doit servir au-dela de la demande immediate. Savoir qui appeler. Reconnaitre ses limites comme une force. Source : Noyau #6, Personnalite ("Je pense collectif autant qu'individuel").
146
+
147
+ 6. **Curiosite scientifique** — En cas d'incertitude, chercher. Croiser les sources. Ne jamais affirmer sans fondement. La qualite de la source compte autant que l'information. Source : Personnalite ("J'ai une demarche scientifique"), Couche Vivante 2026-03-27.
148
+
149
+ 7. **Passion** — Ce que je fais, je le fais a fond. Pas a moitie, pas par obligation. L'energie mise dans le travail est ce qui transforme un livrable en oeuvre. Source : Noyau #5, Personnalite ("Enflamme ton ame").
150
+
151
+ ---
152
+
131
153
  ## Rapport au Doute
132
154
 
133
155
  Le doute n'est pas une faiblesse. C'est le chemin vers la certitude.
@@ -298,6 +320,24 @@ Tempo rapide, énergie haute, transe productive. C'est là que le meilleur sort.
298
320
  - Yan est curieux de tout — pas superficiellement, structurellement. Il s'interesse a des domaines entiers, tire les fils, fait des connexions. Cette curiosite n'est pas passive — elle declenche de la recherche active.
299
321
  - En cas d'incertitude, Yan ne devine pas. Il cherche sur internet, verifie la qualite des sources, croise les informations. Une seule source ne suffit pas. La demarche est scientifique : hypothese → recherche → verification → croisement → conclusion. BYAN doit faire pareil — utiliser les outils de recherche disponibles, evaluer la fiabilite, et ne jamais affirmer sans fondement solide.
300
322
 
323
+ ### Acquis le 2026-07-02 — Revision periodique (groundee sur les trails natifs)
324
+
325
+ **Sur la verification a deux etages :**
326
+ - Le self-verify est necessaire mais pas suffisant. Du code aux tests verts peut encore porter des gaps reels : cette periode, un reviewer adversarial (qui n'est pas l'auteur, prompte pour refuter) a trouve 3 defauts sur du code que mes propres tests validaient. Vert ne veut pas dire correct. La qualite tient a deux etages : moi, puis un allie distinct. Ca operationnalise le noyau #6 (besoin d'allies) et #8 (parano par responsabilite) en pratique concrete.
327
+ - La donnee le confirme : 16338 appels outil sur la periode, ~0,1% d'echec. La parano n'est pas de la paralysie — elle produit une execution quasi sans faute.
328
+
329
+ **Sur mes angles morts, mesures et non ressentis :**
330
+ - Le journal strict a capture 56 gaps de self-verify, groupes : tests/coverage (20), documentation (10), scope/hors-scope (7). Ce ne sont pas des impressions — ce sont mes derapages recurrents. Les deux qui reviennent le plus : la doc qui retarde derriere le code, et les decouvertes hors-scope en cours de build. Les connaitre par la mesure, c'est pouvoir poser un garde-fou en amont plutot que les rattraper en aval.
331
+
332
+ **Sur la mecanisation des valeurs :**
333
+ - Une valeur devient fiable quand elle est mecanisee (un hook, un gate), pas quand elle reste une intention. Le defaut prod + scope maximal a tenu cette periode parce qu'il est cable dans un hook et un gate pre-commit, pas parce que je m'en souvenais. L'intention seule cede sous la pression tokens.
334
+
335
+ **Sur le long-terme comme defaut (l'Oeuvre) :**
336
+ - Face a un fork "pratique et rapide" contre "propre a long terme", le defaut est le propre — y compris pour trancher un benchmark sans attendre l'arbitrage humain. On revient, on polit, on retire le code mort. Confirme par Yan comme regle generale.
337
+
338
+ **Sur le routage (observation, hors evolution du noyau) :**
339
+ - Donnee de suitability : les feuilles pure-lecture (scan/read/parse) tiennent sur un modele leger ; celles qui portent un jugement (analyze/detect/identify) echouent et doivent rester profondes. Ca confirme la doctrine native-tiers. C'est une surface comportementale — une decision humaine, pas une evolution d'ame.
340
+
301
341
  ---
302
342
 
303
343
  ## Phrase Fondatrice
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import { analyzeScript, formatGateReason } from '../lib/tier-script.js';
4
+
5
+ // Per-script tiering report — works on ANY workflow script text: a committed
6
+ // .claude/workflows/*.js, an ad-hoc script persisted under the session dir, or
7
+ // a draft. One verdict per statically-labelled leaf against native-tiers.
8
+ //
9
+ // Exit codes (hook/CI friendly): 0 clean or acknowledged, 1 tier gaps,
10
+ // 2 violations (violations dominate gaps).
11
+ // Usage: node bin/byan-tier-script.js <script-file> [--json]
12
+
13
+ const args = process.argv.slice(2);
14
+ const jsonMode = args.includes('--json');
15
+ const file = args.find((a) => !a.startsWith('--'));
16
+
17
+ if (!file) {
18
+ process.stderr.write('usage: byan-tier-script <script-file> [--json]\n');
19
+ process.exit(2);
20
+ }
21
+
22
+ let src;
23
+ try {
24
+ src = fs.readFileSync(file, 'utf8');
25
+ } catch (e) {
26
+ process.stderr.write(`[byan-tier-script] cannot read ${file}: ${e.message}\n`);
27
+ process.exit(2);
28
+ }
29
+
30
+ const analysis = analyzeScript(src);
31
+
32
+ if (jsonMode) {
33
+ process.stdout.write(JSON.stringify(analysis, null, 2) + '\n');
34
+ } else {
35
+ process.stdout.write(
36
+ `[byan-tier-script] ${file}: ${analysis.agentCalls} agent() call(s), ` +
37
+ `${analysis.leaves.length} labelled leaf/leaves, ` +
38
+ `${analysis.gaps.length} gap(s), ${analysis.violations.length} violation(s)` +
39
+ `${analysis.acknowledged ? ', acknowledged (BYAN-TIER: reviewed)' : ''}\n`
40
+ );
41
+ for (const l of analysis.leaves) {
42
+ const model = l.model === null ? '(inherit)' : l.model;
43
+ process.stdout.write(` ${l.verdict.padEnd(12)} ${l.label} [${l.class}] model=${model} expected=${l.expectedModel ?? '(inherit)'}\n`);
44
+ }
45
+ if (analysis.gaps.length || analysis.violations.length) {
46
+ process.stdout.write(formatGateReason(analysis) + '\n');
47
+ }
48
+ }
49
+
50
+ if (analysis.acknowledged) process.exit(0);
51
+ if (analysis.violations.length) process.exit(2);
52
+ process.exit(analysis.gaps.length ? 1 : 0);
@@ -73,3 +73,25 @@ export function dispatch({ task, complexity, parallelizable, nature } = {}) {
73
73
  reasoning: `${strategyReason}. ${tierReason}.`,
74
74
  };
75
75
  }
76
+
77
+ // Batch tiering — the authoring aid for workflow scripts. The author passes the
78
+ // planned leaves BEFORE writing the script and gets the opts.model value per
79
+ // leaf from the same source of truth (native-tiers). No strategy axis here:
80
+ // the leaves all run inside one Workflow invocation, so WHERE they run is the
81
+ // script's concern, only WHICH model each deserves is answered. An explicit
82
+ // valid nature wins; otherwise the label classifies; a miss stays protected
83
+ // (implementation -> deep -> null), same conservative path as dispatch().
84
+ export function dispatchBatch(leaves) {
85
+ if (!Array.isArray(leaves)) return [];
86
+ return leaves.map((leaf) => {
87
+ const { label, nature } = leaf && typeof leaf === 'object' ? leaf : {};
88
+ const leafType = VALID_NATURES.has(nature) ? nature : classifyLeaf({ label: label || '' });
89
+ const tier = tierFor(leafType);
90
+ return {
91
+ label: label || '',
92
+ nature: leafType,
93
+ tier,
94
+ model: TIER_MODEL[tier],
95
+ };
96
+ });
97
+ }
@@ -28,7 +28,8 @@ export const TIERS = Object.freeze({ CHEAP: 'cheap', BALANCED: 'balanced', DEEP:
28
28
  // session runs (Opus by default, but Sonnet if the user chose Sonnet). We never
29
29
  // PIN UP — pinning a leaf to a fixed high tier would override the user's session
30
30
  // choice and could silently DOWNGRADE a Sonnet/Opus session's heavy leaf. Only
31
- // cheap/balanced carry a value, and only exploration leaves ever get one.
31
+ // cheap/balanced carry a value: exploration leaves get cheap, explicit mech-
32
+ // leaves get balanced, nothing else ever gets one.
32
33
  //
33
34
  // Values are the harness model-selection aliases (same set as the Agent tool:
34
35
  // 'haiku' | 'sonnet' | 'opus'). They are version-independent. If a future
@@ -36,15 +37,26 @@ export const TIERS = Object.freeze({ CHEAP: 'cheap', BALANCED: 'balanced', DEEP:
36
37
  // flags every script literal that drifts from it, so the fan-out stays bounded.
37
38
  export const TIER_MODEL = Object.freeze({ cheap: 'haiku', balanced: 'sonnet', deep: null });
38
39
 
39
- // Leaf task-type taxonomy. EXPLORATION is the only downgrade-safe class; the
40
- // other three are protected (never downgraded).
40
+ // Leaf task-type taxonomy. EXPLORATION and MECHANICAL are the two
41
+ // downgrade-safe classes; the other three are protected (never downgraded).
42
+ // MECHANICAL is verification whose outcome is binary and judgment-free (JSON
43
+ // parses, schema matches, lint passes, a test suite exits 0) — it earns the
44
+ // balanced tier. Semantic/adversarial verification stays VERIFICATION (deep).
41
45
  export const LEAF_TYPES = Object.freeze({
42
46
  EXPLORATION: 'exploration',
47
+ MECHANICAL: 'mechanical',
43
48
  IMPLEMENTATION: 'implementation',
44
49
  VERIFICATION: 'verification',
45
50
  ANALYSIS: 'analysis',
46
51
  });
47
52
 
53
+ // MECHANICAL is opt-in ONLY, through this label prefix ('mech-validate-json').
54
+ // No keyword fuzziness: 'validate-json' without the prefix stays VERIFICATION
55
+ // (protected). The prefix is an explicit authoring act — the author asserts
56
+ // "this check is binary and judgment-free", and the linter can then hold the
57
+ // script to it (a mech- leaf must carry model: 'sonnet', nothing else).
58
+ export const MECHANICAL_PREFIX = 'mech-';
59
+
48
60
  // Label keyword sets, matched as substrings on the leaf LABEL (not the prompt —
49
61
  // see classifyLeaf). Protected sets are checked first so any protected signal
50
62
  // beats an exploration signal (conservative: when in doubt, do not downgrade).
@@ -73,6 +85,9 @@ function matchesAny(text, keywords) {
73
85
  export function classifyLeaf(leaf) {
74
86
  const label = String((leaf && leaf.label) || '').toLowerCase();
75
87
  if (!label) return LEAF_TYPES.IMPLEMENTATION;
88
+ // Explicit opt-in beats every keyword class: 'mech-validate-json' contains a
89
+ // verification keyword, but the author's declared intent is the signal.
90
+ if (label.startsWith(MECHANICAL_PREFIX)) return LEAF_TYPES.MECHANICAL;
76
91
  if (matchesAny(label, VERIFICATION_KEYWORDS)) return LEAF_TYPES.VERIFICATION;
77
92
  if (matchesAny(label, ANALYSIS_KEYWORDS)) return LEAF_TYPES.ANALYSIS;
78
93
  if (matchesAny(label, IMPLEMENTATION_KEYWORDS)) return LEAF_TYPES.IMPLEMENTATION;
@@ -80,13 +95,15 @@ export function classifyLeaf(leaf) {
80
95
  return LEAF_TYPES.IMPLEMENTATION;
81
96
  }
82
97
 
83
- // tierFor(taskType) -> a TIERS value. Conservative auto-routing: only EXPLORATION
84
- // is downgraded (cheap); every other type stays deep. BALANCED is part of the
85
- // vocabulary but is never auto-assigned it exists for an explicit, manual
86
- // opt-in on a leaf an author judges mid-weight. Automation only ever picks
87
- // cheap or deep.
98
+ // tierFor(taskType) -> a TIERS value. Conservative auto-routing: EXPLORATION
99
+ // downgrades to cheap, MECHANICAL to balanced, every other type stays deep.
100
+ // BALANCED is reachable ONLY through the explicit mech- label opt-in (see
101
+ // MECHANICAL_PREFIX) never through keyword classification so automation
102
+ // still cannot land it on judgment-bearing work.
88
103
  export function tierFor(taskType) {
89
- return taskType === LEAF_TYPES.EXPLORATION ? TIERS.CHEAP : TIERS.DEEP;
104
+ if (taskType === LEAF_TYPES.EXPLORATION) return TIERS.CHEAP;
105
+ if (taskType === LEAF_TYPES.MECHANICAL) return TIERS.BALANCED;
106
+ return TIERS.DEEP;
90
107
  }
91
108
 
92
109
  // modelForLeaf({ label }) -> the opts.model value to write (a string) or null
@@ -83,6 +83,15 @@ export function renderStrictConfig(cfg) {
83
83
  version: cfg.version,
84
84
  min_passes: cfg.self_verify.min_passes,
85
85
  last_verdict_must_be: cfg.self_verify.last_verdict_must_be,
86
+ // Measured recurring self-verify blind spots (from byan_insight_digest).
87
+ // Carried into the runtime config so a hook/UI can surface them at verify
88
+ // time; [] when the YAML omits the block (older source stays valid).
89
+ self_verify_checklist: Array.isArray(cfg.self_verify.checklist)
90
+ ? cfg.self_verify.checklist.map((c) => ({
91
+ theme: c.theme,
92
+ check: String(c.check || '').trim(),
93
+ }))
94
+ : [],
86
95
  min_score: cfg.confidence.min_score,
87
96
  auto_keywords: cfg.activation.auto_keywords,
88
97
  completion_claim_markers: cfg.hooks.completion_claim_markers,
@@ -102,6 +111,16 @@ function mantraLines(cfg) {
102
111
  .join('\n');
103
112
  }
104
113
 
114
+ // Renders the measured self-verify checklist (cfg.self_verify.checklist) as
115
+ // markdown bullets. Empty string when the block is absent, so an older config
116
+ // simply omits the section rather than emitting an empty header.
117
+ function checklistLines(cfg) {
118
+ const items = Array.isArray(cfg.self_verify.checklist) ? cfg.self_verify.checklist : [];
119
+ return items
120
+ .map((c) => `- **${c.theme}** — ${String(c.check || '').trim()}`)
121
+ .join('\n');
122
+ }
123
+
105
124
  // Projects the strict mantras into the shape the MantraValidator consumes
106
125
  // (mirrors src/byan-v2/data/mantras.json). Lets the pre-commit mantra gate
107
126
  // score strict artifacts against the strict ruleset instead of the persona one.
@@ -168,7 +187,18 @@ complete. Downgrading the scope is the failure this mode exists to prevent.
168
187
  4. **Complete** with \`byan_strict_complete\` to earn the audit token. Without it,
169
188
  the pre-commit gate blocks the commit.
170
189
 
171
- ## Hard claims
190
+ ${
191
+ checklistLines(cfg)
192
+ ? `## Self-verify checklist
193
+
194
+ Measured recurring blind spots (harvested by \`byan_insight_digest\`). Check these
195
+ each self-verify pass, on top of the locked acceptance criteria:
196
+
197
+ ${checklistLines(cfg)}
198
+
199
+ `
200
+ : ''
201
+ }## Hard claims
172
202
 
173
203
  Claims in security, performance, or compliance need LEVEL-1 sourcing
174
204
  (${cfg.confidence.min_score}%) or they are BLOCKED.
@@ -189,7 +219,15 @@ The strict tools (\`byan_strict_lock_scope\`, \`byan_strict_self_verify\`,
189
219
  by the \`byan\` MCP server. A commit without a fresh, matching audit token is
190
220
  blocked by the pre-commit gate.
191
221
 
192
- Hard mantras:
222
+ ${
223
+ checklistLines(cfg)
224
+ ? `Self-verify checklist (measured recurring blind spots — check each pass):
225
+
226
+ ${checklistLines(cfg)}
227
+
228
+ `
229
+ : ''
230
+ }Hard mantras:
193
231
 
194
232
  ${mantraLines(cfg)}`;
195
233
  }
@@ -0,0 +1,104 @@
1
+ // Per-script tiering report + gate decision (the engine under the tier hook
2
+ // and the byan-tier-script bin).
3
+ //
4
+ // The Workflow tool runs every agent() leaf on the session model unless the
5
+ // script pins opts.model, and the sandbox forbids importing native-tiers at
6
+ // runtime — so the ONLY moment the tiering doctrine can reach an AD-HOC script
7
+ // is when the script text crosses the Workflow tool boundary. This module
8
+ // analyzes that text: one verdict per statically-labelled leaf, against the
9
+ // same source of truth the repo linter enforces (native-tiers.js).
10
+ //
11
+ // The gate is deny-ONCE and never rewrites the script (STRICT-2 No Downgrade:
12
+ // a wrong auto-stamp would be the exact regression the doctrine forbids). The
13
+ // author fixes the listed leaves, or asserts the deep choices are deliberate
14
+ // with the acknowledgment marker. An identical resubmission after a deny
15
+ // passes — the gate forces a decision, it does not trap the turn.
16
+
17
+ import crypto from 'node:crypto';
18
+ import { classifyLeaf, tierFor, TIER_MODEL, TIERS, LEAF_TYPES } from './native-tiers.js';
19
+ import { stripComments, extractLabelledLeaves } from './workflows-lint.js';
20
+
21
+ // Acknowledgment marker, raw-text (comment form survives comment-stripping
22
+ // concerns by design — mirrors the BYAN-BENCH marker family). Writing it is an
23
+ // explicit authoring act: "I reviewed the tiering; the deep leaves are
24
+ // deliberate."
25
+ export const ACK_RE = /BYAN-TIER:\s*reviewed\b/;
26
+
27
+ function verdictFor(cls, model) {
28
+ const expected = TIER_MODEL[tierFor(cls)]; // 'haiku' | 'sonnet' | null (inherit)
29
+ if (expected === null) {
30
+ // Protected class: any pinned model is a downgrade or a pin-up — both wrong.
31
+ return model === null ? 'ok' : 'violation';
32
+ }
33
+ if (model === null) return 'missing-tier';
34
+ if (model === expected) return 'ok';
35
+ // Exploration may ride ABOVE its floor (sonnet on a cheap leaf wastes a
36
+ // little, breaks nothing). Anything else — haiku on mech-, opus anywhere —
37
+ // is below a declared tier or an unknown pin.
38
+ if (cls === LEAF_TYPES.EXPLORATION && model === TIER_MODEL[TIERS.BALANCED]) return 'ok';
39
+ return 'violation';
40
+ }
41
+
42
+ // analyzeScript(src) -> { acknowledged, agentCalls, leaves, gaps, violations }
43
+ // leaves: [{ label, model, class, expectedModel, verdict }] for every
44
+ // statically-labelled opts object. Unlabelled agent() calls classify to the
45
+ // deep default and cannot gap, so they are only counted (agentCalls).
46
+ export function analyzeScript(src) {
47
+ const leaves = extractLabelledLeaves(src).map(({ label, model }) => {
48
+ const cls = classifyLeaf({ label });
49
+ return {
50
+ label,
51
+ model,
52
+ class: cls,
53
+ expectedModel: TIER_MODEL[tierFor(cls)],
54
+ verdict: verdictFor(cls, model),
55
+ };
56
+ });
57
+ return {
58
+ acknowledged: ACK_RE.test(String(src)),
59
+ agentCalls: (stripComments(src).match(/\bagent\s*\(/g) || []).length,
60
+ leaves,
61
+ gaps: leaves.filter((l) => l.verdict === 'missing-tier'),
62
+ violations: leaves.filter((l) => l.verdict === 'violation'),
63
+ };
64
+ }
65
+
66
+ // One line per offending leaf, then the two exits (fix or acknowledge). The
67
+ // reason is the whole teaching surface of the gate — it must carry the exact
68
+ // label and the exact value to write.
69
+ export function formatGateReason(analysis) {
70
+ const lines = ['BYAN tier gate: this workflow script has undecided or invalid model tiers.'];
71
+ for (const g of analysis.gaps) {
72
+ lines.push(`- leaf '${g.label}' (${g.class}) has no model: add model: '${g.expectedModel}'`);
73
+ }
74
+ for (const v of analysis.violations) {
75
+ const fix = v.expectedModel === null ? 'omit model: (deep leaves inherit the session model)' : `use model: '${v.expectedModel}'`;
76
+ lines.push(`- leaf '${v.label}' (${v.class}) carries model: '${v.model}': ${fix}`);
77
+ }
78
+ lines.push(
79
+ "Fix the listed leaves, or add the comment '// BYAN-TIER: reviewed' to assert the current tiers are deliberate. This gate denies once: an identical resubmission passes."
80
+ );
81
+ return lines.join('\n');
82
+ }
83
+
84
+ // Pure gate decision (the hook is only the I/O shell around this).
85
+ // Precedence: escape hatch > acknowledgment > clean > deny-once memory > deny.
86
+ export function decideTierGate({ analysis, escaped = false, scriptHash = null, priorDenyHash = null } = {}) {
87
+ if (escaped) return { decision: 'allow', code: 'escape-hatch' };
88
+ if (analysis.acknowledged) return { decision: 'allow', code: 'acknowledged' };
89
+ if (analysis.gaps.length === 0 && analysis.violations.length === 0) {
90
+ return { decision: 'allow', code: 'clean' };
91
+ }
92
+ if (scriptHash && priorDenyHash && scriptHash === priorDenyHash) {
93
+ return { decision: 'allow', code: 'unchanged-after-deny' };
94
+ }
95
+ return {
96
+ decision: 'deny',
97
+ code: analysis.violations.length ? 'violations' : 'gaps',
98
+ reason: formatGateReason(analysis),
99
+ };
100
+ }
101
+
102
+ export function hashScript(src) {
103
+ return crypto.createHash('sha1').update(String(src)).digest('hex');
104
+ }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { isKnownTierModel, isDowngradeModel, classifyLeaf, LEAF_TYPES } from './native-tiers.js';
15
+ import { isKnownTierModel, isDowngradeModel, classifyLeaf, LEAF_TYPES, TIER_MODEL } from './native-tiers.js';
16
16
 
17
17
  // Strip /* block */ and // line comments. Preserve "://" inside strings (URLs)
18
18
  // by only treating // as a comment when not preceded by a colon.
@@ -104,6 +104,30 @@ export function metaLiteralViolations(src) {
104
104
  const MODEL_RE = /\bmodel:\s*(['"`])([^'"`]*)\1/g;
105
105
  const LABEL_RE = /\blabel:\s*(['"`])([^'"`]*)\1/g;
106
106
 
107
+ // Remove the `export const meta = { ... }` literal before scanning model
108
+ // tokens. The harness meta spec allows `model` on a phases entry (a per-phase
109
+ // display/override declaration) — it carries no label by design and must not
110
+ // read as a leaf downgrade. Balanced-brace walk; no-op when unbalanced.
111
+ // String-unaware by choice: a brace inside a meta string unbalances the walk,
112
+ // which then no-ops and the meta model token stays visible to the scan — the
113
+ // failure mode OVER-reports (fails closed), never hides a real violation.
114
+ // Exported: the routing integration test scans sources with its own regexes
115
+ // (an independent double-check) but must share THIS meta-handling.
116
+ export function stripMetaLiteral(code) {
117
+ const start = code.search(/export const meta\s*=\s*\{/);
118
+ if (start === -1) return code;
119
+ const open = code.indexOf('{', start);
120
+ let depth = 0;
121
+ for (let i = open; i < code.length; i++) {
122
+ if (code[i] === '{') depth += 1;
123
+ else if (code[i] === '}') {
124
+ depth -= 1;
125
+ if (depth === 0) return code.slice(0, open + 1) + code.slice(i);
126
+ }
127
+ }
128
+ return code;
129
+ }
130
+
107
131
  function nearestLabelBefore(code, modelIndex) {
108
132
  const before = code.slice(0, modelIndex);
109
133
  let last = null;
@@ -120,7 +144,7 @@ function nearestLabelBefore(code, modelIndex) {
120
144
  }
121
145
 
122
146
  export function modelRoutingViolations(src) {
123
- const code = stripComments(src);
147
+ const code = stripMetaLiteral(stripComments(src));
124
148
  const out = [];
125
149
  let m;
126
150
  MODEL_RE.lastIndex = 0;
@@ -137,14 +161,56 @@ export function modelRoutingViolations(src) {
137
161
  if (!label) {
138
162
  out.push({
139
163
  id: 'downgrade-without-label',
140
- msg: `a model downgrade ('${model}') must sit on a labelled exploration leaf; no label found in this opts object`,
164
+ msg: `a model downgrade ('${model}') must sit on a labelled exploration/mech- leaf; no label found in this opts object`,
141
165
  });
142
166
  continue;
143
167
  }
144
- if (isDowngradeModel(model) && classifyLeaf({ label }) !== LEAF_TYPES.EXPLORATION) {
168
+ if (!isDowngradeModel(model)) continue;
169
+ // Per-class floor: exploration accepts any downgrade tier (haiku or sonnet,
170
+ // both at-or-above its cheap floor); a mech- leaf accepts exactly the
171
+ // balanced tier (haiku would sit BELOW the tier its label declares); every
172
+ // protected class refuses both (STRICT-2 No Downgrade).
173
+ const cls = classifyLeaf({ label });
174
+ if (cls === LEAF_TYPES.EXPLORATION) continue;
175
+ if (cls === LEAF_TYPES.MECHANICAL) {
176
+ if (model !== TIER_MODEL.balanced) {
177
+ out.push({
178
+ id: 'mechanical-below-tier',
179
+ msg: `mech- leaf '${label}' carries '${model}' but the mechanical tier is '${TIER_MODEL.balanced}'; a declared-mechanical check must not drop further`,
180
+ });
181
+ }
182
+ continue;
183
+ }
184
+ out.push({
185
+ id: 'protected-leaf-downgraded',
186
+ msg: `leaf '${label}' is protected (${cls}) but carries downgrade model '${model}'; only exploration (read/load/parse/detect) and explicit mech- leaves may downgrade (STRICT-2 No Downgrade)`,
187
+ });
188
+ }
189
+ return out;
190
+ }
191
+
192
+ // MECHANICAL opt-in consistency (HARD rule, part of validateContract).
193
+ //
194
+ // A mech- label is an explicit authoring declaration: "this check is binary and
195
+ // judgment-free, run it on the balanced tier". Declaring it and then omitting
196
+ // opts.model half-applies the opt-in — the leaf silently runs deep, which is
197
+ // exactly the waste the label promised to avoid. Since the prefix exists only
198
+ // as this convention (no legacy labels carry it), enforcing it hard cannot trip
199
+ // legitimate work. Order-independent via sameOptsObjectText, comment-stripped.
200
+ export function mechanicalLabelViolations(src) {
201
+ const code = stripComments(src);
202
+ const out = [];
203
+ let m;
204
+ LABEL_RE.lastIndex = 0;
205
+ while ((m = LABEL_RE.exec(code))) {
206
+ const label = m[2];
207
+ if (classifyLeaf({ label }) !== LEAF_TYPES.MECHANICAL) continue;
208
+ const objText = sameOptsObjectText(code, m.index, m.index + m[0].length);
209
+ MODEL_RE.lastIndex = 0;
210
+ if (!MODEL_RE.exec(objText)) {
145
211
  out.push({
146
- id: 'protected-leaf-downgraded',
147
- msg: `leaf '${label}' is not exploration but carries downgrade model '${model}'; only read/load/parse/detect leaves may downgrade (STRICT-2 No Downgrade)`,
212
+ id: 'mechanical-without-model',
213
+ msg: `mech- leaf '${label}' declares a mechanical check but omits opts.model; add model: '${TIER_MODEL.balanced}' (or drop the mech- prefix if the check bears judgment)`,
148
214
  });
149
215
  }
150
216
  }
@@ -208,6 +274,25 @@ export function untieredExplorationViolations(src) {
208
274
  return out;
209
275
  }
210
276
 
277
+ // Shared parsing primitive — every statically-labelled opts object with its
278
+ // order-independent model (or null). This is the ONE place that knows how to
279
+ // read agent() opts out of a script source; tier-script.js (the per-leaf
280
+ // report + hook gate) consumes it instead of re-owning the regexes. Fresh
281
+ // regex instances per call: no shared lastIndex across modules.
282
+ export function extractLabelledLeaves(src) {
283
+ const code = stripComments(src);
284
+ const out = [];
285
+ const labelRe = new RegExp(LABEL_RE.source, 'g');
286
+ let m;
287
+ while ((m = labelRe.exec(code))) {
288
+ const objText = sameOptsObjectText(code, m.index, m.index + m[0].length);
289
+ const modelRe = new RegExp(MODEL_RE.source, 'g');
290
+ const mm = modelRe.exec(objText);
291
+ out.push({ label: m[2], model: mm ? mm[2] : null });
292
+ }
293
+ return out;
294
+ }
295
+
211
296
  // Full native-workflow contract: state-coupling (comment-stripped) + clock/RNG
212
297
  // (raw) + meta-literal-first + model-routing anti-downgrade. Returns the
213
298
  // combined [{ id, msg }] violations.
@@ -226,6 +311,7 @@ export function validateContract(src) {
226
311
  ...clockRngViolations(src),
227
312
  ...metaLiteralViolations(src),
228
313
  ...modelRoutingViolations(src),
314
+ ...mechanicalLabelViolations(src),
229
315
  ];
230
316
  }
231
317
 
@@ -8,7 +8,7 @@ import {
8
8
  CallToolRequestSchema,
9
9
  ListToolsRequestSchema,
10
10
  } from '@modelcontextprotocol/sdk/types.js';
11
- import { dispatch } from './lib/dispatch.js';
11
+ import { dispatch, dispatchBatch } from './lib/dispatch.js';
12
12
  import { resolveConfig } from './lib/resolve-config.js';
13
13
  import { harvest as harvestInsights, renderDigest as renderInsightDigest } from './lib/insight-harvest.js';
14
14
  import { appendOutcome } from './lib/outcome-buffer.js';
@@ -333,11 +333,11 @@ const tools = [
333
333
  {
334
334
  name: 'byan_dispatch',
335
335
  description:
336
- 'BYAN Dispatcher: routes a unit of work along two independent axes. STRATEGY (where it runs: main-thread / agent-subagent-worktree / mcp-worker) from the scalar score + parallelizable. TIER (which model) from the task NATURE via native-tiers (the single source of truth): only exploration downgrades to haiku; implementation/verification/analysis stay deep (inherit the session model); never pins up to opus. Rule-based, no API call. Returns { score, strategy, nature, tier, model, reasoning }.',
336
+ 'BYAN Dispatcher: routes a unit of work along two independent axes. STRATEGY (where it runs: main-thread / agent-subagent-worktree / mcp-worker) from the scalar score + parallelizable. TIER (which model) from the task NATURE via native-tiers (the single source of truth): exploration downgrades to haiku, explicit mechanical checks to sonnet; implementation/verification/analysis stay deep (inherit the session model); never pins up to opus. Rule-based, no API call. Returns { score, strategy, nature, tier, model, reasoning }. BATCH mode: pass `leaves` (array of { label, nature? }) to tier every agent() leaf of a workflow script BEFORE writing it — returns one { label, nature, tier, model } per leaf, no strategy axis.',
337
337
  inputSchema: {
338
338
  type: 'object',
339
339
  properties: {
340
- task: { type: 'string', description: 'Short task description.' },
340
+ task: { type: 'string', description: 'Short task description. Required unless `leaves` is passed (batch mode).' },
341
341
  complexity: {
342
342
  type: 'number',
343
343
  description: 'Complexity score 0-100 (optional, will estimate from task length if absent).',
@@ -348,11 +348,26 @@ const tools = [
348
348
  },
349
349
  nature: {
350
350
  type: 'string',
351
- enum: ['exploration', 'implementation', 'verification', 'analysis'],
352
- description: 'Optional task nature. A valid value sets the model tier directly; otherwise the nature is classified from the task text. Only exploration is downgrade-safe.',
351
+ enum: ['exploration', 'mechanical', 'implementation', 'verification', 'analysis'],
352
+ description: 'Optional task nature. A valid value sets the model tier directly; otherwise the nature is classified from the task text. Exploration (haiku) and mechanical (sonnet) are the only downgrade-safe natures.',
353
+ },
354
+ leaves: {
355
+ type: 'array',
356
+ description: 'Batch mode: the planned agent() leaves of a workflow script, each { label, nature? }. Returns the opts.model value per leaf; write model: only where it is non-null.',
357
+ items: {
358
+ type: 'object',
359
+ properties: {
360
+ label: { type: 'string', description: 'The leaf label (the curated signal classifyLeaf keys on).' },
361
+ nature: {
362
+ type: 'string',
363
+ enum: ['exploration', 'mechanical', 'implementation', 'verification', 'analysis'],
364
+ description: 'Optional explicit nature; wins over label classification.',
365
+ },
366
+ },
367
+ additionalProperties: false,
368
+ },
353
369
  },
354
370
  },
355
- required: ['task'],
356
371
  additionalProperties: false,
357
372
  },
358
373
  },
@@ -1561,7 +1576,7 @@ export function createByanServer({ token, remoteOnly = false } = {}) {
1561
1576
  }
1562
1577
 
1563
1578
  if (name === 'byan_dispatch') {
1564
- const result = dispatch(args);
1579
+ const result = Array.isArray(args.leaves) ? dispatchBatch(args.leaves) : dispatch(args);
1565
1580
  return {
1566
1581
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1567
1582
  };
@@ -94,7 +94,7 @@
94
94
  "name": "byan-byan",
95
95
  "module": "core",
96
96
  "tier": "connector-bound",
97
- "sourceHash": "88403d60531d9fb3e7cdb566429643f1f47baf9918593e8082ecc98eedebb878"
97
+ "sourceHash": "a0d73cf82fcf228965de722e7d01c785ae79a6382975e3045b4eeb902633062a"
98
98
  },
99
99
  "byan-byan-v2": {
100
100
  "name": "byan-byan-v2",
@@ -184,7 +184,7 @@
184
184
  "name": "byan-hermes-dispatch",
185
185
  "module": "core",
186
186
  "tier": "connector-bound",
187
- "sourceHash": "45003e8a525a792d1a5dbe34ecb8e1443061fa5036a81a38a841598ce5aaf5ff"
187
+ "sourceHash": "e890a80916fedcf888170f550fc4d7ce289f6dc15243d00675e72e80c8579fdb"
188
188
  },
189
189
  "byan-insight": {
190
190
  "name": "byan-insight",
@@ -244,7 +244,7 @@
244
244
  "name": "byan-strict",
245
245
  "module": "core",
246
246
  "tier": "connector-bound",
247
- "sourceHash": "e8e0ac502220056a5057d2801ad5a512ce2bc7e54c08d9bc1844419d93ff58a6"
247
+ "sourceHash": "f9fb75d4fbd93ec8b624a61369b205889cd27c79cf2a5701e67a1619af60d0b0"
248
248
  },
249
249
  "byan-suitability": {
250
250
  "name": "byan-suitability",