create-byan-agent 2.49.0 → 2.50.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 +19 -0
- package/install/package.json +1 -1
- package/install/templates/.claude/hooks/agent-gate-check.js +14 -2
- package/install/templates/.claude/hooks/codex-delegate-guard.js +145 -0
- package/install/templates/.claude/hooks/inject-voice-anchor.js +25 -0
- package/install/templates/.claude/hooks/lib/agent-gate.js +95 -5
- package/install/templates/.claude/hooks/lib/codex-delegate-gate.js +0 -0
- package/install/templates/.claude/hooks/lib/fact-check-core.js +34 -2
- package/install/templates/.claude/hooks/lib/voice-conformance.js +97 -0
- package/install/templates/.claude/hooks/strict-scope-guard.js +71 -14
- package/install/templates/.claude/hooks/voice-conformance-check.js +53 -0
- package/install/templates/.claude/settings.json +8 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-armament-report.js +82 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/armament-report.js +80 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.50.0] - 2026-07-17
|
|
13
|
+
|
|
14
|
+
### Added — BYAN souverainete (les concepts BYAN pilotent Claude, ne se font plus doubler)
|
|
15
|
+
- **WI-1 dent delegation Codex** : hook PreToolUse `codex-delegate-guard.js` (+ coeur pur `lib/codex-delegate-gate.js`) qui refuse-une-fois le premier Write/Edit de code delegable quand la voie Codex est armee (option + Codex linke) et qu'aucune delegation n'a eu lieu ce tour. Speed-bump, pas un mur : escape `.byan-codex-autodelegate/off`, marque `// BYAN-DELEGATE: reviewed`, fenetre de grace. Corrige la racine de l'incident (Claude codait lui-meme malgre la directive).
|
|
16
|
+
- **WI-2 filet de conformite de voix** : hook Stop `voice-conformance-check.js` (+ `lib/voice-conformance.js`) qui repere les signaux objectifs de derive (emoji, vouvoiement en grappe) et pose un drapeau relaye au tour suivant. Non bloquant (le registre reste semantique).
|
|
17
|
+
- **WI-3 filet dispatch runtime** : drapeau quand un tour ecrit du code sans avoir consulte `byan_dispatch`, hors FD.
|
|
18
|
+
- **WI-4 garde de porte d'entree renforce** : le signal visuel party-mode (`TaskCreate`/`TaskUpdate`) tient desormais la posture d'entree — plus de faux drapeau sur un tour party-mode.
|
|
19
|
+
- **WI-7 rapport d'armement** : `bin/byan-armament-report.js` (+ `lib/armament-report.js`) lit les registres autobench/punt/completeness et reporte le taux de declenchement (indicateur de risque de faux positif) avant toute decision d'armement. Verdict mesure : les 3 gardes restent DESARMEES (punt 42%, autobench 1%, completeness echantillon trop petit).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **WI-6 fuite Bash du strict-scope-guard** : `decideScope` couvre desormais les redirections d'ecriture Bash (`>`, `>>`, `tee`, heredoc) hors `allowedPaths`. Parseur resserre (cible en forme de chemin uniquement) pour eviter les faux positifs sur les operateurs de comparaison/arithmetique et les variables non resolues.
|
|
23
|
+
- **WI-5 fact-check-absolutes elargi** : plus d'absolus/superlatifs/best-practice/certitudes couverts (alignes sur `.claude/rules/fact-check.md`) ; `optimal` ecarte (trop courant) et strip d'exemples resserre a une puce (une phrase de prose commencant par un absolu reste policee). Le plafond des mantras semantiques (IA-16, #37...) est assume : doctrine + `byan-mantra-audit`, pas de fausse dent regex.
|
|
24
|
+
|
|
25
|
+
### Docs
|
|
26
|
+
- `docs/byan-sovereignty-chantier.md` : le chantier complet (constat, patron des 4 dents, plafond honnete, work items, risques) adosse a l'audit `byan-sovereignty-audit`.
|
|
27
|
+
|
|
28
|
+
### Tests
|
|
29
|
+
- +9 tests MCP (armament report + parite shipping anti-derive) ; +47 tests jest (dents + filets + fact-check + strict-scope Bash). jest 2699/0, MCP 855/0.
|
|
30
|
+
|
|
12
31
|
## [2.49.0] - 2026-07-16
|
|
13
32
|
|
|
14
33
|
### Added
|
package/install/package.json
CHANGED
|
@@ -20,12 +20,24 @@ const gate = require('./lib/agent-gate');
|
|
|
20
20
|
function detect(payload, projectDir) {
|
|
21
21
|
const text = extractLastAssistantText(payload);
|
|
22
22
|
const messages = extractRecentMessages(payload, 8) || [];
|
|
23
|
+
const wroteFiles = gate.hasWriteActivity(messages);
|
|
24
|
+
const fdActive = gate.fdIsActive(projectDir);
|
|
23
25
|
const assessment = gate.assessTurn({
|
|
24
|
-
wroteFiles
|
|
26
|
+
wroteFiles,
|
|
25
27
|
proposedAgent: gate.hasProposalMarker(text),
|
|
26
|
-
|
|
28
|
+
taskVisualSeen: gate.hasTaskActivity(messages), // WI-4: a live task list holds the entry posture
|
|
29
|
+
fdActive,
|
|
27
30
|
});
|
|
28
31
|
if (assessment.slip) gate.writeSlip(projectDir, assessment.reason);
|
|
32
|
+
// WI-3: dispatch net, its own slip. Same signals, different question (was the
|
|
33
|
+
// runtime routeur consulted?).
|
|
34
|
+
const dispatch = gate.assessDispatch({
|
|
35
|
+
wroteFiles,
|
|
36
|
+
dispatchConsulted: gate.hasDispatchActivity(messages),
|
|
37
|
+
fdActive,
|
|
38
|
+
});
|
|
39
|
+
if (dispatch.slip) gate.writeDispatchSlip(projectDir, dispatch.reason);
|
|
40
|
+
assessment.dispatch = dispatch; // backward-compatible: .slip stays primary
|
|
29
41
|
return assessment;
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// PreToolUse hook — WI-1, the DENT for the armed Codex-delegation lane.
|
|
5
|
+
//
|
|
6
|
+
// When the Codex lane is ARMED (yanstaller option on AND Codex linked) and the
|
|
7
|
+
// current turn is about to WRITE delegable code without having delegated to Codex
|
|
8
|
+
// first, DENY ONCE with the exact instruction to delegate. This is the tooth that
|
|
9
|
+
// makes "delegate to Codex when armed" govern instead of being prose Claude can
|
|
10
|
+
// double with a polite excuse (the session incident).
|
|
11
|
+
//
|
|
12
|
+
// Speed-bump, not a wall (the user's red line) :
|
|
13
|
+
// - escape-hatch : `.byan-codex-autodelegate/off` silences it (shared with the
|
|
14
|
+
// nudge hook — one switch for the whole Codex-delegation posture).
|
|
15
|
+
// - `// BYAN-DELEGATE: reviewed` in the file acknowledges a deliberate
|
|
16
|
+
// Claude-lane choice (Codex unavailable, judgment/verify) and passes.
|
|
17
|
+
// - deny-once : an identical resubmission passes, and a grace window after a
|
|
18
|
+
// deny lets a multi-file build proceed without nagging on every file.
|
|
19
|
+
//
|
|
20
|
+
// Never traps a turn, always exits 0. Pure decision in lib/codex-delegate-gate.js;
|
|
21
|
+
// armed/linked detection reused from codex-autodelegate.js; delegability from
|
|
22
|
+
// autodelegate-decision.js; the turn transcript via transcript-read.js.
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const gate = require('./lib/codex-delegate-gate');
|
|
27
|
+
const { loadConfig, codexLinked, toggledOff } = require('./codex-autodelegate');
|
|
28
|
+
const { looksDelegable } = require('./lib/autodelegate-decision');
|
|
29
|
+
const { extractRecentMessages, extractLastAssistantText, contentToText } = require('./lib/transcript-read');
|
|
30
|
+
|
|
31
|
+
const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
32
|
+
const GRACE_MS = 120000;
|
|
33
|
+
|
|
34
|
+
function readStdin() {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
let data = '';
|
|
37
|
+
process.stdin.on('data', (c) => (data += c));
|
|
38
|
+
process.stdin.on('end', () => resolve(data));
|
|
39
|
+
process.stdin.on('error', () => resolve(''));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function allow() {
|
|
44
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deny(reason) {
|
|
48
|
+
return {
|
|
49
|
+
hookSpecificOutput: {
|
|
50
|
+
hookEventName: 'PreToolUse',
|
|
51
|
+
permissionDecision: 'deny',
|
|
52
|
+
permissionDecisionReason: reason,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sidecarPath(root) {
|
|
58
|
+
return path.join(root, '.byan-codex-delegate', 'last-deny.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readPriorDeny(root) {
|
|
62
|
+
try {
|
|
63
|
+
const p = JSON.parse(fs.readFileSync(sidecarPath(root), 'utf8'));
|
|
64
|
+
return p && typeof p === 'object' ? p : null;
|
|
65
|
+
} catch (_e) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writePriorDeny(root, entry) {
|
|
71
|
+
try {
|
|
72
|
+
fs.mkdirSync(path.dirname(sidecarPath(root)), { recursive: true });
|
|
73
|
+
fs.writeFileSync(sidecarPath(root), JSON.stringify(entry));
|
|
74
|
+
} catch (_e) {
|
|
75
|
+
// best-effort : losing the deny memory only costs one extra deny, never a trap
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The last user message text this turn, for the delegability signal (a code
|
|
80
|
+
// target already suffices, this only broadens it).
|
|
81
|
+
function lastUserText(payload) {
|
|
82
|
+
const msgs = extractRecentMessages(payload, 12);
|
|
83
|
+
if (!Array.isArray(msgs)) return '';
|
|
84
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
85
|
+
if (msgs[i] && msgs[i].role === 'user') return contentToText(msgs[i].content);
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Pure-ish assembly of the decision from a payload (exported for tests, fs
|
|
91
|
+
// injected via opts.root + opts.now).
|
|
92
|
+
function runGuard(payload, { root = ROOT, now = Date.now() } = {}) {
|
|
93
|
+
const toolName = payload.tool_name || payload.toolName || '';
|
|
94
|
+
if (toolName !== 'Write' && toolName !== 'Edit') return allow();
|
|
95
|
+
|
|
96
|
+
const config = loadConfig(root);
|
|
97
|
+
const armed = config.enabled === true && codexLinked();
|
|
98
|
+
const escaped = toggledOff(root);
|
|
99
|
+
|
|
100
|
+
const input = payload.tool_input || payload.toolInput || {};
|
|
101
|
+
const filePath = input.file_path || input.filePath || '';
|
|
102
|
+
const content = toolName === 'Edit'
|
|
103
|
+
? String(input.new_string || input.newString || '')
|
|
104
|
+
: String(input.content || '');
|
|
105
|
+
|
|
106
|
+
const userText = lastUserText(payload);
|
|
107
|
+
const delegable = gate.targetIsCode(filePath) || looksDelegable(userText);
|
|
108
|
+
const reviewedMarker = gate.hasReviewedMarker(content) || gate.hasReviewedMarker(extractLastAssistantText(payload));
|
|
109
|
+
const delegationSeen = gate.delegationSeenThisTurn(extractRecentMessages(payload, 12) || []);
|
|
110
|
+
const turnHash = gate.hashTurn(filePath, content);
|
|
111
|
+
|
|
112
|
+
const decision = gate.decideDelegateGate({
|
|
113
|
+
armed,
|
|
114
|
+
delegable,
|
|
115
|
+
delegationSeen,
|
|
116
|
+
escaped,
|
|
117
|
+
reviewedMarker,
|
|
118
|
+
priorDeny: readPriorDeny(root),
|
|
119
|
+
turnHash,
|
|
120
|
+
now,
|
|
121
|
+
graceMs: GRACE_MS,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (decision.decision === 'deny') {
|
|
125
|
+
writePriorDeny(root, { hash: turnHash, ts: now });
|
|
126
|
+
return deny(decision.reason);
|
|
127
|
+
}
|
|
128
|
+
return allow();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (require.main === module) {
|
|
132
|
+
(async () => {
|
|
133
|
+
let out;
|
|
134
|
+
try {
|
|
135
|
+
const payload = JSON.parse((await readStdin()) || '{}');
|
|
136
|
+
out = runGuard(payload);
|
|
137
|
+
} catch (_e) {
|
|
138
|
+
out = allow();
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write(JSON.stringify(out));
|
|
141
|
+
process.exit(0);
|
|
142
|
+
})();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { runGuard, sidecarPath, readPriorDeny, writePriorDeny, lastUserText };
|
|
@@ -28,6 +28,7 @@ const path = require('path');
|
|
|
28
28
|
const { buildTaoContext, turnCounterPath } = require('./inject-tao');
|
|
29
29
|
const pl = require('./lib/plain-language');
|
|
30
30
|
const gate = require('./lib/agent-gate');
|
|
31
|
+
const voice = require('./lib/voice-conformance');
|
|
31
32
|
|
|
32
33
|
const ANCHOR = [
|
|
33
34
|
'Voix BYAN (rappel par tour ; tao complet chargé au démarrage de session) :',
|
|
@@ -96,6 +97,20 @@ function withGateReminder(baseContext, slip) {
|
|
|
96
97
|
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// Append the dispatch reminder (WI-3) when the previous turn wrote code without
|
|
101
|
+
// consulting byan_dispatch. Pure; fs read + clear stays in require.main.
|
|
102
|
+
function withDispatchReminder(baseContext, slip) {
|
|
103
|
+
const reminder = gate.formatDispatchReminder(slip);
|
|
104
|
+
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Append the voice-conformance reminder (WI-2) when the previous turn drifted from
|
|
108
|
+
// the BYAN voice (emoji / vouvoiement). Pure; fs read + clear stays in require.main.
|
|
109
|
+
function withVoiceReminder(baseContext, hits) {
|
|
110
|
+
const reminder = voice.formatReminder(hits);
|
|
111
|
+
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
if (require.main === module) {
|
|
100
115
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
101
116
|
const every = refreshEvery();
|
|
@@ -110,8 +125,16 @@ if (require.main === module) {
|
|
|
110
125
|
// Forward net #2 (F4): agent entry-gate slip. Read + clear one-shot.
|
|
111
126
|
const gateSlip = gate.readSlip(projectDir);
|
|
112
127
|
if (gateSlip) gate.clearSlip(projectDir);
|
|
128
|
+
// Forward net #3 (WI-3): dispatch-runtime slip. Read + clear one-shot.
|
|
129
|
+
const dispatchSlip = gate.readDispatchSlip(projectDir);
|
|
130
|
+
if (dispatchSlip) gate.clearDispatchSlip(projectDir);
|
|
131
|
+
// Forward net #4 (WI-2): voice-conformance slip. Read + clear one-shot.
|
|
132
|
+
const voiceHits = voice.readSlip(projectDir);
|
|
133
|
+
if (voiceHits && voiceHits.length) voice.clearSlip(projectDir);
|
|
113
134
|
let ctx = withSlipReminder(additionalContext, slipHits);
|
|
114
135
|
ctx = withGateReminder(ctx, gateSlip);
|
|
136
|
+
ctx = withDispatchReminder(ctx, dispatchSlip);
|
|
137
|
+
ctx = withVoiceReminder(ctx, voiceHits);
|
|
115
138
|
process.stdout.write(
|
|
116
139
|
JSON.stringify({
|
|
117
140
|
hookSpecificOutput: {
|
|
@@ -131,5 +154,7 @@ module.exports = {
|
|
|
131
154
|
decideAnchor,
|
|
132
155
|
withSlipReminder,
|
|
133
156
|
withGateReminder,
|
|
157
|
+
withDispatchReminder,
|
|
158
|
+
withVoiceReminder,
|
|
134
159
|
DEFAULT_REFRESH_EVERY,
|
|
135
160
|
};
|
|
@@ -32,6 +32,33 @@ function hasProposalMarker(text) {
|
|
|
32
32
|
return PROPOSAL_MARKERS.some((m) => t.includes(m));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// hasToolActivity(messages, predicate) — true if any assistant tool_use block in
|
|
36
|
+
// the recent messages matches the predicate(block). The shared scan used by the
|
|
37
|
+
// write / task-visual / dispatch detectors.
|
|
38
|
+
function hasToolActivity(messages, predicate) {
|
|
39
|
+
if (!Array.isArray(messages)) return false;
|
|
40
|
+
for (const m of messages) {
|
|
41
|
+
if (!m || m.role !== 'assistant' || !Array.isArray(m.content)) continue;
|
|
42
|
+
for (const block of m.content) {
|
|
43
|
+
if (block && block.type === 'tool_use' && predicate(block)) return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// WI-4 — the party-mode live visual signal : did the turn create a native task
|
|
50
|
+
// (TaskCreate) ? A task list IS the visual the entry chain promises, so its
|
|
51
|
+
// presence means the porte d'entree posture held even without a text proposal.
|
|
52
|
+
function hasTaskActivity(messages) {
|
|
53
|
+
return hasToolActivity(messages, (b) => b.name === 'TaskCreate' || b.name === 'TaskUpdate');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// WI-3 — the dispatch-consulted signal : was the routeur (byan_dispatch) called
|
|
57
|
+
// this turn ? Matches the MCP tool name in any namespaced form.
|
|
58
|
+
function hasDispatchActivity(messages) {
|
|
59
|
+
return hasToolActivity(messages, (b) => /byan_dispatch/.test(String(b.name || '')));
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
// hasWriteActivity(messages) — true if any recent assistant message carried a
|
|
36
63
|
// tool_use block that writes to the repo. `messages` is an array of
|
|
37
64
|
// { role, content } where content is a string or a block array.
|
|
@@ -47,14 +74,28 @@ function hasWriteActivity(messages) {
|
|
|
47
74
|
}
|
|
48
75
|
|
|
49
76
|
// assessTurn — the decision. A slip is a task done directly (files written) with
|
|
50
|
-
// no agent proposal AND no active FD cycle.
|
|
51
|
-
// already in play (DISPATCH is part of it), so
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
// no agent proposal, NO party-mode task visual (WI-4), AND no active FD cycle.
|
|
78
|
+
// When an FD is engaged, the gate is already in play (DISPATCH is part of it), so
|
|
79
|
+
// it is never a slip ; likewise a live task list is proof the entry posture held.
|
|
80
|
+
function assessTurn({ wroteFiles = false, proposedAgent = false, taskVisualSeen = false, fdActive = false } = {}) {
|
|
81
|
+
const slip = Boolean(wroteFiles && !proposedAgent && !taskVisualSeen && !fdActive);
|
|
54
82
|
return {
|
|
55
83
|
slip,
|
|
56
84
|
reason: slip
|
|
57
|
-
? 'files written directly without an agent proposal and no active FD cycle'
|
|
85
|
+
? 'files written directly without an agent proposal, no task visual, and no active FD cycle'
|
|
86
|
+
: 'ok',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// WI-3 — dispatch net. A slip is code written this turn without the runtime
|
|
91
|
+
// routeur (byan_dispatch) ever consulted, outside an FD cycle. Non-blocking : the
|
|
92
|
+
// engine choice is a decision, not a binary invariant, so this only flags.
|
|
93
|
+
function assessDispatch({ wroteFiles = false, dispatchConsulted = false, fdActive = false } = {}) {
|
|
94
|
+
const slip = Boolean(wroteFiles && !dispatchConsulted && !fdActive);
|
|
95
|
+
return {
|
|
96
|
+
slip,
|
|
97
|
+
reason: slip
|
|
98
|
+
? 'code written without consulting byan_dispatch (runtime routing) and no active FD cycle'
|
|
58
99
|
: 'ok',
|
|
59
100
|
};
|
|
60
101
|
}
|
|
@@ -100,6 +141,46 @@ function formatReminder(slip) {
|
|
|
100
141
|
].join(' ');
|
|
101
142
|
}
|
|
102
143
|
|
|
144
|
+
// --- dispatch slip (WI-3, its own sidecar, same forward-net mechanics) --------
|
|
145
|
+
|
|
146
|
+
function dispatchSlipPath(projectDir) {
|
|
147
|
+
return path.join(projectDir, '_byan-output', '.dispatch-slip.json');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeDispatchSlip(projectDir, detail) {
|
|
151
|
+
try {
|
|
152
|
+
const p = dispatchSlipPath(projectDir);
|
|
153
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
154
|
+
fs.writeFileSync(p, JSON.stringify({ detail: String(detail || '') }));
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readDispatchSlip(projectDir) {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(fs.readFileSync(dispatchSlipPath(projectDir), 'utf8'));
|
|
164
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearDispatchSlip(projectDir) {
|
|
171
|
+
try { fs.rmSync(dispatchSlipPath(projectDir), { force: true }); } catch { /* never block */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatDispatchReminder(slip) {
|
|
175
|
+
if (!slip) return '';
|
|
176
|
+
return [
|
|
177
|
+
'Rappel dispatch runtime (BYAN) : au dernier tour du code a ete ecrit sans',
|
|
178
|
+
'consulter byan_dispatch (le routeur moteur/modele/effort). La chaine d\'entree',
|
|
179
|
+
'route avant d\'executer : passe par byan_dispatch pour choisir Codex ou Claude,',
|
|
180
|
+
'le modele et l\'effort. Applique-le ce tour-ci.',
|
|
181
|
+
].join(' ');
|
|
182
|
+
}
|
|
183
|
+
|
|
103
184
|
// fdIsActive(projectDir) — reads _byan-output/fd-state.json ; active means a live
|
|
104
185
|
// phase (not COMPLETED / ABORTED / absent). Read-only, best-effort.
|
|
105
186
|
function fdIsActive(projectDir) {
|
|
@@ -117,11 +198,20 @@ module.exports = {
|
|
|
117
198
|
WRITE_TOOLS,
|
|
118
199
|
hasProposalMarker,
|
|
119
200
|
hasWriteActivity,
|
|
201
|
+
hasToolActivity,
|
|
202
|
+
hasTaskActivity,
|
|
203
|
+
hasDispatchActivity,
|
|
120
204
|
assessTurn,
|
|
205
|
+
assessDispatch,
|
|
121
206
|
slipPath,
|
|
122
207
|
writeSlip,
|
|
123
208
|
readSlip,
|
|
124
209
|
clearSlip,
|
|
125
210
|
formatReminder,
|
|
211
|
+
dispatchSlipPath,
|
|
212
|
+
writeDispatchSlip,
|
|
213
|
+
readDispatchSlip,
|
|
214
|
+
clearDispatchSlip,
|
|
215
|
+
formatDispatchReminder,
|
|
126
216
|
fdIsActive,
|
|
127
217
|
};
|
|
Binary file
|
|
@@ -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-
|
|
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(
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -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
|
},
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-byan-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.50.0",
|
|
4
4
|
"description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Claude Code, Codex). Merise Agile + TDD + 71 Mantras. ~54% LLM cost savings.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|