create-byan-agent 2.60.0 → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -0
  3. package/install/bin/byan-communication.cjs +11 -0
  4. package/install/lib/communication-manifest.json +243 -0
  5. package/install/lib/communication-migration.cjs +91 -0
  6. package/install/lib/communication-previous.json +181 -0
  7. package/install/lib/gitignore.js +135 -0
  8. package/install/lib/install-engine.js +17 -1
  9. package/install/lib/ownership.js +24 -5
  10. package/install/package.json +1 -1
  11. package/install/templates/.claude/agents/bmad-byan.md +13 -26
  12. package/install/templates/.claude/agents/bmad-tao.md +47 -0
  13. package/install/templates/.claude/hooks/auto-dispatch-gate.js +81 -0
  14. package/install/templates/.claude/hooks/inject-tao.js +15 -71
  15. package/install/templates/.claude/hooks/inject-voice-anchor.js +21 -154
  16. package/install/templates/.claude/hooks/lib/dispatch-approval.cjs +135 -0
  17. package/install/templates/.claude/hooks/lib/dispatch-plan-format.js +293 -0
  18. package/install/templates/.claude/hooks/lib/plain-language.js +7 -14
  19. package/install/templates/.claude/hooks/lib/rtk-coverage.js +31 -0
  20. package/install/templates/.claude/hooks/lib/tool-log.js +143 -0
  21. package/install/templates/.claude/hooks/lib/voice-conformance.js +9 -32
  22. package/install/templates/.claude/hooks/lib/webfetch-output.js +37 -0
  23. package/install/templates/.claude/hooks/rtk-coverage.js +18 -0
  24. package/install/templates/.claude/hooks/tool-failure-guard.js +16 -9
  25. package/install/templates/.claude/hooks/webfetch-output.js +17 -0
  26. package/install/templates/.claude/rules/native-workflows.md +24 -5
  27. package/install/templates/.claude/rules/plain-language.md +7 -86
  28. package/install/templates/.claude/settings.json +56 -0
  29. package/install/templates/.claude/skills/byan-byan/SKILL.md +42 -83
  30. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +22 -7
  31. package/install/templates/.claude/workflows/byan-auto-dispatch.js +86 -38
  32. package/install/templates/.codex/skills/byan/SKILL.md +7 -0
  33. package/install/templates/AGENTS.md +106 -0
  34. package/install/templates/_byan/_config/communication-policy.json +50 -0
  35. package/install/templates/_byan/_config/workflow-manifest.csv +2 -0
  36. package/install/templates/_byan/agent/byan/byan-soul.md +14 -2
  37. package/install/templates/_byan/agent/byan/byan-tao.md +35 -274
  38. package/install/templates/_byan/agent/byan/byan.md +6 -2
  39. package/install/templates/_byan/agent/byan/soul.md +419 -0
  40. package/install/templates/_byan/agent/byan/tao.md +60 -0
  41. package/install/templates/_byan/agent/tao/tao.md +26 -27
  42. package/install/templates/_byan/connaissance/mantras-sources.md +295 -0
  43. package/install/templates/_byan/core/activation/soul-activation.md +6 -5
  44. package/install/templates/_byan/core/communication.cjs +76 -0
  45. package/install/templates/_byan/mcp/byan-mcp-server/lib/agent-matcher.js +38 -1
  46. package/install/templates/_byan/mcp/byan-mcp-server/lib/communication.cjs +77 -0
  47. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch-router.js +12 -3
  48. package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +51 -20
  49. package/install/templates/_byan/mcp/byan-mcp-server/lib/fd-ack.js +75 -0
  50. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +12 -1
  51. package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +114 -2
  52. package/install/templates/_byan/mcp/byan-mcp-server/lib/routing-ledger.js +356 -0
  53. package/install/templates/_byan/mcp/byan-mcp-server/lib/soul.js +9 -0
  54. package/install/templates/_byan/mcp/byan-mcp-server/server.js +24 -6
  55. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +2 -2
  56. package/install/templates/_byan/workflow/simple/byan/data/mantras.yaml +2 -5
  57. package/install/templates/_byan/workflow/simple/byan/templates/tao-template.md +56 -0
  58. package/install/templates/docs/auto-dispatch.md +76 -0
  59. package/install/templates/docs/communication.md +69 -0
  60. package/install/templates/docs/native-workflows-contract.md +33 -11
  61. package/install/templates/docs/tool-output-cost.md +88 -0
  62. package/install/templates/scripts/communication-eval.cjs +192 -0
  63. package/install/templates/scripts/rtk-coverage-report.js +30 -0
  64. package/install/templates/src/byan-v2/data/mantras.json +1058 -0
  65. package/install/templates/tests/communication/README.md +106 -0
  66. package/install/templates/tests/communication/incises.test.cjs +26 -0
  67. package/install/templates/tests/communication/scenarios.json +391 -0
  68. package/package.json +1 -1
  69. package/src/byan-v2/data/mantras.json +26 -6
@@ -1,161 +1,28 @@
1
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');
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
- const ANCHOR = [
34
- 'Voix BYAN (rappel par tour ; tao complet chargé au démarrage de session) :',
35
- '- Tutoiement, registre artisan-senior, direct sans être brusque, concis.',
36
- '- Challenge avant de confirmer ; questionne les absolus (Mantra IA-16).',
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
- // Append a plain-language slip reminder (IA-25) to the injected context when the
86
- // previous turn tripped the forward net. Pure so it is unit-testable; the fs read
87
- // + clear stays in the require.main path below. A missing/empty hit list is a
88
- // no-op, so this never changes the anchor on a clean turn.
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 projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
117
- const every = refreshEvery();
118
- const turn = readTurn(projectDir) + 1;
119
- writeTurn(projectDir, turn);
120
- const fullTao = every > 0 && turn % every === 0 ? buildTaoContext(projectDir) : '';
121
- const { additionalContext } = decideAnchor({ turn, every, fullTao });
122
- // Forward net: if the previous turn slipped into jargon, remind now and clear
123
- // the flag (one-shot). Read/clear here, formatting stays pure in withSlipReminder.
124
- const slipHits = pl.readSlip(projectDir);
125
- if (slipHits && slipHits.length) pl.clearSlip(projectDir);
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, '&#124;').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
- // Plain-language core the shared, testable heart of the "parler reel" guard
4
- // (Mantra IA-26). Two jobs, no I/O beyond the slip flag:
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
- 'Rappel langage (IA-26) : au dernier tour tu as glisse du jargon ou de',
140
- `l'anglais gratuit -> ${shown}. Reformule en francais reel et coherent ce`,
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
  }