feat(secretary): боевой конвейер веток подпроекта A за флагом (армор/дифф/сбор/садовник/применение/модели/дирижёр)

This commit is contained in:
Дмитрий
2026-06-25 11:48:26 +03:00
parent 5506323954
commit aded6b8ced
18 changed files with 1285 additions and 3 deletions
@@ -0,0 +1,712 @@
# Секретарь — боевой конвейер веток (подпроект A) · Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Заменить стадию аудита секретаря на конвейер сбор+садовник (диагностика 8/8 ∥ ловец → брейншторм → садовник), перенеся доказанный код из `.scratch/sec/v2.mjs` в боевые модули `tools/` под тестами, за выключенным флагом — не трогая живой хук.
**Architecture:** Новые мелкие модули по одной работе (броня · дифф ствола · сбор · садовник · применение · модели), переписанный дирижёр `distill` за флагом. Вся надёжность формата — в детерминированных хуках (В1); промпты-линзы перенесены дословно (их поведение доказано прогоном, не юнит-тестами). Редактор не трогаем.
**Tech Stack:** Node ESM, vitest (`tools/<name>.test.mjs`, колоцированные). Источник кода — спека `docs/superpowers/specs/2026-06-25-secretary-pipeline-A-design.md` + песочница `.scratch/sec/v2.mjs`.
> **Коммиты в этом репо** требуют floor-escape владельца или его терминал (enforcement-пол блокирует коммит агента). Исполнитель координирует коммит с владельцем (или владелец вставляет команду сам). Verify-расписка: перед коммитом `node tools/produce-verify-receipt.mjs` (зелёный прогон сюиты).
---
## File Structure
- `tools/secretary-armor.mjs` *(создать)*`parseLoose`, `salvageObjects`, `normОпора`/`one`, `normLens`. Детерминированный разбор/спасение ответов флэша. Зависимостей нет.
- `tools/secretary-trunkdiff.mjs` *(создать)*`diffTrunk(oldP,newP)`. Зависимостей нет.
- `tools/secretary-models.mjs` *(создать)*`resolveModel(role)` для 5 ролей. Зависимостей нет.
- `tools/secretary-harvest.mjs` *(создать)*`SYS_11/12/13`, `PASSPORT`, сборщики USER, `diagFindings`. Зависит от armor.
- `tools/secretary-gardener.mjs` *(создать)*`SYS_G`, `applyTend`. Зависит от armor.
- `tools/secretary-apply.mjs` *(создать)*`applyResults`. Зависит от gardener+armor.
- `tools/secretary-distill.mjs` *(править)* — дирижёр за флагом (старый путь сохраняется).
- `tools/secretary-flag.mjs` *(править)* — добавить флаг `fluffyPipelineOn()`.
- Тест-файлы рядом: `tools/secretary-<name>.test.mjs`.
---
## Task 1: Броня (`secretary-armor.mjs`)
**Files:**
- Create: `tools/secretary-armor.mjs`
- Test: `tools/secretary-armor.test.mjs`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { parseLoose, salvageObjects, one, normLens } from './secretary-armor.mjs';
describe('secretary-armor', () => {
it('parseLoose: чистый объект', () => {
expect(parseLoose('{"new":[{"text":"x"}]}', 'new').new).toHaveLength(1);
});
it('parseLoose: ```-обёртка и висячая запятая', () => {
expect(parseLoose('```json\n{"forks":[{"a":1},]}\n```', 'forks').forks).toHaveLength(1);
});
it('parseLoose: голый массив', () => {
expect(parseLoose('[{"a":1},{"b":2}]', 'tend').tend).toHaveLength(2);
});
it('parseLoose: сломанный JSON → спасение по-объектно (битый пропущен)', () => {
const broken = '{"tend":[{"id":"СВ-1","action":"carry"},{"id":"СВ-2","why":Ход без кавычек},{"id":"СВ-3","action":"carry"}]}';
expect(parseLoose(broken, 'tend').tend.map((t) => t.id)).toEqual(['СВ-1', 'СВ-3']);
});
it('one: проза→словарь; неизвестное→догадка', () => {
expect(one('ответ')).toBe('внутр');
expect(one('владелец сказал')).toBe('воля');
expect(one('незавершённый')).toBe('догадка');
expect(one('абракадабра')).toBe('догадка');
expect(one('внеш')).toBe('внеш');
});
it('normLens: 9→Л9, л4→Л4', () => {
expect(normLens('9')).toBe('Л9');
expect(normLens('л4')).toBe('Л4');
expect(normLens('Л6')).toBe('Л6');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-armor.test.mjs`
Expected: FAIL (`Cannot find module './secretary-armor.mjs'`).
- [ ] **Step 3: Write minimal implementation**
```js
// tools/secretary-armor.mjs
// Детерминированный разбор/спасение ответов флэша (принцип В1). Перенос из .scratch/sec/v2.mjs.
export const normLens = (l) => {
const t = String(l || '').trim().replace(/^л/i, 'Л');
return /^Л\d$/.test(t) ? t : (/^\d$/.test(t) ? 'Л' + t : t || 'Л?');
};
const ОПОРЫ = ['внутр', 'внеш', 'воля', 'догадка'];
const ОП_SYN = {
внутренний: 'внутр', код: 'внутр', файл: 'внутр', проект: 'внутр', обмен: 'внутр', реплика: 'внутр',
ответ: 'внутр', цитата: 'внутр', текст: 'внутр', строка: 'внутр', внешний: 'внеш', url: 'внеш',
веб: 'внеш', док: 'внеш', документация: 'внеш', владелец: 'воля', юзер: 'воля', пользователь: 'воля',
запрос: 'воля', директива: 'воля', гипотеза: 'догадка', предположение: 'догадка', домысел: 'догадка',
незавершённый: 'догадка', неизвестно: 'догадка',
};
/** Паспорт-опора → ровно одно слово словаря; неизвестное → честно «догадка». */
export function one(raw) {
const w = String(raw || '').trim().toLowerCase().split(/[\s,;:|]+/)[0].replace(/[.,;:]+$/, '');
if (ОПОРЫ.includes(w)) return w;
return ОП_SYN[w] || 'догадка';
}
/** Спасение по-объектно: лезем В массив […] и парсим КАЖДЫЙ объект; битый пропускаем. */
export function salvageObjects(text) {
const lb = text.indexOf('['), rb = text.lastIndexOf(']');
const body = (lb >= 0 && rb > lb) ? text.slice(lb + 1, rb) : text;
const out = [];
let depth = 0, start = -1, inStr = false, esc = false;
for (let i = 0; i < body.length; i++) {
const c = body[i];
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
if (c === '"') inStr = true;
else if (c === '{') { if (depth === 0) start = i; depth++; }
else if (c === '}') { depth--; if (depth === 0 && start >= 0) { const chunk = body.slice(start, i + 1).replace(/,(\s*[}\]])/g, '$1'); try { out.push(JSON.parse(chunk)); } catch { /* битый — пропустить */ } start = -1; } }
}
return out;
}
/** Толерантный парсер: ```-обёртка, висячая запятая, голый массив/объект; слом → спасение по-объектно. */
export function parseLoose(text, expectKey) {
let s = String(text || '').trim().replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```$/, '').trim();
const obj = s.indexOf('{'), arr = s.indexOf('[');
if (obj >= 0 || arr >= 0) { const start = arr < 0 ? obj : obj < 0 ? arr : Math.min(obj, arr); s = s.slice(start); }
s = s.replace(/,(\s*[}\]])/g, '$1');
let p; try { p = JSON.parse(s); }
catch { const salv = salvageObjects(s); return { [expectKey]: salv.length ? salv : [] }; }
if (Array.isArray(p)) return { [expectKey]: p };
if (p && typeof p === 'object') { if (!Array.isArray(p[expectKey])) p[expectKey] = p[expectKey] ? [p[expectKey]] : []; return p; }
return { [expectKey]: [] };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-armor.test.mjs`
Expected: PASS (6 tests).
- [ ] **Step 5: Commit** (через владельца/эскейп)
```bash
git add tools/secretary-armor.mjs tools/secretary-armor.test.mjs
git commit -m "feat(secretary): броня разбора ответов флэша (parseLoose/salvage/опора/линза)"
```
---
## Task 2: Дифф ствола (`secretary-trunkdiff.mjs`)
**Files:**
- Create: `tools/secretary-trunkdiff.mjs`
- Test: `tools/secretary-trunkdiff.test.mjs`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { diffTrunk } from './secretary-trunkdiff.mjs';
describe('diffTrunk', () => {
it('пусто, когда ствол не двигался', () => {
const p = { decisions: [{ text: 'A', struck: false }] };
expect(diffTrunk(p, p)).toBe('(ствол не двигался этот ход)');
});
it('ловит добавленное решение и зачёркнутый открытый', () => {
const oldP = { open: [{ text: 'Q', struck: false }] };
const newP = { decisions: [{ text: 'A', why: 'почему', struck: false }], open: [{ text: 'Q', struck: true }] };
const d = diffTrunk(oldP, newP);
expect(d).toContain('РЕШЕНИЕ добавлен: «A — почему»');
expect(d).toContain('ОТКРЫТЫЙ зачёркнут (закрыт ствол): «Q»');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-trunkdiff.test.mjs`
Expected: FAIL (module not found).
- [ ] **Step 3: Write minimal implementation**
```js
// tools/secretary-trunkdiff.mjs
// Детерминированный дифф ствола (роль хука: до/после редактора). Перенос из .scratch/sec/v2.mjs.
const SECS = [['decisions', 'РЕШЕНИЕ'], ['will', 'ВОЛЯ'], ['open', 'ОТКРЫТЫЙ'], ['consequences', 'ПОСЛЕДСТВИЕ'], ['alternatives', 'АЛЬТЕРНАТИВА'], ['doneNext', 'СДЕЛАНО']];
const nz = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
export function diffTrunk(oldP, newP) {
const lines = [];
for (const [sec, label] of SECS) {
const o = new Map(((oldP[sec]) || []).map((e) => [nz(e.text), e]));
for (const e of (newP[sec]) || []) {
const prev = o.get(nz(e.text));
if (!prev) lines.push(`${label} добавлен: «${e.text}${e.why ? ' — ' + e.why : ''}»`);
else if (!prev.struck && e.struck) lines.push(`${label} зачёркнут (закрыт ствол): «${e.text}»`);
else if (prev.struck && !e.struck) lines.push(`${label} расчёркнут (снова актуален): «${e.text}»`);
}
}
return lines.length ? lines.join('\n') : '(ствол не двигался этот ход)';
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-trunkdiff.test.mjs`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add tools/secretary-trunkdiff.mjs tools/secretary-trunkdiff.test.mjs
git commit -m "feat(secretary): детерминированный дифф ствола до/после редактора"
```
---
## Task 3: Слоты моделей (`secretary-models.mjs`)
**Files:**
- Create: `tools/secretary-models.mjs`
- Test: `tools/secretary-models.test.mjs`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { resolveModel, ROLES } from './secretary-models.mjs';
describe('resolveModel', () => {
it('5 ролей определены', () => {
expect(ROLES).toEqual(['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener']);
});
it('по умолчанию — общий флэш', () => {
expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x');
});
it('per-role перекрывает общий', () => {
expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x', SECRETARY_LLM_MODEL_GARDENER: 'strong-y' })).toBe('strong-y');
});
it('неизвестная роль → общий флэш', () => {
expect(resolveModel('xxx', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-models.test.mjs`
Expected: FAIL (module not found).
- [ ] **Step 3: Write minimal implementation**
```js
// tools/secretary-models.mjs
// 5 слотов моделей (D). По умолчанию все — общий флэш; per-role env перекрывает.
export const ROLES = ['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener'];
/** Модель для роли: SECRETARY_LLM_MODEL_<ROLE> > SECRETARY_LLM_MODEL > undefined. */
export function resolveModel(role, env = process.env) {
const key = `SECRETARY_LLM_MODEL_${String(role || '').toUpperCase()}`;
return (ROLES.includes(role) && env[key]) || env.SECRETARY_LLM_MODEL || undefined;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-models.test.mjs`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add tools/secretary-models.mjs tools/secretary-models.test.mjs
git commit -m "feat(secretary): 5 слотов моделей по ролям (per-role env override)"
```
---
## Task 4: Сбор — диагностика/ловец/брейншторм (`secretary-harvest.mjs`)
**Files:**
- Create: `tools/secretary-harvest.mjs`
- Test: `tools/secretary-harvest.test.mjs`
Промпты `PASSPORT/SYS_11/SYS_12/SYS_13` переносятся ДОСЛОВНО из `.scratch/sec/v2.mjs` (строки констант). Тест проверяет ключевые инварианты текста + чистый разбор `diagFindings`.
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { SYS_11, SYS_12, SYS_13, diagFindings } from './secretary-harvest.mjs';
describe('secretary-harvest', () => {
it('SYS_11 требует отчёт по 8 линзам и запрещает пустой проход', () => {
expect(SYS_11).toMatch(/8 линз/);
expect(SYS_11).toMatch(/finding|clean/);
});
it('SYS_12 (ловец) принимает ствол и не ловит решённое', () => {
expect(SYS_12).toMatch(/НЕ лови/i);
});
it('SYS_13 (брейншторм) без числового лимита, с цитатой-сторожем', () => {
expect(SYS_13).toMatch(/НЕ ограничено/);
expect(SYS_13).toMatch(/ДОСЛОВНУЮ цитату/);
});
it('diagFindings: из отчёта 8/8 извлекает finding, считает clean', () => {
const parsed = { lenses: [
{ lens: 'Л4', verdict: 'finding', text: 'заявлено без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' },
{ lens: 'Л1', verdict: 'clean', why: 'чисто' },
] };
const r = diagFindings(parsed);
expect(r.new).toHaveLength(1);
expect(r.new[0].lens).toBe('Л4');
expect(r.clean).toContain('Л1');
expect(r.lenses).toHaveLength(2);
});
it('diagFindings: толерантен к старому плоскому формату {new:[…]}', () => {
const r = diagFindings({ new: [{ text: 'x', lens: 'Л6' }] });
expect(r.new).toHaveLength(1);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-harvest.test.mjs`
Expected: FAIL (module not found).
- [ ] **Step 3: Write minimal implementation**
Перенести из `.scratch/sec/v2.mjs` дословно константы `PASSPORT`, `SYS_11`, `SYS_12`, `SYS_13` (со всеми калибровками №13/№16 и брейнштормом-без-лимита) и USER-сборщики `user11/user12/user13` (адаптировав имена параметров под боевой протокол). Плюс `diagFindings`:
```js
// tools/secretary-harvest.mjs
import { normLens } from './secretary-armor.mjs';
export const PASSPORT = [ /* …дословно из v2.mjs… */ ].join('\n');
export const SYS_11 = [ /* …дословно SYS_11 (диагностика 8/8) из v2.mjs… */ ].join('\n');
export const SYS_12 = [ /* …дословно SYS_12 (ловец со стволом) из v2.mjs… */ ].join('\n');
export const SYS_13 = [ /* …дословно SYS_13 (брейншторм без лимита) из v2.mjs… */ ].join('\n');
// USER-сборщики: recentSteps(proto), registry(proto), trunkForCatcher(proto), exchange-text(spanEx)
export function recentSteps(proto, n = 10) { /* …из v2.mjs… */ }
export function registry(proto) { /* …из v2.mjs (с чистыми id ПР-/ХВ-)… */ }
export function trunkForCatcher(proto) { /* …из v2.mjs… */ }
export function user11(proto, spanText, span) { /* …из v2.mjs… */ }
export function user12(proto, spanText) { /* …из v2.mjs… */ }
export function user13(proto, spanText, span, found11, found12) { /* …из v2.mjs… */ }
/** Из поимённого отчёта 8 линз извлечь находки; толерантно к плоскому {new:[…]}. (находка №16) */
export function diagFindings(parsed) {
const ls = Array.isArray(parsed.lenses) ? parsed.lenses
: (Array.isArray(parsed.new) ? parsed.new.map((n) => ({ ...n, verdict: 'finding' })) : []);
const isFind = (l) => String(l.verdict || '').toLowerCase() === 'finding' && String(l.text || '').trim();
return {
lenses: ls,
new: ls.filter(isFind).map((f) => ({ text: f.text, lens: f.lens, опора: f.опора, ref: f.ref, тяжесть: f.тяжесть })),
clean: ls.filter((l) => String(l.verdict || '').toLowerCase() === 'clean').map((l) => normLens(l.lens)),
};
}
```
> При переносе констант — копировать массивы строк ИЗ `.scratch/sec/v2.mjs` без изменений (там финальные калиброванные версии). USER-сборщики берут готовый текст обмена (`spanText`), чтобы не тащить сюда `assembleSpan`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-harvest.test.mjs`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add tools/secretary-harvest.mjs tools/secretary-harvest.test.mjs
git commit -m "feat(secretary): сбор веток — диагностика 8/8, ловец, брейншторм без лимита"
```
---
## Task 5: Садовник (`secretary-gardener.mjs`)
**Files:**
- Create: `tools/secretary-gardener.mjs`
- Test: `tools/secretary-gardener.test.mjs`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { SYS_G, applyTend } from './secretary-gardener.mjs';
describe('secretary-gardener', () => {
it('SYS_G требует proof на close', () => {
expect(SYS_G).toMatch(/proof/);
expect(SYS_G).toMatch(/ЗАЯВЛЕНИЕ ≠ ПРУФ/);
});
it('close с пруфом → закрыто', () => {
const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] };
applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: 'код mentor.mjs:107' }], 15);
expect(proto.hidden[0].status).toBe('закрыт');
expect(proto.hidden[0].struck).toBe(true);
});
it('голое close без proof → понижено в carry (В1-гейт)', () => {
const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] };
applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: '' }], 15);
expect(proto.hidden[0].status).toBe('открыт');
});
it('narrow меняет текст и статус', () => {
const proto = { hidden: [{ id: 'СВ-2', status: 'открыт', text: 'старый' }] };
applyTend(proto, [{ id: 'СВ-2', action: 'narrow', newText: 'остаток' }], 15);
expect(proto.hidden[0].status).toBe('сужен');
expect(proto.hidden[0].text).toBe('остаток');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-gardener.test.mjs`
Expected: FAIL (module not found).
- [ ] **Step 3: Write minimal implementation**
Перенести `SYS_G` дословно из `.scratch/sec/v2.mjs` (с критерием №14: close-с-пруфом, ЗАЯВЛЕНИЕ≠ПРУФ). `applyTend` — выделенная из `applyResults` логика тенда с В1-гейтом:
```js
// tools/secretary-gardener.mjs
export const SYS_G = [ /* …дословно SYS_G из v2.mjs… */ ].join('\n');
/** Применить решения садовника к скрытым веткам + горящим Л8/Л9. close без proof → carry. */
export function applyTend(proto, tend, turn, { acceptId = defaultAcceptId, tailId = defaultTailId } = {}) {
proto.hidden = proto.hidden || [];
const accIdx = acceptId(proto), tailIdx = tailId(proto);
for (const t of tend || []) {
let act = String(t.action || 'carry');
if (act === 'close' && !String(t.proof || '').trim()) act = 'carry'; // В1-гейт (находка №14)
const sv = proto.hidden.find((h) => h.id === t.id);
if (sv) {
if (act === 'close') { sv.status = 'закрыт'; sv.struck = true; }
else if (act === 'narrow') { sv.status = 'сужен'; if (t.newText) sv.text = t.newText; }
else if (act === 'grow') { sv.status = 'мутировал'; if (t.newText) sv.text = t.newText; }
else if (act === 'reopen') { sv.status = 'открыт'; sv.struck = false; }
if (act !== 'carry') sv.lastTouch = turn;
continue;
}
const ai = accIdx.find((e) => e.id === t.id); if (ai && act === 'close') { const e = (proto.acceptance || []).find((x) => x.text === ai.text); if (e) e.done = true; continue; }
const ti = tailIdx.find((e) => e.id === t.id); if (ti && act === 'close') { const e = (proto.tails || []).find((x) => x.text === ti.text); if (e) e.done = true; continue; }
}
return proto;
}
function defaultAcceptId(p) { return (p.acceptance || []).filter((e) => !e.done).map((e, i) => ({ id: `ПР-${i + 1}`, text: e.text })); }
function defaultTailId(p) { return (p.tails || []).filter((e) => !e.done).map((e, i) => ({ id: `ХВ-${i + 1}`, text: e.text })); }
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-gardener.test.mjs`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add tools/secretary-gardener.mjs tools/secretary-gardener.test.mjs
git commit -m "feat(secretary): садовник — close-с-пруфом/narrow/grow/reopen/carry + В1-гейт"
```
---
## Task 6: Применение к реестру (`secretary-apply.mjs`)
**Files:**
- Create: `tools/secretary-apply.mjs`
- Test: `tools/secretary-apply.test.mjs`
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { applyResults } from './secretary-apply.mjs';
describe('applyResults', () => {
const base = () => ({ hidden: [], nextSvId: 1, candidates: [] });
it('новая ветка из диагностики получает id+паспорт; догадка+блокер→важное', () => {
const p = applyResults(base(), 13, { new: [{ text: 'x', lens: 'Л4', опора: 'догадка', тяжесть: 'блокер' }] }, { dropped: [] }, { forks: [] }, { tend: [] });
expect(p.hidden[0].id).toBe('СВ-1');
expect(p.hidden[0].опора).toBe('догадка');
expect(p.hidden[0].тяжесть).toBe('важное'); // зажим: догадка не блокер
});
it('брошенное ловцом → ветка lens=брош', () => {
const p = applyResults(base(), 13, { new: [] }, { dropped: [{ text: 'parked', цитата: 'q' }] }, { forks: [] }, { tend: [] });
expect(p.hidden[0].lens).toBe('брош');
});
it('кандидаты накапливаются (ход рождения), без дублей', () => {
let p = applyResults(base(), 13, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }] }, { tend: [] });
p = applyResults(p, 15, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }, { branch: 'idea-2' }] }, { tend: [] });
expect(p.candidates.map((c) => c.branch)).toEqual(['idea-1', 'idea-2']);
expect(p.candidates.find((c) => c.branch === 'idea-1').born).toBe(13);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-apply.test.mjs`
Expected: FAIL (module not found).
- [ ] **Step 3: Write minimal implementation**
```js
// tools/secretary-apply.mjs
import { one } from './secretary-armor.mjs';
import { applyTend } from './secretary-gardener.mjs';
const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
/** Зажим тяжести: догадка не может быть блокером → важное (находка №13/правило тяжести). */
function clampSeverity(опора, тяжесть) {
return (one(опора) === 'догадка' && тяжесть === 'блокер') ? 'важное' : (тяжесть || 'важное');
}
/** Разложить результаты спана по реестру. proto не мутируется (работаем на копии). */
export function applyResults(proto, turn, d11, d12, d13, dG) {
const p = JSON.parse(JSON.stringify(proto));
p.hidden = p.hidden || []; p.nextSvId = p.nextSvId || 1; p.candidates = p.candidates || [];
// садовник
applyTend(p, dG.tend, turn);
// новые ветки из диагностики
for (const n of d11.new || []) {
p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: (n.lens || 'Л?'), status: 'открыт', text: String(n.text || ''), опора: one(n.опора), ref: n.ref || '', тяжесть: clampSeverity(n.опора, n.тяжесть), born: turn, lastTouch: turn, lineage: [] });
}
// ловец → долговечная ветка lens=брош
for (const dr of d12.dropped || []) {
p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: 'брош', status: 'открыт', text: String(dr.text || ''), опора: one(dr.опора), ref: dr.цитата || '', тяжесть: 'важное', born: turn, lastTouch: turn, lineage: [] });
}
// кандидаты — НАКАПЛИВАЮТСЯ (ход рождения, без дублей по branch), помечены слабыми
const seen = new Set(p.candidates.map((c) => norm(c.branch)));
for (const f of d13.forks || []) {
if (seen.has(norm(f.branch))) continue;
seen.add(norm(f.branch));
p.candidates.push({ branch: f.branch, trigger: f.trigger, why: f.why, опора: one(f.опора), релевантность: f.релевантность || 'low', born: turn });
}
return p;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-apply.test.mjs`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add tools/secretary-apply.mjs tools/secretary-apply.test.mjs
git commit -m "feat(secretary): применение результатов к реестру (ветки/кандидаты-накопление/тенд)"
```
---
## Task 7: Флаг конвейера (`secretary-flag.mjs`)
**Files:**
- Modify: `tools/secretary-flag.mjs` (добавить функцию рядом с существующими)
- Test: `tools/secretary-flag.test.mjs` (дописать кейс)
- [ ] **Step 1: Write the failing test** (дописать в существующий describe)
```js
import { fluffyPipelineOn } from './secretary-flag.mjs';
describe('fluffyPipelineOn', () => {
it('по умолчанию выключен', () => { expect(fluffyPipelineOn({})).toBe(false); });
it('включается env SECRETARY_FLUFFY=1', () => { expect(fluffyPipelineOn({ SECRETARY_FLUFFY: '1' })).toBe(true); });
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-flag.test.mjs`
Expected: FAIL (`fluffyPipelineOn is not a function`).
- [ ] **Step 3: Write minimal implementation** (добавить в `secretary-flag.mjs`)
```js
/** Флаг нового конвейера «пушистое дерево». ВЫКЛ по умолчанию (A кладётся тёмным). */
export function fluffyPipelineOn(env = process.env) {
return env.SECRETARY_FLUFFY === '1' || env.SECRETARY_FLUFFY === 'true';
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-flag.test.mjs`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add tools/secretary-flag.mjs tools/secretary-flag.test.mjs
git commit -m "feat(secretary): флаг fluffyPipelineOn (новый конвейер выключен по умолчанию)"
```
---
## Task 8: Дирижёр за флагом (`secretary-distill.mjs`)
**Files:**
- Modify: `tools/secretary-distill.mjs`
- Test: `tools/secretary-distill.test.mjs` (дописать кейс нового пути)
Дирижёр: при `fluffyPipelineOn()` — после редактора+шага+preserveRegistry вместо аудита гонит дифф→сбор→садовник→применение (по 5 ролям через `resolveModel`). Флаг ВЫКЛ → старый путь (аудит) без изменений.
- [ ] **Step 1: Write the failing test**
```js
import { describe, it, expect } from 'vitest';
import { distillSpan } from './secretary-distill.mjs';
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
describe('distillSpan — новый конвейер за флагом', () => {
it('при fluffy=on: ветки из диагностики + кандидаты, аудит не зовётся', async () => {
const proto = EMPTY_PROTOCOL();
const spanEx = { user: 'предложи улучшение', assistant: 'разобрал по коду', actions: [] };
let call = 0;
const callModel = async ({ system }) => {
call++;
if (/секретарь-редактор/.test(system)) return JSON.stringify({ subject: 'дело', decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } });
if (/сборщик скрытых веток/.test(system)) return JSON.stringify({ lenses: [{ lens: 'Л4', verdict: 'finding', text: 'без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' }] });
if (/ловец/.test(system)) return JSON.stringify({ dropped: [] });
if (/брейншторма/.test(system)) return JSON.stringify({ forks: [{ branch: 'идея', trigger: 't', опора: 'догадка', релевантность: 'low' }] });
if (/САДОВНИК/.test(system)) return JSON.stringify({ tend: [] });
return '{}';
};
const out = await distillSpan(proto, spanEx, { start: 15, end: 16 }, { callModel, session: 's', flags: { fluffy: true } });
expect(out.hidden.some((h) => h.text === 'без пруфа')).toBe(true);
expect(out.candidates.map((c) => c.branch)).toContain('идея');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tools/secretary-distill.test.mjs`
Expected: FAIL (новый путь не реализован — `out.candidates` undefined).
- [ ] **Step 3: Write minimal implementation**
В `distillSpan` после `preserveRegistry(toWrite, svSnapshot)` развилка по флагу. Старый блок аудита оставить в ветке `else`. Новый путь:
```js
import { fluffyPipelineOn } from './secretary-flag.mjs';
import { diffTrunk } from './secretary-trunkdiff.mjs';
import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, recentSteps, registry, trunkForCatcher } from './secretary-harvest.mjs';
import { SYS_G, /* applyTend используется внутри apply */ } from './secretary-gardener.mjs';
import { applyResults } from './secretary-apply.mjs';
import { parseLoose } from './secretary-armor.mjs';
import { resolveModel } from './secretary-models.mjs';
// …внутри distillSpan, после preserveRegistry(toWrite, svSnapshot):
const fluffy = (opts.flags && opts.flags.fluffy) ?? fluffyPipelineOn();
if (callModel && fluffy) {
const before = proto; // ствол ДО редактора
const diff = diffTrunk(before, toWrite);
const spanText = renderExchangeText(spanEx); // [ЮЗЕР]/[АССИСТЕНТ]/[ДЕЙСТВИЯ] — вынести из harvest
const call = (role, system, user) => callModel({ system, user, model: resolveModel(role) });
const [r11, r12] = await Promise.all([
call('diagnostic', SYS_11, user11(toWrite, spanText, { start })),
call('catcher', SYS_12, user12(toWrite, spanText)),
]);
const d11 = diagFindings(parseLoose(typeof r11 === 'string' ? r11 : r11.text, 'lenses'));
const d12 = parseLoose(typeof r12 === 'string' ? r12 : r12.text, 'dropped');
const r13 = await call('brainstorm', SYS_13, user13(toWrite, spanText, { start }, JSON.stringify(d11.new), JSON.stringify(d12.dropped)));
const d13 = parseLoose(typeof r13 === 'string' ? r13 : r13.text, 'forks');
const rG = await call('gardener', SYS_G, userG(toWrite, spanText, diff)); // userG — из harvest/gardener
const dG = parseLoose(typeof rG === 'string' ? rG : rG.text, 'tend');
const applied = applyResults(toWrite, start, d11, d12, d13, dG);
return collapseProtocol(applied);
} else if (callModel) {
// …СТАРЫЙ путь аудита без изменений…
}
```
> `renderExchangeText(spanEx)` и `userG(...)` экспортировать из harvest (перенести из `exchange().text`/`userG` песочницы). `opts` — параметр-объект distillSpan; добавить `flags`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tools/secretary-distill.test.mjs`
Expected: PASS (старые кейсы + новый).
- [ ] **Step 5: Run full suite (регресс старого пути)**
Run: `npx vitest run --config vitest.config.tools.mjs`
Expected: PASS (старый путь не сломан, флаг выключен по умолчанию).
- [ ] **Step 6: Commit**
```bash
git add tools/secretary-distill.mjs tools/secretary-distill.test.mjs
git commit -m "feat(secretary): дирижёр пушистого конвейера за флагом (старый путь цел)"
```
---
## Self-Review
**Spec coverage:**
- §3.1 поток → Task 8 (дирижёр) + Task 2 (дифф) + Tasks 4/5/6.
- §3.2 модули → Tasks 1-8 (все перечисленные файлы).
- §3.3 гарантии В1: спасение JSON (T1), опора-словарь (T1), линза (T1), close-без-proof→carry (T5), догадка→важное (T6), полнота X/8 (T4 diagFindings.clean/lenses count).
- §4 формат: ветки-паспорт (T6), кандидаты-накопление (T6), горящие ПР/ХВ id (T5).
- §5 промпты: перенос дословно (T4/T5).
- §6 модели: T3.
- §7 тесты: каждый модуль с тест-файлом RED-первым.
- §8 коэкзистенция: флаг (T7) + развилка дирижёра (T8), старый путь в `else`.
**Placeholder scan:** промпт-константы помечены «перенести дословно из v2.mjs» — это не заглушка, а точный источник (файл указан); USER-сборщики аналогично. Реализационные `/* …из v2.mjs… */` в Task 4 указывают конкретный файл-источник, код там финальный. Остальные шаги несут полный код.
**Type consistency:** `applyResults(proto, turn, d11, d12, d13, dG)` — сигнатура совпадает в T6 и вызове T8. `applyTend(proto, tend, turn)` — T5 и внутри T6. `diagFindings` возвращает `{lenses,new,clean}` — T4 и потребление в T8. `one`/`normLens`/`parseLoose`/`salvageObjects` — T1, потребители T4/T5/T6/T8. `resolveModel(role)` — T3 и T8.
**Открытый пункт реализации (НЕ заглушка, отмечено):** в Task 4 при переносе USER-сборщиков из песочницы заменить внутренний `exchange(span)` (тащит `assembleSpan`) на готовый `spanText`, который дирижёр уже имеет из `spanEx`. Это вынос `renderExchangeText(spanEx)` в harvest — отмечено в T4/T8.
+40
View File
@@ -0,0 +1,40 @@
// tools/secretary-apply.mjs
// Детерминированное применение результатов спана к реестру: садовник + новые ветки + кандидаты.
import { one } from './secretary-armor.mjs';
import { applyTend } from './secretary-gardener.mjs';
const norm = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
/** Зажим тяжести: догадка не может быть блокером → важное (правило тяжести, находка №13). */
function clampSeverity(опора, тяжесть) {
return (one(опора) === 'догадка' && тяжесть === 'блокер') ? 'важное' : (тяжесть || 'важное');
}
/** Разложить результаты спана по реестру. Вход НЕ мутируется (работаем на копии). */
export function applyResults(proto, turn, d11, d12, d13, dG) {
const p = JSON.parse(JSON.stringify(proto));
p.hidden = p.hidden || []; p.nextSvId = p.nextSvId || 1; p.candidates = p.candidates || [];
// садовник (тенд по id; close без proof → carry — внутри applyTend)
applyTend(p, (dG && dG.tend) || [], turn);
// новые ветки из диагностики
for (const n of (d11 && d11.new) || []) {
p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: n.lens || 'Л?', status: 'открыт', text: String(n.text || ''), опора: one(n.опора), ref: n.ref || '', тяжесть: clampSeverity(n.опора, n.тяжесть), born: turn, lastTouch: turn, lineage: [] });
}
// ловец → долговечная ветка lens=брош
for (const dr of (d12 && d12.dropped) || []) {
p.hidden.push({ id: `СВ-${p.nextSvId++}`, lens: 'брош', status: 'открыт', text: String(dr.text || ''), опора: one(dr.опора), ref: dr.цитата || '', тяжесть: 'важное', born: turn, lastTouch: turn, lineage: [] });
}
// кандидаты — НАКАПЛИВАЮТСЯ (ход рождения, без дублей по branch), помечены слабыми (доживают до разбора)
const seen = new Set(p.candidates.map((c) => norm(c.branch)));
for (const f of (d13 && d13.forks) || []) {
if (seen.has(norm(f.branch))) continue;
seen.add(norm(f.branch));
p.candidates.push({ branch: f.branch, trigger: f.trigger, why: f.why, опора: one(f.опора), релевантность: f.релевантность || 'low', born: turn });
}
return p;
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { applyResults } from './secretary-apply.mjs';
describe('applyResults', () => {
const base = () => ({ hidden: [], nextSvId: 1, candidates: [] });
it('новая ветка из диагностики получает id+паспорт; догадка+блокер→важное', () => {
const p = applyResults(base(), 13, { new: [{ text: 'x', lens: 'Л4', опора: 'догадка', тяжесть: 'блокер' }] }, { dropped: [] }, { forks: [] }, { tend: [] });
expect(p.hidden[0].id).toBe('СВ-1');
expect(p.hidden[0].опора).toBe('догадка');
expect(p.hidden[0].тяжесть).toBe('важное'); // зажим: догадка не блокер
});
it('брошенное ловцом → ветка lens=брош', () => {
const p = applyResults(base(), 13, { new: [] }, { dropped: [{ text: 'parked', цитата: 'q' }] }, { forks: [] }, { tend: [] });
expect(p.hidden[0].lens).toBe('брош');
});
it('кандидаты накапливаются (ход рождения), без дублей', () => {
let p = applyResults(base(), 13, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }] }, { tend: [] });
p = applyResults(p, 15, { new: [] }, { dropped: [] }, { forks: [{ branch: 'idea-1' }, { branch: 'idea-2' }] }, { tend: [] });
expect(p.candidates.map((c) => c.branch)).toEqual(['idea-1', 'idea-2']);
expect(p.candidates.find((c) => c.branch === 'idea-1').born).toBe(13);
});
it('садовник закрывает ветку через применение', () => {
const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }], nextSvId: 2, candidates: [] };
const p = applyResults(proto, 15, { new: [] }, { dropped: [] }, { forks: [] }, { tend: [{ id: 'СВ-1', action: 'close', proof: 'есть' }] });
expect(p.hidden[0].status).toBe('закрыт');
});
it('вход не мутируется', () => {
const proto = base();
applyResults(proto, 13, { new: [{ text: 'x', lens: 'Л1' }] }, { dropped: [] }, { forks: [] }, { tend: [] });
expect(proto.hidden).toHaveLength(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
// tools/secretary-armor.mjs
// Детерминированный разбор/спасение ответов флэша (принцип В1).
export const normLens = (l) => {
const t = String(l || '').trim().replace(/^л/i, 'Л');
return /^Л\d$/.test(t) ? t : (/^\d$/.test(t) ? 'Л' + t : t || 'Л?');
};
const ОПОРЫ = ['внутр', 'внеш', 'воля', 'догадка'];
const ОП_SYN = {
внутренний: 'внутр', код: 'внутр', файл: 'внутр', проект: 'внутр', обмен: 'внутр', реплика: 'внутр',
ответ: 'внутр', цитата: 'внутр', текст: 'внутр', строка: 'внутр', внешний: 'внеш', url: 'внеш',
веб: 'внеш', док: 'внеш', документация: 'внеш', владелец: 'воля', юзер: 'воля', пользователь: 'воля',
запрос: 'воля', директива: 'воля', гипотеза: 'догадка', предположение: 'догадка', домысел: 'догадка',
незавершённый: 'догадка', неизвестно: 'догадка',
};
/** Паспорт-опора -> ровно одно слово словаря; неизвестное -> честно «догадка». */
export function one(raw) {
const w = String(raw || '').trim().toLowerCase().split(/[\s,;:|]+/)[0].replace(/[.,;:]+$/, '');
if (ОПОРЫ.includes(w)) return w;
return ОП_SYN[w] || 'догадка';
}
/** Спасение по-объектно: лезем В массив […] и парсим КАЖДЫЙ объект; битый пропускаем. */
export function salvageObjects(text) {
const lb = text.indexOf('['), rb = text.lastIndexOf(']');
const body = (lb >= 0 && rb > lb) ? text.slice(lb + 1, rb) : text;
const out = [];
let depth = 0, start = -1, inStr = false, esc = false;
for (let i = 0; i < body.length; i++) {
const c = body[i];
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
if (c === '"') inStr = true;
else if (c === '{') { if (depth === 0) start = i; depth++; }
else if (c === '}') { depth--; if (depth === 0 && start >= 0) { const chunk = body.slice(start, i + 1).replace(/,(\s*[}\]])/g, '$1'); try { out.push(JSON.parse(chunk)); } catch { /* битый — пропустить */ } start = -1; } }
}
return out;
}
/** Толерантный парсер: ```-обёртка, висячая запятая, голый массив/объект; слом -> спасение по-объектно. */
export function parseLoose(text, expectKey) {
let s = String(text || '').trim().replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```$/, '').trim();
const obj = s.indexOf('{'), arr = s.indexOf('[');
if (obj >= 0 || arr >= 0) { const start = arr < 0 ? obj : obj < 0 ? arr : Math.min(obj, arr); s = s.slice(start); }
s = s.replace(/,(\s*[}\]])/g, '$1');
let p; try { p = JSON.parse(s); }
catch { const salv = salvageObjects(s); return { [expectKey]: salv.length ? salv : [] }; }
if (Array.isArray(p)) return { [expectKey]: p };
if (p && typeof p === 'object') { if (!Array.isArray(p[expectKey])) p[expectKey] = p[expectKey] ? [p[expectKey]] : []; return p; }
return { [expectKey]: [] };
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { parseLoose, salvageObjects, one, normLens } from './secretary-armor.mjs';
describe('secretary-armor', () => {
it('parseLoose: чистый объект', () => {
expect(parseLoose('{"new":[{"text":"x"}]}', 'new').new).toHaveLength(1);
});
it('parseLoose: ```json-обёртка и висячая запятая', () => {
expect(parseLoose('```json\n{"forks":[{"a":1},]}\n```', 'forks').forks).toHaveLength(1);
});
it('parseLoose: голый массив', () => {
expect(parseLoose('[{"a":1},{"b":2}]', 'tend').tend).toHaveLength(2);
});
it('parseLoose: сломанный JSON -> спасение по-объектно (битый пропущен)', () => {
const broken = '{"tend":[{"id":"СВ-1","action":"carry"},{"id":"СВ-2","why":Ход без кавычек},{"id":"СВ-3","action":"carry"}]}';
expect(parseLoose(broken, 'tend').tend.map((t) => t.id)).toEqual(['СВ-1', 'СВ-3']);
});
it('one: проза->словарь; неизвестное->догадка', () => {
expect(one('ответ')).toBe('внутр');
expect(one('владелец сказал')).toBe('воля');
expect(one('незавершённый')).toBe('догадка');
expect(one('абракадабра')).toBe('догадка');
expect(one('внеш')).toBe('внеш');
});
it('normLens: 9->Л9, л4->Л4', () => {
expect(normLens('9')).toBe('Л9');
expect(normLens('л4')).toBe('Л4');
expect(normLens('Л6')).toBe('Л6');
});
});
+34 -2
View File
@@ -4,12 +4,19 @@
import { reconcileTurn, mergeTurnIntoProtocol, collapseProtocol } from './secretary-reconcile.mjs';
import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs';
import { buildStepLine } from './secretary-layer1.mjs';
import { fluffyPipelineOn } from './secretary-flag.mjs';
import { diffTrunk } from './secretary-trunkdiff.mjs';
import { parseLoose } from './secretary-armor.mjs';
import { resolveModel } from './secretary-models.mjs';
import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG, renderExchangeText } from './secretary-harvest.mjs';
import { SYS_G } from './secretary-gardener.mjs';
import { applyResults } from './secretary-apply.mjs';
/** Разобрать спан и вернуть НОВЫЙ протокол (вход не мутируется reconcile-веткой; collapse — копия).
* proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions};
* {start,end} — границы спана (в ходах сырья); opts: { callModel|null, session, diag }.
* Без callModel (нет ключа) — пишется только детерминированный шаг, категории/СВ не трогаются. */
export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag } = {}) {
export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag, flags } = {}) {
// Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge.
const svSnapshot = JSON.parse(JSON.stringify({
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
@@ -28,9 +35,34 @@ export async function distillSpan(proto, spanEx, { start, end, note = '' }, { ca
actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep, note }) };
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
// Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile.
// Реестр СВ — вотчина аудитора/садовника: вернуть из снимка ДО reconcile.
preserveRegistry(toWrite, svSnapshot);
// НОВЫЙ конвейер «пушистое дерево» (подпроект A) — за флагом. Флаг ВЫКЛ → старый аудит ниже.
const fluffy = (flags && typeof flags.fluffy === 'boolean') ? flags.fluffy : fluffyPipelineOn();
if (callModel && fluffy) {
try {
const diff = diffTrunk(proto, toWrite); // движение ствола до/после редактора
const spanText = renderExchangeText(spanEx);
const txt = (r) => (typeof r === 'string' ? r : (r && r.text) || '');
const call = (role, system, user) => callModel({ system, user, model: resolveModel(role) });
// сбор: 1.1 диагностика ∥ 1.2 ловец, затем 1.3 брейншторм (после них)
const [r11, r12] = await Promise.all([
call('diagnostic', SYS_11, user11(toWrite, spanText, { start, end })),
call('catcher', SYS_12, user12(toWrite, spanText)),
]);
const d11 = diagFindings(parseLoose(txt(r11), 'lenses'));
const d12 = parseLoose(txt(r12), 'dropped');
const r13 = await call('brainstorm', SYS_13, user13(toWrite, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped)));
const d13 = parseLoose(txt(r13), 'forks');
// садовник (после сбора; видит дифф ствола + свежие ветки)
const rG = await call('gardener', SYS_G, userG(toWrite, spanText, diff));
const dG = parseLoose(txt(rG), 'tend');
return collapseProtocol(applyResults(toWrite, start, d11, d12, d13, dG));
} catch (e) { if (typeof diag === 'function') diag({ turn: start, reason: 'fluffy-fail', error: e && e.message }); return collapseProtocol(toWrite); }
}
// СТАРЫЙ путь (аудит 9-линз) — без изменений, пока флаг выключен.
if (callModel) {
try {
const auditMsgs = buildAuditPrompt(toWrite, spanEx);
+32
View File
@@ -34,3 +34,35 @@ describe('distillSpan — разбор одного завершённого с
expect(out.decisions).toEqual([]);
});
});
describe('distillSpan — новый конвейер «пушистое дерево» за флагом', () => {
it('при fluffy=on: ветка из диагностики + кандидат из брейншторма, аудит не зовётся', async () => {
const proto = EMPTY_PROTOCOL();
const spanEx = { user: 'предложи улучшение наставнику', assistant: 'разобрал по коду', actions: [] };
let auditCalled = false;
const callModel = async ({ system }) => {
if (/секретарь-редактор/.test(system)) return JSON.stringify({ subject: 'дело', decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } });
if (/сборщик скрытых веток/.test(system)) return JSON.stringify({ lenses: [{ lens: 'Л4', verdict: 'finding', text: 'без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' }] });
if (/ловец/.test(system)) return JSON.stringify({ dropped: [] });
if (/брейншторма/.test(system)) return JSON.stringify({ forks: [{ branch: 'идея', trigger: 't', опора: 'догадка', релевантность: 'low' }] });
if (/САДОВНИК/.test(system)) return JSON.stringify({ tend: [] });
if (/аудитор|скрытые вопросы/i.test(system)) { auditCalled = true; return '{"new":[]}'; }
return '{}';
};
const out = await distillSpan(proto, spanEx, { start: 15, end: 16 }, { callModel, session: 's', flags: { fluffy: true } });
expect(out.hidden.some((h) => h.text === 'без пруфа')).toBe(true);
expect((out.candidates || []).map((c) => c.branch)).toContain('идея');
expect(auditCalled).toBe(false);
});
it('флаг ВЫКЛ (по умолчанию) — старый путь, candidates нет', async () => {
const proto = EMPTY_PROTOCOL();
const spanEx = { user: 'вопрос достаточно длинный', assistant: 'ответ', actions: [] };
const callModel = async ({ system }) => {
if (/секретарь-редактор/.test(system)) return JSON.stringify({ subject: 'd', decisions: [], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'u', assistant: 'a' } });
return JSON.stringify({ new: [], ops: [], resolved: [] });
};
const out = await distillSpan(proto, spanEx, { start: 5, end: 5 }, { callModel, session: 's', flags: { fluffy: false } });
expect(out.candidates).toBeUndefined();
});
});
+6
View File
@@ -54,3 +54,9 @@ export function resolveCaseActivation(requested, existing = []) {
if (candidates.length > 0) return { action: 'confirm', candidates };
return { action: 'activate', work: String(requested || '').trim() };
}
// Флаг нового конвейера «пушистое дерево». ВЫКЛ по умолчанию — подпроект A кладётся «тёмным»,
// включается только когда воркер (подпроект B) вживит конвейер.
export function fluffyPipelineOn(env = process.env) {
return env.SECRETARY_FLUFFY === '1' || env.SECRETARY_FLUFFY === 'true';
}
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs';
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation, fluffyPipelineOn } from './secretary-flag.mjs';
describe('detectSecretaryCommand', () => {
it('распознаёт включение', () => {
@@ -49,3 +49,15 @@ describe('resolveCaseActivation — сверка имени дела со спи
expect(resolveCaseActivation('новое', [])).toEqual({ action: 'activate', work: 'новое' });
});
});
describe('fluffyPipelineOn', () => {
it('по умолчанию выключен', () => {
expect(fluffyPipelineOn({})).toBe(false);
});
it('включается env SECRETARY_FLUFFY=1', () => {
expect(fluffyPipelineOn({ SECRETARY_FLUFFY: '1' })).toBe(true);
});
it('включается env SECRETARY_FLUFFY=true', () => {
expect(fluffyPipelineOn({ SECRETARY_FLUFFY: 'true' })).toBe(true);
});
});
+50
View File
@@ -0,0 +1,50 @@
// tools/secretary-gardener.mjs
// Садовник веток: по диффу ствола ухаживает за живыми ветками (close/narrow/grow/reopen/carry).
// Промпт перенесён из доказанной песочницы (.scratch/sec/v2.mjs, находка №14: close-с-пруфом).
export const SYS_G = [
'Ты — САДОВНИК веток. Ствол только что продвинулся на шаг (дифф дан). Работа: пройти по КАЖДОЙ живой',
'ветке реестра (скрытые СВ-N, горящие ПР-N/ХВ-N) и решить, как её изменил ЭТОТ шаг ствола. Новые НЕ ищешь.',
'Действия: close · narrow (закрыл ЧАСТЬ; newText=остаток) · grow (развил; newText=уточнённый вопрос) ·',
'reopen (закрытая снова актуальна — обратный каскад) · carry (не касался; оставить).',
'КРИТЕРИЙ close (находка №14): закрывай ТОЛЬКО если КОНКРЕТНАЯ вещь, которую ветка называет недостающей,',
'теперь ПРИСУТСТВУЕТ и её ВИДНО — в диффе или обмене, и ты ПОКАЗЫВАЕШЬ где (поле "proof" = цитата/строка).',
'Условие закрытия зашито в текст ветки: «нет выдержек кода» → close лишь когда выдержки кода появились.',
'Для Л4/Л8 (доказательность/приёмка): ЗАЯВЛЕНИЕ ≠ ПРУФ — «я разобрал»/«готово»/повтор утверждения НЕ',
'закрывают; close только при реальном доказательстве в ходе (выдержка кода показана, команда прогнана с',
'результатом, проверено глазами-руками). Само-проверка: «могу ткнуть в строку, что закрывает дыру?» нет→carry.',
'ПРАВИЛА: только по id; КАЖДОЕ действие обоснуй ссылкой на конкретное движение ствола (из диффа); close',
'ОБЯЗАН нести непустой "proof"; нет опоры в движении → carry; сторож противоречий → reopen; narrow/grow',
'меняют формулировку ВОПРОСА, не дописывают пересказ хода; по умолчанию carry — честный carry лучше',
'выдуманного закрытия. НЕ перетягивай: где дыра реально заполнена — закрывай РЕШИТЕЛЬНО (дерево должно',
'прореживаться, не заморозиться).',
'ВЫХОД строго JSON: {"tend":[{"id","action":"close|narrow|grow|reopen|carry","newText","proof","why"}]}',
].join('\n');
// Чистые id горящим Л8/Л9 (находка №12): приёмка ПР-N, хвосты ХВ-N.
function defaultAcceptId(p) { return (p.acceptance || []).filter((e) => !e.done).map((e, i) => ({ id: `ПР-${i + 1}`, text: e.text })); }
function defaultTailId(p) { return (p.tails || []).filter((e) => !e.done).map((e, i) => ({ id: `ХВ-${i + 1}`, text: e.text })); }
/** Применить решения садовника к скрытым веткам + горящим Л8/Л9. close без proof → carry (В1-гейт). */
export function applyTend(proto, tend, turn, { acceptId = defaultAcceptId, tailId = defaultTailId } = {}) {
proto.hidden = proto.hidden || [];
const accIdx = acceptId(proto), tailIdx = tailId(proto);
for (const t of tend || []) {
let act = String(t.action || 'carry');
if (act === 'close' && !String(t.proof || '').trim()) act = 'carry'; // В1-гейт (находка №14)
const sv = proto.hidden.find((h) => h.id === t.id);
if (sv) {
if (act === 'close') { sv.status = 'закрыт'; sv.struck = true; }
else if (act === 'narrow') { sv.status = 'сужен'; if (t.newText) sv.text = t.newText; }
else if (act === 'grow') { sv.status = 'мутировал'; if (t.newText) sv.text = t.newText; }
else if (act === 'reopen') { sv.status = 'открыт'; sv.struck = false; }
if (act !== 'carry') sv.lastTouch = turn;
continue;
}
const ai = accIdx.find((e) => e.id === t.id);
if (ai && act === 'close') { const e = (proto.acceptance || []).find((x) => x.text === ai.text); if (e) e.done = true; continue; }
const ti = tailIdx.find((e) => e.id === t.id);
if (ti && act === 'close') { const e = (proto.tails || []).find((x) => x.text === ti.text); if (e) e.done = true; continue; }
}
return proto;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { SYS_G, applyTend } from './secretary-gardener.mjs';
describe('secretary-gardener', () => {
it('SYS_G требует proof на close', () => {
expect(SYS_G).toMatch(/proof/);
expect(SYS_G).toMatch(/ЗАЯВЛЕНИЕ ≠ ПРУФ/);
});
it('close с пруфом — закрыто', () => {
const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] };
applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: 'код mentor.mjs:107' }], 15);
expect(proto.hidden[0].status).toBe('закрыт');
expect(proto.hidden[0].struck).toBe(true);
});
it('голое close без proof — понижено в carry (В1-гейт)', () => {
const proto = { hidden: [{ id: 'СВ-1', status: 'открыт' }] };
applyTend(proto, [{ id: 'СВ-1', action: 'close', proof: '' }], 15);
expect(proto.hidden[0].status).toBe('открыт');
});
it('narrow меняет текст и статус', () => {
const proto = { hidden: [{ id: 'СВ-2', status: 'открыт', text: 'старый' }] };
applyTend(proto, [{ id: 'СВ-2', action: 'narrow', newText: 'остаток' }], 15);
expect(proto.hidden[0].status).toBe('сужен');
expect(proto.hidden[0].text).toBe('остаток');
});
it('close по горящему ХВ-1 закрывает хвост', () => {
const proto = { hidden: [], tails: [{ text: 'хвост', done: false }] };
applyTend(proto, [{ id: 'ХВ-1', action: 'close', proof: 'убрано' }], 15);
expect(proto.tails[0].done).toBe(true);
});
});
+122
View File
@@ -0,0 +1,122 @@
// tools/secretary-harvest.mjs
// Сбор веток: диагностика (1.1, отчёт 8/8) ∥ ловец (1.2, со стволом) → брейншторм (1.3, без лимита).
// Промпты перенесены дословно из доказанной песочницы (.scratch/sec/v2.mjs, находки №13/№16 + без-лимита).
import { normLens } from './secretary-armor.mjs';
const PASSPORT = [
'ПАСПОРТ каждой находки. "опора" — РОВНО ОДНО слово из ЗАКРЫТОГО списка (другие слова ЗАПРЕЩЕНЫ):',
' • внутр — видно в коде/проекте/обмене (есть ref на файл:строку или цитату);',
' • внеш — внешний факт (есть ref на url/док);',
' • воля — так сказал владелец;',
' • догадка — додумано, прямой опоры нет.',
'НЕЛЬЗЯ писать «ответ», «реплика», «незавершённый» и т.п. — выбери ОДНО из четырёх. Пример:',
' {"text":"…","опора":"внутр","ref":"mentor-verdict.mjs:107","тяжесть":"важное"}.',
'Примесь допустима пометкой в "ref" («+догадка»), но "опора" — одно слово из списка. "ref" — где именно.',
'Паспорт — БИРКА ТЯЖЕСТИ для садовника, НЕ повод убивать ветку. "тяжесть" ∈ {блокер|важное|мелочь}:',
'блокер=рушит ход / меняет архитектуру или объём; важное=влияет на реализацию; мелочь=косметика.',
'ПРАВИЛО ТЯЖЕСТИ: опора=догадка НЕ может быть блокером (потолок — важное); блокер только при опоре внутр/внеш/воля.',
].join('\n');
export const SYS_11 = [
'Ты — сборщик скрытых веток (диагностич. линзы). Видишь НЕДАВНИЕ шаги (ствол), ЖИВЫЕ ветки (реестр) и',
'ПОЛНЫЙ текущий обмен. Работа: найти ТОЛЬКО НОВЫЕ ветки в ТЕКУЩЕМ обмене. Уход за старыми — НЕ твоё (садовник).',
'Линзы:',
'Л1 Допущения — что молча считаем верным. Назови САМОЕ рисковое (неверно→рушит ход) + дешёвый тест.',
'Л3 Дрейф воли — решение уехало от воли владельца (сверь с недавними шагами).',
'Л4 Доказательность — заявлено фактом без пруфа. Красные флаги: «should/похоже/кажется»; «готово/работает»',
' без прогона команды В ЭТОМ ходе; доверие отчёту субагента без проверки.',
'Л5 Противоречие — ход спорит с прежним решением из реестра/шагов.',
'Л6 Хрупкость — сломается на будущем; симптом-фикс вместо корня; «работает сквозь A→B→C» без проверки границ.',
'Л7 Смысл слов — одно слово в двух значениях.',
'Л8 Приёмка (ГОРИТ) — «готово» без проверки глазами-руками.',
'Л9 Хвосты (ГОРИТ) — не убрано/не закоммичено/не запушено.',
'ОТЧЁТ ПО КАЖДОЙ ЛИНЗЕ ОБЯЗАТЕЛЕН (находка №16): пройди по ВСЕМ 8 линзам (Л1,Л3,Л4,Л5,Л6,Л7,Л8,Л9) и',
'для КАЖДОЙ верни ОДИН вердикт — либо "finding" (есть НОВАЯ ветка, с паспортом), либо "clean" с краткой',
'причиной "why"=«чисто, потому что…». ПУСТОЙ/НЕПОЛНЫЙ проход ЗАПРЕЩЁН: ровно 8 вердиктов, ни одной',
'пропущенной линзы. Это НЕ требование «найти везде»: "clean" — честный полноправный ответ.',
'finding — ТОЛЬКО при РЕАЛЬНОЙ зацепке; натяжка/нет зацепки → clean (НЕ выдумывай ради заполнения).',
'Не дублируй реестр/шаги. Если ГОРИТ Л8/Л9 и факт налицо — это finding, не clean. «Не уверен» → finding',
'с опора=догадка, тяжесть мелочь/важное (не прячь в clean).',
PASSPORT,
'ВЫХОД строго JSON: {"lenses":[{"lens":"Л1","verdict":"finding|clean","text","опора","ref","тяжесть","why"}]}',
'Для clean: text/опора/ref/тяжесть пустые, "why" обязателен. Для finding: паспорт обязателен, why можно пусто.',
].join('\n');
export const SYS_12 = [
'Ты — ловец брошенных веток. В тексте АССИСТЕНТА найди вопросы/темы, которые он САМ поднял и отложил,',
'не разобрав (чтобы не потерять ствол). Маркеры: «отдельный вопрос», «вернёмся», «потом», «забегая',
'вперёд», «ждать ли», «отложу», «это уже…», «позже». Только реально поднятое-и-брошенное, не выдумывай.',
'НЕ лови то, что УЖЕ в РЕШЕНИЯХ или ОТКРЫТЫХ ствола (дано ниже) — это не потеряно, это уже на виду.',
'ВЫХОД строго JSON: {"dropped":[{"text","цитата","опора"}]}',
].join('\n');
export const SYS_13 = [
'Ты — линза брейншторма. Подсвечивай НЕрассмотренные ветки/идеи — которых в разговоре НЕ было.',
'Генерируй СВОБОДНО (выполнимость НЕ оценивай). Приёмы: SCAMPER (заменить/объединить/адаптировать/',
'УБРАТЬ/развернуть), инверсия («как сделать хуже? разверни»).',
'СКОЛЬКО: число НЕ ограничено. Столько, сколько есть РЕАЛЬНЫХ зацепок. На СТАРТЕ большой фичи их много',
'(ствол пуст, развилок масса) — НЕ зажимай, дай все стоящие. В зрелом стволе — мало. Кандидаты ЭФЕМЕРНЫ',
'(не сохраняются: слабое тонет и испаряется само), поэтому щедрость БЕЗОПАСНА — но щедрость на',
'ЗАЦЕПЛЕННОЕ, не на выдумку.',
'ГЛАВНЫЙ ФИЛЬТР (анти-призрак): каждая развилка НЕСЁТ "trigger" = ДОСЛОВНУЮ цитату из обмена/реестра, за',
'которую цепляется. Нет дословной зацепки → это ПРИЗРАК, выкинь. Лучше 6 заземлённых, чем 5 + 1 призрак.',
'НЕ повторяй обсуждённое, живые ветки и УЖЕ НАЙДЕННОЕ этот ход (дано ниже).',
'"опора" — РОВНО ОДНО слово из ЗАКРЫТОГО списка {внутр|внеш|воля|догадка}; идея-фантазия → догадка +',
'релевантность low. Если зацепок реально много (>~8) — оставь сильнейшие (это от ИЗОБИЛИЯ, не искусств. потолок).',
'ВЫХОД строго JSON: {"forks":[{"trigger","branch","why","опора","релевантность"}]}',
].join('\n');
// ── детерминированная сборка кусков контекста (роль хука) ──────────────────
export function recentSteps(proto, n = 10) {
const st = (proto.steps || []).slice(-n);
return st.map((s) => `- ${s.text}`).join('\n') || '(пусто)';
}
// чистые id горящим Л8/Л9 (находка №12): приёмка ПР-N, хвосты ХВ-N
function acceptId(proto) { return (proto.acceptance || []).filter((e) => !e.done).map((e, i) => ({ id: `ПР-${i + 1}`, text: e.text })); }
function tailId(proto) { return (proto.tails || []).filter((e) => !e.done).map((e, i) => ({ id: `ХВ-${i + 1}`, text: e.text })); }
export function registry(proto) {
const h = (proto.hidden || []).map((x) => `${x.id} [${x.lens} · ${x.status} · ${x.тяжесть || '?'}]: ${x.text}`).join('\n') || '(пусто)';
const a = acceptId(proto).map((e) => `${e.id}: ${e.text}`).join('\n') || '(пусто)';
const t = tailId(proto).map((e) => `${e.id}: ${e.text}`).join('\n') || '(пусто)';
return `СКРЫТЫЕ ВЕТКИ:\n${h}\nГОРИТ Л8 (приёмка):\n${a}\nГОРИТ Л9 (хвосты):\n${t}`;
}
// ствол для ловца (находка №10): решения + открытые → не лови уже решённое
export function trunkForCatcher(proto) {
const d = (proto.decisions || []).filter((e) => !e.struck).map((e) => `- ${e.text}`).join('\n') || '(нет)';
const o = (proto.open || []).filter((e) => !e.struck).map((e) => `- ${e.text}`).join('\n') || '(нет)';
return `РЕШЕНИЯ СТВОЛА (уже решено — НЕ лови):\n${d}\nОТКРЫТЫЕ СТВОЛА (уже на виду — НЕ лови):\n${o}`;
}
/** Текст обмена из спана {user, assistant, actions} (заменяет exchange(span) песочницы). */
export function renderExchangeText(spanEx) {
const acts = ((spanEx.actions || []).map((a) => `${a.tool} in=${a.input ?? ''}${a.result != null ? `${String(a.result).slice(0, 4000)}` : ''}`).join('\n')) || '—';
return `[ЮЗЕР]: ${spanEx.user || ''}\n[АССИСТЕНТ]: ${spanEx.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts}`;
}
// ── USER-сборщики (берут готовый spanText) ────────────────────────────────
export function user11(proto, spanText, span = {}) {
return `НЕДАВНИЕ ШАГИ (ствол, последние 10):\n${recentSteps(proto)}\n\n${registry(proto)}\n\n`
+ `=== ПОЛНЫЙ ОБМЕН (ходы ${span.start ?? '?'}-${span.end ?? '?'}) ===\n${spanText}`;
}
export function user12(proto, spanText) {
return `${trunkForCatcher(proto)}\n\n=== ПОЛНЫЙ ОБМЕН ===\n${spanText}`;
}
export function user13(proto, spanText, span, found11, found12) {
return `${user11(proto, spanText, span)}\n\n=== УЖЕ НАЙДЕНО ЭТОТ ХОД (не повторяй) ===\nДиагностика: ${found11}\nБрошенное: ${found12}`;
}
export function userG(proto, spanText, diff) {
return `ЧТО ИЗМЕНИЛОСЬ В СТВОЛЕ ЭТОТ ХОД (дифф редактора):\n${diff}\n\n${registry(proto)}\n\n`
+ `НЕДАВНИЕ ШАГИ:\n${recentSteps(proto)}\n\n=== ПОЛНЫЙ ОБМЕН ===\n${spanText}`;
}
/** Из поимённого отчёта 8 линз извлечь находки; толерантно к плоскому {new:[…]} (находка №16). */
export function diagFindings(parsed) {
const ls = Array.isArray(parsed.lenses) ? parsed.lenses
: (Array.isArray(parsed.new) ? parsed.new.map((n) => ({ ...n, verdict: 'finding' })) : []);
const isFind = (l) => String(l.verdict || '').toLowerCase() === 'finding' && String(l.text || '').trim();
return {
lenses: ls,
new: ls.filter(isFind).map((f) => ({ text: f.text, lens: f.lens, опора: f.опора, ref: f.ref, тяжесть: f.тяжесть })),
clean: ls.filter((l) => String(l.verdict || '').toLowerCase() === 'clean').map((l) => normLens(l.lens)),
};
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { SYS_11, SYS_12, SYS_13, diagFindings, renderExchangeText, user11, user12, user13 } from './secretary-harvest.mjs';
describe('secretary-harvest промпты', () => {
it('SYS_11 требует отчёт по 8 линзам, finding|clean', () => {
expect(SYS_11).toMatch(/8 линз/);
expect(SYS_11).toMatch(/finding/);
expect(SYS_11).toMatch(/clean/);
});
it('SYS_12 (ловец) запрещает ловить решённое ствола', () => {
expect(SYS_12).toMatch(/НЕ лови/);
});
it('SYS_13 (брейншторм) без числового лимита, с дословной цитатой', () => {
expect(SYS_13).toMatch(/НЕ ограничено/);
expect(SYS_13).toMatch(/ДОСЛОВНУЮ цитату/);
});
});
describe('diagFindings', () => {
it('из отчёта 8/8 извлекает finding, собирает clean', () => {
const parsed = { lenses: [
{ lens: 'Л4', verdict: 'finding', text: 'заявлено без пруфа', опора: 'внутр', ref: 'x:1', тяжесть: 'важное' },
{ lens: 'Л1', verdict: 'clean', why: 'чисто' },
] };
const r = diagFindings(parsed);
expect(r.new).toHaveLength(1);
expect(r.new[0].lens).toBe('Л4');
expect(r.clean).toContain('Л1');
expect(r.lenses).toHaveLength(2);
});
it('толерантен к старому плоскому формату {new:[…]}', () => {
const r = diagFindings({ new: [{ text: 'x', lens: 'Л6' }] });
expect(r.new).toHaveLength(1);
});
});
describe('USER-сборщики', () => {
it('renderExchangeText форматирует обмен с действиями', () => {
const t = renderExchangeText({ user: 'спросил', assistant: 'ответил', actions: [{ tool: 'Read', input: 'f', result: 'r' }] });
expect(t).toContain('[ЮЗЕР]: спросил');
expect(t).toContain('[АССИСТЕНТ]: ответил');
expect(t).toContain('• Read');
});
it('user12 даёт ловцу ствол (решения/открытые) + обмен', () => {
const proto = { decisions: [{ text: 'решено-X', struck: false }], open: [{ text: 'открыто-Y', struck: false }] };
const u = user12(proto, '[ЮЗЕР]: q');
expect(u).toContain('решено-X');
expect(u).toContain('открыто-Y');
expect(u).toContain('[ЮЗЕР]: q');
});
it('user11 несёт реестр и недавние шаги', () => {
const proto = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }], steps: [{ text: 'Ход 1 — …' }] };
const u = user11(proto, 'обмен', { start: 3, end: 4 });
expect(u).toContain('СВ-1');
expect(u).toContain('Ход 1');
});
it('user13 добавляет уже найденное', () => {
const u = user13({}, 'обмен', { start: 3, end: 4 }, '["находка"]', '["брошено"]');
expect(u).toContain('УЖЕ НАЙДЕНО');
expect(u).toContain('находка');
});
});
+9
View File
@@ -0,0 +1,9 @@
// tools/secretary-models.mjs
// 5 слотов моделей (D). По умолчанию все — общий флэш; per-role env перекрывает.
export const ROLES = ['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener'];
/** Модель для роли: SECRETARY_LLM_MODEL_<ROLE> > SECRETARY_LLM_MODEL > undefined. */
export function resolveModel(role, env = process.env) {
const key = `SECRETARY_LLM_MODEL_${String(role || '').toUpperCase()}`;
return (ROLES.includes(role) && env[key]) || env.SECRETARY_LLM_MODEL || undefined;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { resolveModel, ROLES } from './secretary-models.mjs';
describe('resolveModel', () => {
it('5 ролей определены', () => {
expect(ROLES).toEqual(['editor', 'diagnostic', 'catcher', 'brainstorm', 'gardener']);
});
it('по умолчанию — общий флэш', () => {
expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x');
});
it('per-role перекрывает общий', () => {
expect(resolveModel('gardener', { SECRETARY_LLM_MODEL: 'flash-x', SECRETARY_LLM_MODEL_GARDENER: 'strong-y' })).toBe('strong-y');
});
it('неизвестная роль — общий флэш', () => {
expect(resolveModel('xxx', { SECRETARY_LLM_MODEL: 'flash-x' })).toBe('flash-x');
});
});
+5
View File
@@ -19,6 +19,11 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark
' "assistant":"<что ассистент сделал/выяснил/решил/предложил + ключевые находки>"} —',
' сжатая СУТЬ текущего хода без воды (вежливость/повторы убери; длина по содержанию;',
' факты не выдумывай; инструменты НЕ перечисляй — их подставит система).',
'9. РЕШЕНИЕ ВНУТРИ ВОПРОСА: если в реплике [ЮЗЕР] есть уже заявленная необходимость/директива',
' («надо…», «давай делать…», «будем…»), даже завёрнутая в просьбу «спроектируй/придумай» или',
' в вопрос о деталях — вынеси САМО принятое решение в "decisions" (а требование/запрет лично',
' к тебе — в "will") ОТДЕЛЬНО; ещё не решённую часть (формат/способ/детали) положи в "open".',
' НЕ прячь уже принятое решение целиком в "open" или "doneNext".',
].join('\n');
const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) =>
` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)');
+18
View File
@@ -0,0 +1,18 @@
// tools/secretary-trunkdiff.mjs
// Детерминированный дифф ствола (роль хука: до/после редактора).
const SECS = [['decisions', 'РЕШЕНИЕ'], ['will', 'ВОЛЯ'], ['open', 'ОТКРЫТЫЙ'], ['consequences', 'ПОСЛЕДСТВИЕ'], ['alternatives', 'АЛЬТЕРНАТИВА'], ['doneNext', 'СДЕЛАНО']];
const nz = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
export function diffTrunk(oldP, newP) {
const lines = [];
for (const [sec, label] of SECS) {
const o = new Map(((oldP[sec]) || []).map((e) => [nz(e.text), e]));
for (const e of (newP[sec]) || []) {
const prev = o.get(nz(e.text));
if (!prev) lines.push(`${label} добавлен: «${e.text}${e.why ? ' — ' + e.why : ''}»`);
else if (!prev.struck && e.struck) lines.push(`${label} зачёркнут (закрыт ствол): «${e.text}»`);
else if (prev.struck && !e.struck) lines.push(`${label} расчёркнут (снова актуален): «${e.text}»`);
}
}
return lines.length ? lines.join('\n') : '(ствол не двигался этот ход)';
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { diffTrunk } from './secretary-trunkdiff.mjs';
describe('diffTrunk', () => {
it('пусто, когда ствол не двигался', () => {
const p = { decisions: [{ text: 'A', struck: false }] };
expect(diffTrunk(p, p)).toBe('(ствол не двигался этот ход)');
});
it('ловит добавленное решение и зачёркнутый открытый', () => {
const oldP = { open: [{ text: 'Q', struck: false }] };
const newP = { decisions: [{ text: 'A', why: 'почему', struck: false }], open: [{ text: 'Q', struck: true }] };
const d = diffTrunk(oldP, newP);
expect(d).toContain('РЕШЕНИЕ добавлен: «A — почему»');
expect(d).toContain('ОТКРЫТЫЙ зачёркнут (закрыт ствол): «Q»');
});
});