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.
- package/CHANGELOG.md +361 -0
- package/install/bin/create-byan-agent-v2.js +44 -1
- package/install/lib/claude-native-setup.js +12 -38
- package/install/lib/gdoc-setup.js +210 -0
- package/install/lib/mcp-extensions/gdrive.js +27 -2
- package/install/lib/platforms/claude-code.js +28 -19
- package/install/lib/rtk-integration.js +18 -8
- package/install/package.json +1 -1
- package/install/packages/platform-config/lib/credentials.js +13 -1
- package/install/packages/platform-config/lib/mcp-config.js +71 -5
- package/install/setup-gdoc.js +41 -0
- package/install/setup-rtk.js +1 -1
- package/install/templates/.claude/CLAUDE.md +16 -4
- package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
- package/install/templates/.claude/hooks/inject-soul.js +4 -3
- package/install/templates/.claude/hooks/inject-tao.js +65 -25
- package/install/templates/.claude/hooks/inject-voice-anchor.js +102 -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/rules/portable-core.md +81 -0
- package/install/templates/.claude/settings.json +13 -1
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +16 -2
- 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/gdoc-client.js +203 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-content.js +203 -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/resolve-config.js +15 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +1 -1
- package/install/templates/_byan/mcp/byan-mcp-server/package.json +2 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +70 -0
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
- package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
- package/install/templates/docs/google-docs-publish.md +121 -0
- package/install/templates/docs/leantime-integration.md +11 -1
- package/node_modules/byan-platform-config/lib/credentials.js +13 -1
- package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
- package/package.json +3 -1
- 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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
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
|
-
|
|
20
|
-
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|