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
@@ -0,0 +1,262 @@
1
+ /**
2
+ * usage-estimator — estimate Claude's rolling-5h token consumption from Claude
3
+ * Code's LOCAL usage accounting, so BYAN can decide when to auto-delegate to a
4
+ * secondary pool (Codex) BEFORE the 5h wall.
5
+ *
6
+ * The honest ceiling (stated plainly, not hidden): no provider exposes a
7
+ * machine-readable 5h quota, and Claude Code fetches the authoritative
8
+ * "/usage" figure live from Anthropic — it is NOT persisted locally. What IS
9
+ * local is per-session token accounting under
10
+ * `~/.claude/usage-data/session-meta/*.json` (real input/output token totals +
11
+ * start_time + duration). This module sums those over the rolling window and
12
+ * returns an ESTIMATE with an explicit confidence, never a false-precision
13
+ * figure. `pct` is only produced when a budget (the plan's rough token ceiling)
14
+ * is supplied — otherwise it is null (we do not invent the ceiling).
15
+ *
16
+ * Portable-core posture (PORTABLE-3): `~/.claude` is a per-machine native
17
+ * accelerator, NOT a BYAN source of truth. When it is absent the estimator
18
+ * degrades to a null/none result rather than throwing — the nature-based
19
+ * delegation path stays functional without any gauge.
20
+ *
21
+ * Purity: normalizeSession / sumWindowTokens / estimatePct are pure. All I/O is
22
+ * behind an injected `fs`, so the unit tests never touch the real home dir.
23
+ */
24
+
25
+ const realFs = require('fs');
26
+ const realPath = require('path');
27
+ const os = require('os');
28
+
29
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
30
+
31
+ // Parse one session-meta object into { startMs, endMs, tokens }, or null when it
32
+ // carries no usable start time. Token fields default to 0 (never NaN) so the
33
+ // downstream sum is always safe.
34
+ function normalizeSession(meta) {
35
+ if (!meta || typeof meta !== 'object') return null;
36
+ const startMs = Date.parse(meta.start_time);
37
+ if (!Number.isFinite(startMs)) return null;
38
+ const durationMin = Number(meta.duration_minutes) || 0;
39
+ const endMs = startMs + Math.max(0, durationMin) * 60 * 1000;
40
+ const input = Number(meta.input_tokens) || 0;
41
+ const output = Number(meta.output_tokens) || 0;
42
+ return { startMs, endMs, tokens: input + output };
43
+ }
44
+
45
+ // Sum tokens attributable to the rolling window [now-windowMs, now], pro-rating
46
+ // a session by the fraction of its own span that overlaps the window. A
47
+ // zero-duration session is an instant: counted fully if inside, else zero. This
48
+ // pro-rata is the honest approximation available from per-session totals (the
49
+ // meta has no per-message breakdown).
50
+ function sumWindowTokens(sessions, now, windowMs = FIVE_HOURS_MS) {
51
+ const windowStart = now - windowMs;
52
+ let total = 0;
53
+ for (const s of sessions) {
54
+ if (!s) continue;
55
+ const span = s.endMs - s.startMs;
56
+ if (span <= 0) {
57
+ if (s.startMs >= windowStart && s.startMs <= now) total += s.tokens;
58
+ continue;
59
+ }
60
+ const overlap = Math.min(s.endMs, now) - Math.max(s.startMs, windowStart);
61
+ if (overlap <= 0) continue;
62
+ total += s.tokens * (overlap / span);
63
+ }
64
+ return Math.round(total);
65
+ }
66
+
67
+ // Turn an estimated token count into a percentage of a supplied budget. Without
68
+ // a budget we return { pct: null, confidence: 'none' } — we do not fabricate the
69
+ // ceiling. With a budget the percentage is a LOW-confidence estimate (the token
70
+ // units are not the exact rate-limit units Anthropic meters), clamped to [0,100].
71
+ function estimatePct(estimatedTokens, budget) {
72
+ if (!budget || budget <= 0) return { pct: null, confidence: 'none' };
73
+ const pct = Math.max(0, Math.min(100, Math.round((estimatedTokens / budget) * 100)));
74
+ return { pct, confidence: 'low' };
75
+ }
76
+
77
+ // Weighted token cost of one assistant message's usage block. Fresh tokens
78
+ // (input + output + cache creation) count at full weight; cache READS are
79
+ // weighted down (CACHE_READ_WEIGHT) because the same context is re-read from
80
+ // cache every turn and Anthropic bills a cache hit at roughly a tenth of a fresh
81
+ // input token — counting it at full weight would inflate a long conversation to
82
+ // hundreds of millions of tokens (the same context re-counted each turn). This
83
+ // yields a proportional pressure estimate, not an exact bill. Pure.
84
+ const CACHE_READ_WEIGHT = 0.1;
85
+
86
+ function sumTokensFromUsage(usage) {
87
+ if (!usage || typeof usage !== 'object') return 0;
88
+ const fresh = (Number(usage.input_tokens) || 0)
89
+ + (Number(usage.output_tokens) || 0)
90
+ + (Number(usage.cache_creation_input_tokens) || 0);
91
+ const cacheRead = (Number(usage.cache_read_input_tokens) || 0) * CACHE_READ_WEIGHT;
92
+ return Math.round(fresh + cacheRead);
93
+ }
94
+
95
+ // Sum tokens across transcript events whose timestamp lands in the rolling
96
+ // window. Only `assistant` events carry a usage block. Pure — takes already
97
+ // parsed events. Returns { tokens, messagesCounted }.
98
+ function sumTranscriptWindow(events, now, windowMs = FIVE_HOURS_MS) {
99
+ const windowStart = now - windowMs;
100
+ let tokens = 0;
101
+ let messagesCounted = 0;
102
+ for (const ev of events) {
103
+ if (!ev || ev.type !== 'assistant' || !ev.message) continue;
104
+ const ts = Date.parse(ev.timestamp);
105
+ if (!Number.isFinite(ts) || ts < windowStart || ts > now) continue;
106
+ tokens += sumTokensFromUsage(ev.message.usage);
107
+ messagesCounted += 1;
108
+ }
109
+ return { tokens, messagesCounted };
110
+ }
111
+
112
+ // Read the LIVE per-message usage from the session transcripts under
113
+ // ~/.claude/projects/<hash>/*.jsonl. Unlike session-meta (written at session
114
+ // END), transcripts are appended live, so they capture the IN-PROGRESS session
115
+ // — exactly the signal needed to react before the wall. Files untouched within
116
+ // the window are skipped by mtime when statSync is available (perf). Corrupt
117
+ // lines are skipped, not fatal. Returns { tokens, messagesCounted, filesRead }.
118
+ function readTranscriptUsage({ home, now = Date.now(), windowMs = FIVE_HOURS_MS, fs = realFs, path = realPath } = {}) {
119
+ const root = path.join(home, '.claude', 'projects');
120
+ const empty = { tokens: 0, messagesCounted: 0, filesRead: 0 };
121
+ let projectDirs;
122
+ try {
123
+ if (!fs.existsSync(root)) return empty;
124
+ projectDirs = fs.readdirSync(root);
125
+ } catch {
126
+ return empty;
127
+ }
128
+ const windowStart = now - windowMs;
129
+ let tokens = 0;
130
+ let messagesCounted = 0;
131
+ let filesRead = 0;
132
+ for (const pd of projectDirs) {
133
+ const dir = path.join(root, pd);
134
+ let files;
135
+ try {
136
+ files = fs.readdirSync(dir).filter((n) => String(n).endsWith('.jsonl'));
137
+ } catch {
138
+ continue;
139
+ }
140
+ for (const name of files) {
141
+ const fp = path.join(dir, name);
142
+ try {
143
+ if (typeof fs.statSync === 'function') {
144
+ const st = fs.statSync(fp);
145
+ if (Number.isFinite(st.mtimeMs) && st.mtimeMs < windowStart) continue;
146
+ }
147
+ } catch {
148
+ /* stat failed — fall through and try to read */
149
+ }
150
+ let content;
151
+ try {
152
+ content = fs.readFileSync(fp, 'utf8');
153
+ } catch {
154
+ continue;
155
+ }
156
+ filesRead += 1;
157
+ const events = [];
158
+ for (const line of String(content).split('\n')) {
159
+ const t = line.trim();
160
+ if (!t) continue;
161
+ try {
162
+ events.push(JSON.parse(t));
163
+ } catch {
164
+ /* skip corrupt line */
165
+ }
166
+ }
167
+ const r = sumTranscriptWindow(events, now, windowMs);
168
+ tokens += r.tokens;
169
+ messagesCounted += r.messagesCounted;
170
+ }
171
+ }
172
+ return { tokens, messagesCounted, filesRead };
173
+ }
174
+
175
+ // Read the raw session-meta JSON objects from ~/.claude/usage-data/session-meta.
176
+ // Returns [] when the directory is absent (degraded path). A corrupt file is
177
+ // skipped, not fatal.
178
+ function readSessionMetas({ home, fs = realFs, path = realPath } = {}) {
179
+ const dir = path.join(home, '.claude', 'usage-data', 'session-meta');
180
+ let names;
181
+ try {
182
+ if (!fs.existsSync(dir)) return [];
183
+ names = fs.readdirSync(dir).filter((n) => String(n).endsWith('.json'));
184
+ } catch {
185
+ return [];
186
+ }
187
+ const metas = [];
188
+ for (const name of names) {
189
+ try {
190
+ metas.push(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')));
191
+ } catch {
192
+ /* skip corrupt file */
193
+ }
194
+ }
195
+ return metas;
196
+ }
197
+
198
+ // Top-level: estimate Claude's rolling-window usage. Returns a stable shape in
199
+ // every path (including degraded), so callers never branch on undefined:
200
+ // { estimatedTokens, pct, confidence, source, sessionsCounted, messagesCounted, windowMs }
201
+ // Source precedence, most-live first:
202
+ // 'transcript' — per-message usage, captures the in-progress session (best)
203
+ // 'session-meta' — coarse per-session totals, blind to the live session
204
+ // 'none' — no local data (home absent) -> honest null pct
205
+ function estimateClaudeUsage({
206
+ home = os.homedir(),
207
+ now = Date.now(),
208
+ budget = null,
209
+ windowMs = FIVE_HOURS_MS,
210
+ fs = realFs,
211
+ path = realPath,
212
+ } = {}) {
213
+ // 1. Live transcript signal — preferred (sees the session you are burning NOW).
214
+ const tr = readTranscriptUsage({ home, now, windowMs, fs, path });
215
+ if (tr.messagesCounted > 0) {
216
+ const { pct, confidence } = estimatePct(tr.tokens, budget);
217
+ return {
218
+ estimatedTokens: tr.tokens,
219
+ pct,
220
+ confidence,
221
+ source: 'transcript',
222
+ sessionsCounted: 0,
223
+ messagesCounted: tr.messagesCounted,
224
+ windowMs,
225
+ };
226
+ }
227
+
228
+ // 2. Fallback: coarse per-session totals (post-hoc; blind to the live session).
229
+ const metas = readSessionMetas({ home, fs, path });
230
+ if (metas.length === 0) {
231
+ return { estimatedTokens: 0, pct: null, confidence: 'none', source: 'none', sessionsCounted: 0, messagesCounted: 0, windowMs };
232
+ }
233
+ const sessions = metas.map(normalizeSession).filter(Boolean);
234
+ const inWindow = sessions.filter((s) => {
235
+ const span = s.endMs - s.startMs;
236
+ if (span <= 0) return s.startMs >= now - windowMs && s.startMs <= now;
237
+ return Math.min(s.endMs, now) - Math.max(s.startMs, now - windowMs) > 0;
238
+ });
239
+ const estimatedTokens = sumWindowTokens(sessions, now, windowMs);
240
+ const { pct, confidence } = estimatePct(estimatedTokens, budget);
241
+ return {
242
+ estimatedTokens,
243
+ pct,
244
+ confidence,
245
+ source: 'session-meta',
246
+ sessionsCounted: inWindow.length,
247
+ messagesCounted: 0,
248
+ windowMs,
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ FIVE_HOURS_MS,
254
+ normalizeSession,
255
+ sumWindowTokens,
256
+ estimatePct,
257
+ sumTokensFromUsage,
258
+ sumTranscriptWindow,
259
+ readTranscriptUsage,
260
+ readSessionMetas,
261
+ estimateClaudeUsage,
262
+ };
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook — BYAN tier gate on the Workflow tool.
3
+ //
4
+ // The Workflow runtime executes every agent() leaf on the session model unless
5
+ // the script pins opts.model, and a script cannot import native-tiers at
6
+ // runtime (sandbox). The repo linter only sees committed .claude/workflows/
7
+ // files — an AD-HOC script written inline crosses exactly one chokepoint
8
+ // before it runs: this hook. It analyzes the script text with the same source
9
+ // of truth as the linter (lib/tier-script.js -> native-tiers.js) and:
10
+ //
11
+ // - DENIES ONCE when exploration/mech- leaves have no model tier and no
12
+ // acknowledgment marker, with the exact leaf list to fix. It never
13
+ // rewrites the script (a wrong auto-stamp would be the STRICT-2 No
14
+ // Downgrade regression the doctrine forbids).
15
+ // - allows an IDENTICAL resubmission after a deny (forces a decision,
16
+ // never traps the turn) — the deny memory lives in .byan-tier/ (sidecar,
17
+ // gitignored) keyed by script hash.
18
+ // - allows registry (name-only) invocations: those resolve to committed
19
+ // scripts the pre-commit linter already owns.
20
+ // - logs EVERY decision with a per-model histogram to
21
+ // _byan-output/tier-ledger.jsonl — the measurement basis for real token
22
+ // gains (no replay theatre).
23
+ //
24
+ // Escape hatch: `touch .byan-tier/off` disables gating (still ledger-logged as
25
+ // escape-hatch, so misses stay auditable). Non-blocking on any internal error.
26
+ //
27
+ // CJS shell + ESM lib via dynamic import() (the leantime-fd-sync bridge). The
28
+ // lib resolves relative to THIS file so tests can pass a bare tmp root for
29
+ // sidecar/ledger state without needing the lib copied there.
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const { pathToFileURL } = require('url');
34
+
35
+ const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
36
+ const LIB_PATH = path.resolve(__dirname, '../../_byan/mcp/byan-mcp-server/lib/tier-script.js');
37
+
38
+ function readStdin() {
39
+ return new Promise((resolve) => {
40
+ let data = '';
41
+ process.stdin.on('data', (c) => (data += c));
42
+ process.stdin.on('end', () => resolve(data));
43
+ process.stdin.on('error', () => resolve(''));
44
+ });
45
+ }
46
+
47
+ function allow() {
48
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } };
49
+ }
50
+
51
+ function deny(reason) {
52
+ return {
53
+ hookSpecificOutput: {
54
+ hookEventName: 'PreToolUse',
55
+ permissionDecision: 'deny',
56
+ permissionDecisionReason: reason,
57
+ },
58
+ };
59
+ }
60
+
61
+ function sidecarPath(root) {
62
+ return path.join(root, '.byan-tier', 'last-deny.json');
63
+ }
64
+
65
+ function readPriorDenyHash(root) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(sidecarPath(root), 'utf8')).hash || null;
68
+ } catch (_e) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function writePriorDenyHash(root, hash) {
74
+ try {
75
+ fs.mkdirSync(path.dirname(sidecarPath(root)), { recursive: true });
76
+ fs.writeFileSync(sidecarPath(root), JSON.stringify({ hash }));
77
+ } catch (_e) {
78
+ // best-effort: losing the deny memory only means one extra deny, never a trap
79
+ }
80
+ }
81
+
82
+ function clearPriorDeny(root) {
83
+ try {
84
+ fs.unlinkSync(sidecarPath(root));
85
+ } catch (_e) {
86
+ // already absent
87
+ }
88
+ }
89
+
90
+ function ledgerLog(root, entry) {
91
+ try {
92
+ const dir = path.join(root, '_byan-output');
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ fs.appendFileSync(path.join(dir, 'tier-ledger.jsonl'), JSON.stringify(entry) + '\n');
95
+ } catch (_e) {
96
+ // the ledger is observability, never a blocker
97
+ }
98
+ }
99
+
100
+ // Per-model histogram: pinned tiers by value; 'inherit' = deep labelled leaves
101
+ // plus unlabelled agent() calls (both run on the session model).
102
+ function modelHistogram(analysis) {
103
+ const h = { haiku: 0, sonnet: 0, inherit: 0 };
104
+ for (const l of analysis.leaves) {
105
+ if (l.model === 'haiku') h.haiku += 1;
106
+ else if (l.model === 'sonnet') h.sonnet += 1;
107
+ else h.inherit += 1;
108
+ }
109
+ h.inherit += Math.max(0, analysis.agentCalls - analysis.leaves.length);
110
+ return h;
111
+ }
112
+
113
+ async function runGuard(payload, { root = ROOT } = {}) {
114
+ const toolName = payload.tool_name || payload.toolName || '';
115
+ if (toolName !== 'Workflow') return allow();
116
+ const input = payload.tool_input || payload.toolInput || {};
117
+
118
+ let src = null;
119
+ let source = null;
120
+ if (typeof input.script === 'string' && input.script.trim()) {
121
+ src = input.script;
122
+ source = 'inline';
123
+ } else if (typeof input.scriptPath === 'string' && input.scriptPath.trim()) {
124
+ source = 'scriptPath';
125
+ const p = path.isAbsolute(input.scriptPath) ? input.scriptPath : path.join(root, input.scriptPath);
126
+ try {
127
+ src = fs.readFileSync(p, 'utf8');
128
+ } catch (_e) {
129
+ // unreadable path: the Workflow tool will surface the real error itself
130
+ return allow();
131
+ }
132
+ } else {
133
+ // registry (name-only) invocation: resolves to a committed script the
134
+ // pre-commit linter already validated
135
+ return allow();
136
+ }
137
+
138
+ const lib = await import(pathToFileURL(LIB_PATH).href);
139
+ const analysis = lib.analyzeScript(src);
140
+ const scriptHash = lib.hashScript(src);
141
+ const escaped = fs.existsSync(path.join(root, '.byan-tier', 'off'));
142
+ const decision = lib.decideTierGate({
143
+ analysis,
144
+ escaped,
145
+ scriptHash,
146
+ priorDenyHash: readPriorDenyHash(root),
147
+ });
148
+
149
+ ledgerLog(root, {
150
+ ts: new Date().toISOString(),
151
+ source,
152
+ decision: decision.decision,
153
+ code: decision.code,
154
+ hash: scriptHash,
155
+ agentCalls: analysis.agentCalls,
156
+ leaves: analysis.leaves.length,
157
+ gaps: analysis.gaps.map((g) => g.label),
158
+ violations: analysis.violations.map((v) => v.label),
159
+ models: modelHistogram(analysis),
160
+ acknowledged: analysis.acknowledged,
161
+ });
162
+
163
+ if (decision.decision === 'deny') {
164
+ writePriorDenyHash(root, scriptHash);
165
+ return deny(decision.reason);
166
+ }
167
+ if (decision.code === 'unchanged-after-deny') clearPriorDeny(root);
168
+ return allow();
169
+ }
170
+
171
+ if (require.main === module) {
172
+ (async () => {
173
+ let out;
174
+ try {
175
+ const payload = JSON.parse((await readStdin()) || '{}');
176
+ out = await runGuard(payload);
177
+ } catch (_e) {
178
+ out = allow();
179
+ }
180
+ process.stdout.write(JSON.stringify(out));
181
+ process.exit(0);
182
+ })();
183
+ }
184
+
185
+ module.exports = { runGuard };
@@ -26,16 +26,24 @@ A native workflow script mutates FD/strict state only through the `byan_fd_*` /
26
26
 
