# artifact.mjs

## Огляд

Модуль `artifact.mjs` — це набір **спільних утиліт для фаз `spec` і `plan`** у моделі «Пасивний Турнікет» dispatcher-конвеєра. Він вирішує три задачі:

1. **Резолв traceable-артефакту** в каталозі `docs/<kind>/` (де `<kind>` — `specs` або `plans`) з пріоритезацією за хвостом (`slug`) гілки і вибором найсвіжішого файлу за `mtime`.
2. **Екстракт нумерованих кроків** із секції `## Кроки` плану у структурований масив `{ task, acceptance }`.
3. **Read-only перевірка цілісності ланцюга артефактів** через CLI `n-cursor trace` (без мутацій).

Ключова інваріанта: модуль **не пише** front-matter лінків (`spec.plan`, `plan.spec`, `plan.flow`) — це робить агент згідно з контрактом, описаним у правилі `flow.mdc`. Тут лише **верифікація**, бо мутатор `trace link` свідомо не реалізовано.

Файл є чистим ES-модулем (`.mjs`), не має побічних ефектів на рівні імпорту і експортує три іменовані функції.

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

| Експорт           | Тип        | Призначення                                                   |
| ----------------- | ---------- | ------------------------------------------------------------- |
| `resolveArtifact` | `function` | Знаходить актуальний markdown-артефакт у `docs/<kind>/`.      |
| `extractSteps`    | `function` | Парсить нумерований список кроків із тексту плану.            |
| `verifyTrace`     | `function` | Перевіряє цілісність ланцюга артефактів через runner `trace`. |

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

| Ім'я              | Значення          | Призначення                                                            |
| ----------------- | ----------------- | ---------------------------------------------------------------------- |
| `ACCEPTANCE_MARK` | `'— acceptance:'` | Маркер критерію приймання в рядку кроку (порівняння case-insensitive). |
| `DIGITS_RE`       | `/^\d+$/u`        | RegExp для перевірки, що префікс рядка перед `. ` є лише з цифр.       |

## Функції

### `resolveArtifact(cwd, kind, branch)`

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

```js
export function resolveArtifact(cwd, kind, branch)
```

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

| Ім'я     | Тип                     | Обов'язковий | Опис                                                                                                   |
| -------- | ----------------------- | ------------ | ------------------------------------------------------------------------------------------------------ |
| `cwd`    | `string`                | так          | Абсолютний шлях до кореня worktree.                                                                    |
| `kind`   | `'specs' \| 'plans'`    | так          | Підкаталог усередині `docs/` (`docs/specs/` або `docs/plans/`).                                        |
| `branch` | `string` (опціональний) | ні           | Назва гілки задачі (напр. `claude/flow-gate`). Використовується для пріоритезації за хвостом (`slug`). |

**Повертає**

`string | null` — абсолютний шлях до знайденого `.md`-файлу або `null`, якщо каталог `docs/<kind>/` відсутній чи в ньому немає markdown-файлів.

**Алгоритм / поведінка**

