create-byan-agent 2.29.2 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/install/bin/create-byan-agent-v2.js +44 -1
  3. package/install/lib/claude-native-setup.js +12 -38
  4. package/install/lib/gdoc-setup.js +210 -0
  5. package/install/lib/mcp-extensions/gdrive.js +27 -2
  6. package/install/lib/platforms/claude-code.js +28 -19
  7. package/install/lib/rtk-integration.js +18 -8
  8. package/install/package.json +1 -1
  9. package/install/packages/platform-config/lib/credentials.js +13 -1
  10. package/install/packages/platform-config/lib/mcp-config.js +71 -5
  11. package/install/setup-gdoc.js +41 -0
  12. package/install/setup-rtk.js +1 -1
  13. package/install/templates/.claude/CLAUDE.md +16 -4
  14. package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
  15. package/install/templates/.claude/hooks/inject-soul.js +4 -3
  16. package/install/templates/.claude/hooks/inject-tao.js +65 -25
  17. package/install/templates/.claude/hooks/inject-voice-anchor.js +102 -0
  18. package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
  19. package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
  20. package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
  21. package/install/templates/.claude/hooks/punt-guard.js +126 -0
  22. package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
  23. package/install/templates/.claude/rules/portable-core.md +81 -0
  24. package/install/templates/.claude/settings.json +13 -1
  25. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +16 -2
  26. package/install/templates/_byan/_config/delivery-default.json +22 -0
  27. package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
  28. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
  29. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
  30. package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
  31. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-client.js +203 -0
  32. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-content.js +203 -0
  33. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
  34. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
  35. package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
  36. package/install/templates/_byan/mcp/byan-mcp-server/lib/resolve-config.js +15 -2
  37. package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
  38. package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +1 -1
  39. package/install/templates/_byan/mcp/byan-mcp-server/package.json +2 -0
  40. package/install/templates/_byan/mcp/byan-mcp-server/server.js +70 -0
  41. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
  42. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  43. package/install/templates/docs/google-docs-publish.md +121 -0
  44. package/install/templates/docs/leantime-integration.md +11 -1
  45. package/node_modules/byan-platform-config/lib/credentials.js +13 -1
  46. package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
  47. package/package.json +3 -1
  48. package/install/templates/.mcp.json.tmpl +0 -8
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UserPromptSubmit hook — BYAN delivery-default contract (F1).
4
+ *
5
+ * Injects, every turn, the baseline delivery posture: grade=PROD, scope=MAXIMAL,
6
+ * the AI-2026 cost yardstick (estimate in agent-time x10, never human-by-hand
7
+ * time), and the explicit ban on quietly proposing an MVP / short deliverable /
8
+ * a split that exists only to avoid doing the heavy part. This is the proactive
9
+ * twin of strict mode: strict locks a scope on demand, this sets the DEFAULT.
10
+ *
11
+ * Ships LIVE (unlike the F2 completeness reject and the F3 punt-guard, which
12
+ * ship disarmed): the anchor is pure injected context, it blocks nothing, so a
13
+ * false positive costs a few tokens — never a trapped turn or a blocked commit.
14
+ *
15
+ * The only escape is an explicit opt-out word the user types THIS message (mvp,
16
+ * quick, brouillon, ...). On opt-out the hook emits a single descope-authorized
17
+ * line instead of the full anchor, so the agent is told the contract is relaxed
18
+ * for this one request.
19
+ *
20
+ * Always exits 0; never blocks prompt submission.
21
+ */
22
+
23
+ const { loadConfig, decideContext } = require('./lib/delivery-contract');
24
+ const { readStdin, parseJson } = require('./lib/strict-runtime');
25
+
26
+ if (require.main === module) {
27
+ (async () => {
28
+ let additionalContext = '';
29
+ try {
30
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
31
+ const config = loadConfig(projectRoot);
32
+ const payload = parseJson(await readStdin());
33
+ const userMsg = payload.prompt || payload.user_prompt || payload.userPrompt || '';
34
+ additionalContext = decideContext({ userMsg, config }).text;
35
+ } catch {
36
+ // Never block prompt submission — degrade to no context.
37
+ additionalContext = '';
38
+ }
39
+ process.stdout.write(
40
+ JSON.stringify({
41
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
42
+ })
43
+ );
44
+ process.exit(0);
45
+ })();
46
+ }
@@ -2,9 +2,10 @@
2
2
  /**
3
3
  * SessionStart hook — loads BYAN soul + soul-memory and injects them into
4
4
  * the session's initial context via additionalContext. Tao is intentionally
5
- * NOT bundled here: inject-tao.js injects the full tao on every
6
- * UserPromptSubmit (including the first), so duplicating it at SessionStart
7
- * would double-spend ~15 KB per session for no gain.
5
+ * NOT bundled here: inject-tao.js is its own SessionStart hook that injects the
6
+ * full tao once into the cacheable prefix, and inject-voice-anchor.js carries a
7
+ * compact per-turn voice reminder. Keeping them separate avoids double-spending
8
+ * the tao payload while leaving each hook single-purpose and testable.
8
9
  *
9
10
  * Also resets the per-session mid-session-nudge one-shot marker so the
10
11
  * soul-memory-triggers nudge is per-session (not per-lifetime). Without
@@ -1,38 +1,78 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * UserPromptSubmit hook — injects BYAN tao voice directives into every
4
- * user prompt so Claude's response respects the register even when BYAN
5
- * agent isn't explicitly invoked.
3
+ * SessionStart hook — injects BYAN's FULL tao (voice directives) ONCE into the
4
+ * session's initial context, so it lands in the stable, cacheable prefix instead
5
+ * of being re-sent on every turn.
6
6
  *
7
- * Reads _byan/tao.md. If absent or empty, emits empty additionalContext
8
- * (no-op). Always exits 0.
7
+ * Cache rationale: a UserPromptSubmit injection is appended at the growing edge
8
+ * each turn, so the full ~3.6k-token tao was re-billed N times over a session.
9
+ * Loaded once at SessionStart it sits in the stable prefix (cache read at 10%).
10
+ * The per-turn voice freshness is carried by the tiny inject-voice-anchor.js
11
+ * (UserPromptSubmit): full tao here, compact anchor there. The voice stays 100%
12
+ * present every turn — nothing about it becomes conditional.
13
+ *
14
+ * Reads _byan/agent/byan/tao.md (Gen3) then _byan/tao.md (Gen2). Missing/empty ->
15
+ * empty additionalContext (no-op). Always exits 0.
9
16
  */
