create-byan-agent 2.35.0 → 2.38.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 +139 -0
- package/install/bin/create-byan-agent-v2.js +15 -0
- package/install/lib/claude-native-setup.js +12 -38
- package/install/lib/platforms/claude-code.js +28 -19
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/mcp-config.js +71 -5
- package/install/templates/.claude/CLAUDE.md +1 -0
- package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
- package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
- package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
- package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
- package/install/templates/.claude/hooks/punt-guard.js +126 -0
- package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
- package/install/templates/.claude/settings.json +8 -0
- package/install/templates/_byan/_config/delivery-default.json +22 -0
- package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
- package/install/templates/docs/leantime-integration.md +11 -1
- package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
- package/package.json +1 -1
- package/install/templates/.mcp.json.tmpl +0 -8
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Pure helpers for the BYAN delivery-default contract (F1).
|
|
2
|
+
//
|
|
3
|
+
// The user asked for something complete. This contract makes prod-grade +
|
|
4
|
+
// maximal scope the DEFAULT the agent assumes every turn, instead of quietly
|
|
5
|
+
// drifting toward an MVP / a short deliverable / a "split so the heavy part
|
|
6
|
+
// does not block". It is the proactive twin of strict mode: strict locks a
|
|
7
|
+
// scope on demand; this anchor sets the BASELINE posture before a scope is
|
|
8
|
+
// even named.
|
|
9
|
+
//
|
|
10
|
+
// F1 ships LIVE (unlike the two blockers F2/F3): it is pure injected context,
|
|
11
|
+
// it blocks nothing, so a false positive costs at most a few tokens — never a
|
|
12
|
+
// trapped turn or a blocked commit. The only escape is an explicit opt-out word
|
|
13
|
+
// the user types THIS message (mvp, quick, brouillon, ...), read from
|
|
14
|
+
// _byan/_config/delivery-default.json so the wordlist is one source of truth.
|
|
15
|
+
//
|
|
16
|
+
// Every function here is pure (no clock, no fs beyond the one config read in
|
|
17
|
+
// loadConfig) so the decision is unit-testable without a hook harness.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
// Fallback wordlist used only if the config file is missing/unreadable. Keeping
|
|
25
|
+
// it here means the anchor still has a closed opt-out set on a broken install,
|
|
26
|
+
// rather than treating every message as opt-out (fail-safe toward PROD).
|
|
27
|
+
const DEFAULT_OPT_OUT_WORDS = [
|
|
28
|
+
'mvp',
|
|
29
|
+
'quick',
|
|
30
|
+
'brouillon',
|
|
31
|
+
'jette',
|
|
32
|
+
'prototype',
|
|
33
|
+
'vite fait',
|
|
34
|
+
'pas besoin que ce soit parfait',
|
|
35
|
+
'poc',
|
|
36
|
+
'draft',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function configPath(projectRoot) {
|
|
40
|
+
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
41
|
+
return path.join(root, '_byan', '_config', 'delivery-default.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadConfig(projectRoot) {
|
|
45
|
+
try {
|
|
46
|
+
const p = configPath(projectRoot);
|
|
47
|
+
if (fs.existsSync(p)) {
|
|
48
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
49
|
+
if (cfg && typeof cfg === 'object') return cfg;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Fall through to defaults — a broken config must not silence the anchor.
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function optOutWords(config) {
|
|
58
|
+
const words = config && Array.isArray(config.optOutWords) ? config.optOutWords : null;
|
|
59
|
+
return words && words.length ? words : DEFAULT_OPT_OUT_WORDS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The full delivery contract. PROD + MAXIMAL + the AI-2026 cost yardstick +
|
|
63
|
+
// the explicit ban on proposing an MVP/short-deliverable/dont-block-the-heavy
|
|
64
|
+
// split. Kept as one builder so the hook and the tests render identical text.
|
|
65
|
+
function buildAnchor() {
|
|
66
|
+
return [
|
|
67
|
+
'CONTRAT DE LIVRAISON (defaut): grade=PROD. scope=MAXIMAL -- livre le maximum',
|
|
68
|
+
'coherent, INTERDIT de proposer un MVP/livrable-court/decoupage-pour-ne-pas-bloquer-le-lourd',
|
|
69
|
+
'sauf si l\'utilisateur a tape un mot opt-out CE message. Etalon de cout=AI-2026:',
|
|
70
|
+
'estime en temps-agent (x10), jamais en temps-humain-a-la-main. Tu es un fou',
|
|
71
|
+
'd\'optimisation: maximum a chaque demande, pas minimum.',
|
|
72
|
+
].join(' ');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Opt-out detection is BIASED TOWARD PROD. A false opt-out (silencing the prod
|
|
76
|
+
// anchor when the user did not ask to) is the one failure that matters, so when
|
|
77
|
+
// in doubt we stay PROD. A single opt-out word counts only when it reads as a
|
|
78
|
+
// directive for THIS task -- the message is short enough to be a directive, OR
|
|
79
|
+
// a "go-cheap" cue sits just before the word -- and it is dropped when negated
|
|
80
|
+
// ("pas de mvp"). A word merely MENTIONED (a long meta message about MVPs, a
|
|
81
|
+
// system notification, an "anti-mvp" discussion) leaves the anchor armed.
|
|
82
|
+
// Curated multi-word phrases ("vite fait", "pas besoin que ce soit parfait")
|
|
83
|
+
// are unambiguous and match as-is.
|
|
84
|
+
const OPT_OUT_NEGATORS = new Set(['pas', 'sans', 'aucun', 'aucune', 'ni', 'no', 'not', 'non']);
|
|
85
|
+
const OPT_OUT_CUES = new Set([
|
|
86
|
+
'juste', 'just', 'seulement', 'only', 'mode', 'reste', 'simplement',
|
|
87
|
+
'rapidement', 'vite', 'fais', 'make', 'leave', 'give', 'garde',
|
|
88
|
+
]);
|
|
89
|
+
const OPT_OUT_SHORT_WORDS = 30; // a message this short reads as a directive, not meta
|
|
90
|
+
const OPT_OUT_WINDOW = 3; // tokens before the word inspected for a negator / cue
|
|
91
|
+
|
|
92
|
+
function tokenizeWords(s) {
|
|
93
|
+
return s.toLowerCase().match(/[a-z0-9']+/g) || [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseOptOut(userMsg, config) {
|
|
97
|
+
if (!userMsg || typeof userMsg !== 'string') return false;
|
|
98
|
+
const lower = userMsg.toLowerCase();
|
|
99
|
+
const words = optOutWords(config)
|
|
100
|
+
.map((w) => String(w).toLowerCase().trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
|
|
103
|
+
// 1) Curated multi-word phrases: unambiguous opt-out intent, substring match.
|
|
104
|
+
if (words.some((w) => w.includes(' ') && lower.includes(w))) return true;
|
|
105
|
+
|
|
106
|
+
// 2) Single words: honoured only in a directive context, dropped when negated.
|
|
107
|
+
const singles = new Set(words.filter((w) => /^[a-z]+$/.test(w)));
|
|
108
|
+
if (singles.size === 0) return false;
|
|
109
|
+
const tokens = tokenizeWords(userMsg);
|
|
110
|
+
const directiveShort = tokens.length <= OPT_OUT_SHORT_WORDS;
|
|
111
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
112
|
+
if (!singles.has(tokens[i])) continue;
|
|
113
|
+
const before = tokens.slice(Math.max(0, i - OPT_OUT_WINDOW), i);
|
|
114
|
+
if (before.some((t) => OPT_OUT_NEGATORS.has(t))) continue; // "pas de mvp" -> stays PROD
|
|
115
|
+
if (directiveShort || before.some((t) => OPT_OUT_CUES.has(t))) return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The per-turn decision. With an opt-out word present this turn, emit a single
|
|
121
|
+
// line authorizing descope; otherwise emit the full anchor. Pure: the caller
|
|
122
|
+
// supplies the message and (optionally) the loaded config.
|
|
123
|
+
function decideContext({ userMsg, config } = {}) {
|
|
124
|
+
const cfg = config || {};
|
|
125
|
+
const optOut = parseOptOut(userMsg, cfg);
|
|
126
|
+
if (optOut) {
|
|
127
|
+
return {
|
|
128
|
+
optOut: true,
|
|
129
|
+
text: 'CONTRAT DE LIVRAISON: OPT-OUT detecte ce message -- descope autorise (grade/scope libres pour cette demande).',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { optOut: false, text: buildAnchor() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
DEFAULT_OPT_OUT_WORDS,
|
|
137
|
+
configPath,
|
|
138
|
+
loadConfig,
|
|
139
|
+
optOutWords,
|
|
140
|
+
buildAnchor,
|
|
141
|
+
parseOptOut,
|
|
142
|
+
decideContext,
|
|
143
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Pure detector for the BYAN punt-guard (F3).
|
|
2
|
+
//
|
|
3
|
+
// A "punt" is the laziness pattern the user fights: the agent ends its turn by
|
|
4
|
+
// telling the human to run a command and paste the output back, when the agent
|
|
5
|
+
// could have run it itself this turn. The signal is a CO-OCCURRENCE in the
|
|
6
|
+
// finished turn:
|
|
7
|
+
// 1. an imperative addressed to the user ("lance", "execute", "colle la
|
|
8
|
+
// sortie", "run this", "paste", "donne-moi le ssh"), AND
|
|
9
|
+
// 2. a runnable command (a backticked command, or a `node `/`npm `/`git `/
|
|
10
|
+
// `which `/`curl localhost` invocation), AND
|
|
11
|
+
// 3. NO Bash tool-call for that command this turn (the agent did not actually
|
|
12
|
+
// run it — it handed it off).
|
|
13
|
+
//
|
|
14
|
+
// Carve-out (creds): `git push` and `npm publish` need credentials the server
|
|
15
|
+
// running Claude Code does not have. Asking the user to run THOSE is legitimate,
|
|
16
|
+
// not a punt — documented in MEMORY (server cannot push). So a command whose
|
|
17
|
+
// runnable token is a push/publish is never flagged.
|
|
18
|
+
//
|
|
19
|
+
// This module is pure decide() — no fs, no stdin — so it is exhaustively
|
|
20
|
+
// unit-testable. The hook wires it to the transcript and the arm flag.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
// Imperatives addressed to the user that, paired with a runnable command, mean
|
|
25
|
+
// "you run it". FR + EN. Matched case-insensitively as loose phrases.
|
|
26
|
+
const PUNT_IMPERATIVES = [
|
|
27
|
+
'lance',
|
|
28
|
+
'execute',
|
|
29
|
+
'exécute',
|
|
30
|
+
'colle la sortie',
|
|
31
|
+
'colle le resultat',
|
|
32
|
+
'colle le résultat',
|
|
33
|
+
'run this',
|
|
34
|
+
'run it',
|
|
35
|
+
'paste',
|
|
36
|
+
'donne-moi le ssh',
|
|
37
|
+
'donne moi le ssh',
|
|
38
|
+
'peux-tu lancer',
|
|
39
|
+
'peux tu lancer',
|
|
40
|
+
'lance la commande',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Commands that need creds the server lacks. Asking the user to run these is
|
|
44
|
+
// legitimate (not a punt). Order matters only for reporting the matched token.
|
|
45
|
+
const CREDS_CARVEOUT = ['git push', 'npm publish'];
|
|
46
|
+
|
|
47
|
+
// Loose runnable-command detectors. A backticked span, or a bare invocation of
|
|
48
|
+
// a common runner at a word boundary. `curl localhost` is included because the
|
|
49
|
+
// dev API runs on localhost and the agent can hit it itself.
|
|
50
|
+
const RUNNABLE_PATTERNS = [
|
|
51
|
+
/`([^`]+)`/g, // any backticked span
|
|
52
|
+
/\bnode\s+\S+/gi,
|
|
53
|
+
/\bnpm\s+\S+/gi,
|
|
54
|
+
/\bgit\s+\S+/gi,
|
|
55
|
+
/\bwhich\s+\S+/gi,
|
|
56
|
+
/\bcurl\s+localhost\S*/gi,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function hasImperativeToUser(text) {
|
|
60
|
+
if (!text) return false;
|
|
61
|
+
const lower = text.toLowerCase();
|
|
62
|
+
return PUNT_IMPERATIVES.some((p) => lower.includes(p));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Returns the first runnable command found in the text, or null. Backticked
|
|
66
|
+
// spans win (they are the explicit "here is the command" form); else the first
|
|
67
|
+
// bare invocation. The returned string is the matched command text.
|
|
68
|
+
function findRunnableCommand(text) {
|
|
69
|
+
if (!text) return null;
|
|
70
|
+
for (const re of RUNNABLE_PATTERNS) {
|
|
71
|
+
re.lastIndex = 0;
|
|
72
|
+
const m = re.exec(text);
|
|
73
|
+
if (m) {
|
|
74
|
+
// Backtick pattern captures group 1 (the inner command); the bare-runner
|
|
75
|
+
// patterns have no capture group, so use the full match.
|
|
76
|
+
return (m[1] !== undefined ? m[1] : m[0]).trim();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// True when the command is a creds-gated push/publish (legitimate to delegate).
|
|
83
|
+
function isCredsCarveOut(cmd) {
|
|
84
|
+
if (!cmd) return false;
|
|
85
|
+
const lower = cmd.toLowerCase();
|
|
86
|
+
return CREDS_CARVEOUT.some((c) => lower.includes(c));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// True when a Bash tool-call this turn actually ran (something matching) the
|
|
90
|
+
// command. We match on the meaningful head token of the command (e.g. "npm
|
|
91
|
+
// test" -> we require the Bash command to include "npm test"), so a Bash call
|
|
92
|
+
// that ran a DIFFERENT command does not falsely clear the punt. toolCalls is an
|
|
93
|
+
// array of { name, command } where command is the Bash tool's command string.
|
|
94
|
+
function bashRanCommand(toolCalls, cmd) {
|
|
95
|
+
if (!Array.isArray(toolCalls) || !cmd) return false;
|
|
96
|
+
// Compare on a normalized, whitespace-collapsed head so minor formatting
|
|
97
|
+
// (extra spaces, trailing flags in the prose) does not break the match.
|
|
98
|
+
const needle = cmd.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
99
|
+
const headTokens = needle.split(' ').slice(0, 2).join(' '); // e.g. "npm test"
|
|
100
|
+
return toolCalls.some((tc) => {
|
|
101
|
+
if (!tc || typeof tc.name !== 'string') return false;
|
|
102
|
+
if (!/bash/i.test(tc.name)) return false;
|
|
103
|
+
const ran = String(tc.command || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
104
|
+
if (!ran) return false;
|
|
105
|
+
return ran.includes(needle) || (headTokens.length > 0 && ran.includes(headTokens));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Pure decision. Returns { punt, reason, cmd }.
|
|
110
|
+
// punt : true IFF imperative-to-user + runnable command + NOT a creds
|
|
111
|
+
// carve-out + NO Bash tool-call ran that command this turn.
|
|
112
|
+
// reason: a short human-readable explanation (or why it is not a punt).
|
|
113
|
+
// cmd : the runnable command detected (or null).
|
|
114
|
+
function decide({ lastAssistantText, toolCallsThisTurn } = {}) {
|
|
115
|
+
const text = lastAssistantText || '';
|
|
116
|
+
const imperative = hasImperativeToUser(text);
|
|
117
|
+
const cmd = findRunnableCommand(text);
|
|
118
|
+
|
|
119
|
+
if (!imperative || !cmd) {
|
|
120
|
+
return { punt: false, reason: 'no imperative-to-user + runnable command co-occurrence', cmd: cmd || null };
|
|
121
|
+
}
|
|
122
|
+
if (isCredsCarveOut(cmd)) {
|
|
123
|
+
return { punt: false, reason: `creds carve-out: "${cmd}" needs credentials the server lacks`, cmd };
|
|
124
|
+
}
|
|
125
|
+
if (bashRanCommand(toolCallsThisTurn, cmd)) {
|
|
126
|
+
return { punt: false, reason: `agent ran the command via Bash this turn: "${cmd}"`, cmd };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
punt: true,
|
|
130
|
+
reason: `punt detected: asked the user to run "${cmd}" without running it via Bash this turn`,
|
|
131
|
+
cmd,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
PUNT_IMPERATIVES,
|
|
137
|
+
CREDS_CARVEOUT,
|
|
138
|
+
hasImperativeToUser,
|
|
139
|
+
findRunnableCommand,
|
|
140
|
+
isCredsCarveOut,
|
|
141
|
+
bashRanCommand,
|
|
142
|
+
decide,
|
|
143
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stop hook — BYAN punt-guard (F3).
|
|
4
|
+
*
|
|
5
|
+
* Goal : catch the laziness pattern where the agent ends its turn by telling the
|
|
6
|
+
* user to run a command and paste the output back, when the agent could have run
|
|
7
|
+
* it itself (a Bash tool-call) this turn. The pure detection lives in
|
|
8
|
+
* lib/punt-detect.js; this hook wires it to the real Stop payload and the arm
|
|
9
|
+
* flag.
|
|
10
|
+
*
|
|
11
|
+
* Ships DISARMED (puntGuard.armed=false in _byan/_config/delivery-default.json),
|
|
12
|
+
* same posture as the autobench Stop guard. Arming a Stop blocker without first
|
|
13
|
+
* measuring false positives breaks the flow. So by default the hook only
|
|
14
|
+
* OBSERVES: it appends one line to _byan-output/punt-ledger.jsonl
|
|
15
|
+
* (observed-disarmed / observed-disarmed-punt) and exits 0. Only when armed does
|
|
16
|
+
* it block (exit 2) on a detected punt.
|
|
17
|
+
*
|
|
18
|
+
* Carve-out (in punt-detect): git push / npm publish are never flagged — the
|
|
19
|
+
* server has no creds, so delegating those to the user is legitimate.
|
|
20
|
+
*
|
|
21
|
+
* Non-blocking on any IO/parse error : the hook never traps a turn it cannot read.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { decide } = require('./lib/punt-detect');
|
|
29
|
+
const { loadConfig } = require('./lib/delivery-contract');
|
|
30
|
+
// transcript-read is the canonical Stop-payload reader (text + raw content); it
|
|
31
|
+
// does NOT carry stdin helpers, so readStdin/parseJson come from strict-runtime
|
|
32
|
+
// like the other Stop/prompt hooks.
|
|
33
|
+
const { extractLastAssistantText, extractLastAssistantContent } = require('./lib/transcript-read');
|
|
34
|
+
const { readStdin, parseJson } = require('./lib/strict-runtime');
|
|
35
|
+
|
|
36
|
+
function projectRoot() {
|
|
37
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isArmed(config) {
|
|
41
|
+
const pg = config && config.puntGuard;
|
|
42
|
+
return !!(pg && pg.armed === true);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pull the Bash tool-calls out of the finished assistant turn's RAW content.
|
|
46
|
+
// transcript-read gives the block array; a Bash tool_use is
|
|
47
|
+
// { type:'tool_use', name:'Bash', input:{ command } }. Returns [{ name, command }].
|
|
48
|
+
function bashToolCalls(content) {
|
|
49
|
+
if (!Array.isArray(content)) return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const b of content) {
|
|
52
|
+
if (b && b.type === 'tool_use' && typeof b.name === 'string') {
|
|
53
|
+
const command = b.input && typeof b.input.command === 'string' ? b.input.command : '';
|
|
54
|
+
out.push({ name: b.name, command });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ledgerPath() {
|
|
61
|
+
return path.join(projectRoot(), '_byan-output', 'punt-ledger.jsonl');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function appendLedger(entry) {
|
|
65
|
+
try {
|
|
66
|
+
const p = ledgerPath();
|
|
67
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
68
|
+
fs.appendFileSync(p, JSON.stringify(entry) + '\n');
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (require.main === module) {
|
|
76
|
+
(async () => {
|
|
77
|
+
// Wrap everything : the hook must NEVER trap a turn it cannot read.
|
|
78
|
+
try {
|
|
79
|
+
const config = loadConfig(projectRoot());
|
|
80
|
+
const payload = parseJson(await readStdin());
|
|
81
|
+
const lastAssistantText = extractLastAssistantText(payload);
|
|
82
|
+
const toolCallsThisTurn = bashToolCalls(extractLastAssistantContent(payload));
|
|
83
|
+
|
|
84
|
+
const result = decide({ lastAssistantText, toolCallsThisTurn });
|
|
85
|
+
const armed = isArmed(config);
|
|
86
|
+
|
|
87
|
+
const event = armed
|
|
88
|
+
? result.punt
|
|
89
|
+
? 'fired-block'
|
|
90
|
+
: 'no-punt'
|
|
91
|
+
: result.punt
|
|
92
|
+
? 'observed-disarmed-punt'
|
|
93
|
+
: 'observed-disarmed';
|
|
94
|
+
|
|
95
|
+
appendLedger({
|
|
96
|
+
event,
|
|
97
|
+
punt: result.punt,
|
|
98
|
+
cmd: result.cmd || undefined,
|
|
99
|
+
reason: result.reason,
|
|
100
|
+
armed,
|
|
101
|
+
ts: process.env.BYAN_HOOK_TS || undefined,
|
|
102
|
+
session: process.env.CLAUDE_SESSION_ID || undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (armed && result.punt) {
|
|
106
|
+
const reason =
|
|
107
|
+
`Punt-guard: you asked the user to run "${result.cmd}" and paste the output, ` +
|
|
108
|
+
`but you did not run it via Bash this turn. Run it yourself, then report the result. ` +
|
|
109
|
+
`(git push / npm publish are exempt — the server has no creds for those.)`;
|
|
110
|
+
process.stdout.write(
|
|
111
|
+
JSON.stringify({ decision: 'block', reason, systemMessage: reason })
|
|
112
|
+
);
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
117
|
+
process.exit(0);
|
|
118
|
+
} catch {
|
|
119
|
+
// Last-resort net : on any unexpected failure, let the turn end.
|
|
120
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { bashToolCalls, isArmed, ledgerPath, appendLedger };
|
|
@@ -23,15 +23,38 @@ const { extractLastAssistantText } = require('./lib/transcript-read');
|
|
|
23
23
|
|
|
24
24
|
const DEFAULT_MARKERS = ['done', 'finished', 'complete', 'delivered', 'ready'];
|
|
25
25
|
|
|
26
|
+
// Strip the contexts where a completion marker is a MENTION, not a CLAIM:
|
|
27
|
+
// fenced + inline code, HTML comments (the BYAN-BENCH:done marker lives there),
|
|
28
|
+
// and snake_case / namespaced identifiers (byan_strict_complete, BENCH:done). A
|
|
29
|
+
// marker that survives this strip is prose -- the only place a real "it is done"
|
|
30
|
+
// claim lives. Exported so the regression cases are unit-testable.
|
|
31
|
+
function denoiseForClaim(text) {
|
|
32
|
+
return String(text)
|
|
33
|
+
.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
|
|
34
|
+
.replace(/`[^`]*`/g, ' ') // inline code spans
|
|
35
|
+
.replace(/<!--[\s\S]*?-->/g, ' ') // HTML comments (e.g. <!-- BYAN-BENCH:done -->)
|
|
36
|
+
.replace(/[A-Za-z0-9]+(?:[_:][A-Za-z0-9]+)+/g, ' '); // snake_case / ns identifiers
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A completion marker counts only as a STANDALONE claim, bounded by non-letters
|
|
40
|
+
// (Unicode-aware via the u flag, so "indefini" does not embed "fini" and
|
|
41
|
+
// "determine" does not embed "termine"), with a permissive trailing inflection
|
|
42
|
+
// (livre -> livree / livres). Bias: a false negative is caught by the pre-commit
|
|
43
|
+
// gate (the hard net), while a false positive traps a legitimate turn -- so a
|
|
44
|
+
// marker that is only mentioned is NOT read as a claim.
|
|
26
45
|
function claimsCompletion(text, markers) {
|
|
27
46
|
if (!text) return false;
|
|
28
|
-
const
|
|
47
|
+
const clean = denoiseForClaim(text).toLowerCase();
|
|
29
48
|
return (markers || DEFAULT_MARKERS).some((m) => {
|
|
30
|
-
const marker = String(m).toLowerCase();
|
|
31
|
-
if (
|
|
32
|
-
|
|
49
|
+
const marker = String(m).toLowerCase().trim();
|
|
50
|
+
if (!marker) return false;
|
|
51
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
52
|
+
try {
|
|
53
|
+
return new RegExp(`(?<![\\p{L}])${escaped}(?:s|e|es|ée|ées|és)?(?![\\p{L}])`, 'iu').test(clean);
|
|
54
|
+
} catch {
|
|
55
|
+
// Older runtimes without lookbehind/\p{L} -> fall back to a plain include.
|
|
56
|
+
return clean.includes(marker);
|
|
33
57
|
}
|
|
34
|
-
return lower.includes(marker);
|
|
35
58
|
});
|
|
36
59
|
}
|
|
37
60
|
|
|
@@ -85,4 +108,4 @@ if (require.main === module) {
|
|
|
85
108
|
})();
|
|
86
109
|
}
|
|
87
110
|
|
|
88
|
-
module.exports = { decideStop, claimsCompletion, extractLastAssistantText };
|
|
111
|
+
module.exports = { decideStop, claimsCompletion, denoiseForClaim, extractLastAssistantText };
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"type": "command",
|
|
28
28
|
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-voice-anchor.js"
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-delivery-default.js"
|
|
33
|
+
},
|
|
30
34
|
{
|
|
31
35
|
"type": "command",
|
|
32
36
|
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/soul-memory-triggers.js"
|
|
@@ -70,6 +74,10 @@
|
|
|
70
74
|
"type": "command",
|
|
71
75
|
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/autobench-stop-guard.js"
|
|
72
76
|
},
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/punt-guard.js"
|
|
80
|
+
},
|
|
73
81
|
{
|
|
74
82
|
"type": "command",
|
|
75
83
|
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/drain-advisory.js"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"name": "BYAN Delivery Default",
|
|
4
|
+
"description": "Makes prod-grade + maximal scope the mechanical default. F1 (the delivery-contract anchor) ships LIVE: it is pure injected context, no risk. F2 (completeness reject) and F3 (punt-guard) ship DISARMED behind armed flags: arming a turn/commit blocker without first measuring false positives would break the flow and could lock an in-progress strict session.",
|
|
5
|
+
"optOutWords": [
|
|
6
|
+
"mvp",
|
|
7
|
+
"quick",
|
|
8
|
+
"brouillon",
|
|
9
|
+
"jette",
|
|
10
|
+
"prototype",
|
|
11
|
+
"vite fait",
|
|
12
|
+
"pas besoin que ce soit parfait",
|
|
13
|
+
"poc",
|
|
14
|
+
"draft"
|
|
15
|
+
],
|
|
16
|
+
"completenessGate": {
|
|
17
|
+
"armed": false
|
|
18
|
+
},
|
|
19
|
+
"puntGuard": {
|
|
20
|
+
"armed": false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* channel-entry.js — Entrypoint stdio du channel MCP byan pour Claude Code.
|
|
4
|
+
*
|
|
5
|
+
* POURQUOI un fichier séparé de server.js : le channel est un serveur MCP
|
|
6
|
+
* distinct du serveur principal byan-mcp. Claude Code le spawne comme un
|
|
7
|
+
* subprocess indépendant via --dangerously-load-development-channels.
|
|
8
|
+
* server.js ne doit pas acquérir le transport channel comme effet de bord.
|
|
9
|
+
*
|
|
10
|
+
* Commande de lancement (research preview) :
|
|
11
|
+
* claude --dangerously-load-development-channels server:byan-channel
|
|
12
|
+
*
|
|
13
|
+
* Enregistrement dans .mcp.json (section mcpServers) — exactement ce que pose
|
|
14
|
+
* l'installeur : chemin RELATIF au projectRoot (portable, jamais absolu) et env
|
|
15
|
+
* VIDE (la config est resolue au boot via resolveConfig ci-dessous ; aucun secret
|
|
16
|
+
* n'est ecrit dans .mcp.json qui est tracke par git) :
|
|
17
|
+
* "byan-channel": {
|
|
18
|
+
* "command": "node",
|
|
19
|
+
* "args": ["_byan/mcp/byan-mcp-server/channel-entry.js"],
|
|
20
|
+
* "env": {}
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Voie plugin (Phase 2, non implémentée ici) :
|
|
24
|
+
* Wrapper le channel-entry.js dans un plugin Claude Code et l'enregistrer
|
|
25
|
+
* dans la marketplace pour qu'il soit accessible via
|
|
26
|
+
* --channels plugin:byan-channel@byan-marketplace.
|
|
27
|
+
* Nécessite que le channel soit sur l'allowlist Anthropic ou sur la liste
|
|
28
|
+
* allowedChannelPlugins de l'organisation (Enterprise).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { resolveConfig } from './lib/resolve-config.js';
|
|
32
|
+
import { runChannelStdio } from './lib/channel-server.js';
|
|
33
|
+
|
|
34
|
+
// Config : même resolver que server.js (env -> ~/.byan/credentials.json -> défauts).
|
|
35
|
+
const config = resolveConfig();
|
|
36
|
+
const apiUrl = config.BYAN_API_URL || process.env.BYAN_API_URL;
|
|
37
|
+
const apiToken = config.BYAN_API_TOKEN || process.env.BYAN_API_TOKEN;
|
|
38
|
+
|
|
39
|
+
if (!apiUrl) {
|
|
40
|
+
process.stderr.write('[byan-channel] BYAN_API_URL manquant — le poll sera désactivé\n');
|
|
41
|
+
}
|
|
42
|
+
if (!apiToken) {
|
|
43
|
+
process.stderr.write('[byan-channel] BYAN_API_TOKEN manquant — le poll et les replies seront désactivés\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await runChannelStdio({ apiUrl, apiToken });
|