27
27
  Each `agent()` leaf runs on the session model unless the call sets `opts.model`.
28
28
  The tiering decision lives in one place — `_byan/mcp/byan-mcp-server/lib/native-tiers.js`
29
- (tier vocabulary, leaf classifier, model map). The doctrine is binary: a pure
30
- EXPLORATION leaf (read/load/parse/detect) may downgrade to `model: 'haiku'`;
31
- implementation / verification / analysis leaves OMIT `opts.model` and inherit the
32
- session model (no pin-up to opus).
29
+ (tier vocabulary, leaf classifier, model map). Three tiers:
30
+
31
+ - **cheap (`model: 'haiku'`)** a pure EXPLORATION leaf (read/load/parse/detect).
32
+ - **balanced (`model: 'sonnet'`)** — MECHANICAL verification, opt-in ONLY through
33
+ the `mech-` label prefix (`mech-validate-json`): a binary, judgment-free check
34
+ (JSON parses, schema matches, lint passes). Semantic/adversarial verification
35
+ is NOT mechanical and stays deep. The prefix is an authoring declaration the
36
+ linter holds the script to: a `mech-` leaf without `model: 'sonnet'` is a hard
37
+ violation (`mechanical-without-model` / `mechanical-below-tier`).
38
+ - **deep (OMIT `opts.model`)** — implementation / verification / analysis leaves
39
+ inherit the session model (no pin-up to opus).
33
40
 
