feat(secretary): боевой конвейер веток подпроекта A за флагом (армор/дифф/сбор/садовник/применение/модели/дирижёр)
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]: [] };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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('находка');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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') || ' (пусто)');
|
||||
|
||||
@@ -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') : '(ствол не двигался этот ход)';
|
||||
}
|
||||
@@ -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»');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user