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.
- package/CHANGELOG.md +165 -0
- package/install/bin/create-byan-agent-v2.js +40 -0
- package/install/lib/codex-autodelegate-setup.js +100 -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/strict-config.json +14 -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 +13 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +7 -2
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
- package/install/templates/.claude/skills/byan-strict/SKILL.md +9 -0
- package/install/templates/.claude/workflows/sprint-planning.js +2 -2
- package/install/templates/.claude/workflows/testarch-trace.js +3 -2
- package/install/templates/.githooks/pre-commit +18 -0
- package/install/templates/_byan/_config/strict-mode.yaml +25 -0
- package/install/templates/_byan/agent/byan/byan-soul.md +40 -0
- 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/sync-rules.js +40 -2
- 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/dist/skill-bundles/byan-byan.zip +0 -0
- package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
- package/install/templates/dist/skill-bundles/byan-strict.zip +0 -0
- package/install/templates/docs/native-workflows-contract.md +55 -12
- package/package.json +1 -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,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).
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,
|
|
37
|
-
|
|
38
|
-
STRICT-2
|
|
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
|
|
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-
|
|
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
|