1. Збирає `dir = join(cwd, 'docs', kind)`.
2. Якщо `dir` не існує — повертає `null`.
3. Сканує `dir` і фільтрує лише файли з розширенням `.md`. Якщо їх немає — `null`.
4. Обчислює `slug` як останній сегмент `branch` після `/` (`'claude/flow-gate' -> 'flow-gate'`). Якщо `branch` не задано — `slug = null`.
5. Формує `matched` — підмножину файлів, чиї назви **містять `slug`** (через `String.prototype.includes`). Якщо `slug` відсутній — `matched = []`.
6. Формує `pool`: якщо `matched.length > 0` — `pool = matched`; інакше `pool = md` (усі markdown-файли каталогу).
7. Серед `pool` обирає **найсвіжіший за `mtimeMs`** (а при рівності `mtime` — за зростанням за іменем, тож `.at(-1)` поверне лексикографічно більше ім'я як tie-breaker).
8. Повертає `join(dir, best.f)`.

**Side effects**

- **Тільки читання ФС**: `existsSync`, `readdirSync`, `statSync` із `node:fs`. Жодних записів.

**Особливості / нюанси**

- Сортування використовує `Array.prototype.toSorted` (іммутабельний варіант `sort`), тож вихідний `pool` не модифікується.
- Документація у JSDoc наголошує: попередній «лексикографічний» вибір був **хибним** при кількох артефактах на одну дату — це виявлено dogfood'ом.
- Якщо в `pool` лише один файл — `.at(-1)` поверне його.

### `extractSteps(text)`

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

```js
export function extractSteps(text)
```

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

| Ім'я   | Тип      | Обов'язковий | Опис                                                                                                |
| ------ | -------- | ------------ | --------------------------------------------------------------------------------------------------- |
| `text` | `string` | так          | Повний вміст markdown-файлу плану (або довільний текст). Приводиться до рядка через `String(text)`. |

**Повертає**

`{ task: string, acceptance?: string }[]` — масив кроків у тому порядку, в якому вони зустрілися у вхідному тексті. Якщо нічого не знайдено — порожній масив.

**Формат, який розпізнається**

```
N. <текст задачі> — acceptance: <критерій приймання>
```

де `N` — одне або більше десяткових цифр. Маркер `— acceptance:` розпізнається **case-insensitive** (через `toLowerCase`).

Якщо рядок не починається з `<цифри>. `, він **ігнорується** (best-effort, без помилок).

**Алгоритм / поведінка**

1. Розбиває вхід на рядки за `\n`.
2. Для кожного рядка обрізає пробіли (`trim`).
3. Шукає першу появу `. ` (`dot = line.indexOf('. ')`). Якщо `dot <= 0` (немає крапки з пробілом або вона на самому початку) — рядок пропускається.
4. Перевіряє, що префікс `line.slice(0, dot)` повністю складається з цифр (`DIGITS_RE`). Інакше — пропуск.
5. Виокремлює `body = line.slice(dot + 2).trim()`.
6. Шукає в `body.toLowerCase()` позицію маркера `ACCEPTANCE_MARK` (`'— acceptance:'`).
7. Якщо маркера немає — додає `{ task: body }`.
8. Якщо маркер є — ділить `body` на `task` (до маркера) і `acceptance` (після маркера), обрізає обидва і додає `{ task, acceptance }`.

**Side effects**

- Жодних. Чиста функція.

**Особливості / нюанси**

- Використано `indexOf` замість regex для уникнення **regex-backtracking**, що даватиме лінійну складність на довгих рядках.
- Маркер `—` — це **em-dash** (U+2014), не звичайний дефіс. Це важливо для збігу.
- Поле `acceptance` опціональне у вихідних об'єктах: відсутнє, якщо маркер не знайдено.
- Нумерація кроків зчитується як префікс, але **не повертається** у результат: повертаються лише `task` і опціонально `acceptance`. Сам номер не використовується для впорядкування — порядок повністю визначається порядком появи в тексті.

### `verifyTrace(cwd, runTrace)`

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

```js
export function verifyTrace(cwd, runTrace)
```

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

| Ім'я       | Тип                              | Обов'язковий | Опис                                                                                  |
| ---------- | -------------------------------- | ------------ | ------------------------------------------------------------------------------------- |
| `cwd`      | `string`                         | так          | Абсолютний шлях до кореня worktree, який передається у trace-runner.                  |
| `runTrace` | `(cwd: string) => number` (опц.) | ні           | Кастомний runner trace, що повертає exit-code. Призначений для **ін'єкції у тестах**. |

**Повертає**

`boolean` — `true`, якщо exit-code runner-а дорівнює `0` (ланцюг цілісний); `false` — інакше (розрив ланцюга або помилка).

**Алгоритм / поведінка**

1. Якщо `runTrace` не передано — створюється дефолтний runner, який викликає `runTraceCli([], { cwd: c, log: () => {} })` з модуля `../trace.mjs`. Логування глушиться (`log: () => {}`), тож зовнішніх ефектів у stdout/stderr не буде.
2. Виконує `run(cwd)` і повертає `=== 0`.

**Side effects**

- Викликає `runTraceCli` з `../trace.mjs`, який у звичайному режимі **читає** артефакти й їхні front-matter. Жодних мутацій не передбачено — функція декларується як «read-only сигнал».
- При використанні `runTrace` ін'єкції — side effects цілком визначаються переданим runner-ом.

**Особливості / нюанси**

- Семантика exit-code узгоджена з CLI: `0` — OK, `1` (або інше ненульове) — розрив.
- Аргументи для `runTraceCli` фіксовані: порожній масив `[]` (без додаткових прапорців).

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

### Зовнішні (Node.js core)

- `node:fs`:
  - `existsSync` — перевірка існування каталогу `docs/<kind>/`.
  - `readdirSync` — лістинг файлів у каталозі.
  - `statSync` — отримання `mtimeMs` для сортування.
- `node:path`:
  - `join` — побудова шляхів `docs/<kind>/<file>.md`.

### Внутрішні (проєктні)

- `../trace.mjs` → `runTraceCli` — CLI-runner перевірки цілісності trace-ланцюга. Викликається в `verifyTrace`.

### Контракти / правила

- `flow.mdc` — описує контракт лінкування front-matter (`spec.plan`/`plan.spec`/`plan.flow`), яке робить агент, а не цей модуль.
- Структура каталогів `docs/specs/` і `docs/plans/` — конвенція «Пасивний Турнікет».

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

Модуль є **бібліотечним**: він не запускається самостійно, а імпортується іншими частинами dispatcher-pipeline (фази `spec`/`plan`).

### Типовий сценарій 1 — резолв plan-артефакту і парсинг кроків

```js
import { resolveArtifact, extractSteps } from './artifact.mjs'
import { readFileSync } from 'node:fs'

const planPath = resolveArtifact(process.cwd(), 'plans', 'claude/flow-gate')
if (!planPath) {
  // немає docs/plans/ або файлів — фаза має зупинитись
  return
}

const text = readFileSync(planPath, 'utf8')
const steps = extractSteps(text)
// steps: [{ task: '...', acceptance: '...' }, ...]
```

### Типовий сценарій 2 — read-only перевірка ланцюга перед мерджем

```js
import { verifyTrace } from './artifact.mjs'

if (!verifyTrace(process.cwd())) {
  // ланцюг spec ↔ plan ↔ flow має розрив — сигнал агенту
}
```

### Типовий сценарій 3 — інверсія залежності у тестах

```js
import { verifyTrace } from './artifact.mjs'

// підставляємо фейковий runner, щоб не викликати реальний CLI
const ok = verifyTrace('/tmp/fake-cwd', () => 0)
// ok === true
```

### Контекст у dispatcher-конвеєрі

1. **Фаза `spec`**: викликає `resolveArtifact(cwd, 'specs', branch)` для пошуку специфікації.
2. **Фаза `plan`**: викликає `resolveArtifact(cwd, 'plans', branch)`, читає файл, потім `extractSteps(text)` — і отримує перелік кроків, які виконуватиме наступна фаза.
3. **Гейт цілісності**: перед переходом на наступну фазу — `verifyTrace(cwd)` як сигнал, чи зв'язки front-matter не порушені.

## Rebuild Test

За цією документацією можна **відновити поведінку** модуля без оригінального коду:

- Модуль експортує **рівно три функції**: `resolveArtifact`, `extractSteps`, `verifyTrace`.
- `resolveArtifact(cwd, kind, branch)` шукає `.md` у `docs/<kind>/`, віддає пріоритет файлам, чиє ім'я містить `slug = branch.split('/').pop()`, серед них (або серед усіх, якщо збігу нема) обирає найсвіжіший за `mtime`; повертає абсолютний шлях або `null`.
- `extractSteps(text)` парсить рядки виду `N. task — acceptance: crit` (em-dash, маркер case-insensitive), повертає масив `{ task, acceptance? }` у порядку появи; невалідні рядки ігноруються.
- `verifyTrace(cwd, runTrace?)` повертає `true`, якщо `runTrace(cwd) === 0`; за замовчуванням викликає `runTraceCli([], { cwd, log: () => {} })` з `../trace.mjs`.
- Жодних мутацій ФС, жодного запису front-matter — це робить агент за `flow.mdc`.
