create-byan-agent 2.39.0 → 2.42.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 (46) hide show
  1. package/CHANGELOG.md +153 -1
  2. package/install/bin/byan-handoff.js +139 -0
  3. package/install/bin/create-byan-agent-v2.js +70 -9
  4. package/install/lib/codex-autodelegate-setup.js +100 -0
  5. package/install/lib/codex-native-setup.js +94 -2
  6. package/install/lib/project-handoff.js +300 -0
  7. package/install/lib/rtk-integration.js +81 -14
  8. package/install/templates/.claude/CLAUDE.md +16 -0
  9. package/install/templates/.claude/hooks/codex-autodelegate.js +74 -0
  10. package/install/templates/.claude/hooks/lib/autodelegate-decision.js +152 -0
  11. package/install/templates/.claude/hooks/lib/perf-routing.js +47 -0
  12. package/install/templates/.claude/hooks/lib/usage-estimator.js +262 -0
  13. package/install/templates/.claude/hooks/tier-script-guard.js +185 -0
  14. package/install/templates/.claude/rules/native-workflows.md +33 -7
  15. package/install/templates/.claude/settings.json +35 -22
  16. package/install/templates/.claude/skills/byan-byan/SKILL.md +21 -2
  17. package/install/templates/.claude/skills/byan-codex/SKILL.md +7 -0
  18. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
  19. package/install/templates/.claude/workflows/sprint-planning.js +2 -2
  20. package/install/templates/.claude/workflows/testarch-trace.js +3 -2
  21. package/install/templates/.codex/skills/byan/SKILL.md +77 -0
  22. package/install/templates/_byan/INDEX.md +6 -2
  23. package/install/templates/_byan/_config/workflow-manifest.csv +1 -1
  24. package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-tier-script.js +52 -0
  25. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +22 -0
  26. package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +26 -9
  27. package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +104 -0
  28. package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +92 -6
  29. package/install/templates/_byan/mcp/byan-mcp-server/server.js +22 -7
  30. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +3 -3
  31. package/install/templates/_byan/workflow/simple/byan/project-handoff-workflow.md +90 -0
  32. package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
  33. package/install/templates/dist/skill-bundles/byan-codex.zip +0 -0
  34. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  35. package/install/templates/docs/codex-auto-delegation.md +140 -0
  36. package/install/templates/docs/native-workflows-contract.md +55 -12
  37. package/package.json +2 -1
  38. package/src/loadbalancer/capability-matrix.js +14 -0
  39. package/src/loadbalancer/degradation-ladder.js +121 -0
  40. package/src/loadbalancer/loadbalancer.default.yaml +23 -1
  41. package/src/loadbalancer/mcp-server.js +200 -18
  42. package/src/loadbalancer/providers/codex-provider.js +260 -0
  43. package/src/loadbalancer/providers/factory.js +36 -0
  44. package/src/loadbalancer/subscription-window.js +142 -0
  45. package/src/loadbalancer/switch-tolerance.js +63 -0
  46. package/src/loadbalancer/tools/index.js +17 -2
