create-byan-agent 2.60.1 → 2.60.2
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 +17 -0
- package/README.md +10 -0
- package/install/bin/byan-communication.cjs +11 -0
- package/install/lib/communication-manifest.json +243 -0
- package/install/lib/communication-migration.cjs +91 -0
- package/install/lib/communication-previous.json +181 -0
- package/install/lib/gitignore.js +135 -0
- package/install/lib/install-engine.js +17 -1
- package/install/package.json +1 -1
- package/install/templates/.claude/agents/bmad-byan.md +13 -26
- package/install/templates/.claude/agents/bmad-tao.md +47 -0
- package/install/templates/.claude/hooks/auto-dispatch-gate.js +81 -0
- package/install/templates/.claude/hooks/inject-tao.js +15 -71
- package/install/templates/.claude/hooks/inject-voice-anchor.js +21 -154
- package/install/templates/.claude/hooks/lib/dispatch-approval.cjs +135 -0
- package/install/templates/.claude/hooks/lib/dispatch-plan-format.js +293 -0
- package/install/templates/.claude/hooks/lib/plain-language.js +7 -14
- package/install/templates/.claude/hooks/lib/rtk-coverage.js +31 -0
- package/install/templates/.claude/hooks/lib/tool-log.js +143 -0
- package/install/templates/.claude/hooks/lib/voice-conformance.js +9 -32
- package/install/templates/.claude/hooks/lib/webfetch-output.js +37 -0
- package/install/templates/.claude/hooks/rtk-coverage.js +18 -0
- package/install/templates/.claude/hooks/tool-failure-guard.js +16 -9
- package/install/templates/.claude/hooks/webfetch-output.js +17 -0
- package/install/templates/.claude/rules/native-workflows.md +24 -5
- package/install/templates/.claude/rules/plain-language.md +7 -86
- package/install/templates/.claude/settings.json +56 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +42 -83
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +22 -7
- package/install/templates/.claude/workflows/byan-auto-dispatch.js +86 -38
- package/install/templates/.codex/skills/byan/SKILL.md +7 -0
- package/install/templates/AGENTS.md +106 -0
- package/install/templates/_byan/_config/communication-policy.json +50 -0
- package/install/templates/_byan/_config/workflow-manifest.csv +2 -0
- package/install/templates/_byan/agent/byan/byan-soul.md +14 -2
- package/install/templates/_byan/agent/byan/byan-tao.md +35 -274
- package/install/templates/_byan/agent/byan/byan.md +6 -2
- package/install/templates/_byan/agent/byan/soul.md +419 -0
- package/install/templates/_byan/agent/byan/tao.md +60 -0
- package/install/templates/_byan/agent/tao/tao.md +26 -27
- package/install/templates/_byan/connaissance/mantras-sources.md +295 -0
- package/install/templates/_byan/core/activation/soul-activation.md +6 -5
- package/install/templates/_byan/core/communication.cjs +76 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/agent-matcher.js +38 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/communication.cjs +77 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch-router.js +12 -3
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +51 -20
- package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-ack.js +75 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +12 -1
- package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +114 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/routing-ledger.js +356 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/soul.js +9 -0
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +24 -6
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +2 -2
- package/install/templates/_byan/workflow/simple/byan/data/mantras.yaml +2 -5
- package/install/templates/_byan/workflow/simple/byan/templates/tao-template.md +56 -0
- package/install/templates/docs/auto-dispatch.md +76 -0
- package/install/templates/docs/communication.md +69 -0
- package/install/templates/docs/native-workflows-contract.md +33 -11
- package/install/templates/docs/tool-output-cost.md +88 -0
- package/install/templates/scripts/communication-eval.cjs +192 -0
- package/install/templates/scripts/rtk-coverage-report.js +30 -0
- package/install/templates/src/byan-v2/data/mantras.json +1058 -0
- package/install/templates/tests/communication/README.md +106 -0
- package/install/templates/tests/communication/incises.test.cjs +26 -0
- package/install/templates/tests/communication/scenarios.json +391 -0
- package/package.json +1 -1
- package/src/byan-v2/data/mantras.json +26 -6
|
@@ -1,161 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
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');
|
|
2
|
+
'use strict';
|
|
3
|
+
const communication = require('../../_byan/core/communication.cjs');
|
|
29
4
|
const pl = require('./lib/plain-language');
|
|
30
5
|
const gate = require('./lib/agent-gate');
|
|
31
6
|
const voice = require('./lib/voice-conformance');
|
|
32
7
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'- Signatures : "Attends — pourquoi ?", "OK. On construit.", "Ça, c\'est du générique.".',
|
|
38
|
-
'- Zéro emoji. Orienté solution : on cherche la meilleure option, pas le mur.',
|
|
39
|
-
'- Français réel et cohérent (Mantra IA-26) : pas d\'anglais gratuit (dis "redémarrer le conteneur", pas "cutoff"), pas de jargon interne brut ("nudge", "up-tier", "ladder", "runtime"), pas de métaphore collée de travers ("forger" un token).',
|
|
40
|
-
'- Précision factuelle : nomme l\'outil et le résultat observé ("byan_ping a répondu", pas "le MCP est vivant") ; un état = un fait mesuré, pas une image.',
|
|
41
|
-
].join('\n');
|
|
42
|
-
|
|
43
|
-
const DEFAULT_REFRESH_EVERY = 12;
|
|
44
|
-
|
|
45
|
-
function buildVoiceAnchor() {
|
|
46
|
-
return ANCHOR;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Turns between full-tao refreshes. Invalid/absent env -> default. A value <= 0
|
|
50
|
-
// disables the periodic refresh (anchor every turn) -- an explicit opt-out.
|
|
51
|
-
function refreshEvery(env = process.env) {
|
|
52
|
-
const n = parseInt(env.BYAN_TAO_REFRESH_EVERY, 10);
|
|
53
|
-
return Number.isInteger(n) ? n : DEFAULT_REFRESH_EVERY;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function readTurn(projectDir) {
|
|
57
|
-
try {
|
|
58
|
-
const n = parseInt(fs.readFileSync(turnCounterPath(projectDir), 'utf8').trim(), 10);
|
|
59
|
-
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
60
|
-
} catch {
|
|
61
|
-
return 0;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function writeTurn(projectDir, n) {
|
|
66
|
-
try {
|
|
67
|
-
const p = turnCounterPath(projectDir);
|
|
68
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
69
|
-
fs.writeFileSync(p, String(n));
|
|
70
|
-
} catch {
|
|
71
|
-
// Never block prompt submission.
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Pure cadence decision. Every Nth turn (N > 0) surfaces the full tao when it is
|
|
76
|
-
// available; otherwise the compact anchor. Kept pure so the cadence is unit-testable
|
|
77
|
-
// without fs.
|
|
78
|
-
function decideAnchor({ turn, every, fullTao }) {
|
|
79
|
-
if (every > 0 && turn % every === 0 && fullTao) {
|
|
80
|
-
return { mode: 'full', additionalContext: fullTao };
|
|
81
|
-
}
|
|
82
|
-
return { mode: 'anchor', additionalContext: ANCHOR };
|
|
8
|
+
// Stateless reload: no other session or discarded context can suppress this reminder.
|
|
9
|
+
function buildVoiceAnchor(root = process.env.CLAUDE_PROJECT_DIR || process.cwd()) {
|
|
10
|
+
return communication.renderContract(communication.resolveContract(communication.loadPolicy(root), {}, 'conversation')) +
|
|
11
|
+
'\nLe profil affiché est le défaut, pas un choix imposé au livrable. Pour chaque production, choisir selon la demande et le public ; lire les règles du profil dans _byan/_config/communication-policy.json. Ne pas afficher les diagnostics dans le livrable.';
|
|
83
12
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
function withSlipReminder(baseContext, hits) {
|
|
90
|
-
const reminder = pl.formatReminder(hits);
|
|
91
|
-
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Append the agent-gate reminder (F4) when the previous turn did a task directly
|
|
95
|
-
// without proposing an agent. Pure; the fs read + clear stays in require.main.
|
|
96
|
-
function withGateReminder(baseContext, slip) {
|
|
97
|
-
const reminder = gate.formatReminder(slip);
|
|
98
|
-
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Append the dispatch reminder (WI-3) when the previous turn wrote code without
|
|
102
|
-
// consulting byan_dispatch. Pure; fs read + clear stays in require.main.
|
|
103
|
-
function withDispatchReminder(baseContext, slip) {
|
|
104
|
-
const reminder = gate.formatDispatchReminder(slip);
|
|
105
|
-
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Append the voice-conformance reminder (WI-2) when the previous turn drifted from
|
|
109
|
-
// the BYAN voice (emoji / vouvoiement). Pure; fs read + clear stays in require.main.
|
|
110
|
-
function withVoiceReminder(baseContext, hits) {
|
|
111
|
-
const reminder = voice.formatReminder(hits);
|
|
112
|
-
return reminder ? `${baseContext}\n${reminder}` : baseContext;
|
|
113
|
-
}
|
|
114
|
-
|
|
13
|
+
function append(base, reminder) { return reminder ? `${base}\n${reminder}` : base; }
|
|
14
|
+
function withSlipReminder(base, hits) { return append(base, pl.formatReminder(hits)); }
|
|
15
|
+
function withGateReminder(base, slip) { return append(base, gate.formatReminder(slip)); }
|
|
16
|
+
function withDispatchReminder(base, slip) { return append(base, gate.formatDispatchReminder(slip)); }
|
|
17
|
+
function withVoiceReminder(base, hits) { return append(base, voice.formatReminder(hits)); }
|
|
115
18
|
if (require.main === module) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// Forward net #2 (F4): agent entry-gate slip. Read + clear one-shot.
|
|
127
|
-
const gateSlip = gate.readSlip(projectDir);
|
|
128
|
-
if (gateSlip) gate.clearSlip(projectDir);
|
|
129
|
-
// Forward net #3 (WI-3): dispatch-runtime slip. Read + clear one-shot.
|
|
130
|
-
const dispatchSlip = gate.readDispatchSlip(projectDir);
|
|
131
|
-
if (dispatchSlip) gate.clearDispatchSlip(projectDir);
|
|
132
|
-
// Forward net #4 (WI-2): voice-conformance slip. Read + clear one-shot.
|
|
133
|
-
const voiceHits = voice.readSlip(projectDir);
|
|
134
|
-
if (voiceHits && voiceHits.length) voice.clearSlip(projectDir);
|
|
135
|
-
let ctx = withSlipReminder(additionalContext, slipHits);
|
|
136
|
-
ctx = withGateReminder(ctx, gateSlip);
|
|
137
|
-
ctx = withDispatchReminder(ctx, dispatchSlip);
|
|
138
|
-
ctx = withVoiceReminder(ctx, voiceHits);
|
|
139
|
-
process.stdout.write(
|
|
140
|
-
JSON.stringify({
|
|
141
|
-
hookSpecificOutput: {
|
|
142
|
-
hookEventName: 'UserPromptSubmit',
|
|
143
|
-
additionalContext: ctx,
|
|
144
|
-
},
|
|
145
|
-
})
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
module.exports = {
|
|
150
|
-
buildVoiceAnchor,
|
|
151
|
-
ANCHOR,
|
|
152
|
-
refreshEvery,
|
|
153
|
-
readTurn,
|
|
154
|
-
writeTurn,
|
|
155
|
-
decideAnchor,
|
|
156
|
-
withSlipReminder,
|
|
157
|
-
withGateReminder,
|
|
158
|
-
withDispatchReminder,
|
|
159
|
-
withVoiceReminder,
|
|
160
|
-
DEFAULT_REFRESH_EVERY,
|
|
161
|
-
};
|
|
19
|
+
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
20
|
+
let ctx = buildVoiceAnchor(root);
|
|
21
|
+
const slips = [[pl.readSlip(root), withSlipReminder, () => pl.clearSlip(root)],
|
|
22
|
+
[gate.readSlip(root), withGateReminder, () => gate.clearSlip(root)],
|
|
23
|
+
[gate.readDispatchSlip(root), withDispatchReminder, () => gate.clearDispatchSlip(root)],
|
|
24
|
+
[voice.readSlip(root), withVoiceReminder, () => voice.clearSlip(root)]];
|
|
25
|
+
for (const [slip, format, clear] of slips) if (slip) { ctx = format(ctx, slip); clear(); }
|
|
26
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } }));
|
|
27
|
+
}
|
|
28
|
+
module.exports = { buildVoiceAnchor, withSlipReminder, withGateReminder, withDispatchReminder, withVoiceReminder };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const filePath = root => path.join(root, '_byan-output', 'dispatch-plan.json');
|
|
9
|
+
const markdownPath = root => path.join(root, '_byan-output', 'plan.md');
|
|
10
|
+
|
|
11
|
+
function validatePlan(plan) {
|
|
12
|
+
if (!plan || typeof plan.task !== 'string' || !plan.task.trim() ||
|
|
13
|
+
typeof plan.resume !== 'string' || typeof plan.stamp !== 'string' ||
|
|
14
|
+
!Array.isArray(plan.table) || !plan.table.length || plan.table.length > 100) {
|
|
15
|
+
throw new Error('Plan incomplet');
|
|
16
|
+
}
|
|
17
|
+
const ids = new Set();
|
|
18
|
+
for (const step of plan.table) {
|
|
19
|
+
if (!step || typeof step.id !== 'string' || !/^[\w-]+$/.test(step.id) || ids.has(step.id) ||
|
|
20
|
+
typeof step.titre !== 'string' || typeof step.consigne !== 'string' || !step.consigne.trim() ||
|
|
21
|
+
!Number.isFinite(step.complexite) || step.complexite < 0 || step.complexite > 100 ||
|
|
22
|
+
!['exploration', 'analyse', 'analysis', 'mechanical', 'implementation', 'verification', 'shell', 'deploiement', 'navigation', 'doc'].includes(step.nature) ||
|
|
23
|
+
!['claude', 'codex'].includes(step.moteur) ||
|
|
24
|
+
![null, 'haiku', 'sonnet', 'opus', 'fable'].includes(step.modele) ||
|
|
25
|
+
![null, 'low', 'medium', 'high', 'xhigh', 'max'].includes(step.effort)) throw new Error('Etape invalide');
|
|
26
|
+
if (step.nature === 'verification' && (step.modele !== null || step.effort !== null || step.moteur !== 'claude')) {
|
|
27
|
+
throw new Error('La verification doit heriter de la session');
|
|
28
|
+
}
|
|
29
|
+
ids.add(step.id);
|
|
30
|
+
}
|
|
31
|
+
return plan;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readBounded(file) {
|
|
35
|
+
if (fs.statSync(file).size > MAX_BYTES) throw new Error('Plan trop volumineux');
|
|
36
|
+
return fs.readFileSync(file, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
function renderPlan(plan) {
|
|
39
|
+
const cell = value => String(value).replace(/\|/g, '|').replace(/[\r\n]/g, ' ');
|
|
40
|
+
return [
|
|
41
|
+
'# Plan de dispatch — ' + cell(plan.resume), '',
|
|
42
|
+
'- Tache d origine : ' + cell(plan.task),
|
|
43
|
+
'- Genere par : workflow natif byan-auto-dispatch (' + cell(plan.stamp) + ')', '',
|
|
44
|
+
'| Etape | Titre | Nature | Complexite | Moteur | Modele | Effort |',
|
|
45
|
+
'|-------|-------|--------|------------|--------|--------|--------|',
|
|
46
|
+
...plan.table.map(s => `| ${s.id} | ${cell(s.titre)} | ${s.nature} | ${s.complexite} | ${s.moteur} | ${s.modele || 'modele de session'} | ${s.effort || 'effort de session'} |`),
|
|
47
|
+
'', '## Consignes par etape', '',
|
|
48
|
+
...plan.table.map(s => `### ${s.id} — ${cell(s.titre)}\n\n${s.consigne}\n`),
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
51
|
+
function writeAtomic(root, record) {
|
|
52
|
+
const file = filePath(root);
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(temporary, JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
|
|
57
|
+
fs.renameSync(temporary, file);
|
|
58
|
+
} finally {
|
|
59
|
+
try { fs.unlinkSync(temporary); } catch { /* renamed */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function read(root) {
|
|
63
|
+
const record = JSON.parse(readBounded(filePath(root)));
|
|
64
|
+
validatePlan(record.planData);
|
|
65
|
+
if (record.version !== 1 || record.id !== hash(JSON.stringify(record.planData)) ||
|
|
66
|
+
!['pending', 'approved', 'executing', 'completed'].includes(record.status) ||
|
|
67
|
+
record.markdownHash !== hash(readBounded(markdownPath(root)))) throw new Error('Plan modifie ou invalide : preparer et valider a nouveau');
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
function saveUnlocked(root, planData) {
|
|
71
|
+
validatePlan(planData);
|
|
72
|
+
const markdown = renderPlan(planData);
|
|
73
|
+
if (Buffer.byteLength(markdown) > MAX_BYTES || Buffer.byteLength(JSON.stringify(planData)) > MAX_BYTES) throw new Error('Plan trop volumineux');
|
|
74
|
+
fs.mkdirSync(path.dirname(markdownPath(root)), { recursive: true });
|
|
75
|
+
fs.writeFileSync(markdownPath(root), markdown);
|
|
76
|
+
const record = { version: 1, id: hash(JSON.stringify(planData)), status: 'pending', planData,
|
|
77
|
+
markdownHash: hash(markdown) };
|
|
78
|
+
writeAtomic(root, record);
|
|
79
|
+
return record;
|
|
80
|
+
}
|
|
81
|
+
function approveUnlocked(root, id, approvedBy = 'terminal') {
|
|
82
|
+
const record = read(root);
|
|
83
|
+
if (record.id !== id || record.status !== 'pending') throw new Error('Ce plan ne peut plus etre valide');
|
|
84
|
+
record.status = 'approved';
|
|
85
|
+
record.approvedAt = new Date().toISOString();
|
|
86
|
+
record.approvedBy = approvedBy;
|
|
87
|
+
writeAtomic(root, record);
|
|
88
|
+
return record;
|
|
89
|
+
}
|
|
90
|
+
function authorize(root, input) {
|
|
91
|
+
const record = read(root);
|
|
92
|
+
if (record.status !== 'approved' || !record.approvedAt || record.id !== input.approvedPlanId ||
|
|
93
|
+
JSON.stringify(record.planData) !== JSON.stringify(input.planData)) throw new Error('Execution refusee : validation du plan courant requise');
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
function transitionUnlocked(root, id, status) {
|
|
97
|
+
const record = read(root);
|
|
98
|
+
const from = status === 'executing' ? 'approved' : 'executing';
|
|
99
|
+
if (record.id !== id || record.status !== from) throw new Error('Transition de plan refusee');
|
|
100
|
+
record.status = status;
|
|
101
|
+
writeAtomic(root, record);
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function locked(root, action) {
|
|
106
|
+
const lock = path.join(root, '_byan-output', 'dispatch-plan.lock');
|
|
107
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
108
|
+
try { fs.mkdirSync(lock); } catch (error) {
|
|
109
|
+
if (error.code === 'EEXIST') throw new Error('Plan occupe : reessayer apres la fin de la modification');
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
try { return action(); } finally { fs.rmdirSync(lock); }
|
|
113
|
+
}
|
|
114
|
+
const save = (root, planData) => locked(root, () => saveUnlocked(root, planData));
|
|
115
|
+
const approve = (root, id, by) => locked(root, () => approveUnlocked(root, id, by));
|
|
116
|
+
const transition = (root, id, status) => locked(root, () => transitionUnlocked(root, id, status));
|
|
117
|
+
|
|
118
|
+
module.exports = { hash, validatePlan, renderPlan, read, save, approve, authorize, transition, filePath, MAX_BYTES };
|
|
119
|
+
|
|
120
|
+
if (require.main === module) {
|
|
121
|
+
try {
|
|
122
|
+
const [command, id] = process.argv.slice(2);
|
|
123
|
+
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
124
|
+
let result;
|
|
125
|
+
if (command === 'save') result = save(root, JSON.parse(fs.readFileSync(0, 'utf8')));
|
|
126
|
+
else if (command === 'read') result = read(root);
|
|
127
|
+
else if (command === 'claim') result = transition(root, id, 'executing');
|
|
128
|
+
else if (command === 'complete') { const current = read(root); result = current.id === id && current.status === 'completed' ? current : transition(root, id, 'completed'); }
|
|
129
|
+
else throw new Error('Usage : dispatch-approval.cjs save|read|complete [id]. Validation par geste utilisateur uniquement.');
|
|
130
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
131
|
+
} catch (error) {
|
|
132
|
+
process.stderr.write(error.message + '\n');
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Formateur du plan de dispatch — le rendu terminal.
|
|
4
|
+
//
|
|
5
|
+
// POURQUOI ce module existe : l'utilisateur ne peut pas mesurer ce que le
|
|
6
|
+
// routage lui coute tant qu'on ne lui a pas montre de plan. Le workflow natif
|
|
7
|
+
// `byan-auto-dispatch` calcule bien une table (etape x nature x complexite x
|
|
8
|
+
// moteur x modele x effort) avant d'ecrire _byan-output/plan.md, mais un script
|
|
9
|
+
// natif n'a ni import ni acces fichier : il ne peut pas mettre en forme. Le
|
|
10
|
+
// rendu vit donc ici, en pur code testable, et se nourrit de la meme structure.
|
|
11
|
+
//
|
|
12
|
+
// POURQUOI la couleur n'encode rien ici : dans un terminal on n'a ni palette ni
|
|
13
|
+
// garantie de fond. Le palier — le levier de cout principal, d'apres le brief de
|
|
14
|
+
// conception — est donc encode par la LARGEUR d'une jauge, canal qui survit au
|
|
15
|
+
// monochrome, au copier-coller et au journal de session.
|
|
16
|
+
//
|
|
17
|
+
// POURQUOI aucun montant n'est calcule : au moment du plan, aucun jeton n'a ete
|
|
18
|
+
// consomme. Afficher un montant serait une invention, et un zero serait faux.
|
|
19
|
+
// La regle produit dit : une valeur, ou un tiret accompagne de la raison du
|
|
20
|
+
// silence — pas de troisieme forme. Le plan annonce donc le melange des paliers
|
|
21
|
+
// et le plafond engage, qui sont mesurables, et dit pourquoi le montant manque.
|
|
22
|
+
|
|
23
|
+
// Les quatre paliers, du moins cher au plus cher. L'ordre porte le sens : c'est
|
|
24
|
+
// lui qui remplit la jauge et qui designe le plafond.
|
|
25
|
+
const PALIERS = Object.freeze(['haiku', 'sonnet', 'opus', 'fable']);
|
|
26
|
+
|
|
27
|
+
const LARGEUR_JAUGE = 4;
|
|
28
|
+
const SESSION = 'session';
|
|
29
|
+
const INCONNU = 'inconnu';
|
|
30
|
+
const TIRET = '-';
|
|
31
|
+
|
|
32
|
+
// Le routeur ecrit `null` dans la table et « modele de session » dans le
|
|
33
|
+
// plan.md pour la meme realite. Les deux entrent ici.
|
|
34
|
+
const MOTS_SESSION = Object.freeze([
|
|
35
|
+
'session',
|
|
36
|
+
'modele de session',
|
|
37
|
+
'modèle de session',
|
|
38
|
+
'effort de session',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const RAISON_SESSION =
|
|
42
|
+
"modele de session : le palier n'est connu qu'a l'execution";
|
|
43
|
+
|
|
44
|
+
function texte(v) {
|
|
45
|
+
if (v === null || v === undefined) return '';
|
|
46
|
+
return String(v).trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function estMotSession(mot) {
|
|
50
|
+
return MOTS_SESSION.includes(mot.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// La jauge : un palier se lit a la largeur de son remplissage, pas a sa couleur.
|
|
54
|
+
// Un palier non connu prend un remplissage de tirets et non de diese — il ne
|
|
55
|
+
// doit pas ressembler a « fable » d'un coup d'oeil.
|
|
56
|
+
function jaugePalier(modele) {
|
|
57
|
+
const rang = PALIERS.indexOf(texte(modele));
|
|
58
|
+
if (rang < 0) return `[${TIRET.repeat(LARGEUR_JAUGE)}]`;
|
|
59
|
+
return `[${'#'.repeat(rang + 1)}${'.'.repeat(LARGEUR_JAUGE - rang - 1)}]`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normaliserEtape(brut, rang) {
|
|
63
|
+
const e = brut && typeof brut === 'object' ? brut : {};
|
|
64
|
+
|
|
65
|
+
const modeleBrut = texte(e.modele) || texte(e.model);
|
|
66
|
+
let palier;
|
|
67
|
+
let raison = null;
|
|
68
|
+
if (PALIERS.includes(modeleBrut)) {
|
|
69
|
+
palier = modeleBrut;
|
|
70
|
+
} else if (!modeleBrut || estMotSession(modeleBrut)) {
|
|
71
|
+
palier = SESSION;
|
|
72
|
+
raison = RAISON_SESSION;
|
|
73
|
+
} else {
|
|
74
|
+
palier = INCONNU;
|
|
75
|
+
raison = `modele « ${modeleBrut} » hors des quatre paliers connus`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const effortBrut = texte(e.effort);
|
|
79
|
+
const effort = !effortBrut || estMotSession(effortBrut) ? SESSION : effortBrut;
|
|
80
|
+
|
|
81
|
+
// La complexite peut valoir 0 : on teste la finitude, pas la verite.
|
|
82
|
+
const cxBrut = e.complexite === null || e.complexite === undefined || e.complexite === ''
|
|
83
|
+
? NaN
|
|
84
|
+
: Number(e.complexite);
|
|
85
|
+
const complexite = Number.isFinite(cxBrut) ? cxBrut : null;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: texte(e.id) || `E${rang + 1}`,
|
|
89
|
+
titre: texte(e.titre) || texte(e.title) || 'etape sans titre',
|
|
90
|
+
nature: texte(e.nature) || INCONNU,
|
|
91
|
+
complexite,
|
|
92
|
+
palier,
|
|
93
|
+
raison,
|
|
94
|
+
chiffrable: raison === null,
|
|
95
|
+
effort,
|
|
96
|
+
moteur: texte(e.moteur) || texte(e.engine) || TIRET,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normaliserPlan(plan) {
|
|
101
|
+
const p = plan && typeof plan === 'object' ? plan : {};
|
|
102
|
+
const brutes = Array.isArray(p.etapes) ? p.etapes : Array.isArray(p.table) ? p.table : [];
|
|
103
|
+
return {
|
|
104
|
+
resume: texte(p.resume) || texte(p.tache) || 'resume absent du plan',
|
|
105
|
+
etapes: brutes.map(normaliserEtape),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resumerPlan(plan) {
|
|
110
|
+
const { etapes } = normaliserPlan(plan);
|
|
111
|
+
|
|
112
|
+
const parPalier = {};
|
|
113
|
+
PALIERS.forEach((p) => { parPalier[p] = 0; });
|
|
114
|
+
const parMoteur = {};
|
|
115
|
+
const nonChiffrees = [];
|
|
116
|
+
|
|
117
|
+
etapes.forEach((e) => {
|
|
118
|
+
if (e.chiffrable) parPalier[e.palier] += 1;
|
|
119
|
+
else nonChiffrees.push({ id: e.id, raison: e.raison });
|
|
120
|
+
if (e.moteur !== TIRET) parMoteur[e.moteur] = (parMoteur[e.moteur] || 0) + 1;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const engages = PALIERS.filter((p) => parPalier[p] > 0);
|
|
124
|
+
return {
|
|
125
|
+
nbEtapes: etapes.length,
|
|
126
|
+
parPalier,
|
|
127
|
+
parMoteur,
|
|
128
|
+
plafond: engages.length ? engages[engages.length - 1] : null,
|
|
129
|
+
nonChiffrees,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Au-dela, un titre pousse les colonnes chiffrees hors de vue : on tronque avec
|
|
134
|
+
// une marque visible plutot que de laisser la table se desaligner.
|
|
135
|
+
const LARGEUR_TITRE_MAX = 38;
|
|
136
|
+
const INDENT = 2;
|
|
137
|
+
const ECART = 2;
|
|
138
|
+
|
|
139
|
+
function cellulePalier(etape) {
|
|
140
|
+
return `${etape.palier.padEnd(7)} ${jaugePalier(etape.palier)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function valeurs(etape) {
|
|
144
|
+
return {
|
|
145
|
+
id: etape.id,
|
|
146
|
+
titre: etape.titre,
|
|
147
|
+
nature: etape.nature,
|
|
148
|
+
complexite: etape.complexite === null ? TIRET : String(etape.complexite),
|
|
149
|
+
palier: cellulePalier(etape),
|
|
150
|
+
effort: etape.effort,
|
|
151
|
+
moteur: etape.moteur,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// La mise en page est exportee : c'est elle qui rend le rendu verifiable au
|
|
156
|
+
// caractere pres, et qui garantit que l'en-tete et les lignes partagent les
|
|
157
|
+
// memes bornes.
|
|
158
|
+
function colonnesPlan(plan, opts = {}) {
|
|
159
|
+
const { etapes } = normaliserPlan(plan);
|
|
160
|
+
const cellules = etapes.map(valeurs);
|
|
161
|
+
const largeurTitreMax = opts.largeurTitreMax || LARGEUR_TITRE_MAX;
|
|
162
|
+
|
|
163
|
+
const large = (cle, plancher, plafond) => {
|
|
164
|
+
const mesures = cellules.map((c) => c[cle].length);
|
|
165
|
+
const brut = Math.max(plancher, ...(mesures.length ? mesures : [0]));
|
|
166
|
+
return plafond ? Math.min(brut, plafond) : brut;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const modele = [
|
|
170
|
+
{ cle: 'id', titre: 'ID', largeur: large('id', 2), aligne: 'gauche' },
|
|
171
|
+
{ cle: 'titre', titre: 'TITRE', largeur: large('titre', 5, largeurTitreMax), aligne: 'gauche' },
|
|
172
|
+
{ cle: 'nature', titre: 'NATURE', largeur: large('nature', 6), aligne: 'gauche' },
|
|
173
|
+
{ cle: 'complexite', titre: 'CX', largeur: large('complexite', 2), aligne: 'droite' },
|
|
174
|
+
{ cle: 'palier', titre: 'PALIER', largeur: large('palier', 6), aligne: 'gauche' },
|
|
175
|
+
{ cle: 'effort', titre: 'EFFORT', largeur: large('effort', 6), aligne: 'gauche' },
|
|
176
|
+
{ cle: 'moteur', titre: 'MOTEUR', largeur: large('moteur', 6), aligne: 'gauche' },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
let curseur = INDENT;
|
|
180
|
+
return modele.map((c) => {
|
|
181
|
+
const colonne = { ...c, debut: curseur };
|
|
182
|
+
curseur += c.largeur + ECART;
|
|
183
|
+
return colonne;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function poser(valeur, colonne) {
|
|
188
|
+
let v = valeur;
|
|
189
|
+
if (v.length > colonne.largeur) v = `${v.slice(0, colonne.largeur - 1)}…`;
|
|
190
|
+
return colonne.aligne === 'droite' ? v.padStart(colonne.largeur) : v.padEnd(colonne.largeur);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ligneDe(cellules, colonnes) {
|
|
194
|
+
return (' '.repeat(INDENT) + colonnes.map((c) => poser(cellules[c.cle], c)).join(' '.repeat(ECART))).trimEnd();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function lignesTete(resume, bilan) {
|
|
198
|
+
const etiquette = (mot) => ` ${mot.padEnd(13)}`;
|
|
199
|
+
const moteurs = Object.keys(bilan.parMoteur)
|
|
200
|
+
.sort((a, b) => bilan.parMoteur[b] - bilan.parMoteur[a] || a.localeCompare(b))
|
|
201
|
+
.map((m) => `${m} ${bilan.parMoteur[m]}`)
|
|
202
|
+
.join(', ');
|
|
203
|
+
const melange = PALIERS.map((p) => `${p} ${bilan.parPalier[p]}`).join(' | ');
|
|
204
|
+
|
|
205
|
+
const lignes = [
|
|
206
|
+
`PLAN DE DISPATCH — ${resume}`,
|
|
207
|
+
'',
|
|
208
|
+
`${etiquette('Etapes')}${bilan.nbEtapes}${moteurs ? ` (moteurs : ${moteurs})` : ' (moteur non precise)'}`,
|
|
209
|
+
`${etiquette('Paliers')}${melange}`,
|
|
210
|
+
`${etiquette('Plafond')}${bilan.plafond
|
|
211
|
+
? `${bilan.plafond} — le palier le plus cher engage par ce plan`
|
|
212
|
+
: `${TIRET} aucun des quatre paliers connus n'est engage`}`,
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
if (bilan.nonChiffrees.length) {
|
|
216
|
+
const ids = bilan.nonChiffrees.map((n) => n.id).join(', ');
|
|
217
|
+
const raisons = [...new Set(bilan.nonChiffrees.map((n) => n.raison))].join(' ; ');
|
|
218
|
+
const n = bilan.nonChiffrees.length;
|
|
219
|
+
lignes.push(`${etiquette('Non chiffre')}${n} etape${n > 1 ? 's' : ''} hors melange (${ids}) — ${raisons}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Le montant manque, et la raison du silence est affichee avec le tiret :
|
|
223
|
+
// c'est la seule forme admise pour une valeur absente.
|
|
224
|
+
lignes.push(
|
|
225
|
+
`${etiquette('Montant')}${TIRET} aucun montant estime : les jetons ne sont comptes qu'apres l'execution`
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return lignes;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function legende() {
|
|
232
|
+
const echelle = PALIERS.map((p) => `${jaugePalier(p)} ${p}`).join(' < ');
|
|
233
|
+
return ` Jauge de palier : ${echelle} · ${jaugePalier(null)} palier non connu avant l'execution`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function rendrePlan(plan, opts = {}) {
|
|
237
|
+
const { resume, etapes } = normaliserPlan(plan);
|
|
238
|
+
const bilan = resumerPlan(plan);
|
|
239
|
+
|
|
240
|
+
if (!etapes.length) {
|
|
241
|
+
return [
|
|
242
|
+
`PLAN DE DISPATCH — ${resume}`,
|
|
243
|
+
'',
|
|
244
|
+
' Aucune etape a afficher : le plan est vide ou sa table est illisible.',
|
|
245
|
+
" Rien n'a ete route, donc rien n'a ete estime.",
|
|
246
|
+
].join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const colonnes = colonnesPlan(plan, opts);
|
|
250
|
+
const largeurTable = colonnes[colonnes.length - 1].debut + colonnes[colonnes.length - 1].largeur;
|
|
251
|
+
const enTete = {};
|
|
252
|
+
colonnes.forEach((c) => { enTete[c.cle] = c.titre; });
|
|
253
|
+
|
|
254
|
+
return [
|
|
255
|
+
...lignesTete(resume, bilan),
|
|
256
|
+
'',
|
|
257
|
+
ligneDe(enTete, colonnes),
|
|
258
|
+
' ' + TIRET.repeat(largeurTable - INDENT),
|
|
259
|
+
...etapes.map((e) => ligneDe(valeurs(e), colonnes)),
|
|
260
|
+
'',
|
|
261
|
+
legende(),
|
|
262
|
+
].join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
PALIERS,
|
|
267
|
+
jaugePalier,
|
|
268
|
+
normaliserPlan,
|
|
269
|
+
resumerPlan,
|
|
270
|
+
colonnesPlan,
|
|
271
|
+
rendrePlan,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Rendu direct depuis le terminal, sur un plan JSON :
|
|
275
|
+
// node .claude/hooks/lib/dispatch-plan-format.js chemin/du/plan.json
|
|
276
|
+
// POURQUOI : le plan doit etre lisible dans le terminal aujourd'hui, sans
|
|
277
|
+
// attendre qu'un appelant soit cable.
|
|
278
|
+
if (require.main === module) {
|
|
279
|
+
const fs = require('fs');
|
|
280
|
+
const cible = process.argv[2];
|
|
281
|
+
if (!cible) {
|
|
282
|
+
process.stderr.write('usage : node dispatch-plan-format.js <plan.json>\n');
|
|
283
|
+
process.exit(2);
|
|
284
|
+
}
|
|
285
|
+
let charge = null;
|
|
286
|
+
try {
|
|
287
|
+
charge = JSON.parse(fs.readFileSync(cible, 'utf8'));
|
|
288
|
+
} catch (err) {
|
|
289
|
+
process.stderr.write(`plan illisible (${cible}) : ${err.message}\n`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
process.stdout.write(`${rendrePlan(charge)}\n`);
|
|
293
|
+
}
|
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// 1. scanText: find the KNOWN repeat-offender words in an assistant reply and
|
|
7
|
-
// return each with its plain-French replacement. This is the mechanical net
|
|
8
|
-
// for words we already know we misuse; the generative rule (the principle in
|
|
9
|
-
// .claude/rules/plain-language.md + the voice anchor) handles the long tail.
|
|
10
|
-
// 2. slip flag: a tiny file under _byan-output/ that the Stop hook writes when a
|
|
11
|
-
// reply slipped, and the next-turn voice anchor reads + clears to remind the
|
|
12
|
-
// agent. NO re-answer, NO blocking — the correction is carried FORWARD to the
|
|
13
|
-
// next turn, which is the whole point (avoid the costly regen loop).
|
|
3
|
+
// Lexical suggestions are available only to explicit review callers.
|
|
4
|
+
// They neither prove misuse nor replace text. Normal Stop hooks receive no
|
|
5
|
+
// lexical hits; punctuation is checked separately by voice-conformance.
|
|
14
6
|
|
|
15
7
|
const fs = require('fs');
|
|
16
8
|
const path = require('path');
|
|
@@ -108,7 +100,8 @@ function stripCode(text) {
|
|
|
108
100
|
}
|
|
109
101
|
|
|
110
102
|
// Find every distinct offender present in the prose. Returns [{ bad, good }].
|
|
111
|
-
function scanText(text) {
|
|
103
|
+
function scanText(text, { reviewLexicon = false } = {}) {
|
|
104
|
+
if (!reviewLexicon) return [];
|
|
112
105
|
const prose = stripCode(text);
|
|
113
106
|
if (!prose) return [];
|
|
114
107
|
const hits = [];
|
|
@@ -136,8 +129,8 @@ function formatReminder(hits) {
|
|
|
136
129
|
.map((h) => `"${h.bad}" -> ${h.good}`)
|
|
137
130
|
.join(' ; ');
|
|
138
131
|
return [
|
|
139
|
-
'
|
|
140
|
-
|
|
132
|
+
'Indications lexicales à examiner selon le sens et le destinataire (IA-26) :',
|
|
133
|
+
`${shown}. Aucun mot interdit par sa fréquence, aucun remplacement automatique. Rédige ce`,
|
|
141
134
|
'tour-ci, sans refaire la reponse precedente.',
|
|
142
135
|
].join(' ');
|
|
143
136
|
}
|