Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"backpressure",
"Backpressure",
"badkey",
"bubblewrap",
"cdylib",
"chrono",
"claimом",
Expand Down Expand Up @@ -230,6 +231,7 @@
"десеріалізує",
"десеріалізується",
"джиттер",
"джобом",
"дзеркалює",
"диспатч",
"діючий",
Expand Down Expand Up @@ -423,6 +425,8 @@
"непарсибельний",
"непарсибельні",
"непровалідованому",
"нерезолвлений",
"Нерезолвлений",
"нерозпарсюваний",
"нерозрізнюваними",
"нерозрізнювані",
Expand Down Expand Up @@ -457,6 +461,7 @@
"перемаршрутовуються",
"переносиме",
"переносимості",
"переоткривати",
"переоткриття",
"переперевірки",
"перепідключаються",
Expand Down Expand Up @@ -597,12 +602,14 @@
"Спавнимо",
"Спавнить",
"спавну",
"спавняться",
"спайком",
"спуфінг",
"стрипає",
"стрипається",
"стрімом",
"стріму",
"суперлінійний",
"схвалювачів",
"схлопнутий",
"таргети",
Expand Down
38 changes: 31 additions & 7 deletions docs/conformance.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Відповідність специфікації: карта реалізації

**Дата зрізу:** 2026-08-09
**Специфікація:** `@7n/mt` — [цільова архітектура 0.3.0-draft](https://github.com/nitra/mt/blob/main/docs/architecture/index.md)
**Метод:** прочитання глав архітектури проти коду `crates/`, `relay/`, `layers/`; вердикт ставиться за наявністю реальної логіки, не за назвою файлу чи згадкою в коментарі.
**Дата зрізу:** 2026-08-17 (попередній — 2026-08-09)
**Специфікація:** `@7n/mt` 0.30.8 — [цільова архітектура 0.3.0-draft](https://github.com/nitra/mt/blob/main/docs/architecture/index.md)
**Метод:** прочитання глав архітектури проти коду `crates/`, `relay/`, `layers/`; вердикт ставиться за наявністю реальної логіки, не за назвою файлу чи згадкою в коментарі. Зріз 2026-08-17 додатково звірив заявлені шляхи з деревом, прогнав обидва набори тестів і перевірив, що саме з цього ганяє CI.

> Живий документ. Правило: зміна вердикту йде тим самим PR, що й код, який його змінює. Зріз без дати й методу — не зріз.

Expand All @@ -12,6 +12,14 @@

**Критерій зняття `-draft` з 0.3.0:** усі рядки таблиць нижче в стані РЕАЛІЗОВАНО, кожна вимога має тест у `cargo test --workspace` або `bun test`, і demo-критерії M0–M6 із [roadmap](https://github.com/nitra/mt/blob/main/docs/roadmap.md) відтворюються.

> **Стан сторожів (2026-08-17).** Критерій вище спирається на тести, тож їхній власний стан — частина зрізу, а не деталь:
>
> - `bun run test` — 197 passed, стабільно.
> - `cargo test --workspace` — 306 тестів; **серійно падає 1** (`runner::tests::cascade_exhausted_or_plain_error_paths`), **паралельно падає до 10** — решта 9 чутливі до навантаження машини (`budget_hard_sec` вимірює реальний час, а шими спавняться процесами).
> - **`cargo test` не запускається в CI взагалі** — жоден workflow його не містить. Тобто Rust-половина критерію не має автоматичного сторожа: її тримає лише ручний прогін.
>
> Поки це так, «усі рядки РЕАЛІЗОВАНО» не дорівнює «доведено».

## Зведення за мілстоунами

| Мілстоун | Стан | Головне, чого бракує |
Expand All @@ -38,7 +46,7 @@
| Claims: CAS, lease, grace, takeover | РЕАЛІЗОВАНО | `claims.rs`; читач — `lib.rs` `scan_tasks_with_claims`, CLI `mt status` | — |
| Fenced publish + failure-сімейство | РЕАЛІЗОВАНО | `publish.rs` `fenced_publish`/`publish_failure_run`, `runner.rs` `terminal_conflict_reason` | Батчинг кількох результатів одним push |
| Run-wrapper, watchdog | РЕАЛІЗОВАНО | `runner.rs` | — |
| Retry ladder + каскад CLI | РЕАЛІЗОВАНО | `runner.rs` | Телеметрія `tokens_in/out`/`cost_usd` не збирається |
| Retry ladder + каскад CLI | РЕАЛІЗОВАНО | `runner.rs` | Телеметрія `tokens_in/out`/`cost_usd` не збирається; тести драбини чутливі до навантаження — див. «Стан сторожів» |
| Stage 1 — inline-планування | РЕАЛІЗОВАНО | `runner.rs` `needs_planning`/`build_plan_prompt`/`latest_plan_decision` | — (Етап 1 пропускається за `hint: atomic` або наявного плану; порожній результат читається як неявний atomic) |
| Контекст агента (system-prompt, deps, plan, prior attempts) | РЕАЛІЗОВАНО | `runner.rs` `build_agent_prompt`/`dep_facts`/`prior_attempts` | — (другий шар стискання читає `run-summary.md`, якщо він є; генератора самого файлу ще немає — окремий рядок) |
| `run-summary.md` (другий шар стискання) | РЕАЛІЗОВАНО | `runner.rs` (генерація за `run_summary_threshold`), читач — `build_agent_prompt` | — |
Expand Down Expand Up @@ -95,15 +103,15 @@
| Гейт 2 — plan-review з підписом | ЧАСТКОВО | `spawn.rs` | У фронтматері рішення немає блоку `approved_by` з підписом |
| Гейт 3 — аудит-вердикт людини | ЧАСТКОВО | `signal.rs` | Немає блоку підпису в `audit-result_NNN.md` |
| Push типів 1/2/3 | РЕАЛІЗОВАНО | маршрутизація — `relay/lib/push.mjs`; транспорт — `fcm-sink.mjs` (FCM HTTP v1) і `push-sink.mjs` (dev), вибір — `create-push.mjs`; контракт — `tests/push-sink-contract.test.mjs` | — (APNs окремо не робимо: FCM доставляє на iOS; доставка живим FCM не перевірена — stub-`fetch` доводить лише форму запитів) |
| PII-directory | ЧАСТКОВО | `mt-core/directory.rs` | Мертвий код: парсер є, викликів немає `Escalation.to_account_id` ніхто не заповнює |
| PII-directory | ВІДСУТНЄ (мертвий код) | `mt-core/directory.rs` | Парсер є, викликів немає **жодного** — звірено 2026-08-17. Наслідок: `Escalation.to_account_id` порожній, адресний push типу 3 для ескалацій не працює. Рішення відкладене — «Відкриті питання» №6 |

## i18n, retro, мандати

| Підсистема | Вердикт | Коментар |
| --- | --- | --- |
| i18n: контрактне ядро (base-мова, `source_hash`, «що перекладається», contract-aware сегментація) | РЕАЛІЗОВАНО | `mt-core/i18n.rs`: `I18nConfig`, `TranslationMeta`/`is_fresh`, `is_translatable` (триступенева схема), `segment` (fail-closed), `materialize` (read path, лише свіжі); неоднозначність спеки щодо `## Task`/`## Done when` вирішена явно — див. «Закриті питання» |
| i18n: сховище `refs/mt/i18n`, write path, черга регенерації, live-шар | ВІДСУТНЄ | Ядро є (рядок вище); запис/читання i18n-ref разом із fenced publish; компіляція authored-правки в base; фонова черга agent-server; live-переклад Envelope за capability `self-translate`; `layers/` лишається суміжною задачею з іншим контейнером і конфігом |
| i18n: `lang` у ClientHello | РЕАЛІЗОВАНО | `agent-protocol/handshake.rs`; далі хендшейку `lang` не використовується |
| i18n: `lang` у ClientHello | ЧАСТКОВО | `agent-protocol/handshake.rs`; звірено 2026-08-17 — далі хендшейку `lang` не читає ніхто, live-переклад стрічки не реалізований («Відкриті питання» №5) |
| retro: датасет і детерміновані пропозиції | РЕАЛІЗОВАНО | `mt-core/retro.rs` (`collect_runs`, `analyze`, приватний звіт `~/.nitra/retro/<period>.md`); CLI — `mt retro`/`mt retro --show`; — (opt-in `retro.enabled`, дефолт `false` — контрактна вимога глави) |
| retro: LLM-крок, innovation, impact | ВІДСУТНЄ | Детермінований датасет є (рядок вище); LLM-аналіз поверх датасету; `innovation_NNN.md` і baseline; impact-зрізи; фоновий прогін за `schedule_days`; агрегатор компетенцій як другий вихід |
| Мандати: карта, валідація змін, квіз-гейт, ШІ-мандати | РЕАЛІЗОВАНО | `crates/mt-mandates`: `parse_mandates`, `effective_owner`, `validate_mandate_change` (generation fencing, подвійний підпис `escalates_to`, «остання константа»), `validate_approval` |
Expand Down Expand Up @@ -147,4 +155,20 @@

## Відкриті питання

Немає — усі три конфлікти зрізу 2026-08-09 закриті.
Питання, а не залишок роботи: кожне з них потребує **рішення**, і поки його немає, робити наступний крок наосліп дорожче, ніж спитати.

1. **Пісочниця лише радить, а називається sandbox.** `mt-core/sandbox.rs` жорстко відмовляє там, де агент **питає** дозволу (ACP-гейт), і експортує політику в ENV. Але автономний хід виконує підписочний CLI власним процесом — MT не перехоплює його syscall-и. Питання: чи заводимо реальну ізоляцію (`sandbox-exec` на macOS, `bubblewrap` на Linux) і платимо сумісністю з CLI, чи фіксуємо в спеці, що `skill_profiles` — **декларативна** політика для кооперативних виконавців. Зараз назва обіцяє більше за реалізацію.

2. **Гейт не знає вузла кімнати.** Політика в ACP-гейті — стеля **хоста** (обʼєднання всіх налаштованих профілів), бо інтерактивна сесія підхоплює вузол уже під час розмови. Щоб звузити до `a.md.skills` конкретного вузла, гейт має отримати вузол кімнати. Питання чисто про сигнатуру `PermissionFactory` — але це зміна публічного API `agent-server`.

3. **MCP: набір проєкту проти набору ходу.** Спека каже «схеми тулів потрапляють у контекст агента лише для ходів цього surface — спеціалізація і є економія контексту». Зараз оголошується обʼєднання профілів проєкту, бо ACP-сесія кімнати відкривається один раз. Звуження вимагає переоткривати сесію на кожному перемиканні surface — тобто рвати контекст розмови заради економії контексту. Питання: що дорожче.

4. **`layers/` і `mt-core/i18n.rs` — дві різні відповіді на одне питання.** Обидва перекладають markdown, але різними контейнерами (`x.<lang>.md` у робочому дереві проти `refs/mt/i18n`), різними конфігами (`layers.json` проти `.mt.json`) і різними моделями свіжості. Поки i18n не має сховища, вони не конфліктують. Щойно зʼявиться — треба вирішити, чи `layers/` стає споживачем ядра, чи лишається окремим інструментом для доків.

5. **Live-переклад Envelope нікому не належить.** `i18n.md` каже, що клієнт без capability `self-translate` отримує текстові події вже перекладеними. Це означає виклик моделі на кожну текстову подію на боці хоста — вартість і затримка в гарячому шляху стрічки. Питання: чи це справді хост, чи relay, чи це відкладається до появи дешевого локального перекладача. Зараз `lang` далі хендшейку не використовується взагалі.

6. **`directory.rs` — мертвий код десятий тиждень.** Парсер `.mt/directory.json` є, викликів немає, тому `Escalation.to_account_id` ніхто не заповнює — і адресний push типу 3 для ескалацій де-факто не працює, хоча маршрутизація його чекає. Або дописати резолв у емітер, або видалити крейт-модуль (прецедент `mt-napi`). Тримати мертвим — найгірший із трьох варіантів.

7. **Хто вирішує «вибір, а не баг».** `mandates.md` каже, що `decision-request` **ніколи не пише виконавець** — його пакує агент escalation-intake. Механіка розвилки готова, але судження робить людина, яка викликає `mt escalate`. Питання: чи заводимо цього агента як окремого актора (як зробили з аудитором і EngineerAgent), чи лишаємо ручний виклик як штатний шлях фази 0.

8. **Rust-тести без CI.** Питання не «чи додати `cargo test` у workflow», а що робити з тим, що 9 із 306 тестів чутливі до навантаження: додати їх у CI як є — означає завести червоний, який навчаться ігнорувати. Треба або розвʼязати їх від реального часу (інʼєкція годинника, як у `presence`/`push`), або позначити `#[ignore]` і ганяти окремим повільним джобом.
5 changes: 5 additions & 0 deletions relay/.changes/260817-1037.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
bump: patch
section: Fixed
---
eslint і cspell на повному наборі файлів (CI-набір, не дельта): ReDoS-схильні regex замінено циклом, node-глобали імпортовано явно, base64url через Uint8Array
16 changes: 15 additions & 1 deletion relay/lib/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@
*/
import { randomUUID } from 'node:crypto'

/**
* Прибирає кінцеві слеші без регулярного виразу.
*
* Наївний `/\/+$/` дає суперлінійний бектрекінг на рядку з довгим хвостом
* слешів — дешевий вектор ReDoS, тому це цикл, а не regex.
* @param {string} value адреса
* @returns {string} адреса без кінцевих слешів
*/
function stripTrailingSlashes(value) {
let end = value.length
while (end > 0 && value[end - 1] === '/') end -= 1
return value.slice(0, end)
}

/** Дефолтний час життя сесії — доба (реалізація, не контракт). */
export const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000

Expand Down Expand Up @@ -121,7 +135,7 @@ export class KratosAuth {
*/
constructor({ store, baseUrl, fetch = globalThis.fetch }) {
this.store = store
this.baseUrl = String(baseUrl ?? '').replace(/\/+$/, '')
this.baseUrl = stripTrailingSlashes(String(baseUrl ?? ''))
this.fetch = fetch
}

Expand Down
1 change: 1 addition & 0 deletions relay/lib/create-auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* `NODE_ENV`), щоб продакшн-режим не можна було ввімкнути наполовину:
* без адреси Kratos перевіряти сесію нічим.
*/
import process from 'node:process'
import { DevMagicAuth, KratosAuth } from './auth.mjs'

/**
Expand Down
1 change: 1 addition & 0 deletions relay/lib/create-push.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* без будь-якої з частин непрацездатний, і краще лишитись у dev-режимі
* явно, ніж падати на першій нотифікації.
*/
import process from 'node:process'
import { FcmPushSink, GoogleAccessToken } from './fcm-sink.mjs'
import { DevPushSink } from './push-sink.mjs'

Expand Down
1 change: 1 addition & 0 deletions relay/lib/create-store.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* перемикання не змінює поведінки relay — лише те, чи переживають дані
* рестарт процесу.
*/
import process from 'node:process'
import { InMemoryStore } from './store.mjs'

/**
Expand Down
19 changes: 17 additions & 2 deletions relay/lib/fcm-sink.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@
*/
import { Buffer } from 'node:buffer'
import { createSign } from 'node:crypto'
import { URLSearchParams } from 'node:url'

/**
* Прибирає кінцеві слеші без регулярного виразу.
*
* Наївний `/\/+$/` дає суперлінійний бектрекінг на рядку з довгим хвостом
* слешів — дешевий вектор ReDoS, тому це цикл, а не regex.
* @param {string} value адреса
* @returns {string} адреса без кінцевих слешів
*/
function stripTrailingSlashes(value) {
let end = value.length
while (end > 0 && value[end - 1] === '/') end -= 1
return value.slice(0, end)
}

/** Скоуп, якого вимагає FCM HTTP v1. */
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
Expand All @@ -32,7 +47,7 @@ const ASSERTION_TTL_SEC = 3600
* @returns {string} base64url
*/
function base64url(value) {
return Buffer.from(value).toString('base64url')
return Buffer.from(value).toBase64({ alphabet: 'base64url', omitPadding: true })
}

/**
Expand Down Expand Up @@ -123,7 +138,7 @@ export class FcmPushSink {
this.projectId = projectId
this.accessToken = accessToken
this.fetch = fetch
this.endpoint = String(endpoint).replace(/\/+$/, '')
this.endpoint = stripTrailingSlashes(String(endpoint))
}

/**
Expand Down
2 changes: 1 addition & 1 deletion relay/lib/presence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,6 @@ export class Presence {
this.rooms.delete(root)
return []
}
return [...room.values()]
return room.values().toArray()
}
}
Loading
Loading