@@ -0,0 +1,152 @@
1
+ /**
2
+ * autodelegate-decision — the pure core that decides whether BYAN should hand a
3
+ * task to Codex (on the ChatGPT subscription, no API credit), and how hard to
4
+ * push. Two triggers, honest about their strength:
5
+ *
6
+ * 1. NATURE (always available, no gauge needed): the request looks like
7
+ * delegable coding work (implement / write / fix / refactor / test) -> mode
8
+ * 'delegable-only', propose handing THIS task to Codex.
9
+ * 2. PRESSURE (only when a usage gauge is supplied): the estimated Claude 5h
10
+ * consumption is at/above the threshold (default 80%) -> mode 'all', propose
11
+ * offloading everything delegable to spare the remaining budget.
12
+ * 3. PERF (opt-in, off by default): a config-driven forces table says Codex is
13
+ * reputed stronger for this kind of task -> mode 'perf-routed'. Honest: this
14
+ * is a heuristic below BYAN's L2 perf floor, so it ships neutral (empty
15
+ * table) and asserts nothing until the user populates it. See perf-routing.
16
+ *
17
+ * The red line is never crossed: only DELEGABLE natures are ever proposed —
18
+ * judgment / analysis / soul / verification stay on Claude. This module states
19
+ * that in `redLine` so the hook's nudge always carries it. Pure (no I/O): the
20
+ * hook is a thin shell that feeds it the request text + the F1 usage estimate.
21
+ */
22
+
23
+ const { perfFavors } = require('./perf-routing');
24
+
25
+ const DEFAULT_THRESHOLD = 80;
26
+ const DEFAULT_INVOCATION = 'codex:codex-rescue --model gpt-5.4';
27
+ const RED_LINE = 'delegable work only (code / mechanical) — judgment, analysis, soul and verification stay on Claude';
28
+
29
+ // Verbs/nouns that mark a delegable coding task. Heuristic by construction (free
30
+ // text has no nature field); kept deliberately conservative so conversational or
31
+ // judgment requests do not trip it.
32
+ const DELEGABLE_RE = new RegExp(
33
+ [
34
+ 'code', 'coder', 'impl[ée]mente', 'implement', '[ée]cris', 'write', 'wire',
35
+ 'fix', 'corrige', 'debug', 'd[ée]bogue', 'refactor', 'refactorise',
36
+ 'test', 'tests', 'script', 'module', 'fonction', 'function', 'patch',
37
+ 'ajoute\\s+(le|la|un|une|du)', 'build\\s+(the|a|le|la)', 'port',
38
+ ].join('|'),
39
+ 'i'
40
+ );
41
+
42
+ function looksDelegable(text) {
43
+ return DELEGABLE_RE.test(String(text || ''));
44
+ }
45
+
46
+ // Decide the auto-delegation posture for one turn.
47
+ // Returns { delegate, mode, pct, reason, redLine, invocation } — a stable shape.
48
+ // mode: 'off' | 'all' | 'delegable-only' | 'none'.
49
+ function decideAutodelegation({ requestText = '', usage = null, config = {} } = {}) {
50
+ const {
51
+ enabled = true,
52
+ threshold = DEFAULT_THRESHOLD,
53
+ invocation = DEFAULT_INVOCATION,
54
+ } = config;
55
+
56
+ if (!enabled) {
57
+ return { delegate: false, mode: 'off', pct: null, reason: 'auto-delegation disabled (toggle off)', redLine: RED_LINE, invocation };
58
+ }
59
+
60
+ const pct = usage && typeof usage.pct === 'number' ? usage.pct : null;
61
+ const delegable = looksDelegable(requestText);
62
+
63
+ if (pct != null && pct >= threshold) {
64
+ return {
65
+ delegate: true,
66
+ mode: 'all',
67
+ pct,
68
+ reason: `estimated Claude 5h usage ${pct}% >= ${threshold}% — propose offloading everything delegable to Codex`,
69
+ redLine: RED_LINE,
70
+ invocation,
71
+ };
72
+ }
73
+
74
+ if (delegable) {
75
+ return {
76
+ delegate: true,
77
+ mode: 'delegable-only',
78
+ pct,
79
+ reason: 'request looks like delegable coding work — propose handing it to Codex to spare the Claude 5h budget',
80
+ redLine: RED_LINE,
81
+ invocation,
82
+ };
83
+ }
84
+
85
+ // PERF (opt-in): the forces table (user-populated) may favor Codex for this
86
+ // kind of task even at low pressure. Off by default; always heuristic.
87
+ if (config.perfRouting) {
88
+ const pf = perfFavors(requestText, config.perfForces || []);
89
+ if (pf.favors === 'codex') {
90
+ return {
91
+ delegate: true,
92
+ mode: 'perf-routed',
93
+ pct,
94
+ reason: `perf heuristic favors Codex for '${pf.category}' (heuristic, NOT a measured benchmark)`,
95
+ redLine: RED_LINE,
96
+ invocation,
97
+ };
98
+ }
99
+ }
100
+
101
+ return {
102
+ delegate: false,
103
+ mode: 'none',
104
+ pct,
105
+ reason: pct != null
106
+ ? `usage ${pct}% below ${threshold}% and task not clearly delegable`
107
+ : 'task not clearly delegable and no usage gauge available',
108
+ redLine: RED_LINE,
109
+ invocation,
110
+ };
111
+ }
112
+
113
+ // Defense-in-depth: the invocation string is interpolated into BYAN's injected
114
+ // context. It is config/installer-controlled today (safe), but sanitising before
115
+ // interpolation closes the door on any future path feeding untrusted text in —
116
+ // strip newlines/control chars (no context-structure injection) and cap length.
117
+ function sanitizeForContext(value, max = 120) {
118
+ return String(value == null ? "" : value)
119
+ .replace(/[\u0000-\u001F\u007F]+/g, " ") // control chars + newlines -> space
120
+ .replace(/\s+/g, " ") // collapse whitespace runs
121
+ .trim()
122
+ .slice(0, max);
123
+ }
124
+
125
+ // Render a decision into the one-paragraph nudge injected into BYAN's context.
126
+ // Empty string when there is nothing to propose (so the hook injects nothing).
127
+ // The nudge is ADVISORY: it proposes, it never forces — BYAN still owns the call
128
+ // and the red line is spelled out every time.
129
+ function renderNudge(decision) {
130
+ if (!decision || !decision.delegate) return '';
131
+ const gauge = decision.pct != null ? ` (estimated Claude 5h usage ~${decision.pct}%)` : '';
132
+ const invocation = sanitizeForContext(decision.invocation) || DEFAULT_INVOCATION;
133
+ const scope = decision.mode === 'all'
134
+ ? 'Consider offloading ALL delegable work this session to Codex'
135
+ : decision.mode === 'perf-routed'
136
+ ? 'Codex is heuristically favored for this kind of task (not a measured benchmark) — consider handing it over'
137
+ : 'This looks like delegable coding work — consider handing it to Codex';
138
+ return [
139
+ `[BYAN auto-delegate]${gauge}: ${scope} via \`${invocation}\` `
140
+ + '(runs on the ChatGPT subscription, no API credit).',
141
+ `Red line: ${decision.redLine}. This is advisory — you decide, and you still verify Codex's output before commit.`,
142
+ ].join(' ');
143
+ }
144
+
145
+ module.exports = {
146
+ DEFAULT_THRESHOLD,
147
+ DEFAULT_INVOCATION,
148
+ RED_LINE,
149
+ looksDelegable,
150
+ decideAutodelegation,
151
+ renderNudge,
152
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * perf-routing — the MECHANISM for routing a task to the pool reputed stronger
3
+ * for it, WITHOUT asserting an unsourced ranking.
4
+ *
5
+ * The honest ceiling (BYAN fact-check, performance domain): "model X is better
6
+ * at task Y" needs L2 evidence (a reproducible benchmark). A community arena such
7
+ * as designarena.ai is preference-vote data — below that floor. So this module
8
+ * ships a NEUTRAL default (DEFAULT_FORCES = []): out of the box it claims nothing
9
+ * and changes no routing. It provides the config-driven forces table + a matcher;
10
+ * populating the table with rankings (from the user's own benchmarks, or an arena
11
+ * taken as a weak signal) is an explicit opt-in, and every result it returns is
12
+ * tagged `confidence: 'heuristic'` so downstream never presents it as measured.
13
+ *
14
+ * A forces entry: { category, pattern (regex source, case-insensitive), favors:
15
+ * 'codex' | 'claude' }. Pure — no I/O.
16
+ */
17
+
18
+ // Neutral by default: no unsourced perf claim ships. Populate via config.
19
+ const DEFAULT_FORCES = [];
20
+
21
+ // Return the first forces entry whose pattern matches the text, or null.
22
+ // Malformed entries (missing pattern/favors, or an invalid regex) are skipped.
23
+ function matchCategory(text, forces = DEFAULT_FORCES) {
24
+ const s = String(text || '');
25
+ if (!Array.isArray(forces)) return null;
26
+ for (const entry of forces) {
27
+ if (!entry || !entry.pattern || !entry.favors) continue;
28
+ let re;
29
+ try {
30
+ re = new RegExp(entry.pattern, 'i');
31
+ } catch {
32
+ continue; // invalid regex in a user-supplied table — skip, never throw
33
+ }
34
+ if (re.test(s)) return entry;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Resolve the perf preference for a text: which pool the (user-supplied) table
40
+ // favors, always tagged heuristic. { favors: 'codex'|'claude'|null, category, confidence }.
41
+ function perfFavors(text, forces = DEFAULT_FORCES) {
42
+ const hit = matchCategory(text, forces);
43
+ if (!hit) return { favors: null, category: null, confidence: 'heuristic' };
44
+ return { favors: hit.favors, category: hit.category, confidence: 'heuristic' };
45
+ }
46
+
47
+ module.exports = { DEFAULT_FORCES, matchCategory, perfFavors };
@@ -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 };