34
41
  The linter splits the two directions:
35
42
 
36
- - **Floor (HARD, blocks the commit)** — a downgrade model on a PROTECTED leaf, or
37
- a pin-up, is a contract violation (`modelRoutingViolations`). This is the
38
- STRICT-2 No Downgrade net.
43
+ - **Floor (HARD, blocks the commit)** — a downgrade model on a PROTECTED leaf, a
44
+ pin-up, or a half-applied `mech-` opt-in is a contract violation
45
+ (`modelRoutingViolations` + `mechanicalLabelViolations`). This is the STRICT-2
46
+ No Downgrade net.
39
47
  - **Ceiling (ADVISORY, non-blocking)** — an exploration-labelled leaf that runs
40
48
  deep is *reported* (`byan-lint-workflows.js --advise`), not forced. `classifyLeaf`
41
49
  is permissive: many exploration-labelled leaves legitimately stay deep because
@@ -43,6 +51,24 @@ The linter splits the two directions:
43
51
  consumed verbatim downstream. The human owns that per-leaf call. Forcing haiku
44
52
  on them would be the very downgrade the floor forbids.
45
53
 
54
+ ## Ad-hoc scripts — the tier gate hook
55
+
56
+ The repo linter only sees committed files. An AD-HOC script (written inline for
57
+ one run) crosses exactly one chokepoint before it executes: the Workflow tool
58
+ invocation. `.claude/hooks/tier-script-guard.js` (PreToolUse, matcher `Workflow`)
59
+ runs the same analysis there via `lib/tier-script.js` and DENIES ONCE when
60
+ exploration/`mech-` leaves carry no tier — with the exact leaf list to fix. The
61
+ `// BYAN-TIER: reviewed` comment marker acknowledges deliberate deep choices; an
62
+ identical resubmission passes (deny-once by design); registry (name-only)
63
+ invocations pass untouched. Every decision lands in
64
+ `_byan-output/tier-ledger.jsonl` with a per-model histogram — the measurement
65
+ basis for token gains. Escape hatch: `touch .byan-tier/off`.
66
+
67
+ Authoring flow: BEFORE writing a script, call `byan_dispatch` with
68
+ `{ leaves: [{ label, nature? }] }` (batch mode) to get the `opts.model` per leaf;
69
+ write `model:` only where non-null. Standalone report:
70
+ `node _byan/mcp/byan-mcp-server/bin/byan-tier-script.js <file> [--json]`.
71
+
46
72
  A per-leaf "effort" knob is not available: the native `agent()` / Agent API exposes
