create-byan-agent 2.49.0 → 2.52.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 (27) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/install/package.json +1 -1
  3. package/install/templates/.claude/hooks/agent-gate-check.js +14 -2
  4. package/install/templates/.claude/hooks/codex-delegate-guard.js +137 -0
  5. package/install/templates/.claude/hooks/inject-voice-anchor.js +25 -0
  6. package/install/templates/.claude/hooks/lib/agent-gate.js +95 -5
  7. package/install/templates/.claude/hooks/lib/autodelegate-decision.js +11 -13
  8. package/install/templates/.claude/hooks/lib/codex-delegate-gate.js +130 -0
  9. package/install/templates/.claude/hooks/lib/fact-check-core.js +34 -2
  10. package/install/templates/.claude/hooks/lib/voice-conformance.js +97 -0
  11. package/install/templates/.claude/hooks/strict-scope-guard.js +71 -14
  12. package/install/templates/.claude/hooks/voice-conformance-check.js +53 -0
  13. package/install/templates/.claude/rules/native-workflows.md +17 -7
  14. package/install/templates/.claude/settings.json +8 -0
  15. package/install/templates/.claude/skills/byan-byan/SKILL.md +16 -10
  16. package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-armament-report.js +82 -0
  17. package/install/templates/_byan/mcp/byan-mcp-server/lib/armament-report.js +80 -0
  18. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch-router.js +48 -30
  19. package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +25 -6
  20. package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +8 -4
  21. package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +6 -2
  22. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
  23. package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
  24. package/install/templates/docs/codex-auto-delegation.md +51 -25
  25. package/install/templates/docs/intelligent-dispatch.md +9 -5
  26. package/install/templates/docs/native-workflows-contract.md +9 -4
  27. package/package.json +1 -1
@@ -8,7 +8,14 @@
8
8
 
9
9
  'use strict';
10
10
 
