# planner.mjs

## Огляд

Модуль `planner.mjs` — **декларативний планувальник** диспетчера субагентів (відповідає Ф1 зі spec §3). Його завдання — отримати від користувача опис фічі/задачі, сформулювати спеціалізований промпт, передати його зовнішньому субагенту (через ін'єкцію `runner`), а потім **строго валідувати** повернутий JSON-план реалізації та нормалізувати його кроки до уніфікованої структури з полями `step`, `task`, `status`, `retry_count` (плюс опційне `acceptance`).

Поведінка модуля **fail-closed**: будь-яка невідповідність контракту (відсутність JSON-масиву, невалідний JSON, порожній масив, відсутність текстового `task` у кроці, плейсхолдер на кшталт `tbd`/`todo`/`fixme`/`...`/`placeholder`, помилка субагента) призводить до кидання `Error` без часткових результатів. Це гарантує, що подальші стадії (виконання плану) ніколи не отримають частково сформований/невалідований план.

Файл написано як чистий ES-модуль (`.mjs`) без зовнішніх залежностей — лише вбудовані JS-конструкції (`JSON.parse`, `String`, регулярні вирази, `Array.prototype.map`). Це робить його легким для unit-тестування й мокування `runner` у тестах.

## Експорти / API

Модуль експортує **три іменовані функції** (інших експортів немає, default-експорту немає):

| Експорт                               | Тип                                | Призначення                                                                                   |
| ------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------- |
| `plannerPrompt(task)`                 | `(string) => string`               | Формує текстовий промпт для субагента-планувальника.                                          |
| `parsePlan(text)`                     | `(string) => Step[]`               | Парсить і валідує JSON-план із сирого тексту відповіді; повертає нормалізований масив кроків. |
| `generatePlan({ runner, task, cwd })` | `async ({...}) => Promise<Step[]>` | Високорівнева оркестровка: будує промпт, викликає `runner.runStep`, парсить вихід.            |

Внутрішня (не експортована) константа:

| Ім'я          | Тип      | Значення / опис                                                                                                                           |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `PLACEHOLDER` | `RegExp` | `^(tbd\|todo\|fixme\|\.\.\.\|placeholder)$` із прапором `i` — список «заборонених» текстів кроку, що сигналізують про несформований план. |

### Тип `Step` (нормалізований крок плану)

Об'єкт, який повертають `parsePlan` і `generatePlan` (масивом):

```
{
  step: number,            // 0-індексований порядковий номер
  task: string,            // оригінальний (не тримлений!) текст задачі
  status: 'pending',       // початковий статус — завжди 'pending'
  retry_count: 0,          // лічильник повторів — завжди 0
  acceptance?: string      // опційний критерій приймання (як строка)
}
```

## Функції

### `plannerPrompt(task)`

**Сигнатура.** `plannerPrompt(task: string): string`

**Параметри.**

- `task` — текстовий опис фічі/задачі, який отримав диспетчер від виклику верхнього рівня.

**Повертає.** Готовий **system-user** промпт у вигляді одного рядка, склеєного через `\n`. Зміст промпта:

1. Роль: «архітектор», який має розбити задачу.
2. Обмеження кроку: ≤ 5 хв розробки, чіткі критерії приймання.
3. Формат відповіді: **ЛИШЕ** JSON-масив без коментарів, кожен елемент — об'єкт `{ "task": "...", "acceptance": "..." }`.
4. Останній рядок — фактична задача: `Задача: <task>`.

**Side effects.** Немає — функція суто детермінована й pure. Її вихід можна логувати, кешувати, передавати в тести як snapshot.

**Особливості.**

- Не екранує і не санітизує `task` — викликач відповідає за те, що рядок не зламає форматування промпта (хоча для рядкового рендеру це й не критично).
- Не вставляє приклади чи додаткові few-shot блоки — мінімалістичний контракт.

### `parsePlan(text)`

**Сигнатура.** `parsePlan(text: string): Step[]`

**Параметри.**

- `text` — сира відповідь субагента; може містити markdown-огорожі (наприклад, ` ```json ... ``` `), пояснювальний текст до/після масиву тощо.

**Повертає.** Масив нормалізованих кроків (див. тип `Step` вище).

**Алгоритм.**

1. Конвертує вхід у рядок через `String(text)` — захист від `null`/`undefined`/буферів.
2. Шукає **перший `[`** через `indexOf('[')` і **останній `]`** через `lastIndexOf(']')`. Це робить парсер толерантним до markdown-огорож, преамбули чи коментарів навколо JSON-масиву.
3. Якщо хоча б одна з дужок не знайдена або порядок інвертовано (`end < start`) — кидає `Error('planner: не знайдено JSON-масив плану — fail-closed')`.
4. Виокремлює зріз `str.slice(start, end + 1)` і парсить через `JSON.parse`. На SyntaxError кидає `Error('planner: невалідний JSON плану — fail-closed')`.
5. Перевіряє, що результат — **непорожній масив**; інакше — `Error('planner: план має бути непорожнім масивом — fail-closed')`.
6. Мапить кожен елемент:
   - Витягує `task`: якщо елемент сам є рядком — то він і є task; інакше `s?.task`.
   - Якщо `task` відсутній або не рядок — кидає `Error('planner: крок <i> без текстового поля task — fail-closed')`.
   - Тримить копію (`task.trim()`) і перевіряє: непорожній і **не** збігається з `PLACEHOLDER`. Інакше — `Error('planner: крок <i> — placeholder/порожній task (<task>) — fail-closed')`.
   - Формує об'єкт `{ step: i, task, status: 'pending', retry_count: 0 }`. Важливо: у поле `task` зберігається **оригінальний** (нетримлений) рядок, валідується лише тримлений варіант.
   - Якщо в сирому елементі є поле `acceptance` (truthy) — додає `step.acceptance = String(s.acceptance)`.

**Повертає.** Масив об'єктів `Step` у тому ж порядку, що й у вхідному масиві; індекс `step` — 0-індексований, відповідає позиції в масиві.

**Side effects.** Немає — pure-функція. Винятки кидаються синхронно; внутрішнього логування, мутацій глобального стану чи I/O немає.

**Покриті граничні випадки.**

- Markdown-огорожі / преамбула / postамбула навколо JSON.
- Елемент-рядок (`"крок 1"`) замість `{ task: "крок 1" }`.
- Цифрові `task` (`123`), `null`, відсутні — все відсіюється.
- Регістронезалежні плейсхолдери (`TBD`, `Todo`, `FIXME`, `...`, `Placeholder`).
- Порожній масив `[]` — відхиляється.
- Будь-який `task` із пробілами навколо плейсхолдера (наприклад, `"  tbd  "`) також відсіюється завдяки `.trim()` перед регуляркою.

### `generatePlan({ runner, task, cwd })`

**Сигнатура.**

```
async generatePlan(input: {
  runner: { runStep: (prompt: string, opts?: { cwd?: string })
             => { ok: boolean, output: string } | Promise<{ ok: boolean, output: string }> },
  task: string,
  cwd?: string
}): Promise<Step[]>
```

**Параметри (деструктуровані).**

- `runner` — обов'язкова ін'єкція «бігуна» субагента. Очікується об'єкт із методом `runStep(prompt, opts?)`, що повертає (або резолвить у Promise) `{ ok: boolean, output: string }`. Контракт навмисно мінімальний — це спрощує мокування в тестах.
- `task` — текстовий опис фічі/задачі; передається у `plannerPrompt`.
- `cwd` — опційний робочий каталог, який пробрасується у `runner.runStep` через `{ cwd }`.

**Повертає.** `Promise<Step[]>` — нормалізований план від `parsePlan`.

**Алгоритм.**

1. Формує промпт через `plannerPrompt(task)`.
2. Викликає `await runner.runStep(prompt, { cwd })` — підтримує і синхронний, і асинхронний `runStep` (await на не-promise — no-op).
3. Якщо `res.ok === false` — кидає `Error('planner: субагент-планувальник завершився помилкою[:\n<output>]')`. Якщо `res.output` truthy — він приєднується до повідомлення з префіксом `:\n` для зручної діагностики.
4. Передає `res.output` у `parsePlan` і повертає його результат (валідні винятки парсера пробулькують далі).

**Side effects.**

- Викликає `runner.runStep`, який у реальному середовищі стартує субагента (запис файлів, мережа, процеси — все на боці `runner`). Сам модуль `planner.mjs` ніяких I/O не виконує.
- Може кидати `Error` як від власних перевірок, так і від `parsePlan`.

**Обробка помилок.** Будь-яка помилка веде до **повного провалу** (без часткового плану) — fail-closed контракт, на який спираються наступні Ф2/Ф3 диспетчера.

## Залежності

**Зовнішні npm-залежності:** немає.

**Імпорти у файлі:** немає (`import`-ів нема — модуль самодостатній).

**Що використовується (вбудоване в JS-runtime):**

- `String(...)` — нормалізація вхідного значення в `parsePlan`.
- `JSON.parse` — парсинг витягнутого JSON-зрізу.
- `Array.isArray`, `Array.prototype.map` — перевірка типу й мапінг.
- `RegExp` (`/^(tbd|todo|fixme|\.\.\.|placeholder)$/i`) — детектор плейсхолдерів.
- `String.prototype.indexOf`/`lastIndexOf`/`slice`/`trim`.

**Ін'єкції (контракт ззовні):**

- `runner` із методом `runStep(prompt, opts?)` — інверсія залежностей; реалізація лежить за межами цього модуля (зазвичай — окремий модуль `dispatcher/lib/runner*.mjs`).

**Хто залежить від модуля (типові споживачі).**

- Оркестратор/диспетчер субагентів верхнього рівня — імпортує `generatePlan` для побудови плану перед виконанням.
- Юніт-тести — як правило, ймовірно імпортують `parsePlan` і `plannerPrompt` прямо для перевірки контракту без `runner`.

Точні шляхи споживачів у цьому документі **не фіксуються** (модуль документується ізольовано).

## Потік виконання / Використання

### Типовий happy-path (з боку диспетчера)

1. Диспетчер отримує `task` (опис фічі) і має `runner`-реалізацію.
2. Викликає `await generatePlan({ runner, task, cwd })`.
3. `generatePlan` будує промпт через `plannerPrompt(task)`.
4. `runner.runStep(prompt, { cwd })` стартує субагента-«архітектора», який повертає `{ ok: true, output: '[ ... JSON ... ]' }` (можливо з markdown-огорожею).
5. `parsePlan(output)` витягує JSON-зріз від `[` до `]`, парсить, валідує, нормалізує — повертає масив `Step`.
6. Диспетчер отримує `Step[]` і передає його наступній фазі (Ф2 — виконання кроків).

### Sad-path сценарії (fail-closed)

| Сценарій                                                     | Точка падіння  | Текст помилки (рівно)                                                  |
| ------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------- |
| `runner.runStep` повернув `{ ok: false, output: '' }`        | `generatePlan` | `planner: субагент-планувальник завершився помилкою`                   |
| `runner.runStep` повернув `{ ok: false, output: '<lorem>' }` | `generatePlan` | `planner: субагент-планувальник завершився помилкою:\n<lorem>`         |
| У відповіді немає `[` або `]` (або в зворотному порядку)     | `parsePlan`    | `planner: не знайдено JSON-масив плану — fail-closed`                  |
| JSON всередині `[...]` неваліден                             | `parsePlan`    | `planner: невалідний JSON плану — fail-closed`                         |
| Результат — не масив або порожній                            | `parsePlan`    | `planner: план має бути непорожнім масивом — fail-closed`              |
| Крок без `task` (або не-рядок)                               | `parsePlan`    | `planner: крок <i> без текстового поля task — fail-closed`             |
| Крок з порожнім/плейсхолдер-`task`                           | `parsePlan`    | `planner: крок <i> — placeholder/порожній task (<task>) — fail-closed` |

### Приклади використання

**Лише парсинг (наприклад, у тесті):**

````
import { parsePlan } from './planner.mjs'

const text = '```json\n[\n  { "task": "init repo", "acceptance": "git init ok" },\n  "lint config"\n]\n```'
const steps = parsePlan(text)
// steps[0] === { step: 0, task: 'init repo', status: 'pending', retry_count: 0, acceptance: 'git init ok' }
// steps[1] === { step: 1, task: 'lint config', status: 'pending', retry_count: 0 }
````

**Повна оркестровка з мок-runner:**

```
import { generatePlan } from './planner.mjs'

const runner = {
  runStep: async (prompt) => ({
    ok: true,
    output: '[{ "task": "крок 1" }, { "task": "крок 2", "acceptance": "тести зелені" }]'
  })
}

const plan = await generatePlan({ runner, task: 'додати feature X', cwd: process.cwd() })
// plan.length === 2; plan[1].acceptance === 'тести зелені'
```

**Реакція на fail-closed:**

```
try {
  await generatePlan({ runner, task: '...' })
} catch (e) {
  // e.message починається з 'planner: ' — далі диспетчер може віддати помилку нагору
}
```

### Інваріанти, на які можна спиратися

- Поверне рівно стільки кроків, скільки було у вхідному JSON-масиві (mapping 1:1, порядок збережено).
- `step` зростає монотонно від `0` до `arr.length - 1`.
- `status === 'pending'`, `retry_count === 0` — для **кожного** свіжого кроку (новостворений план — завжди «незачеплений»).
- Поле `acceptance` присутнє **лише** якщо в сирому елементі воно truthy; інакше відсутнє (не `undefined`, а немає ключа взагалі).
- Поле `task` зберігається **в оригінальному вигляді** (з пробілами по краях), валідація використовує тримлений варіант — тобто можливі «візуально нетримлені» рядки в результаті, але вони гарантовано не плейсхолдери й не порожні.

## Rebuild Test

Цей розділ описує контракт настільки повно, що його можна використати як специфікацію для повторної реалізації файлу з нуля.

**Реалізаційні вимоги:**

1. ES-модуль `.mjs` без імпортів.
2. Внутрішня константа-регулярка `PLACEHOLDER = /^(tbd|todo|fixme|\.\.\.|placeholder)$/i`.
3. Експортувати рівно три функції: `plannerPrompt`, `parsePlan`, `generatePlan` (іменовані експорти, без default).
4. `plannerPrompt(task)` повертає рядок, склеєний через `\n` з 5 рядків (4 інструктивних + порожній + `Задача: <task>`).
5. `parsePlan(text)`:
   - Конвертувати в рядок через `String(text)`.
   - Знайти `start = indexOf('[')`, `end = lastIndexOf(']')`. Якщо `start === -1 || end === -1 || end < start` — кинути `'planner: не знайдено JSON-масив плану — fail-closed'`.
   - Спробувати `JSON.parse(slice(start, end+1))`; на catch — кинути `'planner: невалідний JSON плану — fail-closed'`.
   - Якщо результат не масив або порожній — кинути `'planner: план має бути непорожнім масивом — fail-closed'`.
   - Для кожного `(s, i)`: визначити `task = typeof s === 'string' ? s : s?.task`; якщо falsy або не string — кинути `'planner: крок <i> без текстового поля task — fail-closed'`; перевірити `trimmed = task.trim()` — якщо falsy або матчить `PLACEHOLDER` — кинути `'planner: крок <i> — placeholder/порожній task (<task>) — fail-closed'`.
   - Сформувати `{ step: i, task, status: 'pending', retry_count: 0 }`; якщо `s?.acceptance` — додати `acceptance: String(s.acceptance)`.
6. `generatePlan({ runner, task, cwd })` — `async`:
   - `const res = await runner.runStep(plannerPrompt(task), { cwd })`.
   - Якщо `!res.ok` — кинути `'planner: субагент-планувальник завершився помилкою'` плюс `':\n' + res.output` якщо `res.output` truthy.
   - Інакше `return parsePlan(res.output)`.

**Контрольні приклади (повинні проходити після rebuild):**

| Вхід `parsePlan`                                   | Очікуваний результат                                                       |
| -------------------------------------------------- | -------------------------------------------------------------------------- |
| `'[{"task":"a"}]'`                                 | `[{ step:0, task:'a', status:'pending', retry_count:0 }]`                  |
| `'prefix [{"task":"a","acceptance":"ok"}] suffix'` | `[{ step:0, task:'a', status:'pending', retry_count:0, acceptance:'ok' }]` |
| `'[\"a\",\"b\"]'`                                  | `[{step:0,task:'a',...},{step:1,task:'b',...}]`                            |
| `'[]'`                                             | throw `'planner: план має бути непорожнім масивом — fail-closed'`          |
| `'no json here'`                                   | throw `'planner: не знайдено JSON-масив плану — fail-closed'`              |
| `'[{"task":"tbd"}]'`                               | throw `'planner: крок 0 — placeholder/порожній task (tbd) — fail-closed'`  |
| `'[{"task":""}]'`                                  | throw `'planner: крок 0 — placeholder/порожній task () — fail-closed'`     |
| `'[{"task":123}]'`                                 | throw `'planner: крок 0 без текстового поля task — fail-closed'`           |
| `'[oops'` (нема `]`)                               | throw `'planner: не знайдено JSON-масив плану — fail-closed'`              |
| `'[invalid json]'`                                 | throw `'planner: невалідний JSON плану — fail-closed'`                     |

Якщо реалізація проходить усі ці кейси й тримає сигнатури/тексти помилок з таблиць — її можна вважати функціонально еквівалентною оригінальному `planner.mjs`.