47
73
  only `model`, so model tier is the sole token lever. Effort-by-complexity reduces
48
74
  to model-by-complexity.
@@ -42,6 +42,10 @@
42
42
  {
43
43
  "type": "command",
44
44
  "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/strict-context-inject.js"
45
+ },
46
+ {
47
+ "type": "command",
48
+ "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-autodelegate.js"
45
49
  }
46
50
  ]
47
51
  }
@@ -102,6 +106,15 @@
102
106
  "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/strict-scope-guard.js"
103
107
  }
104
108
  ]
109
+ },
110
+ {
111
+ "matcher": "Workflow",
112
+ "hooks": [
113
+ {
114
+ "type": "command",
115
+ "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tier-script-guard.js"
116
+ }
117
+ ]
105
118
  }
106
119
  ],
107
120
  "PostToolUse": [
@@ -64,9 +64,14 @@ Never call `byan_update_apply` without explicit user consent. That tool returns
64
64
  - **Score ≥ 40** → main-thread (heavy) or delegate to `byan-hermes-dispatch`
65
65
  - **Model tier** (WHICH model), from the task NATURE — not its size (`byan_dispatch` returns it as `model`, via native-tiers, the single source of truth) :
66
66
  - nature `exploration` (load/read/scan/list/parse/fetch...) → `haiku`
67
+ - nature `mechanical` (binary judgment-free checks : JSON parses, schema matches, lint passes — label prefix `mech-`, explicit opt-in only) → `sonnet`
67
68
  - nature `implementation` / `verification` / `analysis` / unknown → deep = **inherit the session model**
68
- - Keep protected work (verify/analysis/implement) off haiku regardless of size ; no pin-up to opus. Pass an explicit `nature` to `byan_dispatch` when you know it.
69
- - **Inside native workflow scripts** (`.claude/workflows/*.js`) the SAME tiering applies per `agent()` leaf via `opts.model`, enforced by the workflow linter as a FLOOR not a ceiling : `modelRoutingViolations` HARD-blocks a downgrade on a protected leaf (or a pin-up) ; an exploration-labelled leaf left deep is a NON-blocking ADVISORY (`byan-lint-workflows.js --advise`), since many such leaves bear a gate/classification/exact-conversion and must stay deep — the human owns that call. No per-leaf effort knob exists (the API exposes only `model`), so effort-by-complexity reduces to model-by-complexity.
69
+ - Keep protected work (verify/analysis/implement) off haiku/sonnet regardless of size ; no pin-up to opus. Pass an explicit `nature` to `byan_dispatch` when you know it.
70
+ - **Inside native workflow scripts** (`.claude/workflows/*.js` OR ad-hoc) the SAME tiering applies per `agent()` leaf via `opts.model`, enforced as a FLOOR not a ceiling on TWO nets :
71
+ - **Repo linter** (committed scripts, pre-commit) : `modelRoutingViolations` HARD-blocks a downgrade on a protected leaf, a pin-up, or a half-applied `mech-` opt-in (`mechanical-without-model` / `mechanical-below-tier`) ; an exploration-labelled leaf left deep is a NON-blocking ADVISORY (`byan-lint-workflows.js --advise`), since many such leaves bear a gate/classification/exact-conversion and must stay deep — the human owns that call.
72
+ - **Tier gate hook** (EVERY Workflow invocation, inline or scriptPath) : `tier-script-guard.js` (PreToolUse) runs the same analysis (`lib/tier-script.js`) and DENIES ONCE when exploration/`mech-` leaves have no tier, with the exact leaf list to fix. Acknowledge deliberate deep choices with the `// BYAN-TIER: reviewed` comment marker ; an identical resubmission passes (deny-once by design, no trap). Every decision lands in `_byan-output/tier-ledger.jsonl` (the measurement basis for token gains). Escape hatch : `.byan-tier/off`.
73
+ - **Authoring aid** : BEFORE writing a script, call `byan_dispatch` with `{ leaves: [{ label, nature? }] }` (batch mode) to get the `opts.model` per leaf ; write `model:` only where non-null. Report with `node _byan/mcp/byan-mcp-server/bin/byan-tier-script.js <file> [--json]`.
74
+ - No per-leaf effort knob exists (the API exposes only `model`), so effort-by-complexity reduces to model-by-complexity.
70
75
  - **Output** : a table `{ feature → specialist → model → strategy → estimated_tokens }`.
71
76
  - **If no specialist matches** : halt. Ask user whether to run INT (agent recruitment) first. Do NOT fallback silently to general-purpose.
72
77
  - **Exit gate** : user validates the mapping.
@@ -55,7 +55,9 @@ Call the `byan_dispatch` MCP tool with `{ task: <goal>, parallelizable: <bool>,
55
55
  - `main-thread` — do it inline, no delegation
56
56
  - `agent-subagent-worktree` — spawn Agent tool with isolation worktree
57
57
  - `mcp-worker` — spawn Agent tool, no worktree
58
- - **model** (WHICH model), from the task NATURE via native-tiers, not its size : `haiku` (exploration only) or `null` = deep (inherit the session model). Pass an explicit `nature` (`exploration`/`implementation`/`verification`/`analysis`) when you know it; protected natures stay off haiku.
58
+ - **model** (WHICH model), from the task NATURE via native-tiers, not its size : `haiku` (exploration), `sonnet` (mechanical — explicit binary judgment-free checks only), or `null` = deep (inherit the session model). Pass an explicit `nature` (`exploration`/`mechanical`/`implementation`/`verification`/`analysis`) when you know it; protected natures stay off haiku/sonnet.
59
+
60
+ **Batch mode (workflow authoring)** : before WRITING a workflow script, call `byan_dispatch` with `{ leaves: [{ label, nature? }, ...] }` — it returns the `opts.model` value per leaf from the same source of truth. Write `model:` only where it is non-null. The `tier-script-guard` PreToolUse hook gates every Workflow invocation against this contract (deny-once with the exact leaf list; acknowledge deliberate deep choices with the `// BYAN-TIER: reviewed` comment marker).
59
61
 
60
62
  ### 4. Spawn the work
61
63
 
@@ -36,6 +36,15 @@ complete. Downgrading the scope is the failure this mode exists to prevent.
36
36
  4. **Complete** with `byan_strict_complete` to earn the audit token. Without it,
37
37
  the pre-commit gate blocks the commit.
38
38
 
39
+ ## Self-verify checklist
40
+
41
+ Measured recurring blind spots (harvested by `byan_insight_digest`). Check these
42
+ each self-verify pass, on top of the locked acceptance criteria:
43
+
44
+ - **tests/coverage** — Every changed branch has a test, the pre-existing suite still passes with no regression, and any new behaviour has a test that would fail without the change?
45
+ - **doc-follows-code** — Did a public-surface or contract change leave a doc behind that must move with it — CHANGELOG, README, a rule @-reference, a manifest, a template mirror?
46
+ - **scope-discovery** — Anything discovered mid-build outside the locked scope (a legacy tree, an extra decision, an adjacent bug) surfaced to the user rather than silently absorbed or cut?
47
+
39
48
  ## Hard claims
40
49
 
41
50
  Claims in security, performance, or compliance need LEVEL-1 sourcing
@@ -6,7 +6,7 @@ export const meta = {
6
6
  { title: 'STRUCTURE', detail: 'build the ordered development_status map: epic, its stories, its retrospective' },
7
7
  { title: 'DETECT', detail: 'intelligent per-story status detection, never downgrade an existing status' },
8
8
  { title: 'GENERATE', detail: 'write sprint-status.yaml with metadata as comments AND as parseable fields' },
9
- { title: 'VALIDATE', detail: 'coverage + legal-status + YAML checks, then count totals and report' },
9
+ { title: 'VALIDATE', detail: 'coverage + legal-status + YAML checks, then count totals and report', model: 'sonnet' },
10
10
  ],
11
11
  }
