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.
- package/CHANGELOG.md +153 -1
- package/install/bin/byan-handoff.js +139 -0
- package/install/bin/create-byan-agent-v2.js +70 -9
- package/install/lib/codex-autodelegate-setup.js +100 -0
- package/install/lib/codex-native-setup.js +94 -2
- package/install/lib/project-handoff.js +300 -0
- package/install/lib/rtk-integration.js +81 -14
- package/install/templates/.claude/CLAUDE.md +16 -0
- package/install/templates/.claude/hooks/codex-autodelegate.js +74 -0
- package/install/templates/.claude/hooks/lib/autodelegate-decision.js +152 -0
- package/install/templates/.claude/hooks/lib/perf-routing.js +47 -0
- package/install/templates/.claude/hooks/lib/usage-estimator.js +262 -0
- package/install/templates/.claude/hooks/tier-script-guard.js +185 -0
- package/install/templates/.claude/rules/native-workflows.md +33 -7
- package/install/templates/.claude/settings.json +35 -22
- package/install/templates/.claude/skills/byan-byan/SKILL.md +21 -2
- package/install/templates/.claude/skills/byan-codex/SKILL.md +7 -0
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
- package/install/templates/.claude/workflows/sprint-planning.js +2 -2
- package/install/templates/.claude/workflows/testarch-trace.js +3 -2
- package/install/templates/.codex/skills/byan/SKILL.md +77 -0
- package/install/templates/_byan/INDEX.md +6 -2
- package/install/templates/_byan/_config/workflow-manifest.csv +1 -1
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-tier-script.js +52 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +22 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +26 -9
- package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +104 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +92 -6
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +22 -7
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +3 -3
- package/install/templates/_byan/workflow/simple/byan/project-handoff-workflow.md +90 -0
- package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
- package/install/templates/dist/skill-bundles/byan-codex.zip +0 -0
- package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
- package/install/templates/docs/codex-auto-delegation.md +140 -0
- package/install/templates/docs/native-workflows-contract.md +55 -12
- package/package.json +2 -1
- package/src/loadbalancer/capability-matrix.js +14 -0
- package/src/loadbalancer/degradation-ladder.js +121 -0
- package/src/loadbalancer/loadbalancer.default.yaml +23 -1
- package/src/loadbalancer/mcp-server.js +200 -18
- package/src/loadbalancer/providers/codex-provider.js +260 -0
- package/src/loadbalancer/providers/factory.js +36 -0
- package/src/loadbalancer/subscription-window.js +142 -0
- package/src/loadbalancer/switch-tolerance.js +63 -0
- 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 };
|