10
17
 
11
18
  const fs = require('fs');
12
19
  const path = require('path');
13
20
 
14
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
15
- // Gen3 _byan/agent/byan/tao.md first, Gen2 _byan/tao.md fallback.
16
- const taoGen3 = path.join(projectDir, '_byan', 'agent', 'byan', 'tao.md');
17
- const taoPath = fs.existsSync(taoGen3) ? taoGen3 : path.join(projectDir, '_byan', 'tao.md');
21
+ // Gen3 puts tao under _byan/agent/byan/; Gen2 keeps it at the _byan/ root.
22
+ function taoFile(projectDir) {
23
+ const g3 = path.join(projectDir, '_byan', 'agent', 'byan', 'tao.md');
24
+ const g2 = path.join(projectDir, '_byan', 'tao.md');
25
+ return fs.existsSync(g3) ? g3 : g2;
26
+ }
18
27
 
19
- let additionalContext = '';
20
- try {
21
- if (fs.existsSync(taoPath)) {
22
- const content = fs.readFileSync(taoPath, 'utf8').trim();
23
- if (content.length > 0) {
24
- additionalContext = `BYAN tao directives (active for this turn — follow register, signatures, forbidden vocabulary):\n\n${content}`;
28
+ function buildTaoContext(projectDir) {
29
+ try {
30
+ const p = taoFile(projectDir);
31
+ if (fs.existsSync(p)) {
32
+ const content = fs.readFileSync(p, 'utf8').trim();
33
+ if (content.length > 0) {
34
+ return `BYAN tao (voice directives, loaded once at session start — register, signatures, forbidden vocabulary):\n\n${content}`;
35
+ }
25
36
  }
37
+ } catch {
38
+ // Hook must never block session start.
39
+ }
40
+ return '';
41
+ }
42
+
43
+ // Per-session turn counter for the voice-anchor refresh cadence (inject-voice-anchor.js).
44
+ // It lives under _byan-output/ (gitignored). inject-tao OWNS the path and the reset
45
+ // because the full tao it injects at SessionStart (including source=compact) restarts
46
+ // the cadence: the next periodic full-tao refresh is then N turns later. The anchor hook
47
+ // reads/writes the same path via require('./inject-tao') -- single source, no drift.
48
+ function turnCounterPath(projectDir) {
49
+ return path.join(projectDir, '_byan-output', '.tao-refresh-turn');
50
+ }
51
+
52
+ function resetTurnCounter(projectDir) {
53
+ try {
54
+ const p = turnCounterPath(projectDir);
55
+ fs.mkdirSync(path.dirname(p), { recursive: true });
56
+ fs.writeFileSync(p, '0');
57
+ } catch {
58
+ // Never block session start.
59
+ }
60
+ }
61
+
62
+ if (require.main === module) {
63
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
64
+ // A fresh full tao is about to be injected -> restart the voice-anchor cadence.
65
+ resetTurnCounter(projectDir);
66
+ const additionalContext = buildTaoContext(projectDir);
67
+ if (additionalContext) {
68
+ process.stdout.write(
69
+ JSON.stringify({
70
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext },
71
+ })
72
+ );
73
+ } else {
74
+ process.stdout.write('{}');
26
75
  }
27
- } catch {
28
- // Hook must never block prompt submission.
29
76
  }
30
77
 
31
- process.stdout.write(
32
- JSON.stringify({
33
- hookSpecificOutput: {
34
- hookEventName: 'UserPromptSubmit',
35
- additionalContext: additionalContext || '',
36
- },
37
- })
38
- );
78
+ module.exports = { taoFile, buildTaoContext, turnCounterPath, resetTurnCounter };
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UserPromptSubmit hook — keeps BYAN's voice present near the live edge each turn.
4
+ *
5
+ * Most turns inject a COMPACT voice anchor (~95 tokens). Every Nth turn re-inject
6
+ * the FULL tao instead, so on a long session the heart is refreshed close to the
7
+ * live edge. N defaults to 12 (override with BYAN_TAO_REFRESH_EVERY; <= 0 disables
8
+ * the refresh, anchor every turn). The per-turn counter lives under _byan-output/
9
+ * (gitignored) and is reset at SessionStart by inject-tao.js, so the cadence
10
+ * restarts from each fresh full-tao load.
11
+ *
12
+ * Layered guarantee (best-effort, honest about its floor): each turn is a separate
13
+ * process, so the cadence needs a WRITABLE counter file to advance. If _byan-output/
14
+ * cannot be written, the counter cannot advance and this hook degrades to the anchor
15
+ * every turn -- it never crashes (exit 0), and the full heart still returns via
16
+ * inject-tao at SessionStart AND after every compaction (source=compact, pinned by
17
+ * the F1 test). So the periodic refresh is the in-window ENHANCEMENT; the
18
+ * SessionStart/compaction re-injection is the FLOOR that always holds. A persistent
19
+ * degradation means _byan-output/ is not writable -- check its permissions.
20
+ *
21
+ * The full tao is read via inject-tao.buildTaoContext (single source, no
22
+ * duplication; require is side-effect-free thanks to its require.main guard).
23
+ * Always exits 0 ; never blocks prompt submission.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const { buildTaoContext, turnCounterPath } = require('./inject-tao');
29
+
30
+ const ANCHOR = [
31
+ 'Voix BYAN (rappel par tour ; tao complet chargé au démarrage de session) :',
32
+ '- Tutoiement, registre artisan-senior, direct sans être brusque, concis.',
33
+ '- Challenge avant de confirmer ; questionne les absolus (Mantra IA-16).',
34
+ '- Signatures : "Attends — pourquoi ?", "OK. On construit.", "Ça, c\'est du générique.".',
35
+ '- Zéro emoji. Orienté solution : on cherche la meilleure option, pas le mur.',
36
+ ].join('\n');
37
+
38
+ const DEFAULT_REFRESH_EVERY = 12;
39
+
40
+ function buildVoiceAnchor() {
41
+ return ANCHOR;
42
+ }
43
+
44
+ // Turns between full-tao refreshes. Invalid/absent env -> default. A value <= 0
45
+ // disables the periodic refresh (anchor every turn) -- an explicit opt-out.
46
+ function refreshEvery(env = process.env) {
47
+ const n = parseInt(env.BYAN_TAO_REFRESH_EVERY, 10);
48
+ return Number.isInteger(n) ? n : DEFAULT_REFRESH_EVERY;
49
+ }
50
+
51
+ function readTurn(projectDir) {
52
+ try {
53
+ const n = parseInt(fs.readFileSync(turnCounterPath(projectDir), 'utf8').trim(), 10);
54
+ return Number.isInteger(n) && n >= 0 ? n : 0;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ function writeTurn(projectDir, n) {
61
+ try {
62
+ const p = turnCounterPath(projectDir);
63
+ fs.mkdirSync(path.dirname(p), { recursive: true });
64
+ fs.writeFileSync(p, String(n));
65
+ } catch {
66
+ // Never block prompt submission.
67
+ }
68
+ }
69
+
70
+ // Pure cadence decision. Every Nth turn (N > 0) surfaces the full tao when it is
71
+ // available; otherwise the compact anchor. Kept pure so the cadence is unit-testable
72
+ // without fs.
73
+ function decideAnchor({ turn, every, fullTao }) {
74
+ if (every > 0 && turn % every === 0 && fullTao) {
75
+ return { mode: 'full', additionalContext: fullTao };
76
+ }
77
+ return { mode: 'anchor', additionalContext: ANCHOR };
78
+ }
79
+
80
+ if (require.main === module) {
81
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
82
+ const every = refreshEvery();
83
+ const turn = readTurn(projectDir) + 1;
84
+ writeTurn(projectDir, turn);
85
+ const fullTao = every > 0 && turn % every === 0 ? buildTaoContext(projectDir) : '';
86
+ const { additionalContext } = decideAnchor({ turn, every, fullTao });
87
+ process.stdout.write(
88
+ JSON.stringify({
89
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
90
+ })
91
+ );
92
+ }
93
+
94
+ module.exports = {
95
+ buildVoiceAnchor,
96
+ ANCHOR,
97
+ refreshEvery,
98
+ readTurn,
99
+ writeTurn,
100
+ decideAnchor,
101
+ DEFAULT_REFRESH_EVERY,
102
+ };
@@ -162,7 +162,18 @@ function logLine(entry) {
162
162
  if (createIntents.length && sidecar.projectId && timeLeft() > 0) {
163
163
  const results = await Promise.allSettled(
164
164
  createIntents.map((i) =>
165
- lt.createTask({ projectId: sidecar.projectId, headline: i.headline }, opts).then((r) => ({ i, r })),
165
+ lt
166
+ .createTask(
167
+ {
168
+ projectId: sidecar.projectId,
169
+ headline: i.headline,
170
+ description: i.description,
171
+ priority: i.priority,
172
+ storypoints: i.storypoints,
173
+ },
174
+ opts,
175
+ )
176
+ .then((r) => ({ i, r })),
166
177
  ),
167
178
  );
168
179
  for (const s of results) {
@@ -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
+ };