12
12
 
@@ -236,7 +236,7 @@ const validation = await agent(
236
236
  `- validYaml: the file parses as YAML.\n` +
237
237
  `Count totals: epicCount, storyCount, epicsInProgress, storiesDone. ` +
238
238
  `passed = true only if ALL six checks hold; otherwise list specific failures.`,
239
- { label: 'validate-and-report', phase: 'VALIDATE', schema: VALIDATE_SCHEMA }
239
+ { label: 'mech-validate-status', phase: 'VALIDATE', schema: VALIDATE_SCHEMA, model: 'sonnet' }
240
240
  )
241
241
 
242
242
  // Single top-level return — DATA only. The orchestrating skill presents this at
@@ -6,7 +6,7 @@ export const meta = {
6
6
  { title: 'DISCOVER_TESTS' },
7
7
  { title: 'MAP_CRITERIA' },
8
8
  { title: 'ANALYZE_GAPS' },
9
- { title: 'GATE_DECISION' }
9
+ { title: 'GATE_DECISION', model: 'sonnet' }
10
10
  ]
11
11
  }
12
12
 
@@ -260,8 +260,9 @@ const gate = await agent(
260
260
  'Return the deterministic decision, the rationale string, and the gate criteria (p0 met?, overall status MET/PARTIAL/NOT MET).'
261
261
  ].join(' '),