11
+ // WI-5 — the absolute-claim patterns. Kept HIGH-SIGNAL on purpose : this gate
12
+ // hard-blocks a doc write, so a false positive costs the user a block. Every
13
+ // entry is either a plain absolute, a comparative superlative, an unsourced
14
+ // best-practice claim, or a certainty phrase — the exact trigger families named
15
+ // in .claude/rules/fact-check.md. Noisy bare words ("mieux", "the best",
16
+ // "impossible", "bien sur") are deliberately excluded.
11
17
  const ABSOLUTES = [
18
+ // plain absolutes
12
19
  /\btoujours\b/i,
13
20
  /\bjamais\b/i,
14
21
  /\bforc[eé]ment\b/i,
@@ -17,10 +24,31 @@ const ABSOLUTES = [
17
24
  /\bnever\b/i,
18
25
  /\bclearly\b/i,
19
26
  /\bundoubtedly\b/i,
27
+ /\b[ée]videmment\b/i,
28
+ /\bcertainly\b/i,
29
+ /\bwithout a doubt\b/i,
30
+ /\bsans aucun doute\b/i,
31
+ /\bguaranteed\b/i,
32
+ // comparative superlatives (a claim, not phrasing)
20
33
  /\bfaster than\b/i,
21
34
  /\bbetter than\b/i,
22
35
  /\bplus rapide que\b/i,
23
36
  /\bmeilleur que\b/i,
37
+ /\bthe fastest\b/i,
38
+ /\ble plus rapide\b/i,
39
+ /\bsuperior to\b/i,
40
+ // ("optimal" alone was dropped in WI-5 review : too common in legit doc prose
41
+ // ("the optimal layout") to hard-block a doc write without a false positive.)
42
+ // unsourced best-practice / authority claims
43
+ /\bbest practice\b/i,
44
+ /\bbonne pratique\b/i,
45
+ /\bindustry standard\b/i,
46
+ /\bstandard de l['’]industrie\b/i,
47
+ // certainty framing
48
+ /\bil est clair que\b/i,
49
+ /\bit is well[- ]known\b/i,
50
+ /\bwell[- ]known that\b/i,
51
+ /\bprouv[eé] que\b/i,
24
52
  ];
25
53
 
26
54
  const SOURCE_MARKERS = [
@@ -37,14 +65,18 @@ const SOURCE_MARKERS = [
37
65
  // - fenced code blocks ``` ... ```
38
66
  // - inline backticks `...`
39
67
  // - block quotes (lines starting with >)
40
- // - list-of-pattern lines (e.g. "- toujours")
68
+ // - BULLET list-example lines (e.g. "- toujours") — a doc listing the trigger
69
+ // words as examples, not asserting them. WI-5 review fix : this REQUIRES a
70
+ // bullet marker (- or *), so a prose sentence merely STARTING with an
71
+ // absolute ("Never mutate state directly.") is NOT stripped and stays policed
72
+ // (the old `^[\s-]*` matched any leading whitespace -> a false negative).
41
73
  function stripNonClaimZones(text) {
42
74
  if (!text) return '';
43
75
  return text
44
76
  .replace(/```[\s\S]*?```/g, '')
45
77
  .replace(/`[^`\n]+`/g, '')
46
78
  .replace(/^> .*$/gm, '')
47
- .replace(/^[\s-]*['"]?\b(toujours|jamais|forc[eé]ment|obviously|always|never|clearly|undoubtedly)\b['"]?/gim, '');
79
+ .replace(/^\s*[-*]\s+['"]?\b(toujours|jamais|forc[eé]ment|obviously|always|never|clearly|undoubtedly|[ée]videmment|certainly|guaranteed)\b['"]?/gim, '');
48
80
  }
49
81
 
50
82
  // Return the first unsourced absolute (with surrounding context) or null when
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // WI-2 core — the reactive net for BYAN voice conformance (soul/tao).
4
+ //
5
+ // The tao is injected as context (inject-tao / voice-anchor) but nothing checks
6
+ // that a reply actually holds the voice : it is prose Claude can drift from. This
7
+ // net scans the finished reply for the OBJECTIVE, low-false-positive voice
8
+ // signals and flags a slip carried to the next turn — the same forward-net
9
+ // mechanics as plain-language / agent-gate. It is NON-BLOCKING by design : the
10
+ // register/timbre of the tao is semantic and cannot be a hard wall without
11
+ // constant false positives (honest ceiling ; deep audit stays byan-mantra-audit).
12
+ //
13
+ // Two objective signals only :
14
+ // 1. emoji in the reply (Mantra IA-23 : zero emoji — hard, unambiguous).
15
+ // 2. vouvoiement (BYAN tutoies always, per tao) — flagged only on a CLUSTER
16
+ // (>= 2 occurrences) so a single quoted "vous" does not trip it.
17
+ //
18
+ // Pure (no I/O beyond the slip flag). Code spans are stripped before scanning so
19
+ // a quoted `vous` variable or an emoji inside a code sample is not policed.
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { stripCode } = require('./plain-language');
24
+
25
+ // True pictographic emoji (Mantra IA-23). Excludes plain arrows (U+2190-21FF)
26
+ // which BYAN uses legitimately in prose ("->", "→").
27
+ const EMOJI_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2B00}-\u{2BFF}\u{1F1E6}-\u{1F1FF}\u{FE0F}]/u;
28
+
29
+ // Second-person-plural address. French-aware boundaries so "nous"/"vousXYZ" and
30
+ // accented neighbours do not false-match.
31
+ const VOUS_RE = /(?<![A-Za-zÀ-ÿ0-9_])(vous|votre|vos|vôtre|vôtres)(?![A-Za-zÀ-ÿ0-9_])/gi;
32
+
33
+ // scanVoice(text) -> [{ kind, good }]. Empty when the reply holds the voice.
34
+ function scanVoice(text) {
35
+ const prose = stripCode(text);
36
+ if (!prose) return [];
37
+ const hits = [];
38
+ if (EMOJI_RE.test(prose)) {
39
+ hits.push({ kind: 'emoji', good: 'zero emoji (Mantra IA-23) — retire-les' });
40
+ }
41
+ const vousCount = (prose.match(VOUS_RE) || []).length;
42
+ if (vousCount >= 2) {
43
+ hits.push({ kind: 'vouvoiement', good: 'BYAN tutoie toujours (tao) — dis "tu", pas "vous"' });
44
+ }
45
+ return hits;
46
+ }
47
+
48
+ function formatReminder(hits) {
49
+ if (!Array.isArray(hits) || hits.length === 0) return '';
50
+ const shown = hits.map((h) => `${h.kind} (${h.good})`).join(' ; ');
51
+ return [
52
+ 'Rappel voix (tao / soul) : au dernier tour la voix BYAN a glisse ->', `${shown}.`,
53
+ 'Reformule ce tour-ci dans la voix BYAN (tutoiement, zero emoji), sans refaire',
54
+ 'la reponse precedente.',
55
+ ].join(' ');
56
+ }
57
+
58
+ // --- slip flag (isolated I/O, same family as the other forward nets) ----------
59
+
60
+ function slipPath(projectDir) {
61
+ return path.join(projectDir, '_byan-output', '.voice-slip.json');
62
+ }
63
+
64
+ function writeSlip(projectDir, hits) {
65
+ try {
66
+ const p = slipPath(projectDir);
67
+ fs.mkdirSync(path.dirname(p), { recursive: true });
68
+ fs.writeFileSync(p, JSON.stringify({ hits }));
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function readSlip(projectDir) {
76
+ try {
77
+ const parsed = JSON.parse(fs.readFileSync(slipPath(projectDir), 'utf8'));
78
+ return Array.isArray(parsed.hits) ? parsed.hits : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function clearSlip(projectDir) {
85
+ try { fs.rmSync(slipPath(projectDir), { force: true }); } catch { /* never block */ }
86
+ }
87
+
88
+ module.exports = {
89
+ EMOJI_RE,
90
+ VOUS_RE,
91
+ scanVoice,
92
+ formatReminder,
93
+ slipPath,
94
+ writeSlip,
95
+ readSlip,
96
+ clearSlip,
97
+ };
@@ -57,9 +57,48 @@ function matchesPrefix(rel, prefix) {
57
57
  return rel === p || rel.startsWith(p + '/');
58
58
  }
59
59
 
60
- // Pure decision : returns { deny, reason }.
61
- function decideScope({ state, config, toolName, filePath }) {
62
- if (!['Write', 'Edit'].includes(toolName)) return { deny: false };
60
+ // WI-6 the Bash write-redirection leak.
61
+ //
62
+ // The Write/Edit deny is bypassable : `cat > src/x.js`, `echo >> a`, `tee f`,
63
+ // `cmd <<EOF > f` write files through Bash, which the tool-name check missed.
64
+ // bashWriteTargets extracts the file targets of write-redirections from a Bash
65
+ // command so the SAME allowed-paths rule applies. Deliberately conservative to
66
+ // avoid denying legit Bash : it skips fd-dups (`2>&1`, `>&2`), process
67
+ // substitution (`>(...)`), and the /dev/* sinks. It is not a hermetic sandbox
68
+ // (pipes, `python -c open()`, variables escape) — it closes the COMMON reflex
69
+ // leak, honestly bounded (see docs).
70
+ function bashWriteTargets(command) {
71
+ const cmd = String(command || '');
72
+ const out = [];
73
+ // A redirection target only counts when it LOOKS like a file path : it contains
74
+ // a "/" or a file extension. This is the false-positive kill switch : it drops
75
+ // comparison / arithmetic / here-string operands (`[ 5 > 3 ]`, `(( a > 2 ))`,
76
+ // `<<< "a > b"` -> "3", "2", "b") which are numbers or bare words, and drops an
77
+ // unexpanded variable target (`> $F`) which we cannot resolve — denying it would
78
+ // be a false positive on a possibly in-scope path. Honest ceiling : a bare
79
+ // filename with no extension (`> outfile`) or a variable target is NOT caught.
80
+ const consider = (t) => {
81
+ if (!t) return;
82
+ if (t.startsWith('$') || t.startsWith('&') || t.startsWith('-')) return; // variable / fd-dup / flag
83
+ if (/^\/dev\//.test(t)) return; // /dev sinks
84
+ const pathShaped = t.includes('/') || /\.[A-Za-z0-9]+$/.test(t);
85
+ if (pathShaped) out.push(t);
86
+ };
87
+ // > , >> , &> , &>> , and fd-prefixed (2>) redirections. The lookahead (?![&(])
88
+ // drops `>&1` (fd dup) and `>(` (process substitution).
89
+ const redir = /(?:^|[\s;|&(])\d*(?:>>?|&>>?)\s*(?![&(])("?)([^\s"'`|;&<>()]+)\1/g;
90
+ let m;
91
+ while ((m = redir.exec(cmd)) !== null) consider(m[2]);
92
+ // tee [flags] file...
93
+ const tee = /\btee\b((?:\s+-\S+)*)\s+("?)([^\s"'`|;&<>()]+)\2/g;
94
+ while ((m = tee.exec(cmd)) !== null) consider(m[3]);
95
+ return out;
96
+ }
97
+
98
+ // Pure decision : returns { deny, reason }. Handles Write/Edit (file_path) and,
99
+ // since WI-6, Bash (write-redirection targets that land INSIDE the repo).
100
+ function decideScope({ state, config, toolName, filePath, command }) {
101
+ if (!['Write', 'Edit', 'Bash'].includes(toolName)) return { deny: false };
63
102
  if (!isEngaged(state)) return { deny: false };
64
103
 
65
104
  const guard = (config && config.scope_guard) || {};
@@ -69,25 +108,42 @@ function decideScope({ state, config, toolName, filePath }) {
69
108
  if (!Array.isArray(allowed) || allowed.length === 0) return { deny: false };
70
109
 
71
110
  const root = projectRoot();
72
- const rel = toRelative(filePath, root);
73
- if (!rel) return { deny: false };
74
-
75
111
  const exempt = guard.exempt_globs || [];
76
- if (exempt.some((g) => matchesPrefix(rel, g))) return { deny: false };
77
-
78
- if (allowed.some((a) => matchesPrefix(rel, a))) return { deny: false };
79
-
80
112
  const base =
81
113
  (config && config.banners && config.banners.scope_deny) ||
82
114
  'Strict mode: this write targets a path outside the locked scope.';
83
- const reason =
115
+
116
+ // Returns the offending rel path, or null when the target is allowed/exempt.
117
+ const offending = (rawTarget, { repoOnly = false } = {}) => {
118
+ const rel = toRelative(rawTarget, root);
119
+ if (!rel) return null;
120
+ // repoOnly (Bash): a target outside the repo (../, /tmp, absolute elsewhere)
121
+ // is transient, not a repo change under cover of the locked task -> ignore.
122
+ if (repoOnly && rel.startsWith('..')) return null;
123
+ if (exempt.some((g) => matchesPrefix(rel, g))) return null;
124
+ if (allowed.some((a) => matchesPrefix(rel, a))) return null;
125
+ return rel;
126
+ };
127
+
128
+ const buildReason = (rel) =>
84
129
  `${base}\n` +
85
130
  `Target: ${rel}\n` +
86
131
  `Locked paths: ${allowed.join(', ')}\n` +
87
132
  `Either this file belongs to the scope (re-lock with byan_strict_lock_scope ` +
88
133
  `including the corrected paths) or it does not (do not write it).`;
89
134
 
90
- return { deny: true, reason };
135
+ if (toolName === 'Write' || toolName === 'Edit') {
136
+ const bad = offending(filePath);
137
+ return bad ? { deny: true, reason: buildReason(bad) } : { deny: false };
138
+ }
139
+
140
+ // Bash : deny if any in-repo write-redirection target is out of scope.
141
+ const targets = bashWriteTargets(command);
142
+ for (const t of targets) {
143
+ const bad = offending(t, { repoOnly: true });
144
+ if (bad) return { deny: true, reason: buildReason(bad) };
145
+ }
146
+ return { deny: false };
91
147
  }
92
148
 
93
149
  function allow() {
@@ -106,8 +162,9 @@ if (require.main === module) {
106
162
  const toolName = payload.tool_name || payload.toolName || '';
107
163
  const input = payload.tool_input || payload.toolInput || {};
108
164
  const filePath = input.file_path || '';
165
+ const command = input.command || '';
109
166
 
110
- const decision = decideScope({ state, config, toolName, filePath });
167
+ const decision = decideScope({ state, config, toolName, filePath, command });
111
168
  if (!decision.deny) {
112
169
  process.stdout.write(JSON.stringify(allow()));
113
170
  process.exit(0);
@@ -125,4 +182,4 @@ if (require.main === module) {
125
182
  })();
126
183
  }
127
184
 
128
- module.exports = { decideScope, toRelative, matchesPrefix };
185
+ module.exports = { decideScope, toRelative, matchesPrefix, bashWriteTargets };
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Stop hook — WI-2, the reactive net for BYAN voice conformance (soul/tao).
5
+ //
6
+ // At end of turn it scans the finished reply for the objective voice slips (emoji,
7
+ // vouvoiement cluster) and, on a slip, writes a one-turn flag under _byan-output/.
8
+ // The next-turn voice anchor surfaces it in plain French. It NEVER blocks the turn
9
+ // (the register is semantic — a hard wall would false-positive) : always exits 0.
10
+ //
11
+ // The judgment lives in lib/voice-conformance.js (pure) ; this shell only pulls
12
+ // the reply text from the transcript.
13
+
14
+ const { extractLastAssistantText } = require('./lib/transcript-read');
15
+ const voice = require('./lib/voice-conformance');
16
+
17
+ function detect(payload, projectDir) {
18
+ const text = extractLastAssistantText(payload);
19
+ const hits = voice.scanVoice(text);
20
+ if (hits.length) voice.writeSlip(projectDir, hits);
21
+ return hits;
22
+ }
23
+
24
+ function readStdin() {
25
+ return new Promise((resolve) => {
26
+ let data = '';
27
+ process.stdin.on('data', (c) => (data += c));
28
+ process.stdin.on('end', () => resolve(data));
29
+ process.stdin.on('error', () => resolve(''));
30
+ });
31
+ }
32
+
33
+ if (require.main === module) {
34
+ (async () => {
35
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
36
+ let payload = {};
37
+ try {
38
+ const raw = await readStdin();
39
+ if (raw && raw.trim()) payload = JSON.parse(raw);
40
+ } catch {
41
+ payload = {};
42
+ }
43
+ try {
44
+ detect(payload, projectDir);
45
+ } catch {
46
+ // never block end-of-turn
47
+ }
48
+ process.stdout.write('{}');
49
+ process.exit(0);
50
+ })();
51
+ }
52
+
53
+ module.exports = { detect };
@@ -26,7 +26,8 @@ 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). Three tiers:
29
+ (tier vocabulary, leaf classifier, model map). Three AUTO-routed tiers, plus an
30
+ explicit UP-TIER an author may pin by complexity (v3):
30
31
 
31
32
  - **cheap (`model: 'haiku'`)** — a pure EXPLORATION leaf (read/load/parse/detect).
32
33
  - **balanced (`model: 'sonnet'`)** — two classes land here. (1) MECHANICAL
@@ -40,16 +41,25 @@ The tiering decision lives in one place — `_byan/mcp/byan-mcp-server/lib/nativ
40
41
  leaf may carry `model: 'sonnet'` (its tier) but not `haiku` (`analysis-below-tier`).
41
42
  Semantic/adversarial VERIFICATION is NOT analysis and stays deep.
42
43
  - **deep (OMIT `opts.model`)** — implementation + verification leaves inherit the
43
- session model (no pin-up to opus). A genuinely frontier ANALYSIS leaf opts back to
44
- deep with the `deep-` label prefix (`deep-assess-architecture`), the mirror of the
45
- `mech-` opt-in.
44
+ session model (the cost-safe default: no AUTO pin-up). A genuinely frontier
45
+ ANALYSIS leaf opts back to deep with the `deep-` label prefix
46
+ (`deep-assess-architecture`), the mirror of the `mech-` opt-in.
47
+ - **up-tier (`model: 'opus'` / `model: 'fable'`)** — an EXPLICIT authoring choice
48
+ to raise a genuinely complex leaf ABOVE the inherited tier (v3). `opus` for a
49
+ hard leaf, `fable` as the last-resort top-reasoning model for extreme complexity
50
+ (~2x Opus price). This is the workflow-leaf mirror of the dispatch-router
51
+ complexity ladder (haiku -> sonnet -> opus -> fable). Auto-routing does not pick
52
+ it — the author writes it deliberately. The anti-downgrade floor does not apply
53
+ upward, so the linter allows an up-tier pin on any leaf (it costs more, it does
54
+ not regress quality). The old blanket "no pin-up / no Fable" ban is lifted.
46
55
 
47
56
  The linter splits the two directions:
48
57
 
49
- - **Floor (HARD, blocks the commit)** — a downgrade model on a PROTECTED leaf, a
50
- pin-up, or a half-applied `mech-` opt-in is a contract violation
58
+ - **Floor (HARD, blocks the commit)** — a DOWNGRADE model (haiku/sonnet) on a
59
+ PROTECTED leaf, or a half-applied `mech-` opt-in, is a contract violation
51
60
  (`modelRoutingViolations` + `mechanicalLabelViolations`). This is the STRICT-2
52
- No Downgrade net.
61
+ No Downgrade net. An UP-TIER pin (opus/fable) is not a violation — the floor
62
+ guards downward only.
53
63
  - **Ceiling (ADVISORY, non-blocking)** — an exploration- OR analysis-labelled leaf
54
64
  that runs deep is *reported* (`byan-lint-workflows.js --advise`:
55
65
  `untiered-exploration` -> `model: 'haiku'`, `untiered-analysis` -> `model: 'sonnet'`),
@@ -90,6 +90,10 @@
90
90
  "type": "command",
91
91
  "command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-gate-check.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
92
92
  },
93
+ {
94
+ "type": "command",
95
+ "command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/voice-conformance-check.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
96
+ },
93
97
  {
94
98
  "type": "command",
95
99
  "command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/drain-advisory.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
@@ -112,6 +116,10 @@
112
116
  {
113
117
  "type": "command",
114
118
  "command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/strict-scope-guard.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
119
+ },
120
+ {
121
+ "type": "command",
122
+ "command": "p=\"$CLAUDE_PROJECT_DIR/.claude/hooks/codex-delegate-guard.js\"; [ -f \"$p\" ] || exit 0; exec node \"$p\""
115
123
  }
116
124
  ]
117
125
  },
@@ -21,8 +21,10 @@ the user should not have to ask for it. Four moments:
21
21
  (`_byan/mcp/byan-mcp-server/lib/agent-matcher.js`), the right model + effort
22
22
  (`dispatch-router.js` / native-tiers), and the right runtime — Codex for
23
23
  execution / shell / deploy / devops / browser ; Claude for architecture /
24
- refactor / quality / planning ; verification stays on Claude ; Fable is not
25
- emitted. No user ask. A suited agent -> use it. NO suited agent -> interview to
24
+ refactor / quality / planning ; verification stays on Claude. The Claude model
25
+ scales on a four-rung complexity ladder (haiku -> sonnet -> opus -> fable, fable
26
+ the extreme last resort) ; Fable is refused on the Codex path only. No user ask.
27
+ A suited agent -> use it. NO suited agent -> interview to
26
28
  frame the need, web-research the trade's competencies + best practices, create
27
29
  the tailored agent (the one place the human is required upstream).
28
30
  3. **Execute in party-mode with a live visual.** The user SEES what happens, live:
@@ -30,13 +32,17 @@ the user should not have to ask for it. Four moments:
30
32
  `TaskCreate` / `TaskUpdate`) plus the dispatch table shown at the top of BUILD.
31
33
  - **Codex-lane** — when the lane is ARMED (yanstaller option on AND Codex
32
34
  linked, i.e. `_byan/_config/autodelegate.json` present with `enabled:true`
33
- AND `~/.codex/auth.json` or `CODEX_API_KEY`) and the task is delegable,
35
+ AND `~/.codex/auth.json` or `CODEX_API_KEY`) and the task is delegable code,
34
36
  ACTUALLY delegate via `codex-bridge.js` (`codex exec` -> unified diff -> YOU
35
- apply it). Fall back to Claude if Codex is unavailable. When armed, the
36
- injected `[BYAN auto-delegate]` note is a DIRECTIVE, not a mere suggestion:
37
- you delegate the delegable work rather than doing it on Claude by default.
38
- When NOT armed (option off or Codex not linked), there is no delegation
39
- you run it on Claude.
37
+ apply it) or the `codex:codex-rescue` agent. This is a DIRECTIVE, enforced by
38
+ the `codex-delegate-guard` PreToolUse tooth : it DENIES a delegable code
39
+ Write/Edit that skipped Codex. The ONLY valid reasons to stay on Claude are
40
+ (1) Codex unavailable (auto-detected), or (2) the USER asked to stay on Claude
41
+ ("reste sur Claude" / "sans codex", or the `.byan-codex-autodelegate/off`
42
+ switch). "Small script / latency / I verify anyway" is NOT valid — that is
43
+ exactly the self-dodge the tooth closes (option B). You cannot self-grant a
44
+ bypass. When NOT armed (option off or Codex not linked), there is no
45
+ delegation — you run it on Claude.
40
46
  - **Claude-lane** — run it on Claude at the chosen model.
41
47
  4. **User gate at the END.** The user-validation gate lands at the end, once the
42
48
  feature/task is done and there is a deliverable to review — not upstream. (In a
@@ -129,9 +135,9 @@ Never call `byan_update_apply` without explicit user consent. That tool returns
129
135
  - nature `exploration` (load/read/scan/list/parse/fetch...) → `haiku`
130
136
  - nature `mechanical` (binary judgment-free checks : JSON parses, schema matches, lint passes — label prefix `mech-`, explicit opt-in only) → `sonnet`
131
137
  - nature `implementation` / `verification` / `analysis` / unknown → deep = **inherit the session model**
132
- - 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.
138
+ - Keep protected work (verify/analysis/implement) off haiku/sonnet regardless of size. Auto-routing does not pin up ; but an author MAY deliberately UP-TIER a genuinely complex leaf to `opus` (hard) or `fable` (extreme, last resort) — the linter allows it (the floor guards downward only). Pass an explicit `nature` to `byan_dispatch` when you know it.
133
139
  - **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 :
134
- - **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.
140
+ - **Repo linter** (committed scripts, pre-commit) : `modelRoutingViolations` HARD-blocks a downgrade (haiku/sonnet) on a protected leaf, or a half-applied `mech-` opt-in (`mechanical-without-model` / `mechanical-below-tier`) ; an UP-TIER pin (opus/fable) is allowed, not blocked (the floor guards downward only) ; 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.
135
141
  - **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`.
136
142
  - **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]`.
137
143
  - No per-leaf effort knob exists (the API exposes only `model`), so effort-by-complexity reduces to model-by-complexity.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // WI-7 — the armament observation report (CLI, ESM).
3
+ //
4
+ // Reads the observation ledgers under _byan-output/, reads the CURRENT armed
5
+ // flags from config, and prints per-guard : sample size, would-fire count (the
6
+ // false-positive risk proxy), current armed state, and a calibrated
7
+ // recommendation. It NEVER arms anything — arming is a deliberate config flip the
8
+ // human makes after reading this.
9
+ //
10
+ // Usage : node bin/byan-armament-report.js [--json] [--root <dir>]
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { buildReport } from '../lib/armament-report.js';
15
+
16
+ function parseArgs(argv) {
17
+ const a = { json: false, root: process.env.CLAUDE_PROJECT_DIR || process.cwd() };
18
+ for (let i = 2; i < argv.length; i++) {
19
+ if (argv[i] === '--json') a.json = true;
20
+ else if (argv[i] === '--root') a.root = argv[++i];
21
+ }
22
+ return a;
23
+ }
24
+
25
+ function readLedger(root, name) {
26
+ try {
27
+ const p = path.join(root, '_byan-output', name);
28
+ return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
29
+ try { return JSON.parse(l); } catch { return null; }
30
+ }).filter(Boolean);
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ // Best-effort read of the current armed flags from config (absent -> false).
37
+ function readArmedFlags(root) {
38
+ const flags = { autobench: false, punt: false, completeness: false };
39
+ try {
40
+ const a = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'hooks', 'lib', 'autobench-config.json'), 'utf8'));
41
+ flags.autobench = Boolean(a && a.enforcement && a.enforcement.armed);
42
+ } catch { /* absent -> false */ }
43
+ try {
44
+ const d = JSON.parse(fs.readFileSync(path.join(root, '_byan', '_config', 'delivery-default.json'), 'utf8'));
45
+ flags.punt = Boolean(d && d.puntGuard && d.puntGuard.armed);
46
+ flags.completeness = Boolean(d && d.completenessGate && d.completenessGate.armed);
47
+ } catch { /* absent -> false */ }
48
+ return flags;
49
+ }
50
+
51
+ function render(report) {
52
+ const lines = ['BYAN armament report (observe first, arm on evidence) :', ''];
53
+ const label = { autobench: 'Auto-Benchmark', punt: 'Punt guard', completeness: 'Completeness gate' };
54
+ for (const key of Object.keys(report)) {
55
+ const g = report[key];
56
+ const s = g.summary;
57
+ lines.push(`- ${label[key] || key} : armed=${g.armed} | observations=${s.total} | would-fire=${s.wouldFire} (${Math.round(s.fireRate * 100)}%)`);
58
+ lines.push(` -> ${g.recommend.arm ? 'ARM' : 'HOLD'} : ${g.recommend.reason}`);
59
+ }
60
+ lines.push('');
61
+ lines.push('Note : "would-fire" est un indicateur de risque a relire, pas un compte prouve de faux positifs.');
62
+ lines.push('Armer reste une decision manuelle (autobench-config.json enforcement.armed, delivery-default.json puntGuard/completenessGate.armed).');
63
+ return lines.join('\n');
64
+ }
65
+
66
+ function main() {
67
+ const args = parseArgs(process.argv);
68
+ const report = buildReport(
69
+ {
70
+ benchmark: readLedger(args.root, 'benchmark-ledger.jsonl'),
71
+ punt: readLedger(args.root, 'punt-ledger.jsonl'),
72
+ completeness: readLedger(args.root, 'completeness-ledger.jsonl'),
73
+ },
74
+ readArmedFlags(args.root)
75
+ );
76
+ if (args.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
77
+ else process.stdout.write(render(report) + '\n');
78
+ }
79
+
80
+ main();
81
+
82
+ export { readLedger, readArmedFlags, render };
@@ -0,0 +1,80 @@
1
+ // WI-7 core — the armament observation report (ESM, matches this package's type).
2
+ //
3
+ // Two BYAN teeth are BUILT but DISARMED by config : the auto-benchmark Stop guard
4
+ // and the punt / completeness guards. They only observe + ledger today. Arming
5
+ // one turns it into a refuse-once, which costs a regeneration on every FALSE
6
+ // positive — the risk the user rejected for a hard wall. So arming must be a
7
+ // measured decision, not a blind flip.
8
+ //
9
+ // This pure core reads the observation ledgers and, per guard, reports : how many
10
+ // entries the guard WOULD have acted on if armed (the false-positive risk proxy),
11
+ // over how large a sample, and a recommendation. It NEVER arms anything — the
12
+ // human flips the config after reading this. Honest limit : the ledger has no
13
+ // ground truth, so "wouldFire" is a risk proxy to eyeball, not a proven
14
+ // false-positive count.
15
+
16
+ // Per-ledger classifiers : does this entry represent the guard ACTING (a block /
17
+ // refuse-once) if it were armed ?
18
+ export function classifyBenchmark(e) {
19
+ if (!e || typeof e !== 'object') return 'skip';
20
+ if (typeof e.event === 'string' && /block|regen|unmarked/i.test(e.event)) return 'wouldFire';
21
+ // a real choice presented (choice-language + an artifact) without a marker and
22
+ // not on the never-list is exactly what the armed guard blocks.
23
+ if (e.choiceLang && e.artifact && !e.marker && !e.neverHit) return 'wouldFire';
24
+ return 'satisfied';
25
+ }
26
+
27
+ export function classifyPunt(e) {
28
+ if (!e || typeof e !== 'object') return 'skip';
29
+ if (e.punt === true) return 'wouldFire';
30
+ if (typeof e.event === 'string' && /punt/i.test(e.event)) return 'wouldFire';
31
+ return 'satisfied';
32
+ }
33
+
34
+ export function classifyCompleteness(e) {
35
+ if (!e || typeof e !== 'object') return 'skip';
36
+ if (Array.isArray(e.missing) && e.missing.length > 0) return 'wouldFire';
37
+ if (typeof e.event === 'string' && /block|gap|fire/i.test(e.event)) return 'wouldFire';
38
+ return 'satisfied';
39
+ }
40
+
41
+ export function summarize(entries, classify) {
42
+ const s = { total: 0, wouldFire: 0, satisfied: 0, armedSeen: false };
43
+ for (const e of Array.isArray(entries) ? entries : []) {
44
+ const c = classify(e);
45
+ if (c === 'skip') continue;
46
+ s.total += 1;
47
+ if (e && e.armed === true) s.armedSeen = true;
48
+ if (c === 'wouldFire') s.wouldFire += 1;
49
+ else s.satisfied += 1;
50
+ }
51
+ s.fireRate = s.total ? s.wouldFire / s.total : 0;
52
+ return s;
53
+ }
54
+
55
+ // recommend — the calibrated arming call. Never arm on a small sample ; arm only
56
+ // on a clean record ; otherwise send the human to review the contexts first.
57
+ export function recommend(summary, { minSample = 20 } = {}) {
58
+ if (!summary || summary.total < minSample) {
59
+ return { arm: false, reason: `echantillon trop petit (${summary ? summary.total : 0} < ${minSample}) — continuer d'observer avant de decider` };
60
+ }
61
+ if (summary.wouldFire === 0) {
62
+ return { arm: true, reason: `0 declenchement sur ${summary.total} observations — armement sur (aucun faux positif observe)` };
63
+ }
64
+ const pct = Math.round(summary.fireRate * 100);
65
+ return { arm: false, reason: `${summary.wouldFire}/${summary.total} declenchements (${pct}%) — relire ces contextes avant d'armer (chaque faux positif coute une regeneration)` };
66
+ }
67
+
68
+ // buildReport — the whole picture. `armedFlags` carries the CURRENT config state
69
+ // (read by the bin) so the report shows observed-vs-armed side by side.
70
+ export function buildReport({ benchmark = [], punt = [], completeness = [] } = {}, armedFlags = {}) {
71
+ const guards = {
72
+ autobench: { summary: summarize(benchmark, classifyBenchmark), armed: Boolean(armedFlags.autobench) },
73
+ punt: { summary: summarize(punt, classifyPunt), armed: Boolean(armedFlags.punt) },
74
+ completeness: { summary: summarize(completeness, classifyCompleteness), armed: Boolean(armedFlags.completeness) },
75
+ };
76
+ for (const k of Object.keys(guards)) {
77
+ guards[k].recommend = recommend(guards[k].summary);
78
+ }
79
+ return guards;
80
+ }