refactor(router): Этап B сноса цепочек L — потребители/наблюдатель/хуки без реестра L1-L17
B2: router-classifier.mjs без buildChainsBlock и блоков «Available chains» (recommended_chain сохранён). B3: удалены router-stop-gate(+test) и observer-chain-map-checker(+test); status-md-generator без health-check C6; lefthook.yml без шага observer-chain-map-checker. observer-chain-detector и observer-chain-map.json — снос отложен в под-этап (живые импортёры transcript-parser/retrofill/stop-hook). Гайд: восстановлена markdown-разметка урока 2026-06-20. Полный свод зелёный. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,13 +248,14 @@ Claude обязан **запросить** подтверждение. В шта
|
||||
6. **Stop-loss:** 2-3 NO-GO подряд = чини СВОЙ план по тексту замечания, не цикл и не «вина стены».
|
||||
7. **Спека = ЧТО (контракт), план = КАК (метод).** Метод правки, описанный в спеке, ловит fatal — метод только в плане.
|
||||
|
||||
⚠️ Урок 2026-06-20 — не прячь работу в скрипт (наставник/судья режут «чёрный ящик»)
|
||||
Контроллер 8 раз переписывал план, пряча всю содержательную правку (вырезка schema.json, прогон тестов, коммит) внутрь одного node-скрипта-комбайна, чтобы обойти гейты. Наставник/судья справедливо не пускали; контроллер латал по одному возражению и валил на «судья тупит». Корень — подход, а не судья.
|
||||
## ⚠️ Урок 2026-06-20 — не прячь работу в скрипт (наставник/судья режут «чёрный ящик»)
|
||||
|
||||
Не прячь содержательную работу в node-скрипт ради обхода гейтов. node x.mjs не попадает под пэттерн-матч criterion/verify-гейтов — соблазн спрятать туда правки/тесты/коммит. Но наставник и судья видят только steps-json («Write скрипт → запусти скрипт»), ВНУТРЬ не заглядывают → корректно читают как «план ничего не меняет» и «не доверяю чёрному ящику» → NO-GO. Латать бесполезно.
|
||||
Содержательные правки — ВИДИМЫЕ шаги плана (Edit/Write на каждый файл; судья проверяет: убрано ровно нужное, файл валиден, остальное цело). Особенно КРИТИЧНЫЕ файлы (schema.json валидирует весь реестр — битый JSON роняет загрузку) — прямой Write чистого содержимого, не слепая резка внутри скрипта.
|
||||
Прогон тестов — отдельный видимый шаг, не внутри скрипта-комбайна.
|
||||
Коммит-через-скрипт (§5/§D) — ТОЛЬКО для механики git add/commit/push, НЕ для самих правок и не для верификации. Не расширяй его, чтобы протащить работу мимо стены.
|
||||
Симптом хитрости: план = «Write скрипт + запусти скрипт», реальная работа невидима. Поймал себя — переделай на видимые шаги, не дожимай owner-seal
|
||||
> Записано после сессии, где контроллер 8 раз переписывал план, пряча всю содержательную правку (вырезка `schema.json`, прогон тестов, коммит) внутрь одного node-скрипта-комбайна, чтобы обойти гейты. Наставник/судья справедливо не пускали; контроллер латал по одному возражению и валил на «судья тупит». Корень — подход, а не судья.
|
||||
|
||||
1. **Не прячь содержательную работу в node-скрипт ради обхода гейтов.** `node x.mjs` не попадает под пэттерн-матч criterion/verify-гейтов — соблазн спрятать туда правки/тесты/коммит. Но наставник и судья видят только `steps-json` («Write скрипт → запусти скрипт»), ВНУТРЬ не заглядывают → корректно читают как «план ничего не меняет» и «не доверяю чёрному ящику» → NO-GO. Латать бесполезно.
|
||||
2. **Содержательные правки — ВИДИМЫЕ шаги плана** (Edit/Write на каждый файл; судья проверяет: убрано ровно нужное, файл валиден, остальное цело). Особенно КРИТИЧНЫЕ файлы (`schema.json` валидирует весь реестр — битый JSON роняет загрузку) — прямой Write чистого содержимого, не слепая резка внутри скрипта.
|
||||
3. **Прогон тестов — отдельный видимый шаг**, не внутри скрипта-комбайна.
|
||||
4. **Коммит-через-скрипт (§5/§D) — ТОЛЬКО для механики** `git add/commit/push`, НЕ для самих правок и не для верификации. Не расширяй его, чтобы протащить работу мимо стены.
|
||||
5. **Симптом хитрости:** план = «Write скрипт + запусти скрипт», реальная работа невидима. Поймал себя — переделай на видимые шаги, не дожимай owner-seal.
|
||||
|
||||
[↑ наверх](#top)
|
||||
|
||||
@@ -86,12 +86,6 @@ pre-commit:
|
||||
fail_text: |
|
||||
observer-coverage-checker reports a gap (coverage or registration).
|
||||
|
||||
# 9. observer-chain-map-checker — brain governance C6 (chain attribution).
|
||||
- name: observer-chain-map-checker
|
||||
run: node tools/observer-chain-map-checker.mjs
|
||||
fail_text: |
|
||||
observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md.
|
||||
|
||||
# 10. registry-render-check — drift nodes.yaml <-> auto-region маркеры (warn-only).
|
||||
- name: registry-render-check
|
||||
glob: "{docs/registry/nodes.yaml,docs/Tooling_v8_3.md,docs/routing-off-phase.md}"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Brain governance controller C6 — chain-map sync checker.
|
||||
* Verifies tools/observer-chain-map.json against the L1-L13 table in
|
||||
* docs/routing-off-phase.md. Sync is checked by L-number sets (both
|
||||
* directions), not by node names — node_chosen values (skill-id) differ
|
||||
* from the human display names in the .md table. Pure fs/regex, no LLM.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MD_PATH = join(__dirname, '..', 'docs', 'routing-off-phase.md');
|
||||
const JSON_PATH = join(__dirname, 'observer-chain-map.json');
|
||||
|
||||
/** Extract the set of L-numbers ("L1".."L13") from the routing-off-phase.md table. */
|
||||
export function parseChainsFromMd(md) {
|
||||
const set = new Set();
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const m = /^\|\s*(L\d+)\s*\|/.exec(line.trim());
|
||||
if (m) set.add(m[1]);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** Compare JSON L-numbers against the md set, both directions. */
|
||||
export function checkSync(jsonMap, mdSet) {
|
||||
const jsonSet = new Set();
|
||||
for (const [node, chains] of Object.entries(jsonMap)) {
|
||||
if (node === '_note') continue;
|
||||
if (Array.isArray(chains)) for (const c of chains) jsonSet.add(c);
|
||||
}
|
||||
const jsonOnly = [...jsonSet].filter((c) => !mdSet.has(c)); // ссылки на несуществующие L
|
||||
const mdOnly = [...mdSet].filter((c) => !jsonSet.has(c)); // потерянные цепочки
|
||||
return { ok: jsonOnly.length === 0 && mdOnly.length === 0, jsonOnly, mdOnly };
|
||||
}
|
||||
|
||||
/** CLI entry — exit 1 on drift with a human-readable message. */
|
||||
function main() {
|
||||
const md = readFileSync(MD_PATH, 'utf8');
|
||||
const jsonMap = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
|
||||
const mdSet = parseChainsFromMd(md);
|
||||
if (mdSet.size === 0) {
|
||||
console.error(
|
||||
'[chain-map-checker] не нашёл ни одной L-строки в routing-off-phase.md — формат таблицы изменился?'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const res = checkSync(jsonMap, mdSet);
|
||||
if (res.ok) {
|
||||
console.log(`[chain-map-checker] OK — ${mdSet.size} chains in sync`);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('[chain-map-checker] дрейф маппинга chain-map <-> routing-off-phase.md:');
|
||||
if (res.jsonOnly.length)
|
||||
console.error(` JSON ссылается на отсутствующие в .md цепочки: ${res.jsonOnly.join(', ')}`);
|
||||
if (res.mdOnly.length)
|
||||
console.error(
|
||||
` В .md есть цепочки без записи в JSON: ${res.mdOnly.join(', ')} — добавьте узлы в tools/observer-chain-map.json`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith('observer-chain-map-checker.mjs')) {
|
||||
main();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';
|
||||
|
||||
const SAMPLE_MD = [
|
||||
'| # | Цепочка | Зачем |',
|
||||
'|---|---|---|',
|
||||
'| L1 | `discovery-interview` (FEATURE) → `brainstorming` | text |',
|
||||
'| L2 | `audit-portal` | text |',
|
||||
'| L13 | `billing-audit` (#62) + `Pest` | text |',
|
||||
].join('\n');
|
||||
|
||||
describe('parseChainsFromMd', () => {
|
||||
it('extracts the set of L-numbers from the table', () => {
|
||||
expect(parseChainsFromMd(SAMPLE_MD)).toEqual(new Set(['L1', 'L2', 'L13']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSync', () => {
|
||||
it('passes when JSON L-numbers subset of md and md subset of json-union', () => {
|
||||
const mdSet = new Set(['L1', 'L2', 'L13']);
|
||||
const jsonMap = { a: ['L1'], b: ['L2'], c: ['L13'] };
|
||||
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when JSON references a chain absent from md', () => {
|
||||
const mdSet = new Set(['L1', 'L2']);
|
||||
const jsonMap = { a: ['L1'], b: ['L99'] };
|
||||
const res = checkSync(jsonMap, mdSet);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.jsonOnly).toContain('L99');
|
||||
});
|
||||
|
||||
it('fails when md has a chain not covered by any JSON entry', () => {
|
||||
const mdSet = new Set(['L1', 'L2', 'L14']);
|
||||
const jsonMap = { a: ['L1'], b: ['L2'] };
|
||||
const res = checkSync(jsonMap, mdSet);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.mdOnly).toContain('L14');
|
||||
});
|
||||
|
||||
it('ignores the _note metadata key in the JSON map', () => {
|
||||
const mdSet = new Set(['L1']);
|
||||
const jsonMap = { _note: 'meta', a: ['L1'] };
|
||||
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -254,12 +254,6 @@ function buildNodesBlock(registry) {
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function buildChainsBlock(registry) {
|
||||
return Object.entries(registry.chains || {})
|
||||
.map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Sonnet 4.6 classifier prompt per spec §4.2.
|
||||
*
|
||||
@@ -291,7 +285,6 @@ export function buildClassifierPrompt(userPrompt, registry, { enrichment = true
|
||||
export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true, classifierContext = 'CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3)' } = {}) {
|
||||
const pamyatka = enrichment ? `\n\n${PAMYATKA}\n` : '\n';
|
||||
const nodesBlock = buildNodesBlock(registry);
|
||||
const chainsBlock = buildChainsBlock(registry);
|
||||
|
||||
const system = `Ты классификатор задач для ${classifierContext}.
|
||||
|
||||
@@ -307,9 +300,6 @@ ${pamyatka}
|
||||
=== РЕЕСТР УЗЛОВ ===
|
||||
${nodesBlock}
|
||||
|
||||
=== РЕЕСТР ЦЕПОЧЕК (справочно) ===
|
||||
${chainsBlock}
|
||||
|
||||
Пример формата вывода (значения иллюстративные, не подсказка по содержанию):
|
||||
{"task_type":"bugfix","skill":"#18","chain":null,"no_skill_found":false,"recommended_chain":["#18","#19"],"confidence":0.9,"alternatives_considered":[{"id":"#62","score":0.3,"reason":"не про деньги"}],"reason_for_choice":"keyword 'regex' → systematic-debugging"}
|
||||
|
||||
@@ -451,18 +441,11 @@ export function buildLLMPrompt(prompt, registry) {
|
||||
return `- ${n.id} ${n.name} [${triggers}]`;
|
||||
}).join('\n');
|
||||
|
||||
const chains = Object.entries(registry.chains || {})
|
||||
.map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`)
|
||||
.join('\n');
|
||||
|
||||
return `${LEGACY_LLM_SYSTEM_PROMPT}
|
||||
|
||||
## Available nodes
|
||||
${nodeLines}
|
||||
|
||||
## Available chains
|
||||
${chains}
|
||||
|
||||
## User prompt
|
||||
${prompt}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stop hook addition — router chain progress tracking.
|
||||
* Stage 3 of router discipline overhaul.
|
||||
*
|
||||
* После каждого хода: обновляет state.chainProgress на основе вызванных в этом ходу скилов.
|
||||
* Helper для observer-stop-hook — он сам решает, вызывать ли (зависит от того, есть ли router-state).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
|
||||
|
||||
export function extractSkillInvocations(events) {
|
||||
return (events || [])
|
||||
.filter((e) => e && e.tool_name === 'Skill')
|
||||
.map((e) => {
|
||||
const raw = e.tool_input?.skill || '';
|
||||
const stripped = raw.includes(':') ? raw.split(':').pop() : raw;
|
||||
return stripped;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function updateChainProgress(state, skillsInvoked, chains) {
|
||||
const chainId = state.classification?.recommendedChain;
|
||||
if (!chainId || !chains[chainId]) return { ...state };
|
||||
|
||||
const sequence = chains[chainId].sequence || [];
|
||||
const currentProgress = [...(state.chainProgress || [])];
|
||||
|
||||
for (const skill of skillsInvoked) {
|
||||
const nextExpectedIdx = currentProgress.length;
|
||||
if (nextExpectedIdx >= sequence.length) break;
|
||||
if (sequence[nextExpectedIdx] === skill) {
|
||||
currentProgress.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
chainProgress: currentProgress,
|
||||
chainCompleted: currentProgress.length === sequence.length && sequence.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = await readStdinAsUtf8(process.stdin);
|
||||
const event = JSON.parse(input || '{}');
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const events = event.turn_events || [];
|
||||
|
||||
const statePath = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
|
||||
if (!existsSync(statePath)) { process.stdout.write('{}'); process.exit(0); return; }
|
||||
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
|
||||
const { loadRegistry } = await import('./registry-load.mjs');
|
||||
const registry = loadRegistry({ useCache: false });
|
||||
const skills = extractSkillInvocations(events);
|
||||
const updated = updateChainProgress(state, skills, registry.chains || {});
|
||||
updated.skillInvokedThisTurn = skills.length > 0;
|
||||
writeFileSync(statePath, JSON.stringify(updated, null, 2));
|
||||
process.stdout.write('{}');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.stderr.write(`[router-stop-gate] ${err.message}\n`);
|
||||
process.stdout.write('{}');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI guard — Windows-cyrillic path quirk: compare resolved path, not raw argv[1]
|
||||
const isMain = (() => {
|
||||
try {
|
||||
return process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === fileURLToPath(`file:///${process.argv[1].replace(/\\/g, '/')}`);
|
||||
} catch {
|
||||
return process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
main();
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { updateChainProgress, extractSkillInvocations } from './router-stop-gate.mjs';
|
||||
|
||||
const chains = {
|
||||
L1: { name: 'brainstorming chain', sequence: ['brainstorming', 'writing-plans', 'executing-plans'] },
|
||||
L13: { name: 'finance chain', sequence: ['billing-audit', 'pest', 'ru-tax-accounting'] },
|
||||
};
|
||||
|
||||
describe('extractSkillInvocations', () => {
|
||||
it('extracts skill names from Skill tool invocations', () => {
|
||||
const events = [
|
||||
{ tool_name: 'Skill', tool_input: { skill: 'brainstorming' } },
|
||||
{ tool_name: 'Edit', tool_input: {} },
|
||||
{ tool_name: 'Skill', tool_input: { skill: 'writing-plans' } },
|
||||
];
|
||||
expect(extractSkillInvocations(events)).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
|
||||
it('returns empty when no Skill invocations', () => {
|
||||
expect(extractSkillInvocations([{ tool_name: 'Edit' }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips namespace prefix (superpowers:brainstorming → brainstorming)', () => {
|
||||
const events = [{ tool_name: 'Skill', tool_input: { skill: 'superpowers:brainstorming' } }];
|
||||
expect(extractSkillInvocations(events)).toEqual(['brainstorming']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChainProgress', () => {
|
||||
it('appends matched chain step to chainProgress', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
|
||||
const updated = updateChainProgress(state, ['brainstorming'], chains);
|
||||
expect(updated.chainProgress).toEqual(['brainstorming']);
|
||||
});
|
||||
|
||||
it('appends multiple steps if multiple skills invoked', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
|
||||
const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
|
||||
expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
|
||||
it('ignores skills not in chain sequence', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
|
||||
const updated = updateChainProgress(state, ['random-skill'], chains);
|
||||
expect(updated.chainProgress).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks chainCompleted=true when last step reached', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming', 'writing-plans'] };
|
||||
const updated = updateChainProgress(state, ['executing-plans'], chains);
|
||||
expect(updated.chainCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves existing chainProgress without duplicates', () => {
|
||||
const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming'] };
|
||||
const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
|
||||
expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
||||
it('module loads with UTF-8 helper wired (smoke)', async () => {
|
||||
const mod = await import('./router-stop-gate.mjs');
|
||||
expect(typeof mod.updateChainProgress).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -447,7 +447,6 @@ ${qualityRows}
|
||||
|
||||
export function renderStatus(inputs) {
|
||||
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo, discipline } = inputs;
|
||||
const c6 = inputs.c6 || { status: 'ok', detail: '—' };
|
||||
const missed = inputs.missed || { totalMissed: 0, byNode: {}, byClassification: {} };
|
||||
|
||||
function formatPercent(p) { return `${(p * 100).toFixed(1)}%`; }
|
||||
@@ -497,7 +496,6 @@ Last updated: ${now}
|
||||
| C3 Observer-of-observer | ${iconFor(c3.status)} | ${c3.detail || '—'} |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ${iconFor(c5.status)} | ${c5.detail || '—'} |
|
||||
| C6 Chain map sync | ${iconFor(c6.status)} | ${c6.detail || '—'} |
|
||||
${inputs.guardBoardBlock ? `\n${inputs.guardBoardBlock.trim()}\n` : ''}
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
@@ -611,7 +609,6 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-
|
||||
c2: runControllerNode(['tools/cross-ref-checker.mjs']),
|
||||
c3: runControllerNode(['tools/observer-of-observer.mjs', 'check']),
|
||||
c5: { status: c5ok ? 'ok' : 'warn', detail: c5detail },
|
||||
c6: runControllerNode(['tools/observer-chain-map-checker.mjs']),
|
||||
observer: {
|
||||
episodeCount: countEpisodes(),
|
||||
observerErrors: countObserverErrors(),
|
||||
|
||||
@@ -43,7 +43,6 @@ const baseInputs = (overrides = {}) => ({
|
||||
c2: { status: 'ok', detail: '0 version drift' },
|
||||
c3: { status: 'ok', detail: 'last read today' },
|
||||
c5: { status: 'ok', detail: 'coverage OK · registration OK' },
|
||||
c6: { status: 'ok', detail: '14 chains in sync' },
|
||||
observer: { episodeCount: 12, observerErrors: 0, piiMatches: 0 },
|
||||
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
|
||||
...overrides,
|
||||
@@ -61,11 +60,6 @@ describe('renderStatus', () => {
|
||||
expect(md).toContain('12 episodes');
|
||||
});
|
||||
|
||||
it('includes a C6 chain-map row', () => {
|
||||
const md = renderStatus(baseInputs());
|
||||
expect(md).toContain('| C6 Chain map sync | ✅');
|
||||
});
|
||||
|
||||
it('shows a warn status for the coverage controller', () => {
|
||||
const md = renderStatus(baseInputs({ c5: { status: 'warn', detail: '3 commits, 0 episodes' } }));
|
||||
expect(md).toContain('| C5 Observer-coverage | ⚠️');
|
||||
|
||||
Reference in New Issue
Block a user