262
262
  {
263
- label: 'gate-decision',
263
+ label: 'mech-gate-decision',
264
264
  phase: 'GATE_DECISION',
265
+ model: 'sonnet',
265
266
  schema: {
266
267
  type: 'object',
267
268
  required: ['decision', 'rationale', 'gateCriteria'],
@@ -82,6 +82,24 @@ if [ -f "$TEMPLATE_SYNC" ]; then
82
82
  fi
83
83
  fi
84
84
 
85
+ # Soul source drift gate — BYAN's active soul (_byan/agent/byan/{soul,tao}.md) is
86
+ # mirrored into the shippable prefixed copies (byan-{soul,tao}.md) that the
87
+ # installer copies to soul.md/tao.md at setup. This gate blocks a commit whose
88
+ # shippable soul has drifted from the active one, so a fresh install ships a
89
+ # current identity. soul-memory is out of scope (curated seed). Dev-repo tooling:
90
+ # the bin is not shipped, so this gate no-ops in installed projects (the [ -f ]
91
+ # guard below). Re-sync with the apply command, then restage.
92
+ SOUL_SYNC="_byan/mcp/byan-mcp-server/bin/byan-sync-soul.js"
93
+ if [ -f "$SOUL_SYNC" ]; then
94
+ if ! node "$SOUL_SYNC" --check --root "$(git rev-parse --show-toplevel)"; then
95
+ echo ""
96
+ echo "Commit blocked : the shippable soul has drifted from the active soul."
97
+ echo "Re-sync with 'node $SOUL_SYNC' then restage, or bypass with"
98
+ echo "'git commit --no-verify' (emergency only)."
99
+ exit 1
100
+ fi
101
+ fi
102
+
85
103
  # Stub path drift gate — the installer generated platform stubs (.codex/prompts,
86
104
  # .github/agents, .claude/skills) over many versions; older generators emitted the
87
105
  # legacy _bmad/@bmad path layout while the agent sources are clean. This gate