diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml deleted file mode 100644 index 71ac6a7..0000000 --- a/.cargo/mutants.toml +++ /dev/null @@ -1,7 +0,0 @@ -# .cargo/mutants.toml — universal cargo-mutants baseline (test.mdc). -# Цей baseline нейтральний: він не робить припущень про framework/app shell, -# не виключає platform glue, generated wrappers або binary entrypoints. -# Framework-specific tuning (Tauri, Capacitor тощо) належить відповідним -# правилам — вони без дублювання доповнюють цей файл, не перетирають його. -# cargo-mutants має робочі defaults; цей файл — стартова точка для customization. -# Документація: https://mutants.rs/ diff --git a/.changes/260722-1412.md b/.changes/260722-1412.md new file mode 100644 index 0000000..46a6b7f --- /dev/null +++ b/.changes/260722-1412.md @@ -0,0 +1,5 @@ +--- +bump: minor +section: Changed +--- +Прибрано workspaces із кореня — розблоковано n-cursor release для @7n/mt (раніше монорепо-детекція завжди пропускала корінь). layers/ став вкладеним пакетом зі своїм bun.lock; доданий pretest-скрипт (bun install --cwd layers) встановлює його залежності перед vitest. diff --git a/.cspell.json b/.cspell.json index eca7ce4..6977e6a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -14,9 +14,7 @@ "*.svg", "**/k8s/**/*.yaml", "docs/adr/**", - "**/dist/**", - "target/**", - "Cargo.lock" + "**/dist/**" ], "words": [ "aaif", diff --git a/.cursor/rules/n-npm-module.mdc b/.cursor/rules/n-npm-module.mdc deleted file mode 100644 index d383a91..0000000 --- a/.cursor/rules/n-npm-module.mdc +++ /dev/null @@ -1,364 +0,0 @@ ---- -description: Оформлення репозиторію для npm модуля -globs: "npm/**,**/package.json,**/hk.pkl,.github/workflows/npm-publish.yml,**/tsconfig*.json" -alwaysApply: false -version: '1.14' ---- - -Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**. - -## Версія та CHANGELOG - -Версію (`version` у **`npm/package.json`**) і **`npm/CHANGELOG.md`** **не редагуй вручну** — навіть для hotfix. Єдиний артефакт зміни — **change-файл** (`npx @7n/n ch [--bump ] [--section ] [--message "<…>"]`); bump `version` і генерацію секції CHANGELOG робить `n-rules release` у CI на `main`. Будь-який ручний bump `version` поза CI завалює `check changelog` — навіть із change-файлом. - -Повна модель (база порівняння, інверсія шляхів, формат CHANGELOG, post-release-інваріант «верхня секція CHANGELOG == `version`») — у **`n-changelog.mdc`** (джерело істини). Це правило їй підпорядковане й власних інструкцій bump/CHANGELOG не дублює. - -### Канонічний крок `npm-changelog` у hk.pkl - -У v14 команду `check` прибрано (уніфікована поверхня `lint`) — виклик `npx @7n/rules check changelog` у hk.pkl **завалить** кожен коміт з `❌ Невідома команда: check`. Канонічний pre-commit-крок (hk `amends hk@1.42.0`): - -```pkl -["npm-changelog"] { - glob = List("npm/**") - check_first = false - fix = "N_RULES_CHANGELOG_AUTOFIX=1 bun ./npm/bin/n-rules.js lint changelog" -} -``` - -Лише `fix` (без `check`): env-прапорець `N_RULES_CHANGELOG_AUTOFIX=1` вмикає autofix-режим — за відсутності change-файлу правило само створює його (`patch`/`Changed`, subject останнього коміту) і одразу ставить у git-індекс (`git add`) через `writeChange`/`reportOrFixMissingChangeFile`. Тому додатковий `stage = List("npm/.changes/**")` у цьому wiring **не потрібен** — індексація вже всередині JS-кроку. `package_structure` (`npx @7n/rules lint npm-module`) валідує цей крок: fail на застарілий `check changelog` і fail, якщо кроку `npm-changelog` немає взагалі. - -## Швидкий gate через conftest - -Rego-пакети (запускаються через `npx @7n/rules fix`): - -- `npm_module.npm_publish_yml` — template-driven перевірка `.github/workflows/npm-publish.yml` (deep-subset: усі обовʼязкові поля й кроки з канонічного сніпету). -- `package.json`: - -```json -{ "files": ["types"] } -``` - -- `npm-publish.yml`: - -```yaml -name: npm-publish - -on: - push: - paths: - - 'npm/**' - branches: - - main - -concurrency: - group: ${{ github.ref }}-${{ github.workflow }} - cancel-in-progress: true - -jobs: - release-publish: - runs-on: ubuntu-latest - permissions: - contents: write # commit-back версії + git-тег - id-token: write # КРИТИЧНО для OIDC! - - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - fetch-depth: 0 - - - uses: ./.github/actions/setup-bun-deps - - - uses: actions/setup-node@v6 - with: - node-version: '24' # includes npm@11.6.0 - registry-url: 'https://registry.npmjs.org' - - - name: Configure git identity + push auth - # persist-credentials лишається false (ga-rule, безпека): checkout не зберігає токен. - # Release робить commit-back `git push`, тож даємо job-scoped ephemeral GITHUB_TOKEN - # явно через remote-url (а не persisted у git config усього прогону). - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" - - - name: Release (bump + CHANGELOG + tag) - run: bunx n-rules release - - - name: Publish package - uses: JS-DevTools/npm-publish@v4.1.5 - with: - package: npm/package.json -``` - -- `package.json`: - -```json -{ "workspaces": ["npm"] } -``` - -- `tsconfig.emit-types.json`: - -```json -{ - "compilerOptions": { - "allowJs": true, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "types", - "skipLibCheck": true - } -} -``` - -## Rego-gate: конфігурація генерації типів `npm/tsconfig.emit-types.json` - -Rego-пакет: `npm-module.emit_types_config` - -Цільовий файл: `npm/tsconfig.emit-types.json` - -### Що перевіряється - -Leaf-by-leaf порівняння з канонічним сніпетом (через `--data`): кожне поле всередині `compilerOptions` має точно відповідати очікуваному значенню. Якщо секція `compilerOptions` відсутня або не є обʼєктом — окрема deny-помилка. - -Канонічний сніпет: [tsconfig.emit-types.json.snippet.json](./template/tsconfig.emit-types.json.snippet.json) - -### Допустимі відхилення - -Додаткові поля у `compilerOptions` (наприклад `rootDir`, `baseUrl`) не спричиняють помилку — перевіряється лише наявність і коректність обовʼязкових ключів зі сніпету. - -### Приклади - -✓ Правильно: - -```json -{ - "compilerOptions": { - "allowJs": true, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "types", - "skipLibCheck": true - } -} -``` - -✗ Неправильно — неправильний `outDir`: - -```json -{ "compilerOptions": { "outDir": "dist" } } -``` - -✗ Неправильно — відсутній `compilerOptions`: - -```json -{} -``` - -## Module-level JSDoc як pointer - -Якщо поряд із `js/.mjs` є файл `js/docs/.md` — module-level JSDoc у `.mjs` має бути **pointer** (не більше одного непорожнього рядка), а не повноцінний наратив. - -Логіка перевірки (`header_doc_pointer.mjs`): - -- Сканується перший JSDoc-блок (`/** … */`) до першого `import`/`export` у файлі. -- Підраховуються непорожні рядки тіла (після зрізання `*`-відступу). -- Якщо їх більше одного — `check` падає з повідомленням про те, що `docs/.md` вже описує поведінку і module-level JSDoc має залишатись коротким. - -**Покриття:** `npm/rules/*/js/*.mjs` і `npm/skills/*/js/*.mjs` (не тестові файли `*.test.mjs`). Якщо `docs/.md` відсутня — обмежень на довжину JSDoc немає. - -**Приклад правильного pointer-JSDoc:** - -```js -/** @see ./docs/package_structure.md */ -import { existsSync } from 'node:fs' -``` - -## Rego-gate: валідація `npm/package.json` - -Rego-пакет: `npm-module.npm_package_json` - -Цільовий файл: `npm/package.json` - -### Що перевіряється - -**Поле `types`** (логіка в rego, не template-driven): - -- Має відповідати патерну `./types/index.d.ts` або `./types/<назва>.d.ts|.d.mts`. -- Шляхи поза директорією `types/` або з іншими розширеннями (`.ts` без `.d`) — deny. - -**Поле `files`** (частково template-driven): - -- Обовʼязкове, має бути непорожнім масивом. -- Subset-of перевірка: кожне значення з канонічного сніпету має бути присутнє у `files`. За замовчуванням — `"types"` обовʼязковий. - -**Поле `devDependencies`** (inverse-pattern, логіка в rego): - -- Не публікуються користувачам пакета — має бути відсутнє або порожнє `{}`. -- Наявність будь-яких devDeps → deny з переліком залежностей. Dev-інструментарій переноситься у кореневий `package.json`; CLI-тули, які пакет спавнить через `bunx` у репозиторіях-споживачах (пінінг версій), — у `dependencies` (кореневе bun-правило `package_json` такі пакети в root devDeps не пускає). - -Канонічний сніпет `files`: [package.json.snippet.json](./template/package.json.snippet.json) - -FS-перевірки (наявність файлу зі шляху `types`, скан tarball на тест-патерни) — у JS-перевірці, не тут. - -### Приклади - -✓ Правильно: - -```json -{ - "name": "@7n/rules", - "types": "./types/bin/n-rules.d.ts", - "files": ["types", "mdc", "bin", "CHANGELOG.md"], - "dependencies": { "oxc-parser": "^0.128.0" } -} -``` - -✗ Неправильно — `types` поза директорією `types/`: - -```json -{ "types": "./dist/index.d.ts" } -``` - -✗ Неправильно — `files` без `"types"`: - -```json -{ "files": ["bin", "mdc"] } -``` - -✗ Неправильно — наявні `devDependencies`: - -```json -{ "devDependencies": { "@7n/rules": "^1.0.0" } } -``` - -## Структура монорепо та компактний пакет - -Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**. - -Мета — **максимально компактний** опублікований пакет: у npm потрапляє тільки те, що потрібно під час `require`/`import` користувачем. - -- **`"files"` обовʼязковий** у `npm/package.json` як **whitelist** того, що публікується (без `"files"` npm пакує майже все — це антипатерн для цього правила). -- **Тести й фікстури не публікуються — через негативні glob-патерни у `"files"`.** Тести можна (і зазвичай зручніше) тримати **поруч з кодом** усередині шляхів, перелічених у `"files"` (наприклад `*.test.mjs` поруч з модулем чи `fixtures/` під `rules//js/`). Щоб вони не потрапляли у tarball, `"files"` **обовʼязково має містити негативні glob-патерни**, що їх виключають. Покрий усі форми тестового: фреймворк-тести (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, …), test-style каталоги (`tests/`, `__tests__/`, `fixtures/`, `__fixtures__/`, `spec/`, `test/`), файли за патернами `*.test.*` / `*.spec.*`. Орієнтовний набір: `"!**/*.test.*"`, `"!**/*.spec.*"`, `"!**/test-helpers.*"`, `"!**/fixtures/**"`, `"!**/__tests__/**"`. **Rego (`*_test.rego`):** за конвенцією conftest юніт-тест лежить поруч з полісі у тому самому `package` — `*_test.rego` усередині опублікованого `policy/` дозволені; якщо потрібна максимальна компактність, додай `"!**/*_test.rego"` явно (як у самому `@7n/rules`). -- **Лише runtime-залежності у `npm/package.json`.** `devDependencies` тримай у **кореневому** `package.json` монорепо — тоді `npm install @nitra/` не тягне інструментарій, потрібний лише для розробки самого пакета. - -Перевірки JS (`package_structure.mjs`): - -- Наявність `package.json`, `npm/`, `npm/package.json`. -- Walk шляхів з `"files"` з застосуванням негативних patterns і скан залишку на тест-патерни (walking + AST). Якщо після застосування негативних patterns у tarball лишається test-style файл — `check` падає з вказівкою, який саме негативний glob треба додати у `"files"`. - -## TypeScript declaration (`npm/types`) - -Файл **`npm/package.json`** має містити **`"types"`** (шлях до головного `.d.ts` або `.d.mts` під **`./types/…`**) і запис **`"types"`** у **`files`**, щоб npm публікував декларації. - -Генерація — через **`tsc`** і **`bunx -p typescript`** (окремий пакет **`typescript`** у `devDependencies` не потрібен). - -### Варіант A: є вихідний **`.js`** під **`npm/src/`** - -Якщо під **`npm/src`** (рекурсивно) є хоча б один файл **`.js`**: - -- **`"types": "./types/index.d.ts"`**; -- у **hk** на **`pre-commit`** з каталогу **`npm/`** викликай: - -```bash -bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types --skipLibCheck -``` - -Якщо glob не розгортається — **`bash -O globstar`** або **`include`** у **`tsconfig`** з тими самими **`compilerOptions`**. - -### Варіант B: немає **`npm/src/**/*.js`** (наприклад лише **`bin/`**, **`scripts/**/*.mjs`**) - -Не створюй штучний **`src/index.js`**. Замість цього: - -1. Додай **`npm/tsconfig.emit-types.json`** з **`include`** на реальні шляхи (`.js` / `.mjs`), **`compilerOptions`**: **`allowJs`**, **`declaration`**, **`emitDeclarationOnly`**, **`outDir`: `"types"`**, **`skipLibCheck`**: **`true`** (за потреби **`rootDir`**, **`module`**, **`moduleResolution`**). -2. Після першого **`tsc`** подивись, який **`.d.ts`** / **`.d.mts`** відповідає публічному API, і вкажи його в **`types`** (наприклад **`./types/bin/cli.d.ts`**). -3. У **hk** на **`pre-commit`** з **`npm/`** викликай **`tsc -p tsconfig.emit-types.json`**. Якщо через імпорти **TypeScript** все одно згенерує зайві **`.d.mts`** у **`types/`**, після **`tsc`** обрізай дерево до потрібного entrypoint (наприклад залиш лише **`types/bin/n-rules.d.ts`** через **`find`**), щоб у пакеті не збирались декларації внутрішніх модулів. - -Файл **`tsconfig.emit-types.json`** тримай у репозиторії для **hk** / локальної генерації; у **`files`** його не додавай, якщо не хочеш публікувати його на **npm**. - -## Git hooks: hk + pre-commit - -У корені репозиторію — **`hk.pkl`** (або **`.config/hk.pkl`**) з **`["pre-commit"]`** і командою з відповідного варіанту (A або B) вище. - -Після додавання **`hk.pkl`**: **`hk install`**. - -## npm publish - -**`npm-publish.yml`:** push у **`main`**, **`on.push.paths`** з **`npm/**`**, **`JS-DevTools/npm-publish@v4.1.5`**, **`with.package: npm/package.json`**, **`permissions.id-token: write`** (OIDC на npm). - -Workflow робить **release + publish** одним job (`release-publish`): крок **`Release (bump + CHANGELOG + tag)`** (`bunx n-rules release` — агрегує change-файли, bump `version`, генерує секцію `CHANGELOG.md`, ставить git-тег) виконується **перед** публікацією. Тому потрібні **`permissions.contents: write`** і **`persist-credentials: true`** з **`fetch-depth: 0`** на `checkout` (release пушить commit-back версії та тег), а також локальний composite **`./.github/actions/setup-bun-deps`** і крок `Configure git identity`. Це узгоджено з **`n-changelog`**: `version`/`CHANGELOG.md` змінює лише `n-rules release` у CI на `main`. Програмна перевірка (`npm_module.npm_publish_yml`) звіряє **весь канонічний сніпет** напряму (`target.json:"check":"template"`, generic deep-subset): усі поля й кроки сніпета (`on.push.paths`/`branches`, `concurrency`, `permissions.contents/id-token`, `checkout` з `persist-credentials/fetch-depth`, `setup-bun-deps`, `Configure git identity`, `Release`, publish-крок) **обовʼязкові**; зайві кроки/поля дозволені (subset-of), масиви матчаться за наявністю (порядок кроків не важить). Сніпет — єдине джерело істини: його редагування одразу змінює enforce, без правок rego й без міграторів. - -- Канон: [npm-publish.yml.snippet.yml](./policy/npm_publish_yml/template/npm-publish.yml.snippet.yml) - -## Канонічні конфіги - -- Кореневий `package.json` (workspaces): [package.json.snippet.json](./policy/root_package_json/template/package.json.snippet.json) -- `npm/package.json` (whitelist `files` обовʼязково має містити `types`): [package.json.snippet.json](./policy/npm_package_json/template/package.json.snippet.json) -- `npm/tsconfig.emit-types.json` (canonical `compilerOptions` для emit-types): [tsconfig.emit-types.json.snippet.json](./policy/emit_types_config/template/tsconfig.emit-types.json.snippet.json) - -## Rego-gate: валідація кореневого `package.json` - -Rego-пакет: `npm-module.root_package_json` - -Цільовий файл: `package.json` (корінь репозиторію) - -### Що перевіряється - -Subset-of перевірка масиву `workspaces`: кожне значення з канонічного сніпету має бути присутнє у `workspaces`. За замовчуванням обовʼязковий елемент — `"npm"`. - -Якщо `workspaces` відсутнє або не є масивом — окрема deny-помилка. - -Канонічний сніпет: [package.json.snippet.json](./template/package.json.snippet.json) - -Решта перевірок кореневого `package.json` (заборонені поля, devDeps лише `@nitra/*`) — у пакеті `bun.package_json`. FS-перевірки (наявність директорії `npm/`, `npm/package.json`) — у JS. - -### Приклади - -✓ Правильно — `workspaces` містить `"npm"`: - -```json -{ "workspaces": ["npm"] } -``` - -✓ Правильно — `workspaces` містить `"npm"` разом з іншими: - -```json -{ "workspaces": ["demo", "npm", "tests"] } -``` - -✗ Неправильно — `workspaces` відсутній: - -```json -{ "name": "monorepo" } -``` - -✗ Неправильно — `workspaces` без `"npm"`: - -```json -{ "workspaces": ["demo"] } -``` - -## Валідація `main.json` правил - -Кожне правило у `npm/rules//` повинно мати коректний `main.json` (метадані правила). - -Перевірки (`rule_meta.mjs`): - -- **`main.mdc` обовʼязковий** у кожному `npm/rules//` — без нього `check` падає. -- **`auto.md` є пережитком** — якщо такий файл залишився, `check` вимагає його видалити (метадані тепер у `main.json`). -- **`main.json` має бути валідним JSON** із коректними полями: - - **`auto`** (опційне): `"завжди"` / масив glob-рядків / `{ glob }` / `{ predicate }`. Якщо `predicate` вказано — він має бути зареєстрований у `RULE_PREDICATES`. - - **`lint` заборонене**: rule-level scope скасовано — lint-поверхня декларується per-concern у `//concern.json#lint` (spec 2026-06-28-concern-lint-scope-design). - -## Валідація `main.json` скілів - -Кожен скіл у `npm/skills//` повинен мати коректний `main.json` (метадані скіла). - -Перевірки (`skill_meta.mjs`): - -- **`auto.md` є пережитком** — якщо такий файл залишився, `check` вимагає його видалити (метадані тепер у `main.json`). -- **`main.json` має бути валідним JSON** із коректними полями: - - **`worktree`** (обовʼязкове): `boolean`. Якщо `true` — скіл запускається виключно в окремому git-worktree. - - **`auto`** (опційне): `"завжди"` або непорожній масив ідентифікаторів правил-тригерів. - - **`requireRoot`** (опційне): `boolean`. Значення `false` несумісне з `worktree: true` — таку комбінацію `check` відхиляє. diff --git a/.cursor/rules/n-rust.mdc b/.cursor/rules/n-rust.mdc deleted file mode 100644 index 46fa51e..0000000 --- a/.cursor/rules/n-rust.mdc +++ /dev/null @@ -1,73 +0,0 @@ ---- -description: Перевірка Rust коду -globs: "**/{Cargo.toml,Cargo.lock,rustfmt.toml,clippy.toml,.vscode/extensions.json,package.json},**/*.rs" -alwaysApply: false -version: '1.4' ---- - -Правило забезпечує форматування (rustfmt), лінт (clippy), CI workflow та покриття для Rust-проєктів. - -## Перевірка `.github/workflows/lint-rust.yml` - -Rego-пакет: `rust.lint_rust_yml` - -Цільовий файл: `.github/workflows/lint-rust.yml` - -Перевіряє: - -- кожен `uses`-крок з канону присутній у workflow (підмножина): `actions/checkout@v6`, `dtolnay/rust-toolchain@stable`, `Swatinem/rust-cache@v2` -- кожен `run`-крок з канону присутній як підрядок серед усіх `run`-кроків: `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings` -- канон завантажується через `--data` з template-сніпету — drift-safe: зміна шаблону автоматично рухає перевірку - -Канонічний template: [lint-rust.yml.snippet.yml](./template/lint-rust.yml.snippet.yml) - -Вимога `Swatinem/rust-cache@v2` після `dtolnay/rust-toolchain@…` тут перевіряється лише для цього конкретного файлу (як частина канону). Та сама вимога для **будь-якого** іншого workflow (coverage, release/build через `tauri-apps/tauri-action`, кастомні) — concern `rust/toolchain_cache`. - -Tauri-проєкти (`src-tauri/Cargo.toml`) додатково потребують у цьому файлі apt-кроку системних залежностей Linux перед Clippy — concern `tauri/linux_deps` (tauri.mdc), не частина канону тут. - -## Rego-gate: `cargo`, `rustfmt`, `clippy` заборонені у `package.json` - -Rego-пакет: `rust.package_json` - -Цільовий файл: `package.json` - -`cargo`, `rustfmt` і `clippy` — це частина **Rust toolchain**, а не npm-пакети. Вони **не додаються** у `dependencies`, `devDependencies` або `peerDependencies`. Встановлення: - -- **локально** — через `rustup` (`rustup component add rustfmt clippy`); -- **у CI** — через крок `dtolnay/rust-toolchain@stable` із `with.components: rustfmt, clippy`. - -Gate видає deny, якщо `cargo`, `rustfmt` або `clippy` з'являється у будь-якій секції залежностей `package.json`. - -## Rust-cache після rust-toolchain — у будь-якому workflow - -Concern: `rust/toolchain_cache` (JS-detector, `main.mjs` — не rego) - -Цільові файли: `.github/workflows/*.yml`, `.github/workflows/*.yaml` (кожен файл окремо) - -У кожному job-і, що ставить Rust toolchain через `dtolnay/rust-toolchain@stable` -(або будь-яку іншу версію), одразу після цього кроку має бути `Swatinem/rust-cache@v2` — -незалежно від того, lint це, coverage чи release/build job, що збирає Tauri-додаток -через `tauri-apps/tauri-action`. Без кешу CI щоразу заново качає весь cargo registry. - -Це узагальнення перевірки `rust/lint_rust_yml` (яка звіряє канонічний -`.github/workflows/lint-rust.yml` цілком) — той concern лишається specific до -lint-workflow, цей — universal per-job перевірка для решти workflow-файлів -(coverage, release, будь-який кастомний). - -Якщо job також викликає `tauri-apps/tauri-action`, а `Cargo.toml` лежить не в -корені репо, а під `/src-tauri/` (типовий Tauri-layout) — крок -`Swatinem/rust-cache@v2` має мати `with.workspaces: /src-tauri`. - -Автофікс (`fix-toolchain_cache.mjs`, T0, `fixability: config`) вставляє відсутній -крок одразу після `dtolnay/rust-toolchain@…` (і `with.workspaces`, де потрібно) -текстовим splice-ом — зберігає формат/коментарі, ідемпотентно. - -## Перевірка `.vscode/extensions.json` (Rego-gate) - -Rego-пакет: `rust.vscode_extensions` - -Цільовий файл: `.vscode/extensions.json` - -Семантика `contains`: кожен запис з канону має бути присутнім у `recommendations` — додаткові розширення дозволені. Канон завантажується через `--data` з template-сніпету. - -Канонічний template: [extensions.json.snippet.json](./template/extensions.json.snippet.json) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 41d119f..1a9bc41 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -3,9 +3,8 @@ name: npm-publish on: push: paths: - - 'npm/**' - - 'crates/**' - - 'packages/**' + - 'docs/**' + - 'package.json' branches: - main workflow_dispatch: {} @@ -14,78 +13,11 @@ concurrency: group: ${{ github.ref }}-${{ github.workflow }} cancel-in-progress: true -permissions: {} # deny-all за замовчуванням; кожен job оголошує потрібне +permissions: {} # deny-all за замовчуванням; job оголошує потрібне jobs: - # ── Збірка prebuilt-артефактів (mt-scanner бінарник + mt napi-аддон) ────────── - build-binaries: - permissions: - contents: read # лише checkout; артефакти йдуть через runtime-токен - strategy: - fail-fast: true - matrix: - include: - - os: macos-14 # native Apple Silicon - target: aarch64-apple-darwin - napi-target: aarch64-apple-darwin - pkg: mt-darwin-arm64 - napi-artifact: mt.darwin-arm64.node - cdylib: libmt_napi.dylib - zig: false - - os: ubuntu-latest # бінарник — static musl через cargo-zigbuild; аддон — gnu - target: x86_64-unknown-linux-musl - napi-target: x86_64-unknown-linux-gnu - pkg: mt-linux-x64 - napi-artifact: mt.linux-x64-gnu.node - cdylib: libmt_napi.so - zig: true - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false # build-only, токен не потрібен - - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }},${{ matrix.napi-target }} - - - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-zigbuild (Linux musl) - if: ${{ matrix.zig }} - run: | - pip install ziglang - cargo install --locked cargo-zigbuild - - - name: Build mt-scanner (zigbuild) - if: ${{ matrix.zig }} - run: cargo zigbuild --release --target ${{ matrix.target }} -p mt-cli - - - name: Build mt-scanner (native) - if: ${{ !matrix.zig }} - run: cargo build --release --target ${{ matrix.target }} -p mt-cli - - # napi-аддон — cdylib; .node = той самий файл під napi-конвенцією імені. - - name: Build mt napi addon - run: cargo build --release --target ${{ matrix.napi-target }} -p mt-napi - - - name: Stage artifacts into platform package - run: | - cp "target/${{ matrix.target }}/release/mt-scanner" "packages/${{ matrix.pkg }}/mt-scanner" - chmod +x "packages/${{ matrix.pkg }}/mt-scanner" - cp "target/${{ matrix.napi-target }}/release/${{ matrix.cdylib }}" "packages/${{ matrix.pkg }}/${{ matrix.napi-artifact }}" - - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.pkg }} - path: | - packages/${{ matrix.pkg }}/mt-scanner - packages/${{ matrix.pkg }}/*.node - if-no-files-found: error - - # ── Release-bump + публікація платформних підпакетів і головного @7n/mt ────── + # ── Release-bump + публікація @7n/mt (спека) ────────────────────────────── release-publish: - needs: build-binaries runs-on: ubuntu-latest permissions: contents: write # commit-back версії + git-тег @@ -113,39 +45,10 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" - - name: Download platform binaries - uses: actions/download-artifact@v4 - with: - path: packages # → packages//{mt-scanner, mt..node} - - name: Release (bump + CHANGELOG + tag) run: bunx n-cursor release - # Платформні підпакети версіонуються в lockstep з @7n/mt; optionalDependencies - # головного пакета теж синхронізуються з новою версією перед публікацією. - - name: Sync platform versions to @7n/mt - run: | - VERSION=$(node -p "require('./npm/package.json').version") - echo "Publishing version $VERSION" - for pkg in mt-darwin-arm64 mt-linux-x64; do - V="$VERSION" node -e "const f='packages/'+process.argv[1]+'/package.json';const p=require('./'+f);p.version=process.env.V;require('fs').writeFileSync(f, JSON.stringify(p,null,2)+'\n')" "$pkg" - chmod +x "packages/$pkg/mt-scanner" - done - V="$VERSION" node -e "const f='npm/package.json';const p=require('./'+f);for(const k of Object.keys(p.optionalDependencies||{}))p.optionalDependencies[k]=process.env.V;require('fs').writeFileSync(f, JSON.stringify(p,null,2)+'\n')" - - # Платформні підпакети — тим самим перевіреним каналом, що й головний пакет. - # Публікуються ПЕРЕД @7n/mt (його optionalDependencies вже вказують на ці версії). - - name: Publish @7n/mt-darwin-arm64 - uses: JS-DevTools/npm-publish@v4.1.5 - with: - package: packages/mt-darwin-arm64/package.json - - - name: Publish @7n/mt-linux-x64 - uses: JS-DevTools/npm-publish@v4.1.5 - with: - package: packages/mt-linux-x64/package.json - - name: Publish package uses: JS-DevTools/npm-publish@v4.1.5 with: - package: npm/package.json + package: package.json diff --git a/.gitignore b/.gitignore index 2465cbd..ca1f8ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,6 @@ node_modules/ dist/ -# Rust build artifacts -target/ - -# Prebuilt scanner binaries are produced by CI, never committed -packages/*/mt-scanner -packages/*/mt-scanner.exe - -# napi addon artifacts (napi build / CI), never committed -crates/mt-napi/*.node -crates/mt-napi/index.js -crates/mt-napi/index.d.ts -packages/*/*.node - *.secret .claude/hooks/*.log @@ -32,6 +19,3 @@ packages/*/*.node # Python bytecode (випадкові артефакти, джерел .py у репо немає) __pycache__/ *.pyc - -# PII-мапінг handle → email (operations.md) — ніколи в git -.mt/directory.json diff --git a/.mt.json b/.mt.json deleted file mode 100644 index 8001f21..0000000 --- a/.mt.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "mt_dir": "./mt", - "worktrees_dir": "./.worktrees", - "warn_worktrees_above": 4, - "max_worktrees": 8, - "default_budget_sec": 1800, - "default_mode": "human", - "default_model_tier": "AVG", - "budget_hard_sec_multiplier": 3, - "progress_timeout_sec": 300, - "agent_concurrency": 5, - "claim_lease_sec": 3600, - "claim_grace_sec": 60, - "publish_retry_max": 8, - "publish_retry_base_ms": 250, - "stale_worktree_min": 30, - "system_prompt": ".mt/system-prompt.md" -} diff --git a/.n-rules.json b/.n-rules.json index 537d1f6..5a65222 100644 --- a/.n-rules.json +++ b/.n-rules.json @@ -11,8 +11,6 @@ "image-compress", "js", "js-run", - "npm-module", - "rust", "security", "test", "text", diff --git a/.v8rignore b/.v8rignore index 8482bbe..0d9a4df 100644 --- a/.v8rignore +++ b/.v8rignore @@ -2,7 +2,5 @@ .vscode/settings.json .git/** .cursor/hooks.json -npm/tsconfig.emit-types.json .marksman.toml .claude/settings.local.json -npm/lib/tests/fixtures/name-vectors.json diff --git a/AGENTS.md b/AGENTS.md index 5f299cd..11f2b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,6 @@ The primary development rules are stored in the Cursor rules directory: - .cursor/rules/n-image-compress.mdc - .cursor/rules/n-js-run.mdc - .cursor/rules/n-js.mdc -- .cursor/rules/n-npm-module.mdc -- .cursor/rules/n-rust.mdc - .cursor/rules/n-security.mdc - .cursor/rules/n-test.mdc - .cursor/rules/n-text.mdc @@ -41,7 +39,6 @@ Generated from the root `package.json` on each `npx @7n/rules` sync. Prefer `bun - **Залежності**: `bun i` - **test**: `bun run test` -- **start**: `bun run start` - **Оновити правила та AGENTS.md** (після змін у правилах/шаблоні CLI): `npx @7n/rules` - **Перевірки правил (programmatic)**: `npx @7n/rules lint` - **knip (невикористані залежності та експорти)**: `bunx knip` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f1a1bdb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Історія версій `@7n/mt` **≤ 0.28.0** (CLI-утиліта) — у [nitra/mt-js/CHANGELOG.md](https://github.com/nitra/mt-js/blob/main/CHANGELOG.md). Версії від **0.29.0** — це специфікація (вміст `docs/` цього репозиторію). diff --git a/CLAUDE.md b/CLAUDE.md index 90e28b9..c289777 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,6 @@ @.cursor/rules/n-image-compress.mdc @.cursor/rules/n-js-run.mdc @.cursor/rules/n-js.mdc -@.cursor/rules/n-npm-module.mdc -@.cursor/rules/n-rust.mdc @.cursor/rules/n-security.mdc @.cursor/rules/n-test.mdc @.cursor/rules/n-text.mdc diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index a5c245b..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1659 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "agent-cli" -version = "0.1.0" -dependencies = [ - "agent-core", - "agent-protocol", - "agent-server", - "chrono", - "clap", - "futures", - "serde_json", - "tokio", - "tokio-tungstenite 0.30.0", - "uuid", -] - -[[package]] -name = "agent-core" -version = "0.1.0" -dependencies = [ - "agent-protocol", - "serde_json", - "tokio", -] - -[[package]] -name = "agent-protocol" -version = "0.1.0" -dependencies = [ - "base64", - "chrono", - "ed25519-dalek", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "agent-server" -version = "0.1.0" -dependencies = [ - "agent-core", - "agent-protocol", - "async-trait", - "axum", - "chrono", - "futures", - "mt-core", - "serde", - "serde_json", - "sha2", - "tempfile", - "tokio", - "tokio-tungstenite 0.30.0", - "uuid", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "async-trait" -version = "0.1.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1 0.10.7", - "sync_wrapper", - "tokio", - "tokio-tungstenite 0.29.0", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "convert_case" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctor" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek", - "ed25519", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-executor" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - -[[package]] -name = "futures-macro" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.187" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732" - -[[package]] -name = "libloading" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "mt-cli" -version = "0.1.0" -dependencies = [ - "mt-core", - "serde_json", -] - -[[package]] -name = "mt-core" -version = "0.1.0" -dependencies = [ - "chrono", - "serde", - "serde_json", - "sha2", - "tempfile", -] - -[[package]] -name = "mt-napi" -version = "0.1.0" -dependencies = [ - "mt-core", - "napi", - "napi-build", - "napi-derive", - "serde_json", -] - -[[package]] -name = "napi" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" -dependencies = [ - "bitflags", - "ctor", - "futures", - "napi-build", - "napi-sys", - "nohash-hasher", - "rustc-hash", - "serde", - "serde_json", -] - -[[package]] -name = "napi-build" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" - -[[package]] -name = "napi-derive" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5" -dependencies = [ - "convert_case", - "ctor", - "napi-derive-backend", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "napi-derive-backend" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4747005fa3e2c9989ac45a723a514c5db2411238b72981a3cda4c701a9dfea17" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "semver", - "syn 2.0.119", -] - -[[package]] -name = "napi-sys" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" -dependencies = [ - "libloading", -] - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "indexmap", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "thiserror" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.30.0", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1 0.10.7", - "thiserror", -] - -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.2", - "sha1 0.11.0", - "thiserror", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index a4061c5..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -[workspace] -members = [ - "crates/agent-cli", - "crates/agent-core", - "crates/agent-protocol", - "crates/agent-server", - "crates/mt-core", - "crates/mt-cli", - "crates/mt-napi", -] -resolver = "2" - -[workspace.package] -version = "0.1.0" -edition = "2021" -license = "ISC" -repository = "https://github.com/nitra/mt" - -[workspace.dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order"] } -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -tempfile = "3" -sha2 = "0.11" -uuid = { version = "1", features = ["serde"] } -ed25519-dalek = "3" -base64 = "0.22" -async-trait = "0.1" -schemars = "1" -tokio = { version = "1", default-features = false } -axum = { version = "0.8", features = ["ws"] } -tokio-tungstenite = "0.30" -futures = "0.3" -clap = { version = "4", features = ["derive", "env"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ba3409 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# nitra/mt + +Специфікація протоколу **MT** — платформи задач, де виконавці рівноправно людина і ШІ, а координація йде через файловий граф і git. + +**Точка входу:** [`docs/index.md`](docs/index.md). + +## Реалізації + +Специфікація і реалізація рознесені по трьох репозиторіях: + +- **[nitra/mt-rust](https://github.com/nitra/mt-rust)** — повна реалізація протоколу: crates (ядро, agent-server, agent-protocol, mt-napi, mt-scanner), relay, CI збірки бінарників. +- **[nitra/mt-js](https://github.com/nitra/mt-js)** — JS-клієнт; наразі не публікується в npm. +- **nitra/mt** (цей репозиторій) — тільки специфікація: протокол ([`docs/`](docs/)) і рушій шарової документації ([`layers/`](layers/)), яким вона побудована. + +## npm-пакет `@7n/mt` + +Починаючи з версії **0.29.0**, npm-пакет [`@7n/mt`](https://www.npmjs.com/package/@7n/mt) — це сама специфікація (вміст `docs/` + цей README), а не CLI. Версії **≤ 0.28.0** були CLI-утилітою; її код переїхав у [nitra/mt-js](https://github.com/nitra/mt-js) і наразі не публікується в npm. + +```sh +npm i @7n/mt +``` + +Точка входу після встановлення — `node_modules/@7n/mt/docs/index.md`. + +## `docs/` + +Документація побудована **шарами**: короткий підсумок нагорі (`index.md`), тематичні огляди (`overview/`), детальні глави (`architecture/`) — кожен рівень самодостатній, спускайся туди, де цікаво. Топологію шарів і джерела задає [`docs/layers.json`](docs/layers.json). + +`docs/adr/` — журнал архітектурних рішень цього репозиторію (специфікація); рішення реалізації — в ADR відповідного репозиторію. + +## `layers/` + +Рушій шарової документації: подвійний CRC (суть/файл), LLM-генерація верхніх шарів із суті джерел, derived-переклади. Використовується для збірки `docs/`. + +```sh +bun ./layers/lib/cli.mjs status docs +``` + +Дока рушія: [`layers/docs/`](layers/docs/). diff --git a/bun.lock b/bun.lock index df58ade..5b91c8d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,9 @@ "": { "name": "mono", "devDependencies": { - "@7n/rules": "^1.36.1", + "@7n/rules": "^1.43.1", "@7n/rules-ci-github": "^1.9.0", "@7n/rules-lang-js": "^0.9.0", - "@7n/rules-lang-rust": "^0.6.1", "@nitra/cspell-dict": "^2.2.2", "@nitra/eslint-config": "^3.10.3", "@stryker-mutator/vitest-runner": "^9.6.1", @@ -16,73 +15,29 @@ "vitest": "^4.1.10", }, }, - "crates/mt-napi": { - "name": "@7n/mt-napi", - "version": "0.3.1", - "devDependencies": { - "@napi-rs/cli": "^3.7.4", - "ajv": "^8.20.0", - }, - }, - "layers": { - "name": "@7n/layers", - "version": "0.1.0", - "dependencies": { - "@7n/llm-lib": "^2.5.0", - }, - "optionalDependencies": { - "@earendil-works/pi-ai": "0.80.2", - }, - }, - "npm": { - "name": "@7n/mt", - "version": "0.27.1", - "bin": { - "mt": "bin/mt.js", - }, - "optionalDependencies": { - "@7n/mt-darwin-arm64": "0.2.0", - "@7n/mt-linux-x64": "0.2.0", - }, - }, - "relay": { - "name": "@7n/relay", - "version": "0.8.1", - "dependencies": { - "ws": "^8.21.1", - }, - }, }, "trustedDependencies": [ "unrs-resolver", ], "packages": { - "@7n/layers": ["@7n/layers@workspace:layers"], - "@7n/llm-lib": ["@7n/llm-lib@2.8.3", "", { "optionalDependencies": { "@7n/llm-lib-darwin-arm64": "2.8.3", "@7n/llm-lib-linux-x64": "2.8.3" }, "peerDependencies": { "@earendil-works/pi-ai": "~0.80.10", "@earendil-works/pi-coding-agent": "~0.80.10" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"], "bin": { "n-llm-chains-report": "bin/chains-report.mjs" } }, "sha512-pLK7HSVfXJ69en0gNQ+eXEUTdq/olqabX9RXymeSOAVIhE6egbDD4XNAZYdp26pLxP+Dr3t7Yomj3YihzeUgLw=="], "@7n/llm-lib-darwin-arm64": ["@7n/llm-lib-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2OI/wt+p1NLSbCoOSmvBxLWYJhAtkJdkTd7UUQj6zVTGV6VY00oGfzGv4pGD8Ka/NpuAcHyBq8jcbDDrQqGQGA=="], "@7n/llm-lib-linux-x64": ["@7n/llm-lib-linux-x64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-q21Pc86jI7lC+rc+ai24nNe+c2ng8DU5CNuPU7t1wC/xmSWWZ+k0XuSMxJvGzHmHh/ARoEyphIECbYC13l6iSw=="], - "@7n/mt": ["@7n/mt@workspace:npm"], + "@7n/mt": ["@7n/mt@0.5.1", "", { "optionalDependencies": { "@7n/mt-darwin-arm64": "0.5.1", "@7n/mt-linux-x64": "0.5.1" }, "bin": { "mt": "bin/mt.js" } }, "sha512-fIP75AODAX6ri+Geznpph0BJR0kkOWeCarSNJCibLCPH3vnfbEvHC9/MgTk3zg9cYIGEFAHd1gkCEnOekexIdg=="], "@7n/mt-darwin-arm64": ["@7n/mt-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ozofQxbsdrv7cVZJeb9P9MLwzpdNxdqTcmzRQjwSf316TK8ptxzZhU0b24CsoH/Ia98zhbJLBPsdK/ueHWFl4Q=="], "@7n/mt-linux-x64": ["@7n/mt-linux-x64@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-c4b1c435vfGZcRKkLnWcoKs8O6KTATFFx6gPGnid4ee+JDqzyhtxb5kzxYkcTQySO8iBHPAtUMEOBCHDrF1UjQ=="], - "@7n/mt-napi": ["@7n/mt-napi@workspace:crates/mt-napi"], - - "@7n/relay": ["@7n/relay@workspace:relay"], - - "@7n/rules": ["@7n/rules@1.36.1", "", { "dependencies": { "@7n/llm-lib": "^2.8.2", "@7n/mt": "^0.5.1", "@zed-industries/agent-client-protocol": "^0.4.5", "ajv": "^8.20.0", "cli-progress": "^3.12.0", "github-actionlint": "^1.7.12", "globby": "^16.0.0", "ignore": "^7.0.5", "markdownlint-cli2": "^0.22.1", "oxc-parser": "^0.137.0", "picomatch": "^4.0.4", "smol-toml": "^1.7.0", "v8r": "^6.1.0", "yaml": "^2.9.0", "zod": "^4.4.3" }, "optionalDependencies": { "@agentclientprotocol/claude-agent-acp": "^0.59.0", "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10" }, "bin": { "n-rules": "bin/n-rules.js", "n-cursor": "bin/n-rules.js" } }, "sha512-8ARZnDsAvAaZn5ivv3JRqnBVYi9loxlt5I3Ox2a+LeO8fv7jTWB6sdpgBMVN3yAEs/waWVKttTNQTMo7Y7d1xw=="], + "@7n/rules": ["@7n/rules@1.43.1", "", { "dependencies": { "@7n/llm-lib": "^2.8.2", "@7n/mt": "^0.5.1", "@zed-industries/agent-client-protocol": "^0.4.5", "ajv": "^8.20.0", "cli-progress": "^3.12.0", "github-actionlint": "^1.7.12", "globby": "^16.0.0", "ignore": "^7.0.5", "markdownlint-cli2": "^0.22.1", "oxc-parser": "^0.137.0", "picomatch": "^4.0.4", "smol-toml": "^1.7.0", "v8r": "^6.1.0", "yaml": "^2.9.0", "zod": "^4.4.3" }, "optionalDependencies": { "@agentclientprotocol/claude-agent-acp": "^0.59.0", "@earendil-works/pi-ai": "0.80.10", "@earendil-works/pi-coding-agent": "0.80.10" }, "bin": { "n-rules": "bin/n-rules.js", "n-cursor": "bin/n-rules.js" } }, "sha512-vsZyaEYwePoibaCmh0UQDpb93qMOjL7m76+GvBdxTCopj0Ej2A34iS58wQ4jLL+NPh26R5/xWLJKcT4WqGSPaw=="], "@7n/rules-ci-github": ["@7n/rules-ci-github@1.9.0", "", { "dependencies": { "yaml": "^2.9.0" }, "peerDependencies": { "@7n/rules": ">=1.2.0" } }, "sha512-X/f2qKb1XT/QDryJXgK2L/vzpJY8lsBLUTazK8p3L9M5pyGYYY3edkRnz9WgavrYi0TXVE6pzhZcOZUagvocYA=="], "@7n/rules-lang-js": ["@7n/rules-lang-js@0.9.0", "", { "dependencies": { "eslint": "^10.4.1", "globby": "^16.0.0", "ignore": "^7.0.5", "jscpd": "^5.0.11", "knip": "^6.22.0", "oxc-parser": "^0.137.0", "oxlint": "^1.68.0", "stylelint": "^17.6.0" }, "peerDependencies": { "@7n/rules": ">=1.27.0", "vue": "^3.0.0" }, "optionalPeers": ["vue"] }, "sha512-brPVWO46EXuSNIUbGF36tQCUirnrhbeIvKWhU+I0tHDevuxDMqojlETc8AuzxD+vVjxXCN1mH732LCdLfeT6UA=="], - "@7n/rules-lang-rust": ["@7n/rules-lang-rust@0.6.1", "", { "dependencies": { "smol-toml": "^1.7.0" }, "peerDependencies": { "@7n/rules": ">=1.15.0" } }, "sha512-V/UCQYWO9DCCmr1aD0SJ0JiEXfNARsgZTs9Px+i1LycaHLDC+qH8pi3kcBD4R/EiNLIZdVxEg6W4pN9aXn4xmg=="], - "@agentclientprotocol/claude-agent-acp": ["@agentclientprotocol/claude-agent-acp@0.59.0", "", { "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@anthropic-ai/claude-agent-sdk": "0.3.207", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "claude-agent-acp": "dist/index.js" } }, "sha512-GejLH5qxsI5IoSDfhyOVDEsRNxqi6y0Rcj5FstVeOwMACSht/bUXII0HILbzOQNoA5qlyZle3FRvf+CAjD7Rpg=="], "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], @@ -291,7 +246,7 @@ "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.10", "@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-tui": "^0.80.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg=="], @@ -467,110 +422,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@napi-rs/cli": ["@napi-rs/cli@3.7.4", "", { "dependencies": { "@inquirer/prompts": "^8.5.2", "@napi-rs/cross-toolchain": "^1.0.3", "@napi-rs/wasm-tools": "^1.0.1", "@octokit/rest": "^22.0.1", "clipanion": "^4.0.0-rc.4", "colorette": "^2.0.20", "emnapi": "^1.11.1", "es-toolkit": "^1.47.0", "js-yaml": "^4.2.0", "obug": "^2.1.2", "semver": "^7.8.2", "typanion": "^3.14.0" }, "peerDependencies": { "@emnapi/runtime": "^1.7.1" }, "optionalPeers": ["@emnapi/runtime"], "bin": { "napi": "dist/cli.js", "napi-raw": "cli.mjs" } }, "sha512-idELIUceJ9zPgx/shcMt1fSSsteFG68v56RIXi4qUm7OYuJOt7CoMj2KnHVmbOqXgUHWQU1lCULrtQdZDG4F5A=="], - - "@napi-rs/cross-toolchain": ["@napi-rs/cross-toolchain@1.0.3", "", { "dependencies": { "@napi-rs/lzma": "^1.4.5", "@napi-rs/tar": "^1.1.0", "debug": "^4.4.1" }, "peerDependencies": { "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" }, "optionalPeers": ["@napi-rs/cross-toolchain-arm64-target-aarch64", "@napi-rs/cross-toolchain-arm64-target-armv7", "@napi-rs/cross-toolchain-arm64-target-ppc64le", "@napi-rs/cross-toolchain-arm64-target-s390x", "@napi-rs/cross-toolchain-arm64-target-x86_64", "@napi-rs/cross-toolchain-x64-target-aarch64", "@napi-rs/cross-toolchain-x64-target-armv7", "@napi-rs/cross-toolchain-x64-target-ppc64le", "@napi-rs/cross-toolchain-x64-target-s390x", "@napi-rs/cross-toolchain-x64-target-x86_64"] }, "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg=="], - - "@napi-rs/lzma": ["@napi-rs/lzma@1.4.5", "", { "optionalDependencies": { "@napi-rs/lzma-android-arm-eabi": "1.4.5", "@napi-rs/lzma-android-arm64": "1.4.5", "@napi-rs/lzma-darwin-arm64": "1.4.5", "@napi-rs/lzma-darwin-x64": "1.4.5", "@napi-rs/lzma-freebsd-x64": "1.4.5", "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", "@napi-rs/lzma-linux-arm64-musl": "1.4.5", "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-gnu": "1.4.5", "@napi-rs/lzma-linux-x64-musl": "1.4.5", "@napi-rs/lzma-wasm32-wasi": "1.4.5", "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", "@napi-rs/lzma-win32-x64-msvc": "1.4.5" } }, "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg=="], - - "@napi-rs/lzma-android-arm-eabi": ["@napi-rs/lzma-android-arm-eabi@1.4.5", "", { "os": "android", "cpu": "arm" }, "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q=="], - - "@napi-rs/lzma-android-arm64": ["@napi-rs/lzma-android-arm64@1.4.5", "", { "os": "android", "cpu": "arm64" }, "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ=="], - - "@napi-rs/lzma-darwin-arm64": ["@napi-rs/lzma-darwin-arm64@1.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA=="], - - "@napi-rs/lzma-darwin-x64": ["@napi-rs/lzma-darwin-x64@1.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw=="], - - "@napi-rs/lzma-freebsd-x64": ["@napi-rs/lzma-freebsd-x64@1.4.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw=="], - - "@napi-rs/lzma-linux-arm-gnueabihf": ["@napi-rs/lzma-linux-arm-gnueabihf@1.4.5", "", { "os": "linux", "cpu": "arm" }, "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw=="], - - "@napi-rs/lzma-linux-arm64-gnu": ["@napi-rs/lzma-linux-arm64-gnu@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg=="], - - "@napi-rs/lzma-linux-arm64-musl": ["@napi-rs/lzma-linux-arm64-musl@1.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w=="], - - "@napi-rs/lzma-linux-ppc64-gnu": ["@napi-rs/lzma-linux-ppc64-gnu@1.4.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ=="], - - "@napi-rs/lzma-linux-riscv64-gnu": ["@napi-rs/lzma-linux-riscv64-gnu@1.4.5", "", { "os": "linux", "cpu": "none" }, "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA=="], - - "@napi-rs/lzma-linux-s390x-gnu": ["@napi-rs/lzma-linux-s390x-gnu@1.4.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA=="], - - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw=="], - - "@napi-rs/lzma-linux-x64-musl": ["@napi-rs/lzma-linux-x64-musl@1.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ=="], - - "@napi-rs/lzma-wasm32-wasi": ["@napi-rs/lzma-wasm32-wasi@1.4.5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA=="], - - "@napi-rs/lzma-win32-arm64-msvc": ["@napi-rs/lzma-win32-arm64-msvc@1.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg=="], - - "@napi-rs/lzma-win32-ia32-msvc": ["@napi-rs/lzma-win32-ia32-msvc@1.4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg=="], - - "@napi-rs/lzma-win32-x64-msvc": ["@napi-rs/lzma-win32-x64-msvc@1.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q=="], - - "@napi-rs/tar": ["@napi-rs/tar@1.1.0", "", { "optionalDependencies": { "@napi-rs/tar-android-arm-eabi": "1.1.0", "@napi-rs/tar-android-arm64": "1.1.0", "@napi-rs/tar-darwin-arm64": "1.1.0", "@napi-rs/tar-darwin-x64": "1.1.0", "@napi-rs/tar-freebsd-x64": "1.1.0", "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", "@napi-rs/tar-linux-arm64-gnu": "1.1.0", "@napi-rs/tar-linux-arm64-musl": "1.1.0", "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", "@napi-rs/tar-linux-s390x-gnu": "1.1.0", "@napi-rs/tar-linux-x64-gnu": "1.1.0", "@napi-rs/tar-linux-x64-musl": "1.1.0", "@napi-rs/tar-wasm32-wasi": "1.1.0", "@napi-rs/tar-win32-arm64-msvc": "1.1.0", "@napi-rs/tar-win32-ia32-msvc": "1.1.0", "@napi-rs/tar-win32-x64-msvc": "1.1.0" } }, "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ=="], - - "@napi-rs/tar-android-arm-eabi": ["@napi-rs/tar-android-arm-eabi@1.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA=="], - - "@napi-rs/tar-android-arm64": ["@napi-rs/tar-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw=="], - - "@napi-rs/tar-darwin-arm64": ["@napi-rs/tar-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw=="], - - "@napi-rs/tar-darwin-x64": ["@napi-rs/tar-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA=="], - - "@napi-rs/tar-freebsd-x64": ["@napi-rs/tar-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g=="], - - "@napi-rs/tar-linux-arm-gnueabihf": ["@napi-rs/tar-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg=="], - - "@napi-rs/tar-linux-arm64-gnu": ["@napi-rs/tar-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA=="], - - "@napi-rs/tar-linux-arm64-musl": ["@napi-rs/tar-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ=="], - - "@napi-rs/tar-linux-ppc64-gnu": ["@napi-rs/tar-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ=="], - - "@napi-rs/tar-linux-s390x-gnu": ["@napi-rs/tar-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ=="], - - "@napi-rs/tar-linux-x64-gnu": ["@napi-rs/tar-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww=="], - - "@napi-rs/tar-linux-x64-musl": ["@napi-rs/tar-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ=="], - - "@napi-rs/tar-wasm32-wasi": ["@napi-rs/tar-wasm32-wasi@1.1.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw=="], - - "@napi-rs/tar-win32-arm64-msvc": ["@napi-rs/tar-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg=="], - - "@napi-rs/tar-win32-ia32-msvc": ["@napi-rs/tar-win32-ia32-msvc@1.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ=="], - - "@napi-rs/tar-win32-x64-msvc": ["@napi-rs/tar-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@napi-rs/wasm-tools": ["@napi-rs/wasm-tools@1.0.1", "", { "optionalDependencies": { "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", "@napi-rs/wasm-tools-android-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", "@napi-rs/wasm-tools-darwin-x64": "1.0.1", "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" } }, "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ=="], - - "@napi-rs/wasm-tools-android-arm-eabi": ["@napi-rs/wasm-tools-android-arm-eabi@1.0.1", "", { "os": "android", "cpu": "arm" }, "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw=="], - - "@napi-rs/wasm-tools-android-arm64": ["@napi-rs/wasm-tools-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg=="], - - "@napi-rs/wasm-tools-darwin-arm64": ["@napi-rs/wasm-tools-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g=="], - - "@napi-rs/wasm-tools-darwin-x64": ["@napi-rs/wasm-tools-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A=="], - - "@napi-rs/wasm-tools-freebsd-x64": ["@napi-rs/wasm-tools-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg=="], - - "@napi-rs/wasm-tools-linux-arm64-gnu": ["@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q=="], - - "@napi-rs/wasm-tools-linux-arm64-musl": ["@napi-rs/wasm-tools-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ=="], - - "@napi-rs/wasm-tools-linux-x64-gnu": ["@napi-rs/wasm-tools-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw=="], - - "@napi-rs/wasm-tools-linux-x64-musl": ["@napi-rs/wasm-tools-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ=="], - - "@napi-rs/wasm-tools-wasm32-wasi": ["@napi-rs/wasm-tools-wasm32-wasi@1.0.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.3" }, "cpu": "none" }, "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA=="], - - "@napi-rs/wasm-tools-win32-arm64-msvc": ["@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ=="], - - "@napi-rs/wasm-tools-win32-ia32-msvc": ["@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw=="], - - "@napi-rs/wasm-tools-win32-x64-msvc": ["@napi-rs/wasm-tools-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ=="], - "@nitra/cspell-dict": ["@nitra/cspell-dict@2.2.2", "", { "dependencies": { "@cspell/dict-bash": "^4.2.2", "@cspell/dict-csharp": "^4.0.8", "@cspell/dict-css": "^4.1.1", "@cspell/dict-docker": "^1.1.17", "@cspell/dict-html": "^4.0.15", "@cspell/dict-k8s": "^1.0.12", "@cspell/dict-kotlin": "^1.1.1", "@cspell/dict-lua": "^4.0.8", "@cspell/dict-markdown": "^2.0.16", "@cspell/dict-php": "^4.1.1", "@cspell/dict-python": "^4.2.26", "@cspell/dict-ro-ro": "^2.0.6", "@cspell/dict-ru_ru": "^2.3.2", "@cspell/dict-sql": "^2.2.1", "@cspell/dict-swift": "^2.0.6", "@cspell/dict-tr-tr": "^3.0.6", "@cspell/dict-uk-ua": "^4.0.6", "@cspell/dict-vue": "^3.0.5" } }, "sha512-BQvJ4qGRJLeGezIXNe2sGdG4dVaT/BpCUVYvQbxbqg9wEqcDCPjGRPuoTKLpz8YxahEa4i7Usme8vipitQ0d2A=="], "@nitra/eslint-config": ["@nitra/eslint-config@3.10.3", "", { "dependencies": { "@e18e/eslint-plugin": "^0.5.1", "@eslint/compat": "^2.1.0", "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.2", "@graphql-eslint/eslint-plugin": "^4.4.0", "@graphql-tools/code-file-loader": "^8.1.32", "@graphql-tools/graphql-tag-pluck": "^8.3.31", "@graphql-tools/utils": "^11.1.0", "@microsoft/eslint-plugin-sdl": "^1.1.0", "eslint": "^10.6.0", "eslint-merge-processors": "^2.0.0", "eslint-plugin-import-x": "^4.17.0", "eslint-plugin-jsdoc": "^63.0.10", "eslint-plugin-jsonc": "^3.2.0", "eslint-plugin-n": "^18.2.1", "eslint-plugin-no-unsanitized": "^4.1.5", "eslint-plugin-oxlint": "^1.71.0", "eslint-plugin-security": "^4.0.1", "eslint-plugin-sonarjs": "^4.1.0", "eslint-plugin-unicorn": "^69.0.0", "eslint-plugin-vue": "^10.9.2", "eslint-plugin-yml": "^3.5.0", "eslint-processor-vue-blocks": "^2.0.0", "globals": "^17.7.0", "graphql": "^16.14.0", "vue-eslint-parser": "^10.4.1" } }, "sha512-QSHs6k6kOFUdq6ZGxjyKsLsNLBllXjAeG4sEt+b0Hw6fg4dVV+5w4omtqtChJRTJdxzPm2vQDkCjtkoY8TxzMw=="], @@ -581,30 +434,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], - - "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], - - "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - - "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], - - "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - - "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], - - "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], - - "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], - - "@octokit/request": ["@octokit/request@10.0.11", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q=="], - - "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], - - "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], - - "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.42.0", "", {}, "sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw=="], @@ -997,8 +826,6 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], - "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], @@ -1063,8 +890,6 @@ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "clipanion": ["clipanion@4.0.0-rc.4", "", { "dependencies": { "typanion": "^3.8.0" } }, "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q=="], - "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1073,8 +898,6 @@ "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "comment-parser": ["comment-parser@1.4.7", "", {}, "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ=="], @@ -1161,8 +984,6 @@ "electron-to-chromium": ["electron-to-chromium@1.5.388", "", {}, "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw=="], - "emnapi": ["emnapi@1.11.2", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-iMt/XQc69fFn2EvcU6tm14HmXKwyy0lnABugsQlqp6xFuZIUuO+ONVSg2mz+MTVF8WbC+bic65AvRXdoldALKg=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], @@ -1197,8 +1018,6 @@ "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], - "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1571,7 +1390,7 @@ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jscpd": ["jscpd@5.0.12", "", { "optionalDependencies": { "jscpd-darwin-arm64": "5.0.12", "jscpd-darwin-x64": "5.0.12", "jscpd-linux-arm64-gnu": "5.0.12", "jscpd-linux-x64-gnu": "5.0.12", "jscpd-linux-x64-musl": "5.0.12", "jscpd-windows-x64-msvc": "5.0.12" }, "bin": { "jscpd": "run-jscpd.js" } }, "sha512-87dC+akj2mCywlt8p3xnRlDg0B55u4GDbfE9cuwOOeIxbEuWuIoNqGoYi65A0516aJ4EzAjGwBj1Qb/Ahc8WAQ=="], @@ -1607,8 +1426,6 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-eslint-parser": ["jsonc-eslint-parser@3.1.0", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^5.0.0", "semver": "^7.3.5" } }, "sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng=="], @@ -2169,8 +1986,6 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], @@ -2219,8 +2034,6 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], - "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -2273,7 +2086,7 @@ "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], - "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-name-validator": ["xml-name-validator@4.0.0", "", {}, "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw=="], @@ -2299,12 +2112,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@7n/llm-lib/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - - "@7n/rules/@7n/mt": ["@7n/mt@0.5.1", "", { "optionalDependencies": { "@7n/mt-darwin-arm64": "0.5.1", "@7n/mt-linux-x64": "0.5.1" }, "bin": { "mt": "bin/mt.js" } }, "sha512-fIP75AODAX6ri+Geznpph0BJR0kkOWeCarSNJCibLCPH3vnfbEvHC9/MgTk3zg9cYIGEFAHd1gkCEnOekexIdg=="], - - "@7n/rules/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="], "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1080.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.28", "@aws-sdk/nested-clients": "^3.997.28", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw=="], @@ -2321,24 +2128,14 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - - "@earendil-works/pi-coding-agent/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - "@earendil-works/pi-coding-agent/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@google/genai/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@graphql-eslint/eslint-plugin/@graphql-tools/utils": ["@graphql-tools/utils@10.11.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q=="], "@graphql-tools/code-file-loader/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], - "@graphql-tools/executor-graphql-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "@graphql-tools/executor-legacy-ws/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@graphql-tools/graphql-file-loader/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], "@graphql-tools/import/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -2347,18 +2144,12 @@ "@graphql-tools/load/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "@graphql-tools/url-loader/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "@microsoft/eslint-plugin-sdl/eslint-plugin-n": ["eslint-plugin-n@17.10.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "enhanced-resolve": "^5.17.0", "eslint-plugin-es-x": "^7.5.0", "get-tsconfig": "^4.7.0", "globals": "^15.8.0", "ignore": "^5.2.4", "minimatch": "^9.0.5", "semver": "^7.5.3" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw=="], "@microsoft/eslint-plugin-sdl/eslint-plugin-security": ["eslint-plugin-security@1.4.0", "", { "dependencies": { "safe-regex": "^1.1.0" } }, "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA=="], - "@mistralai/mistralai/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -2387,6 +2178,8 @@ "cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "cosmiconfig/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -2421,8 +2214,6 @@ "markdownlint-cli2/globby": ["globby@16.2.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], - "markdownlint-cli2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "markdownlint-cli2/smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -2465,6 +2256,8 @@ "unixify/normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], + "v8r/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -2509,6 +2302,8 @@ "file-entry-cache/flat-cache/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "graphql-config/cosmiconfig/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "markdownlint/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/crates/agent-cli/Cargo.toml b/crates/agent-cli/Cargo.toml deleted file mode 100644 index 7dd6b81..0000000 --- a/crates/agent-cli/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "agent-cli" -description = "Тонкий клієнт agent-server: serve (хост-процес) і attach (інтерактивна сесія вузла)" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[[bin]] -name = "agent-cli" -path = "src/main.rs" - -[dependencies] -agent-core = { path = "../agent-core" } -agent-protocol = { path = "../agent-protocol" } -agent-server = { path = "../agent-server" } -chrono = { workspace = true, features = ["serde"] } -clap.workspace = true -futures.workspace = true -serde_json.workspace = true -tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread", "signal"] } -tokio-tungstenite.workspace = true -uuid = { workspace = true, features = ["v4"] } diff --git a/crates/agent-cli/src/docs/index.md b/crates/agent-cli/src/docs/index.md deleted file mode 100644 index f9cc880..0000000 --- a/crates/agent-cli/src/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: crates/agent-cli/src -resource: crates/agent-cli/src/ ---- - -| Файл | Тип | -| ------------------ | ----------- | -| [main.rs](main.md) | Rust Module | diff --git a/crates/agent-cli/src/docs/main.md b/crates/agent-cli/src/docs/main.md deleted file mode 100644 index 0a63d47..0000000 --- a/crates/agent-cli/src/docs/main.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -type: Rust Module -title: main.rs -resource: crates/agent-cli/src/main.rs -docgen: - crc: 0899a583 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Цей файл реалізує тонкого клієнта для взаємодії з agent-server. Команда `serve` ініціює старту хост-процесу, який відкривається на WS з адресою 127.0.0.1, використовує discovery port-file та токен; runner може бути налаштований як `OpenAiProvider` (за умови вказання `--base-url`) або функціонувати в режимі `echo`. Команда `attach ` дозволяє підключитися до існуючого вузла, ініціювати хендшейк v4 та розпочати інтерактивну сесію REPL, де вхідні дані з stdin передаються як `UserMessage`, а стрічка подій виводиться у термінал. - -## Поведінка - -1. Запуск хост-процесу: При виклику `serve` програма ініціалізує WS на 127.0.0.1. Якщо вказано `base_url`, запускається `AgentTurnRunner` з провайдером `OpenAiProvider`; інакше використовується `EchoTurnRunner`. Генерується унікальний токен, який записується у discovery-файл, а сам сервер моніторить натискання Ctrl+C для коректного видалення токена та завершення роботи. -2. Підключення до вузла: При виклику `attach` програма читає discovery-файл, щоб отримати порт та токен. Створюється WebSocket-з'єднання із сервером. Виконується хендшейк v4: клієнт надсилає `ClientHello` із метаданими. -3. Встановлення сесії: Після успішного хендшейку, клієнт починає інтерактивний режим. Змінні команди зчитуються з вводу (`stdin`) та відправляються на сервер як `UserMessage`, тоді як події від сервера відображаються у терміналі. -4. Обробка вхідних даних: Коли з вводу надходить текст, він формується в `Envelope` для вузла, що відповідає параметру `node`, і відправляється на сервер. -5. Відображення вихідних даних: Отримані від сервера повідомлення декодуються та обробляються: текстові відповіді від агента відображаються як частина потоку (`AgentTextDelta`), завершення відповіді відображається як новий рядок (`AgentTextDone`), результати викликів інструментів (`ToolResult`) відображаються із відповідним статусом, помилки (`Error`) виводяться на помилку (`stderr`), а мета-повідомлення (`UserMessage`) відображаються з префіксом `>`. -6. Завершення роботи: Сесія припиняється при натисканні Ctrl+D або при розриві WebSocket-з'єднання. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-cli/src/main.rs b/crates/agent-cli/src/main.rs deleted file mode 100644 index fe73941..0000000 --- a/crates/agent-cli/src/main.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Тонкий клієнт agent-server (M1-заділ `mt serve`/`mt attach`). -//! -//! `serve` — стартує хост-процес: WS на 127.0.0.1, discovery port-file + -//! токен; runner — ACP-адаптер підписочного CLI (`--acp-cmd` або env -//! `MT_ACP_AGENT_CMD`; ADR `260713-2110`: ACP — єдиний транспорт -//! AI-викликів), без нього — echo-заглушка транспорту. -//! `attach ` — читає discovery, хендшейк v4, REPL: stdin → -//! `UserMessage`, стрічка подій → термінал. M1-заділ адресує кімнату -//! рядком вузла; hash-адресація і graph-операції (claim/publish через -//! `mt … --json`) — окрема задача інтеграції. - -use std::io::Write as _; -use std::path::PathBuf; -use std::sync::Arc; - -use agent_core::PermissionHandler; -use agent_protocol::{ClientHello, Envelope, Event, ServerHello, PROTOCOL_VERSION}; -use agent_server::approvals_gate::request_approval; -use agent_server::{ - serve, spawn_relay_bridge, AcpTurnRunner, AppState, ApprovalGate, Discovery, EchoTurnRunner, - GraphConfig, PermissionFactory, RelayBridgeConfig, SessionHost, TurnRunner, -}; -use clap::{Parser, Subcommand}; -use futures::{SinkExt, StreamExt}; -use tokio::io::AsyncBufReadExt; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -#[derive(Parser)] -#[command(name = "agent-cli", about = "Тонкий клієнт agent-server (M1)")] -struct Cli { - /// Директорія discovery/стану (дефолт — ~/.nitra). - #[arg(long, global = true)] - state_dir: Option, - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Запустити хост-процес (WS + discovery). - Serve { - /// Порт (0 — ефемерний). - #[arg(long, default_value_t = 0)] - port: u16, - /// Команда ACP-адаптера підписочного CLI (напр. `npx claude-code-acp`); - /// без прапора береться env `MT_ACP_AGENT_CMD`, без обох — echo-заглушка. - #[arg(long, env = "MT_ACP_AGENT_CMD")] - acp_cmd: Option, - /// Адреса relay (`ws://…`/`wss://…`) — вмикає міст до relay. - #[arg(long)] - relay_url: Option, - /// device_token host-пристрою на relay. - #[arg(long, default_value = "")] - relay_token: String, - /// Кімната relay (кореневий вузол задачі). - #[arg(long, default_value = "")] - relay_root: String, - }, - /// Підключитись до вузла інтерактивною сесією. - Attach { - /// Вузол (шлях у tasks-директорії). - node: String, - /// BCP-47 мова учасника (обовʼязкове поле v4). - #[arg(long, default_value = "uk")] - lang: String, - }, -} - -fn state_dir(cli_dir: Option) -> PathBuf { - cli_dir.unwrap_or_else(|| { - PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())).join(".nitra") - }) -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let cli = Cli::parse(); - match cli.command { - Command::Serve { - port, - acp_cmd, - relay_url, - relay_token, - relay_root, - } => { - let relay = relay_url.map(|url| RelayBridgeConfig { - url, - device_token: relay_token, - root: relay_root, - }); - run_serve(state_dir(cli.state_dir), port, acp_cmd, relay).await - } - Command::Attach { node, lang } => run_attach(state_dir(cli.state_dir), node, lang).await, - } -} - -async fn run_serve( - dir: PathBuf, - port: u16, - acp_cmd: Option, - relay: Option, -) -> Result<(), Box> { - let sessions = Arc::new(SessionHost::new(dir.join("sessions"))?); - let gate = Arc::new(ApprovalGate::default()); - // Виконавець ходу — ACP-адаптер підписочного CLI; request_permission - // мапиться на approval-гейт (ApprovalRequest у кімнату вузла, таймаут - // 120s → відмова). Без адаптера — echo-заглушка транспорту. - let runner: Arc = match acp_cmd { - Some(command) => { - let approval_sessions = Arc::clone(&sessions); - let approval_gate = Arc::clone(&gate); - let factory: PermissionFactory = Arc::new(move |node: &str| { - let sessions = Arc::clone(&approval_sessions); - let gate = Arc::clone(&approval_gate); - let node = node.to_string(); - let handler: PermissionHandler = Arc::new(move |action, diff| { - let sessions = Arc::clone(&sessions); - let gate = Arc::clone(&gate); - let node = node.clone(); - Box::pin(async move { - let Ok(receiver) = request_approval(&sessions, &gate, &node, action, diff) - else { - return false; - }; - matches!( - tokio::time::timeout(std::time::Duration::from_secs(120), receiver) - .await, - Ok(Ok(true)) - ) - }) - }); - handler - }); - println!("ACP-адаптер: {command}"); - Arc::new(AcpTurnRunner::new(&command, Some(factory))) - } - None => Arc::new(EchoTurnRunner), - }; - let token = Uuid::new_v4().to_string(); - let mut state = AppState::from_parts(sessions, gate, runner, Some(token.clone())); - // Кімната = вузол графа, якщо запущено з кореня MT-проєкту (tasks-дир - // `mt/` поряд): UserMessage веде claim/worktree, /done — fenced publish. - let tasks_dir = std::env::current_dir()?.join("mt"); - if tasks_dir.is_dir() { - state = state.with_graph(GraphConfig::new(tasks_dir)); - } - let state = Arc::new(state); - let (addr, handle) = serve(Arc::clone(&state), format!("127.0.0.1:{port}").parse()?).await?; - let discovery = Discovery::new(dir); - discovery.write(addr.port(), &token)?; - println!("agent-server: ws://{addr}/ws (protocol v{PROTOCOL_VERSION})"); - // Міст до relay: віддалені пристрої бачать стрічку і шлють команди. - let relay_bridge = relay.map(|config| { - println!("relay-міст: {} (кімната {})", config.url, config.root); - spawn_relay_bridge(Arc::clone(&state), config) - }); - - tokio::signal::ctrl_c().await?; - discovery.remove()?; - if let Some(bridge) = relay_bridge { - bridge.abort(); - } - handle.abort(); - Ok(()) -} - -async fn run_attach( - dir: PathBuf, - node: String, - lang: String, -) -> Result<(), Box> { - let (port_file, token) = Discovery::new(dir).read().map_err(|error| { - format!("discovery не знайдено ({error}); спершу запусти `agent-cli serve`") - })?; - let url = format!("ws://127.0.0.1:{}/ws", port_file.port); - let (mut stream, _) = tokio_tungstenite::connect_async(&url).await?; - - let hello = ClientHello { - protocol_version: PROTOCOL_VERSION, - device_id: Uuid::new_v4(), - device_token: token, - client_kind: "cli".into(), - client_capabilities: vec!["approvals".into(), "diff_view".into()], - lang, - want_replay_from: Some(0), - }; - stream - .send(Message::text(serde_json::to_string(&hello)?)) - .await?; - - let Some(Ok(Message::Text(first))) = stream.next().await else { - return Err("сервер закрив зʼєднання на хендшейку".into()); - }; - if let Ok(Event::Error { message }) = serde_json::from_str::(first.as_str()) { - return Err(message.into()); - } - let server_hello: ServerHello = serde_json::from_str(first.as_str())?; - println!( - "підключено (v{}); сесій: {}. Пиши повідомлення, Ctrl-D — вихід.", - server_hello.protocol_version, - server_hello.session_list.len() - ); - - let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()).lines(); - loop { - tokio::select! { - incoming = stream.next() => match incoming { - Some(Ok(Message::Text(text))) => { - if let Ok(envelope) = serde_json::from_str::(text.as_str()) { - print_event(&node, &envelope); - } - } - Some(Ok(_)) => {} - _ => break, - }, - line = stdin.next_line() => match line? { - Some(text) if !text.trim().is_empty() => { - // Команди сесії: /done — fenced publish fact у main, - // /release — пауза (відпустити claim). - let event = match text.trim() { - "/done" => Event::DoneSession {}, - "/release" => Event::ReleaseSession {}, - _ => Event::UserMessage { text, attachments: vec![], surface: Some("cli".into()) }, - }; - let envelope = Envelope { - seq: 0, - ts: chrono_now(), - node_hash: node.clone(), - run_token: Uuid::nil(), - device_id: Some(hello.device_id), - account_id: None, - event, - }; - stream.send(Message::text(serde_json::to_string(&envelope)?)).await?; - } - Some(_) => {} - None => break, - }, - } - } - Ok(()) -} - -/// `agent-cli` не залежить від chrono напряму — бере реекспорт типу з -/// agent-protocol через Envelope; клієнтський ts сервер однаково ігнорує. -fn chrono_now() -> chrono::DateTime { - chrono::Utc::now() -} - -fn print_event(node: &str, envelope: &Envelope) { - if envelope.node_hash != node { - return; - } - match &envelope.event { - Event::AgentTextDelta { text } => { - print!("{text}"); - let _ = std::io::stdout().flush(); - } - Event::AgentTextDone {} => println!(), - Event::UserMessage { text, .. } => println!("> {text}"), - Event::ToolCall { name, .. } => println!("⚙ {name} …"), - Event::ToolResult { ok, summary, .. } => { - println!("{} {summary}", if *ok { "✓" } else { "✗" }) - } - Event::Committed { - commit_hash, - message, - } => println!("✔ {message} ({commit_hash})"), - Event::ClaimChanged { - holder_device_id: None, - .. - } => println!("⏸ claim відпущено — вузол вільний, журнал у run ref"), - Event::Error { message } => eprintln!("помилка: {message}"), - _ => {} - } -} diff --git a/crates/agent-core/Cargo.toml b/crates/agent-core/Cargo.toml deleted file mode 100644 index 7a2c932..0000000 --- a/crates/agent-core/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "agent-core" -description = "ACP-клієнт (Agent Client Protocol) — єдиний транспорт AI-викликів до зовнішніх підписочних CLI" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -name = "agent_core" - -[dependencies] -agent-protocol = { path = "../agent-protocol" } -serde_json.workspace = true -tokio = { workspace = true, features = ["io-util", "rt", "sync", "time"] } - -[dev-dependencies] -tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "sync"] } diff --git a/crates/agent-core/src/acp.rs b/crates/agent-core/src/acp.rs deleted file mode 100644 index 663b453..0000000 --- a/crates/agent-core/src/acp.rs +++ /dev/null @@ -1,537 +0,0 @@ -//! Мінімальний ACP-клієнт (Agent Client Protocol v1) — єдиний транспорт -//! AI-викликів (ADR `260713-2110`): JSON-RPC 2.0 поверх ndjson-стріму -//! (звичайно stdio дочірнього процесу ACP-адаптера підписочного CLI). -//! -//! Покрита підмножина, потрібна runner-у інтерактивних сесій: -//! `initialize` → `session/new` → `session/prompt`; нотифікації -//! `session/update` мапляться на `Event` agent-protocol -//! (`AgentTextDelta`/`ToolCall`/`ToolResult`); запит агента -//! `session/request_permission` іде у [`PermissionHandler`] — хост мапить -//! його на `ApprovalRequest` (Ed25519). Клієнт generic над потоками: -//! продакшн — stdio child-процесу, тести — `tokio::io::duplex`. -//! -//! Читання стріму — фоновий таск на весь час життя клієнта, не прив'язаний -//! до конкретного виклику (як у Zed): нотифікації, що приходять між -//! викликами (напр. деякі адаптери, зокрема `pi-acp`, шлють `agent_message_chunk` -//! з prelude-банером самого CLI одразу після `session/new`, ще до першого -//! prompt), не приліплюються механічно до наступної відповіді. - -use std::fmt; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; -use std::time::Duration; - -use agent_protocol::Event; -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, Mutex as AsyncMutex}; - -/// Помилка ACP-транспорту/протоколу. -#[derive(Debug)] -pub struct AcpError(pub String); - -impl fmt::Display for AcpError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for AcpError {} - -/// Обробник `session/request_permission`: `(action, diff) → approved`. -/// Хост підключає сюди approval-гейт (`ApprovalRequest` + підпис пристрою). -pub type PermissionHandler = - Arc) -> Pin + Send>> + Send + Sync>; - -/// Скільки чекати на "осідання" нотифікацій одразу після відповіді на -/// `session/new`, перш ніж вважати чергу порожньою — щоб prelude-банер -/// адаптера не приліпився до першого `prompt`. -const SETTLE_TIMEOUT: Duration = Duration::from_millis(150); - -/// Класифіковане повідомлення від фонового читача стріму. -enum Incoming { - /// Відповідь на наш запит (`id` — наш власний лічильник). - Response(u64, Result), - /// `session/update`-нотифікація (без `id`). - Notification(Value), -} - -/// ACP-клієнт однієї агент-сесії поверх пари потоків. -pub struct AcpClient { - writer: Arc>, - next_id: u64, - rx: mpsc::UnboundedReceiver, - reader: tokio::task::JoinHandle<()>, -} - -impl Drop for AcpClient { - fn drop(&mut self) { - self.reader.abort(); - } -} - -impl AcpClient -where - W: AsyncWrite + Unpin + Send + 'static, -{ - /// Стартує фоновий читач стріму, живе разом із клієнтом. - pub fn new(reader: R, writer: W, permission: Option) -> Self - where - R: AsyncRead + Unpin + Send + 'static, - { - let writer = Arc::new(AsyncMutex::new(writer)); - let (tx, rx) = mpsc::unbounded_channel(); - let reader = tokio::spawn(read_loop(reader, tx, Arc::clone(&writer), permission)); - Self { - writer, - next_id: 0, - rx, - reader, - } - } - - /// `initialize`: хендшейк версії протоколу (v1). ФС-можливостей клієнт - /// не заявляє — файли виконавець править сам у `cwd` сесії. - pub async fn initialize(&mut self) -> Result<(), AcpError> { - let params = json!({ - "protocolVersion": 1, - "clientCapabilities": { "fs": { "readTextFile": false, "writeTextFile": false } } - }); - self.call("initialize", params, &|_| {}).await.map(|_| ()) - } - - /// `session/new` у робочій директорії (worktree run-а) → sessionId. - /// Дренує prelude-нотифікації, що осіли одразу після відповіді - /// (`SETTLE_TIMEOUT`), перш ніж повернути керування — інакше вони - /// приліпляться до першого `prompt`. - pub async fn new_session(&mut self, cwd: &str) -> Result { - let params = json!({ "cwd": cwd, "mcpServers": [] }); - let result = self.call("session/new", params, &|_| {}).await?; - self.settle(&|_| {}).await; - result["sessionId"] - .as_str() - .map(str::to_string) - .ok_or_else(|| AcpError("session/new без sessionId".into())) - } - - /// `session/prompt`: один хід. Події ходу емітяться через `emit`; - /// завершення → `AgentTextDone` + stopReason. - pub async fn prompt( - &mut self, - session_id: &str, - text: &str, - emit: &(dyn Fn(Event) + Send + Sync), - ) -> Result { - let params = json!({ - "sessionId": session_id, - "prompt": [ { "type": "text", "text": text } ] - }); - let result = self.call("session/prompt", params, emit).await?; - emit(Event::AgentTextDone {}); - Ok(result["stopReason"] - .as_str() - .unwrap_or("end_turn") - .to_string()) - } - - /// Викликає метод і читає з черги фонового читача до відповіді на свій - /// id, обробляючи дорогою нотифікації (`session/update` → Event). - /// Зустрічні запити агента (`session/request_permission`) обробляє сам - /// фоновий читач — незалежно від того, який виклик зараз активний. - async fn call( - &mut self, - method: &str, - params: Value, - emit: &(dyn Fn(Event) + Send + Sync), - ) -> Result { - self.next_id += 1; - let id = self.next_id; - self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) - .await?; - - loop { - match self.rx.recv().await { - Some(Incoming::Response(rid, result)) if rid == id => { - return result - .map_err(|AcpError(error)| AcpError(format!("{method}: {error}"))); - } - Some(Incoming::Response(..)) => continue, - Some(Incoming::Notification(message)) => self.handle_notification(&message, emit), - None => return Err(AcpError("ACP-агент закрив стрім".into())), - } - } - } - - /// Дренує чергу, поки нотифікації надходять швидше за `SETTLE_TIMEOUT`; - /// тайм-аут або порожня черга — сигнал, що осідання завершилось. - async fn settle(&mut self, emit: &(dyn Fn(Event) + Send + Sync)) { - loop { - match tokio::time::timeout(SETTLE_TIMEOUT, self.rx.recv()).await { - Ok(Some(Incoming::Notification(message))) => { - self.handle_notification(&message, emit) - } - Ok(Some(Incoming::Response(..))) | Ok(None) | Err(_) => return, - } - } - } - - /// `session/update` → Event: agent_message_chunk → AgentTextDelta; - /// tool_call → ToolCall; tool_call_update (термінальний статус) → - /// ToolResult. Невідомі варіанти ігноруються (forward-compat). - fn handle_notification(&self, message: &Value, emit: &(dyn Fn(Event) + Send + Sync)) { - if message["method"] != "session/update" { - return; - } - let update = &message["params"]["update"]; - match update["sessionUpdate"].as_str() { - Some("agent_message_chunk") => { - if let Some(text) = update["content"]["text"].as_str() { - emit(Event::AgentTextDelta { - text: text.to_string(), - }); - } - } - Some("tool_call") => emit(Event::ToolCall { - call_id: update["toolCallId"] - .as_str() - .unwrap_or_default() - .to_string(), - name: update["title"] - .as_str() - .or(update["kind"].as_str()) - .unwrap_or("tool") - .to_string(), - args: update["rawInput"].clone(), - }), - Some("tool_call_update") => { - let status = update["status"].as_str().unwrap_or_default(); - if status == "completed" || status == "failed" { - emit(Event::ToolResult { - call_id: update["toolCallId"] - .as_str() - .unwrap_or_default() - .to_string(), - ok: status == "completed", - summary: update["title"].as_str().unwrap_or(status).to_string(), - }); - } - } - _ => {} - } - } - - async fn send(&self, message: &Value) -> Result<(), AcpError> { - send_frame(&self.writer, message).await - } -} - -async fn send_frame( - writer: &AsyncMutex, - message: &Value, -) -> Result<(), AcpError> { - let mut frame = message.to_string(); - frame.push('\n'); - let mut writer = writer.lock().await; - writer - .write_all(frame.as_bytes()) - .await - .map_err(|e| AcpError(format!("запис ACP-стріму: {e}")))?; - writer - .flush() - .await - .map_err(|e| AcpError(format!("flush ACP-стріму: {e}"))) -} - -/// Фоновий читач стріму: класифікує кадри на відповіді/нотифікації -/// (форвардить у канал виклику) і сам відповідає на зустрічні запити -/// агента (`session/request_permission` → `PermissionHandler`) — доки -/// живе клієнт, незалежно від того, який `call()` зараз читає з каналу. -async fn read_loop( - reader: impl AsyncRead + Unpin, - tx: mpsc::UnboundedSender, - writer: Arc>, - permission: Option, -) { - let mut lines = BufReader::new(reader).lines(); - loop { - let line = match lines.next_line().await { - Ok(Some(line)) => line, - _ => return, - }; - if line.trim().is_empty() { - continue; - } - let message: Value = match serde_json::from_str(&line) { - Ok(value) => value, - Err(_) => continue, - }; - - if message["method"].is_string() { - if message["id"].is_null() { - let _ = tx.send(Incoming::Notification(message)); - } else { - handle_agent_request(&writer, &permission, &message).await; - } - continue; - } - let Some(id) = message["id"].as_u64() else { - continue; - }; - let result = match message.get("error").filter(|e| !e.is_null()) { - Some(error) => Err(AcpError(error.to_string())), - None => Ok(message["result"].clone()), - }; - let _ = tx.send(Incoming::Response(id, result)); - } -} - -/// Зустрічний запит агента. `session/request_permission` → handler -/// (без handler-а — відмова); вибирається перший option відповідного -/// kind (`allow*`/`reject*`). Інші методи → JSON-RPC method not found. -async fn handle_agent_request( - writer: &AsyncMutex, - permission: &Option, - message: &Value, -) { - let id = message["id"].clone(); - if message["method"] != "session/request_permission" { - let _ = send_frame( - writer, - &json!({ - "jsonrpc": "2.0", "id": id, - "error": { "code": -32601, "message": "method not found" } - }), - ) - .await; - return; - } - let params = &message["params"]; - let action = params["toolCall"]["title"] - .as_str() - .or(params["toolCall"]["kind"].as_str()) - .unwrap_or("tool") - .to_string(); - let diff = params["toolCall"]["content"].as_str().map(str::to_string); - let approved = match permission { - Some(handler) => handler(action, diff).await, - None => false, - }; - let wanted = if approved { "allow" } else { "reject" }; - let option_id = params["options"] - .as_array() - .and_then(|options| { - options - .iter() - .find(|o| o["kind"].as_str().unwrap_or_default().starts_with(wanted)) - }) - .and_then(|o| o["optionId"].as_str()) - .unwrap_or(wanted) - .to_string(); - let _ = send_frame( - writer, - &json!({ - "jsonrpc": "2.0", "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": option_id } } - }), - ) - .await; -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::*; - - /// Фейковий ACP-агент на другому кінці duplex: скриптує initialize, - /// session/new і session/prompt (чанки + tool call + відповідь). - /// `prelude` — імітує `pi-acp`: одразу після `session/new`, ще до - /// першого prompt, шле `agent_message_chunk` з банером CLI. - async fn fake_agent(stream: tokio::io::DuplexStream, request_permission: bool, prelude: bool) { - let (read, mut write) = tokio::io::split(stream); - let mut lines = BufReader::new(read).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let message: Value = serde_json::from_str(&line).unwrap(); - let id = message["id"].clone(); - match message["method"].as_str() { - Some("initialize") => { - respond( - &mut write, - &json!({ "jsonrpc": "2.0", "id": id, "result": { "protocolVersion": 1 } }), - ) - .await; - } - Some("session/new") => { - respond( - &mut write, - &json!({ "jsonrpc": "2.0", "id": id, "result": { "sessionId": "s1" } }), - ) - .await; - if prelude { - respond( - &mut write, - &json!({ - "jsonrpc": "2.0", "method": "session/update", - "params": { "sessionId": "s1", "update": { - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": "pi v0.79.9\n---\n" } } } - }), - ) - .await; - } - } - Some("session/prompt") => { - for text in ["при", "віт"] { - respond( - &mut write, - &json!({ - "jsonrpc": "2.0", "method": "session/update", - "params": { "sessionId": "s1", "update": { - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": text } } } - }), - ) - .await; - } - if request_permission { - respond( - &mut write, - &json!({ - "jsonrpc": "2.0", "id": 777, "method": "session/request_permission", - "params": { "sessionId": "s1", - "toolCall": { "title": "write_file", "kind": "edit" }, - "options": [ - { "optionId": "ok", "kind": "allow_once" }, - { "optionId": "no", "kind": "reject_once" } ] } - }), - ) - .await; - // Відповідь клієнта на permission приходить наступним кадром. - let reply = lines.next_line().await.unwrap().unwrap(); - let reply: Value = serde_json::from_str(&reply).unwrap(); - let picked = reply["result"]["outcome"]["optionId"].clone(); - respond( - &mut write, - &json!({ - "jsonrpc": "2.0", "method": "session/update", - "params": { "sessionId": "s1", "update": { - "sessionUpdate": "tool_call_update", "toolCallId": "c1", - "status": if picked == "ok" { "completed" } else { "failed" }, - "title": "write_file" } } - }), - ) - .await; - } - respond(&mut write, &json!({ "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } })).await; - } - _ => {} - } - } - } - - async fn respond(write: &mut (impl AsyncWrite + Unpin), message: &Value) { - let mut frame = message.to_string(); - frame.push('\n'); - write.write_all(frame.as_bytes()).await.unwrap(); - write.flush().await.unwrap(); - } - - fn client_for( - stream: tokio::io::DuplexStream, - permission: Option, - ) -> AcpClient> { - let (read, write) = tokio::io::split(stream); - AcpClient::new(read, write, permission) - } - - /// Повний хід: initialize → session/new → prompt; чанки стають - /// AgentTextDelta, завершення — AgentTextDone. - #[tokio::test] - async fn prompt_maps_updates_to_events() { - let (local, remote) = tokio::io::duplex(64 * 1024); - tokio::spawn(fake_agent(remote, false, false)); - let mut client = client_for(local, None); - - client.initialize().await.unwrap(); - let session = client.new_session("/tmp").await.unwrap(); - assert_eq!(session, "s1"); - - let events = Mutex::new(Vec::new()); - let emit = |event: Event| events.lock().unwrap().push(event); - let stop = client.prompt(&session, "звук", &emit).await.unwrap(); - - assert_eq!(stop, "end_turn"); - assert_eq!( - *events.lock().unwrap(), - vec![ - Event::AgentTextDelta { - text: "при".into() - }, - Event::AgentTextDelta { - text: "віт".into() - }, - Event::AgentTextDone {}, - ] - ); - } - - /// request_permission: approve → агент отримує allow-option і шле - /// completed; deny → reject-option і failed. - #[tokio::test] - async fn permission_request_routes_through_handler() { - for (approve, expect_ok) in [(true, true), (false, false)] { - let (local, remote) = tokio::io::duplex(64 * 1024); - tokio::spawn(fake_agent(remote, true, false)); - let handler: PermissionHandler = - Arc::new(move |_action, _diff| Box::pin(async move { approve })); - let mut client = client_for(local, Some(handler)); - - client.initialize().await.unwrap(); - let session = client.new_session("/tmp").await.unwrap(); - let events = Mutex::new(Vec::new()); - let emit = |event: Event| events.lock().unwrap().push(event); - client.prompt(&session, "запиши", &emit).await.unwrap(); - - let events = events.lock().unwrap(); - assert!( - events.iter().any(|e| matches!( - e, - Event::ToolResult { ok, .. } if *ok == expect_ok - )), - "{events:?}" - ); - } - } - - /// Prelude-банер адаптера (напр. `pi-acp`), що приходить одразу після - /// `session/new`, ще до першого prompt, — дренується `settle()` і не - /// потрапляє в події першого реального ходу (регресія на mt/pull/51). - #[tokio::test] - async fn session_new_drains_prelude_before_first_prompt() { - let (local, remote) = tokio::io::duplex(64 * 1024); - tokio::spawn(fake_agent(remote, false, true)); - let mut client = client_for(local, None); - - client.initialize().await.unwrap(); - let session = client.new_session("/tmp").await.unwrap(); - - let events = Mutex::new(Vec::new()); - let emit = |event: Event| events.lock().unwrap().push(event); - client.prompt(&session, "звук", &emit).await.unwrap(); - - assert_eq!( - *events.lock().unwrap(), - vec![ - Event::AgentTextDelta { - text: "при".into() - }, - Event::AgentTextDelta { - text: "віт".into() - }, - Event::AgentTextDone {}, - ], - "банер адаптера не мав приліпитись до першої відповіді" - ); - } -} diff --git a/crates/agent-core/src/docs/acp.md b/crates/agent-core/src/docs/acp.md deleted file mode 100644 index 9429d14..0000000 --- a/crates/agent-core/src/docs/acp.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -type: Rust Module -title: acp.rs -resource: crates/agent-core/src/acp.rs -docgen: - crc: 02c86766 - model: manual - score: 100 ---- - -## Огляд - -Мінімальний ACP-клієнт (Agent Client Protocol v1) — єдиний транспорт AI-викликів (ADR `260713-2110`): JSON-RPC 2.0 поверх ndjson-стріму stdio дочірнього процесу ACP-адаптера підписочного CLI. Читання стріму — фоновий таск на весь час життя клієнта, не прив'язаний до конкретного виклику: нотифікації, що приходять між викликами (напр. prelude-банер CLI, який деякі адаптери, зокрема `pi-acp`, шлють одразу після `session/new`, ще до першого prompt), не приліплюються механічно до наступної відповіді. - -## Поведінка - -- `AcpClient::new` — стартує фоновий читач стріму (tokio-таск), живе разом із клієнтом; абортується при `Drop`. -- `initialize`/`new_session`/`prompt` — послідовний хендшейк ACP-сесії; `new_session` додатково дренує нотифікації, що осіли одразу після відповіді, перед тим як повернути керування. -- `session/update`-нотифікації мапляться на `Event` (`AgentTextDelta`/`ToolCall`/`ToolResult`); невідомі варіанти ігноруються (forward-compat). -- Зустрічний запит агента `session/request_permission` обробляє фоновий читач через `PermissionHandler` — незалежно від того, який виклик клієнта зараз активний; без обробника — відмова. - -## Гарантії поведінки - -- Пише в stdin дочірнього процесу (JSON-RPC запити й відповіді на зустрічні запити агента) — **не** read-only. -- Помилки повертаються значенням `Result<_, AcpError>`, не панікою. diff --git a/crates/agent-core/src/docs/index.md b/crates/agent-core/src/docs/index.md deleted file mode 100644 index 78461ca..0000000 --- a/crates/agent-core/src/docs/index.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -type: Directory Index -title: crates/agent-core/src -resource: crates/agent-core/src/ ---- - -| Файл | Тип | -| ---------------- | ----------- | -| [acp.rs](acp.md) | Rust Module | -| [lib.rs](lib.md) | Rust Module | diff --git a/crates/agent-core/src/docs/lib.md b/crates/agent-core/src/docs/lib.md deleted file mode 100644 index 381b118..0000000 --- a/crates/agent-core/src/docs/lib.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: Rust Module -title: lib.rs -resource: crates/agent-core/src/lib.rs -docgen: - crc: 630d2e1f - model: openai-codex/gpt-5.5 - score: 100 - issues: judge:inaccurate:0.96 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Забезпечує переносиме ядро агента: запускає agent loop, координує tools і взаємодіє з provider без залежності від Tauri чи серверної обгортки. Фізична межа крейта визначена в `npm/docs/architecture/stack.md`: `agent-core` не містить Tauri-залежностей, але може використовувати `tokio`. Передає події `agent-protocol` через callback, залишає Envelope для `agent-server` і не пропускає SDK-специфічні типи provider у API ядра. Працює як read-only компонент: не записує дані у ФС або БД. - -## Поведінка - -1. Надає спільну точку входу до ядра агента для запуску agent loop, роботи з tools і взаємодії з provider. - -2. Відокремлює ядро агента від Tauri та серверного шару, щоб бізнес-логіка агента залишалась переносимою й незалежною від UI/runtime-обгортки. - -3. Публікує події agent-protocol через callback, залишаючи формування Envelope із послідовністю, часом і адресацією для agent-server. - -4. Визначає нейтральний контракт provider, щоб конкретні SDK-представлення не потрапляли в публічний API ядра. - -5. Експортує основні сутності агента, provider і tools як стабільну поверхню для інших компонентів системи. - -6. Не виконує запис у файлову систему або базу даних; відповідає лише за поведінку ядра та передачу результатів через надані контракти. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-core/src/lib.rs b/crates/agent-core/src/lib.rs deleted file mode 100644 index e0d2529..0000000 --- a/crates/agent-core/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! `agent-core` — ACP-клієнт (Agent Client Protocol). -//! -//! ACP — **єдиний транспорт AI-викликів** (ADR `260713-2110`): виконавці — -//! зовнішні підписочні CLI (claude / codex / cursor / pi для локальних -//! omlx-моделей), кожен підключається своїм ACP-адаптером; -//! `session/request_permission` мапиться на `ApprovalRequest` протоколу -//! (Ed25519). Власного agent loop, реєстру tools і provider-шару тут НЕМАЄ — -//! це свідомо видалені відхилення від ACP-норми. - -pub mod acp; - -pub use acp::{AcpClient, AcpError, PermissionHandler}; diff --git a/crates/agent-protocol/Cargo.toml b/crates/agent-protocol/Cargo.toml deleted file mode 100644 index ec859db..0000000 --- a/crates/agent-protocol/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "agent-protocol" -description = "Протокол подій v4 для agent-server: Envelope/Event, хендшейк, Ed25519-підписи approvals" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -name = "agent_protocol" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -chrono = { workspace = true, features = ["serde"] } -uuid.workspace = true -ed25519-dalek.workspace = true -base64.workspace = true diff --git a/crates/agent-protocol/src/approvals.rs b/crates/agent-protocol/src/approvals.rs deleted file mode 100644 index d6aa411..0000000 --- a/crates/agent-protocol/src/approvals.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Ed25519-підписи approvals (спека access.md, «Approvals: три гейти, один -//! механізм»). -//! -//! Пристрій учасника з роллю approver+ підписує кортеж -//! `(request_id, approved, node_hash, run_token)` власним ключем; хост -//! звіряє підпис із pubkey-кешем relay і матеріалізує у файл вузла. -//! Канонічне повідомлення — доменний префікс + поля через NUL-роздільник -//! (див. [`ApprovalPayload::message`]), однакове для всіх трьох гейтів. - -use ed25519_dalek::Signer; -pub use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; -use uuid::Uuid; - -/// Доменний префікс канонічного повідомлення — захист від повторного -/// використання підпису в іншому контексті. -const DOMAIN: &[u8] = b"mt-approval-v4"; - -/// Кортеж, який підписує пристрій. Той самий для mid-run tool approval, -/// plan-review і аудит-вердикту людини. -#[derive(Debug, Clone, PartialEq)] -pub struct ApprovalPayload { - pub request_id: String, - pub approved: bool, - /// Кімната/адреса вузла. - pub node_hash: String, - /// = token claim-а (ідентифікатор сесії). - pub run_token: Uuid, -} - -impl ApprovalPayload { - /// Канонічні байти для підпису: `DOMAIN \0 request_id \0 approved-байт - /// (0x01/0x00) \0 node_hash \0 run_token(hyphenated)`. NUL-роздільник - /// унеможливлює склейку сусідніх полів. - pub fn message(&self) -> Vec { - let mut message = Vec::with_capacity( - DOMAIN.len() + self.request_id.len() + self.node_hash.len() + 36 + 5, - ); - message.extend_from_slice(DOMAIN); - message.push(0); - message.extend_from_slice(self.request_id.as_bytes()); - message.push(0); - message.push(u8::from(self.approved)); - message.push(0); - message.extend_from_slice(self.node_hash.as_bytes()); - message.push(0); - message.extend_from_slice(self.run_token.hyphenated().to_string().as_bytes()); - message - } -} - -/// Помилка перевірки підпису approval-а. -#[derive(Debug, Clone, PartialEq)] -pub enum ApprovalError { - /// Ed25519-підпис — рівно 64 байти. - BadSignatureLength { actual: usize }, - /// Підпис не сходиться з payload-ом чи pubkey-ем (зіпсований, чужий - /// ключ або підмінене поле кортежу). - VerificationFailed, -} - -impl std::fmt::Display for ApprovalError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ApprovalError::BadSignatureLength { actual } => { - write!(f, "approval signature must be 64 bytes, got {actual}") - } - ApprovalError::VerificationFailed => { - write!(f, "approval signature verification failed") - } - } - } -} - -impl std::error::Error for ApprovalError {} - -/// Підписати approval приватним ключем пристрою. Байти для -/// `ApprovalResponse.signature` — `signature.to_bytes()`. -pub fn sign_approval(key: &SigningKey, payload: &ApprovalPayload) -> Signature { - key.sign(&payload.message()) -} - -/// Перевірити підпис проти pubkey пристрою (з pubkey-кешу relay). -/// Використовує `verify_strict` — відхиляє нестандартні (malleable) підписи. -pub fn verify_approval( - pubkey: &VerifyingKey, - payload: &ApprovalPayload, - signature: &[u8], -) -> Result<(), ApprovalError> { - let bytes: [u8; 64] = signature - .try_into() - .map_err(|_| ApprovalError::BadSignatureLength { - actual: signature.len(), - })?; - pubkey - .verify_strict(&payload.message(), &Signature::from_bytes(&bytes)) - .map_err(|_| ApprovalError::VerificationFailed) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn payload() -> ApprovalPayload { - ApprovalPayload { - request_id: "req-1".into(), - approved: true, - node_hash: "d".repeat(20), - run_token: Uuid::from_u128(42), - } - } - - fn key() -> SigningKey { - SigningKey::from_bytes(&[7u8; 32]) - } - - #[test] - fn sign_then_verify_ok() { - let signature = sign_approval(&key(), &payload()); - assert_eq!( - verify_approval(&key().verifying_key(), &payload(), &signature.to_bytes()), - Ok(()) - ); - } - - /// Зіпсований підпис (один перевернутий байт) — відмова. - #[test] - fn corrupted_signature_fails() { - let mut bytes = sign_approval(&key(), &payload()).to_bytes(); - bytes[10] ^= 0xFF; - assert_eq!( - verify_approval(&key().verifying_key(), &payload(), &bytes), - Err(ApprovalError::VerificationFailed) - ); - } - - /// Підміна будь-якого поля кортежу (тут `approved`) інвалідовує підпис. - #[test] - fn tampered_payload_fails() { - let bytes = sign_approval(&key(), &payload()).to_bytes(); - let tampered = ApprovalPayload { - approved: false, - ..payload() - }; - assert_eq!( - verify_approval(&key().verifying_key(), &tampered, &bytes), - Err(ApprovalError::VerificationFailed) - ); - } - - /// Ключ поза pubkey-списком — відмова. - #[test] - fn foreign_key_fails() { - let bytes = sign_approval(&SigningKey::from_bytes(&[9u8; 32]), &payload()).to_bytes(); - assert_eq!( - verify_approval(&key().verifying_key(), &payload(), &bytes), - Err(ApprovalError::VerificationFailed) - ); - } - - #[test] - fn wrong_length_is_explicit_error() { - assert_eq!( - verify_approval(&key().verifying_key(), &payload(), &[1, 2, 3]), - Err(ApprovalError::BadSignatureLength { actual: 3 }) - ); - } - - /// NUL-роздільник: зсув межі полів дає ІНШЕ повідомлення. - #[test] - fn message_field_boundaries_are_unambiguous() { - let a = ApprovalPayload { - request_id: "ab".into(), - node_hash: "c".into(), - ..payload() - }; - let b = ApprovalPayload { - request_id: "a".into(), - node_hash: "bc".into(), - ..payload() - }; - assert_ne!(a.message(), b.message()); - } -} diff --git a/crates/agent-protocol/src/docs/approvals.md b/crates/agent-protocol/src/docs/approvals.md deleted file mode 100644 index f081244..0000000 --- a/crates/agent-protocol/src/docs/approvals.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -type: Rust Module -title: approvals.rs -resource: crates/agent-protocol/src/approvals.rs -docgen: - crc: e8cc3ee3 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - tier: local-min-retry - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Модуль використовує Ed25519-підписи для механізму підтвердження (approvals). Пристрій учасника з роллю approver підписує кортеж `` власним ключем. Канонічне повідомлення, яке підписується, являє собою доменний префікс плюс поля, розділені NUL-роздільником (як визначено в `ApprovalPayload::message`), і є ідентичним для всіх трьох гейтів. Хост звіряє отриманий підпис із `pubkey-кешем relay` та матеріалізує результат у файл вузла. Функції дозволяють створювати ці підписи (`sign_approval`) та верифікувати їх (`verify_approval`), при цьому система обробляє помилки внутрішньо (fail-safe), повертаючи порожнє значення замість кидання винятків. - -## Поведінка - -ApprovalPayload описує дані для авторизації, які підписує учасник. -message генерує унікальний канонічний рядок для підпису на основі даних `ApprovalPayload`. -ApprovalError описує можливі помилки під час верифікації підпису. -sign_approval створює Ed25519-підпис власним приватним ключем на канонічному повідомленні. -verify_approval перевіряє, чи відповідає наданий підпис публічному ключу та канонічному повідомленню. - -## Публічний API - -ApprovalPayload — дані, які позначає пристрій для затвердження у процесах (mid-run tool approval, план-рецензування, аудиторський вирок). -message — стандартизований набір байтів для підпису, що включає домен, ідентифікатор запиту, підтверджений байт (0x01/0x00), хеш вузла та токен виконання. -ApprovalError — помилка, що виникає при невдалій валідації підпису затвердження. -sign_approval — створює цифровий підпис затвердження за допомогою приватного ключа пристрою, даючи байти для `ApprovalResponse.signature`. -verify_approval — підтверджує підпис, порівнюючи його з публічним ключем пристрою (доступним у кеші релея), ігноруючи нестандартні (malleable) підписи. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-protocol/src/docs/envelope.md b/crates/agent-protocol/src/docs/envelope.md deleted file mode 100644 index bbbb5c0..0000000 --- a/crates/agent-protocol/src/docs/envelope.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -type: Rust Module -title: envelope.rs -resource: crates/agent-protocol/src/envelope.rs -docgen: - crc: 56f75017 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -`Envelope`, `Rect`, `ClaimInfo`, `Event`, `serialize`, `deserialize` описують стрічку подій сесії за контрактом `runtime.md`, де `session.jsonl` є append-only списком `Envelope`-ів; ефемерні події на кшталт `PreviewScreenshot` і `AgentTextDelta` можуть не потрапляти в журнал. Невідомий `Event`-варіант у межах сумісної версії десеріалізується в `Event::Unknown` і клієнтом ігнорується для forward-compatibility мінорних розширень. Модуль read-only і не пише у ФС чи БД; помилки перехоплюються fail-safe, назовні не кидаються, а за певних помилок повертається `null` замість винятку. - -## Поведінка - -- `Envelope` — описує один запис стрічки подій сесії з часом, джерелом і вкладеною подією. -- `Rect` — задає прямокутну область для контексту вибору. -- `ClaimInfo` — зберігає стан claim-а вузла: хто тримає, до коли й яка версія. -- `Event` — моделює події протоколу сесії, включно з сумісним `Unknown` для нових варіантів. -- `serialize` — кодує байти підпису в JSON-рядок у base64. -- `deserialize` — декодує base64-рядок підпису назад у байти; при некоректному вхідному значенні повертає помилку serde. - -## Публічний API - -- Envelope — один запис стрічки подій run-а; `seq` зростає в межах run і вказує на тримач claim; `run_token` збігається з токеном claim-а, тобто з ідентифікатором сесії. -- Rect — прямокутник, що окреслює область вибору в `ContextSelected.bounding_box`. -- ClaimInfo — знімок claim-а вузла в `NodeState`, де джерелом істини є git ref. -- Event — подія протоколу v4; спочатку йдуть повідомлення «клієнт → хост», потім «хост → клієнти», у порядку й назвах, що відповідають `runtime.md`. -- serialize — перетворює внутрішні дані в переносний формат для збереження або передачі. -- deserialize — відновлює внутрішні дані збереженого або отриманого формату. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-protocol/src/docs/handshake.md b/crates/agent-protocol/src/docs/handshake.md deleted file mode 100644 index 0ac78f2..0000000 --- a/crates/agent-protocol/src/docs/handshake.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: Rust Module -title: handshake.rs -resource: crates/agent-protocol/src/handshake.rs -docgen: - crc: 988e65c3 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -[`runtime.md`, розділ «Хендшейк»] Керує обміном `ClientHello` ↔ `ServerHello` між клієнтом і хостом: якщо `protocol_version` несумісна, повертає явну `ProtocolError` із підказкою оновитись; якщо у v4 відсутнє обов’язкове `lang` у форматі BCP-47, хендшейк не десеріалізується, бо саме це поле керує live-перекладом за `i18n.md`. Публічні елементи: `ClientHello`, `ServerHello`, `SessionInfo`, `ProtocolError`, `check_protocol_version`, `check_compatibility`. Код read-only: не пише у ФС чи БД. - -## Поведінка - -- `ClientHello` — описує перше повідомлення клієнта для старту хендшейку; містить версію протоколу, ідентифікацію пристрою, тип клієнта, його можливості, мову та за потреби запит на відтворення подій. -- `ServerHello` — відповідає на сумісний хендшейк і передає версію протоколу та список активних сесій хоста. -- `SessionInfo` — представляє одну активну сесію хоста у відповіді `ServerHello`. -- `ProtocolError` — фіксує помилку хендшейку, коли версії протоколу клієнта і сервера не збігаються, з явною підказкою про оновлення. -- `check_protocol_version` — перевіряє, чи версія клієнта збігається з поточною версією протоколу; несумісність повертає `ProtocolError`. -- `check_compatibility` — перевіряє сумісність власного `ClientHello` із поточною версією протоколу та відхиляє несумісний хендшейк. - -Changelog: не перевірено — змін у файлах не було. - -## Публічний API - -- ClientHello — початковий пакет клієнта, який повідомляє серверу тип клієнта та доступні можливості, щоб той відсік зайві події. -- ServerHello — відповідь сервера на валідний `ClientHello` із даними для продовження обміну. -- SessionInfo — запис про поточну сесію хоста, який сервер повертає в `ServerHello.session_list`. -- ProtocolError — помилка під час узгодження протоколу. -- check_protocol_version — звіряє версію клієнта з `PROTOCOL_VERSION`, щоб не пускати несумісні з’єднання. -- check_compatibility — звіряє власну версію хендшейку з очікуваною, щоб виявити розсинхрон між сторонами. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-protocol/src/docs/index.md b/crates/agent-protocol/src/docs/index.md deleted file mode 100644 index c25ea9a..0000000 --- a/crates/agent-protocol/src/docs/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -type: Directory Index -title: crates/agent-protocol/src -resource: crates/agent-protocol/src/ ---- - -| Файл | Тип | -| ---------------------------- | ----------- | -| [approvals.rs](approvals.md) | Rust Module | -| [envelope.rs](envelope.md) | Rust Module | -| [handshake.rs](handshake.md) | Rust Module | -| [lib.rs](lib.md) | Rust Module | -| [transfers.rs](transfers.md) | Rust Module | diff --git a/crates/agent-protocol/src/docs/lib.md b/crates/agent-protocol/src/docs/lib.md deleted file mode 100644 index bf3cef0..0000000 --- a/crates/agent-protocol/src/docs/lib.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: Rust Module -title: lib.rs -resource: crates/agent-protocol/src/lib.rs -docgen: - crc: 4844f2b9 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.97 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл задає спільний контракт подій для клієнта й хоста agent-server: `Envelope` і `Event` описують обмін повідомленнями, а `ClientHello` і `ServerHello` — handshake із перевіркою сумісності версії. Також тут зафіксовано `approvals` з Ed25519-підписами, щоб дозволи на дію перевірялися в межах того самого контракту. - -## Поведінка - -1. Виступає як стабільний контракт подій між клієнтом і хостом agent-server, щоб обидві сторони працювали за однаковою схемою обміну. -2. Відокремлює цей контракт від runtime-інфраструктури: тут є лише моделі протоколу, без залежності від tokio/tauri. -3. Дає спільні типи для пакування подій, envelope, контексту права на дію та геометричних даних, щоб передача стану була однозначною. -4. Ініціює хендшейк між клієнтом і сервером через обмін привітаннями, щоб сторонам було зрозуміло, чи вони говорять однією версією протоколу. -5. Відхиляє несумісну версію на хендшейку з явною помилкою і підказкою оновлення, щоб не запускати сесію на різних контрактах. -6. Підтримує перевірку та підпис approvals через Ed25519, щоб дозвіл на дію можна було верифікувати між сторонами. -7. Експортує ключові сутності контракту назовні, щоб інші частини системи використовували один і той самий опис подій і сесії. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-protocol/src/docs/transfers.md b/crates/agent-protocol/src/docs/transfers.md deleted file mode 100644 index 241c693..0000000 --- a/crates/agent-protocol/src/docs/transfers.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -type: Rust Module -title: transfers.rs -resource: crates/agent-protocol/src/transfers.rs -docgen: - crc: d2fec6bc - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл описує canonical акт transfer ownership у Membership API та його byte-for-byte сумісність із relay через доменний префікс і NUL-роздільник полів. Він потрібен, щоб пристрій поточного owner-а міг підписати цей акт Ed25519, а relay — перевірити підпис проти pubkey пристрою поточного owner-а за тим самим форматом повідомлення. `TransferPayload`, `message`, `sign_transfer`, `verify_transfer` працюють read-only, fail-safe, не кидають винятків назовні й за окремих помилок повертають порожнє значення замість винятку. - -## Поведінка - -- `TransferPayload` — описує акт передачі ownership між поточним і новим owner для кореневої задачі. -- `message` — формує canonical bytes акта transfer для підпису й перевірки, сумісні з relay. -- `sign_transfer` — створює Ed25519-підпис акта transfer приватним ключем пристрою owner-а. -- `verify_transfer` — перевіряє підпис акта transfer проти public key пристрою та повертає помилку, якщо підпис хибний або має некоректну довжину. - -## Публічний API - -- TransferPayload — Фіксує акт передачі права власності на кореневу задачу. -- message — Формує канонічні байти акта передачі для підпису й звірки. -- sign_transfer — Підписує акт transfer ключем пристрою власника. -- verify_transfer — Звіряє підпис акта з pubkey пристрою, що ініціював передачу. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-protocol/src/envelope.rs b/crates/agent-protocol/src/envelope.rs deleted file mode 100644 index bf3550c..0000000 --- a/crates/agent-protocol/src/envelope.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! `Envelope`/`Event` — стрічка подій сесії (спека runtime.md, «Протокол подій»). -//! -//! `session.jsonl` — append-only список Envelope-ів; ефемерні події -//! (`PreviewScreenshot`, `AgentTextDelta`) можна не журналити. Невідомий -//! `Event`-варіант у межах сумісної версії десеріалізується в -//! [`Event::Unknown`] і клієнтом ігнорується (forward-compatibility -//! мінорних розширень). - -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::Value; -use uuid::Uuid; - -/// Один запис стрічки подій run-а. `seq` монотонний у межах run; -/// призначає тримач claim. `run_token` = token claim-а (ідентифікатор сесії). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Envelope { - pub seq: u64, - pub ts: DateTime, - /// Кімната/адреса вузла. - pub node_hash: String, - pub run_token: Uuid, - /// Хто ініціював — для подій від клієнтів. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub device_id: Option, - /// У спільних задачах учасників кілька. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account_id: Option, - pub event: Event, -} - -/// Прямокутник контексту вибору (`ContextSelected.bounding_box`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -/// Знімок claim-а вузла у `NodeState` (джерело істини — git ref). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ClaimInfo { - pub holder_device: Uuid, - pub lease_until: DateTime, - pub generation: u64, -} - -/// Подія протоколу v4. Варіанти «клієнт → хост» ідуть першими, далі -/// «хост → клієнти» — порядок і назви віддзеркалюють runtime.md. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum Event { - // ── клієнт → хост ────────────────────────────────────────────────────── - /// `surface`-hint («designer» | «writer» | «cli» | …) — агент може - /// підставити відповідний профіль провайдера/промпт. - UserMessage { - text: String, - attachments: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - surface: Option, - }, - /// Контекст, у який «тицьнув» користувач, незалежно від додатку: - /// `kind` — «dom_element» | «text_range» | «file_region» | …. - ContextSelected { - kind: String, - payload: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - bounding_box: Option, - }, - /// Ed25519-підпис пристрою над `(request_id, approved, node_hash, - /// run_token)`; пристрій може належати ІНШОМУ акаунту з роллю approver+. - ApprovalResponse { - request_id: String, - approved: bool, - #[serde(with = "base64_bytes")] - signature: Vec, - }, - CancelTurn {}, - /// Завершити run вузла: хост виконує `mt done`-семантику — fenced - /// publish fact у main (мінорне розширення v4). - DoneSession {}, - /// Пауза/відпустити: хост CAS-delete claim; журнал лишається в run ref - /// базою відновлення (мінорне розширення v4). - ReleaseSession {}, - - // ── хост → клієнти ───────────────────────────────────────────────────── - /// ЕФЕМЕРНА: не журналиться — журналиться `AgentTextDone`-агрегат. - AgentTextDelta { - text: String, - }, - AgentTextDone {}, - ToolCall { - call_id: String, - name: String, - args: Value, - }, - ToolResult { - call_id: String, - ok: bool, - summary: String, - }, - ApprovalRequest { - request_id: String, - action: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - diff: Option, - }, - /// ЕФЕМЕРНА: лише relay/WS, ніколи в git; лише клієнтам з capability - /// «preview». Несе лише `ref_id` — байти клієнт тягне окремим запитом. - PreviewScreenshot { - ref_id: String, - mime: String, - }, - FileChanged { - path: String, - }, - Committed { - commit_hash: String, - message: String, - }, - /// Derived-стан вузла — і для сесії, і для `mt-dashboard`. - NodeState { - path: String, - state: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - claim: Option, - }, - /// Транслюється relay-ем; джерело істини — git ref. - ClaimChanged { - node_hash: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - holder_device_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - lease_until: Option>, - generation: u64, - }, - /// `role: None` = учасника видалено. - MemberChanged { - account_id: Uuid, - #[serde(default, skip_serializing_if = "Option::is_none")] - role: Option, - }, - /// Composite-план чекає approve. - PlanReview { - plan_ref: String, - }, - /// Fact чекає вердикту людини-аудитора. - AuditPending { - fact_ref: String, - }, - /// Ескалація «вгору»: записка власника гілки замовникові вузла - /// (owner-app, спека 260714). `from`/`to` — handles, як у git-файлах - /// (`escalation_NNN.md`); `to_account_id` резолвиться емітером через - /// git-ignored `.mt/directory.json` (PII у стрічку не тече — account_id - /// непрозорий) і потрібен relay для адресного push «потребує уваги». - Escalation { - from: String, - to: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - to_account_id: Option, - /// Шлях записки у теці вузла, напр. `escalation_001.md`. - reason_ref: String, - }, - Error { - message: String, - }, - - /// Невідомий варіант сумісної версії — клієнт ігнорує. Хости цей - /// варіант ніколи не надсилають. - #[serde(other)] - Unknown, -} - -/// Serde-хелпер: `signature: bytes` як base64-рядок у JSON. -mod base64_bytes { - use super::*; - - pub fn serialize(bytes: &[u8], serializer: S) -> Result { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - let encoded = String::deserialize(deserializer)?; - BASE64.decode(encoded).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn envelope(event: Event) -> Envelope { - Envelope { - seq: 7, - ts: DateTime::parse_from_rfc3339("2026-07-11T12:00:00Z") - .unwrap() - .with_timezone(&Utc), - node_hash: "a".repeat(20), - run_token: Uuid::from_u128(42), - device_id: Some(Uuid::from_u128(1)), - account_id: None, - event, - } - } - - /// Roundtrip serde на КОЖЕН варіант Event з runtime.md. - #[test] - fn roundtrip_every_event_variant() { - let variants = vec![ - Event::UserMessage { - text: "зроби прев'ю".into(), - attachments: vec![serde_json::json!({"path": "logo.svg"})], - surface: Some("designer".into()), - }, - Event::ContextSelected { - kind: "dom_element".into(), - payload: serde_json::json!({"selector": "#hero"}), - bounding_box: Some(Rect { - x: 1.0, - y: 2.0, - width: 30.0, - height: 40.0, - }), - }, - Event::ApprovalResponse { - request_id: "req-1".into(), - approved: true, - signature: vec![0xAB; 64], - }, - Event::CancelTurn {}, - Event::DoneSession {}, - Event::ReleaseSession {}, - Event::AgentTextDelta { - text: "част".into(), - }, - Event::AgentTextDone {}, - Event::ToolCall { - call_id: "c1".into(), - name: "bash".into(), - args: serde_json::json!({"command": "ls"}), - }, - Event::ToolResult { - call_id: "c1".into(), - ok: true, - summary: "2 файли".into(), - }, - Event::ApprovalRequest { - request_id: "req-1".into(), - action: "git push origin main".into(), - diff: Some("+1 -1".into()), - }, - Event::PreviewScreenshot { - ref_id: "shot-9".into(), - mime: "image/png".into(), - }, - Event::FileChanged { - path: "src/app.vue".into(), - }, - Event::Committed { - commit_hash: "deadbeef".into(), - message: "fix: hero".into(), - }, - Event::NodeState { - path: "mt/demo".into(), - state: "running".into(), - claim: Some(ClaimInfo { - holder_device: Uuid::from_u128(2), - lease_until: DateTime::parse_from_rfc3339("2026-07-11T13:00:00Z") - .unwrap() - .with_timezone(&Utc), - generation: 3, - }), - }, - Event::ClaimChanged { - node_hash: "b".repeat(20), - holder_device_id: None, - lease_until: None, - generation: 4, - }, - Event::MemberChanged { - account_id: Uuid::from_u128(5), - role: None, - }, - Event::PlanReview { - plan_ref: "refs/mt/runs/x/plan".into(), - }, - Event::AuditPending { - fact_ref: "refs/mt/runs/x/fact".into(), - }, - Event::Escalation { - from: "olena".into(), - to: "vkozlov".into(), - to_account_id: Some(Uuid::from_u128(6)), - reason_ref: "escalation_001.md".into(), - }, - Event::Error { - message: "boom".into(), - }, - ]; - for event in variants { - let original = envelope(event); - let json = serde_json::to_string(&original).unwrap(); - let parsed: Envelope = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, original, "roundtrip зламано: {json}"); - } - } - - /// Невідомий tag сумісної версії → `Event::Unknown` (ігнорується), а не помилка. - #[test] - fn unknown_variant_deserializes_to_unknown() { - let json = r#"{"type": "SomeFutureEvent", "anything": 1}"#; - let event: Event = serde_json::from_str(json).unwrap(); - assert_eq!(event, Event::Unknown); - } - - /// Підпис їде як base64-рядок, не масив байтів. - #[test] - fn signature_serializes_as_base64_string() { - let event = Event::ApprovalResponse { - request_id: "req-1".into(), - approved: false, - signature: vec![1, 2, 3], - }; - let json = serde_json::to_value(&event).unwrap(); - assert_eq!(json["signature"], serde_json::json!("AQID")); - } -} diff --git a/crates/agent-protocol/src/handshake.rs b/crates/agent-protocol/src/handshake.rs deleted file mode 100644 index 7c2ec7b..0000000 --- a/crates/agent-protocol/src/handshake.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Хендшейк клієнт↔хост (спека runtime.md, «Хендшейк»). -//! -//! `ClientHello` → `ServerHello`; несумісна `protocol_version` → відмова -//! з явною помилкою і підказкою оновитись. `lang` (BCP-47) — ОБОВ'ЯЗКОВЕ -//! поле v4: керує live-перекладом (i18n.md), без нього хендшейк не -//! десеріалізується. - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::PROTOCOL_VERSION; - -/// Перше повідомлення клієнта. `client_kind` і `client_capabilities` — -/// відкриті множини рядків («designer» | «writer» | «cli» | «mobile» | -/// «mt-dashboard» | …; «preview», «approvals», «diff_view», -/// «self-translate», …) — сервер фільтрує події за capabilities. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ClientHello { - pub protocol_version: u32, - pub device_id: Uuid, - pub device_token: String, - pub client_kind: String, - pub client_capabilities: Vec, - /// ОБОВ'ЯЗКОВЕ (v4): BCP-47 мова учасника. - pub lang: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub want_replay_from: Option, -} - -/// Відповідь сервера на сумісний `ClientHello`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ServerHello { - pub protocol_version: u32, - pub session_list: Vec, -} - -/// Активна сесія хоста у `ServerHello.session_list`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SessionInfo { - pub node_hash: String, - pub run_token: Uuid, -} - -/// Помилка хендшейку. -#[derive(Debug, Clone, PartialEq)] -pub enum ProtocolError { - /// Версії не збігаються — точна рівність, бо номер версії і є мажором - /// (мінорні розширення — нові Event-варіанти, які клієнт ігнорує). - IncompatibleVersion { server: u32, client: u32 }, -} - -impl std::fmt::Display for ProtocolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ProtocolError::IncompatibleVersion { server, client } => write!( - f, - "incompatible protocol version: server speaks v{server}, client sent v{client} — \ - update the {} side to v{server}", - if client < server { "client" } else { "server" } - ), - } - } -} - -impl std::error::Error for ProtocolError {} - -/// Перевірка сумісності версії клієнта з [`PROTOCOL_VERSION`]. -pub fn check_protocol_version(client: u32) -> Result<(), ProtocolError> { - if client == PROTOCOL_VERSION { - Ok(()) - } else { - Err(ProtocolError::IncompatibleVersion { - server: PROTOCOL_VERSION, - client, - }) - } -} - -impl ClientHello { - /// Перевірка сумісності власної версії хендшейку. - pub fn check_compatibility(&self) -> Result<(), ProtocolError> { - check_protocol_version(self.protocol_version) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hello_json(with_lang: bool) -> String { - let lang = if with_lang { r#""lang": "uk-UA","# } else { "" }; - format!( - r#"{{ - "protocol_version": 4, - "device_id": "00000000-0000-0000-0000-000000000001", - "device_token": "tok", - "client_kind": "cli", - "client_capabilities": ["approvals"], - {lang} - "want_replay_from": 12 - }}"# - ) - } - - /// `ClientHello` без `lang` НЕ десеріалізується (обов'язкове поле v4). - #[test] - fn client_hello_without_lang_is_rejected() { - let error = serde_json::from_str::(&hello_json(false)).unwrap_err(); - assert!( - error.to_string().contains("lang"), - "помилка мовчить про lang: {error}" - ); - } - - #[test] - fn client_hello_with_lang_roundtrips() { - let hello: ClientHello = serde_json::from_str(&hello_json(true)).unwrap(); - assert_eq!(hello.lang, "uk-UA"); - assert_eq!(hello.want_replay_from, Some(12)); - let json = serde_json::to_string(&hello).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), hello); - } - - /// Сумісна версія проходить; несумісна → явна помилка з підказкою. - #[test] - fn version_check_is_exact_with_hint() { - assert!(check_protocol_version(PROTOCOL_VERSION).is_ok()); - let error = check_protocol_version(3).unwrap_err(); - assert_eq!( - error, - ProtocolError::IncompatibleVersion { - server: 4, - client: 3 - } - ); - let message = error.to_string(); - assert!( - message.contains("v3") && message.contains("v4"), - "{message}" - ); - assert!(message.contains("update the client"), "{message}"); - } - - #[test] - fn server_hello_roundtrips() { - let hello = ServerHello { - protocol_version: PROTOCOL_VERSION, - session_list: vec![SessionInfo { - node_hash: "c".repeat(20), - run_token: Uuid::from_u128(9), - }], - }; - let json = serde_json::to_string(&hello).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), hello); - } -} diff --git a/crates/agent-protocol/src/lib.rs b/crates/agent-protocol/src/lib.rs deleted file mode 100644 index 4f4e5aa..0000000 --- a/crates/agent-protocol/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Протокол подій v4 — контракт клієнт↔хост agent-server (спека -//! npm/docs/architecture/runtime.md, «Протокол подій»). -//! -//! Крейт свідомо БЕЗ tokio/tauri — чистий контракт (фізична межа зі -//! stack.md): типи `Envelope`/`Event`, хендшейк `ClientHello`/`ServerHello` -//! з перевіркою сумісності версії та Ed25519-підписи approvals за -//! npm/docs/architecture/access.md. - -pub mod approvals; -pub mod envelope; -pub mod handshake; -pub mod transfers; - -pub use approvals::{ - sign_approval, verify_approval, ApprovalError, ApprovalPayload, Signature, SigningKey, - VerifyingKey, -}; -pub use envelope::{ClaimInfo, Envelope, Event, Rect}; -pub use handshake::{check_protocol_version, ClientHello, ProtocolError, ServerHello, SessionInfo}; -pub use transfers::{sign_transfer, verify_transfer, TransferPayload}; - -/// Поточна версія протоколу подій. v1/v2 — історія scaffold-spec; v3 — -/// проміжний draft без `lang`. Несумісна версія відхиляється на хендшейку -/// з явною помилкою і підказкою оновитись (runtime.md). -pub const PROTOCOL_VERSION: u32 = 4; diff --git a/crates/agent-protocol/src/transfers.rs b/crates/agent-protocol/src/transfers.rs deleted file mode 100644 index f78dfd8..0000000 --- a/crates/agent-protocol/src/transfers.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Ed25519-підпис акта transfer ownership (access.md, «Membership API»). -//! -//! Передача власності задачі — криптографічний факт, а не лише право -//! device_token-а: пристрій поточного owner-а підписує canonical-акт, -//! relay перевіряє підпис проти pubkey пристрою (relay/lib/signing.mjs — -//! байт-у-байт той самий формат повідомлення). Механіка віддзеркалює -//! approvals: доменний префікс + NUL-роздільник полів. - -use ed25519_dalek::Signer; -use uuid::Uuid; - -pub use crate::approvals::ApprovalError; -use crate::approvals::{Signature, SigningKey, VerifyingKey}; - -/// Доменний префікс canonical-акта transfer — підпис не переноситься -/// в контекст approvals і навпаки. -const DOMAIN: &[u8] = b"mt-transfer-v4"; - -/// Акт передачі власності кореневої задачі. -#[derive(Debug, Clone, PartialEq)] -pub struct TransferPayload { - /// Кореневий вузол задачі (кімната relay). - pub root_node_hash: String, - /// Поточний owner (акаунт-ініціатор). - pub from_account: Uuid, - /// Новий owner (мусить бути учасником задачі). - pub to_account: Uuid, -} - -impl TransferPayload { - /// Canonical-байти акта: `DOMAIN \0 root \0 from(hyphenated) \0 - /// to(hyphenated)` — дзеркало `transferMessage` у relay (JS). - pub fn message(&self) -> Vec { - let from = self.from_account.hyphenated().to_string(); - let to = self.to_account.hyphenated().to_string(); - let mut message = Vec::with_capacity( - DOMAIN.len() + self.root_node_hash.len() + from.len() + to.len() + 3, - ); - message.extend_from_slice(DOMAIN); - message.push(0); - message.extend_from_slice(self.root_node_hash.as_bytes()); - message.push(0); - message.extend_from_slice(from.as_bytes()); - message.push(0); - message.extend_from_slice(to.as_bytes()); - message - } -} - -/// Підписати акт transfer приватним ключем пристрою owner-а. -/// У WS-кадр `transfer_ownership` підпис їде як base64 від `to_bytes()`. -pub fn sign_transfer(key: &SigningKey, payload: &TransferPayload) -> Signature { - key.sign(&payload.message()) -} - -/// Перевірити підпис акта проти pubkey пристрою-ініціатора. -pub fn verify_transfer( - pubkey: &VerifyingKey, - payload: &TransferPayload, - signature: &[u8], -) -> Result<(), ApprovalError> { - let bytes: [u8; 64] = signature - .try_into() - .map_err(|_| ApprovalError::BadSignatureLength { - actual: signature.len(), - })?; - pubkey - .verify_strict(&payload.message(), &Signature::from_bytes(&bytes)) - .map_err(|_| ApprovalError::VerificationFailed) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn payload() -> TransferPayload { - TransferPayload { - root_node_hash: "r".repeat(20), - from_account: Uuid::from_u128(1), - to_account: Uuid::from_u128(2), - } - } - - fn key() -> SigningKey { - SigningKey::from_bytes(&[7u8; 32]) - } - - #[test] - fn sign_then_verify_ok() { - let signature = sign_transfer(&key(), &payload()); - assert_eq!( - verify_transfer(&key().verifying_key(), &payload(), &signature.to_bytes()), - Ok(()) - ); - } - - /// Підміна отримувача інвалідовує підпис — transfer не «переадресувати». - #[test] - fn tampered_recipient_fails() { - let bytes = sign_transfer(&key(), &payload()).to_bytes(); - let tampered = TransferPayload { - to_account: Uuid::from_u128(3), - ..payload() - }; - assert_eq!( - verify_transfer(&key().verifying_key(), &tampered, &bytes), - Err(ApprovalError::VerificationFailed) - ); - } - - /// Домен відрізняє transfer від approval: підпис approval-повідомлення - /// тим самим ключем не проходить як transfer. - #[test] - fn approval_domain_signature_is_rejected() { - let approval = crate::approvals::ApprovalPayload { - request_id: "req".into(), - approved: true, - node_hash: payload().root_node_hash, - run_token: Uuid::from_u128(9), - }; - let bytes = crate::approvals::sign_approval(&key(), &approval).to_bytes(); - assert_eq!( - verify_transfer(&key().verifying_key(), &payload(), &bytes), - Err(ApprovalError::VerificationFailed) - ); - } - - /// Формат повідомлення — стабільний контракт із relay (signing.mjs): - /// domain і поля через NUL, uuid — hyphenated lowercase. - #[test] - fn message_matches_relay_canonical_format() { - let expected = format!( - "mt-transfer-v4\0{}\0{}\0{}", - "r".repeat(20), - Uuid::from_u128(1).hyphenated(), - Uuid::from_u128(2).hyphenated() - ); - assert_eq!(payload().message(), expected.as_bytes()); - } -} diff --git a/crates/agent-server/Cargo.toml b/crates/agent-server/Cargo.toml deleted file mode 100644 index afbb5e3..0000000 --- a/crates/agent-server/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "agent-server" -description = "Хост-процес M1: session host протоколу v4 — Envelope/журнал/broadcast, WS-хендшейк, discovery" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -name = "agent_server" - -[dependencies] -agent-core = { path = "../agent-core" } -agent-protocol = { path = "../agent-protocol" } -async-trait.workspace = true -mt-core = { path = "../mt-core" } -axum.workspace = true -chrono = { workspace = true, features = ["serde"] } -futures.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } -tokio-tungstenite.workspace = true -uuid = { workspace = true, features = ["v4"] } - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/agent-server/src/approvals_gate.rs b/crates/agent-server/src/approvals_gate.rs deleted file mode 100644 index 43cd22e..0000000 --- a/crates/agent-server/src/approvals_gate.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Гейт approvals хоста (спека access.md, «Approvals: три гейти, один -//! механізм»): хост шле `ApprovalRequest` у кімнату → пристрій учасника -//! approver+ підписує `(request_id, approved, node_hash, run_token)` → -//! хост звіряє підпис із pubkey-кешем relay; підпис поза списком → відмова. -//! -//! Кеш наповнює relay-міст (`pubkeys`-кадр); разом із ним вмикається -//! `require_signed`. Без relay (локальний dev) порожній підпис приймається — -//! довіра локальному транспорту з discovery-токеном. Матеріалізація у -//! `## Approvals` файлів вузла — окрема задача (потребує синтезу -//! `run_NNN.md` в інтерактивному done). - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Mutex; - -use agent_protocol::{verify_approval, ApprovalPayload, VerifyingKey}; -use tokio::sync::oneshot; -use uuid::Uuid; - -/// Очікуваний approval: адресація для канонічного повідомлення підпису. -struct PendingApproval { - node_hash: String, - run_token: Uuid, - sender: oneshot::Sender, -} - -/// Стан гейту: pending-запити + pubkey-кеш пристроїв approver+. -#[derive(Default)] -pub struct ApprovalGate { - pending: Mutex>, - pubkeys: Mutex>, - /// Вмикається разом із pubkey-кешем (relay-міст): підпис обовʼязковий. - require_signed: AtomicBool, -} - -impl ApprovalGate { - /// Реєструє pending-запит; емісію `ApprovalRequest` у сесію робить - /// викликач. Повертає one-shot із вердиктом (true = approved). - pub fn register( - &self, - request_id: &str, - node_hash: &str, - run_token: Uuid, - ) -> oneshot::Receiver { - let (sender, receiver) = oneshot::channel(); - self.pending.lock().unwrap().insert( - request_id.to_string(), - PendingApproval { - node_hash: node_hash.to_string(), - run_token, - sender, - }, - ); - receiver - } - - /// Оновлює pubkey-кеш (кадр `pubkeys` від relay) і вмикає - /// обовʼязковість підпису. - pub fn set_pubkeys(&self, keys: Vec<(Uuid, VerifyingKey)>) { - let mut pubkeys = self.pubkeys.lock().unwrap(); - pubkeys.clear(); - pubkeys.extend(keys); - self.require_signed.store(true, Ordering::Relaxed); - } - - /// Обробляє `ApprovalResponse`. Успіх → pending завершується вердиктом. - /// Помилка (невідомий request_id / підпис поза списком / зіпсований) → - /// `Err` із поясненням; pending ЛИШАЄТЬСЯ — інший пристрій може - /// відповісти валідним підписом. - pub fn resolve( - &self, - request_id: &str, - approved: bool, - signature: &[u8], - device_id: Option, - ) -> Result { - let mut pending = self.pending.lock().unwrap(); - let entry = pending - .get(request_id) - .ok_or_else(|| format!("approval: невідомий request_id {request_id}"))?; - - if signature.is_empty() { - if self.require_signed.load(Ordering::Relaxed) { - return Err("approval: підпис обовʼязковий (require_signed)".into()); - } - } else { - let device_id = - device_id.ok_or_else(|| "approval: підпис без device_id".to_string())?; - let pubkeys = self.pubkeys.lock().unwrap(); - let key = pubkeys.get(&device_id).ok_or_else(|| { - format!("approval: пристрій {device_id} поза pubkey-списком — відмова") - })?; - let payload = ApprovalPayload { - request_id: request_id.to_string(), - approved, - node_hash: entry.node_hash.clone(), - run_token: entry.run_token, - }; - verify_approval(key, &payload, signature) - .map_err(|error| format!("approval: підпис не пройшов перевірку — {error}"))?; - } - - let entry = pending.remove(request_id).expect("щойно перевірено"); - let _ = entry.sender.send(approved); - Ok(approved) - } -} - -/// Mid-run approval-запит (access.md, перший гейт): публікує -/// `ApprovalRequest` у кімнату вузла і повертає one-shot із верифікованим -/// вердиктом. Вільна функція — щоб runner-фабрика могла гейтити тули без -/// циклу залежностей із AppState. -pub fn request_approval( - sessions: &crate::session::SessionHost, - gate: &ApprovalGate, - node: &str, - action: String, - diff: Option, -) -> std::io::Result> { - let session = sessions.get_or_open(node)?; - let request_id = Uuid::new_v4().to_string(); - let receiver = gate.register(&request_id, node, session.run_token); - sessions.publish( - &session, - agent_protocol::Event::ApprovalRequest { - request_id, - action, - diff, - }, - None, - None, - ); - Ok(receiver) -} - -#[cfg(test)] -mod tests { - use agent_protocol::{sign_approval, SigningKey}; - - use super::*; - - fn key() -> SigningKey { - SigningKey::from_bytes(&[3u8; 32]) - } - - fn signed( - gate: &ApprovalGate, - request_id: &str, - approved: bool, - signer: &SigningKey, - ) -> Vec { - let _ = gate; // підпис не залежить від гейту — лише від payload - sign_approval( - signer, - &ApprovalPayload { - request_id: request_id.to_string(), - approved, - node_hash: "room-1".into(), - run_token: Uuid::from_u128(9), - }, - ) - .to_bytes() - .to_vec() - } - - /// Валідний підпис пристрою з кешу завершує pending вердиктом. - #[tokio::test] - async fn valid_signature_resolves_pending() { - let gate = ApprovalGate::default(); - let device = Uuid::from_u128(1); - gate.set_pubkeys(vec![(device, key().verifying_key())]); - let receiver = gate.register("req-1", "room-1", Uuid::from_u128(9)); - - let signature = signed(&gate, "req-1", true, &key()); - assert_eq!( - gate.resolve("req-1", true, &signature, Some(device)), - Ok(true) - ); - assert_eq!(receiver.await, Ok(true)); - } - - /// Чужий ключ (поза pubkey-списком) і зіпсований підпис — відмова; - /// pending лишається і приймає наступну валідну відповідь. - #[tokio::test] - async fn invalid_signatures_are_rejected_but_pending_survives() { - let gate = ApprovalGate::default(); - let device = Uuid::from_u128(1); - gate.set_pubkeys(vec![(device, key().verifying_key())]); - let receiver = gate.register("req-1", "room-1", Uuid::from_u128(9)); - - // Невідомий пристрій. - let foreign = signed(&gate, "req-1", true, &SigningKey::from_bytes(&[7u8; 32])); - let error = gate - .resolve("req-1", true, &foreign, Some(Uuid::from_u128(2))) - .unwrap_err(); - assert!(error.contains("поза pubkey-списком"), "{error}"); - - // Зіпсований підпис відомого пристрою. - let mut corrupted = signed(&gate, "req-1", true, &key()); - corrupted[5] ^= 0xFF; - let error = gate - .resolve("req-1", true, &corrupted, Some(device)) - .unwrap_err(); - assert!(error.contains("не пройшов"), "{error}"); - - // Валідна відповідь після відмов досі можлива. - let signature = signed(&gate, "req-1", false, &key()); - assert_eq!( - gate.resolve("req-1", false, &signature, Some(device)), - Ok(false) - ); - assert_eq!(receiver.await, Ok(false)); - } - - /// Політики непідписаної відповіді: dev (без relay) приймає, із - /// pubkey-кешем — відмова. - #[tokio::test] - async fn unsigned_policy_depends_on_require_signed() { - let gate = ApprovalGate::default(); - let receiver = gate.register("req-1", "room-1", Uuid::from_u128(9)); - assert_eq!(gate.resolve("req-1", true, &[], None), Ok(true)); - assert_eq!(receiver.await, Ok(true)); - - gate.set_pubkeys(vec![]); - let _receiver = gate.register("req-2", "room-1", Uuid::from_u128(9)); - let error = gate.resolve("req-2", true, &[], None).unwrap_err(); - assert!(error.contains("обовʼязковий"), "{error}"); - } -} diff --git a/crates/agent-server/src/bin/docs/fake-acp-agent.md b/crates/agent-server/src/bin/docs/fake-acp-agent.md deleted file mode 100644 index e46097f..0000000 --- a/crates/agent-server/src/bin/docs/fake-acp-agent.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -type: Rust Module -title: fake-acp-agent.rs -resource: crates/agent-server/src/bin/fake-acp-agent.rs -docgen: - crc: 6dc49153 - model: omlx/gemma-4-e2b-it-4bit - tier: local-min - score: 100 ---- - -## Огляд - -Огляд -Файл реалізує фейковий ACP-агент для інтеграційних тестів AcpTurnRunner, забезпечуючи комунікацію через ndjson JSON-RPC на stdio. Він відповідає за ініціалізацію, відкриття сесії, надсилання чанків промпту з ехо-текстом та завершення сесії. - -## Поведінка - -Поведінка - -1. Приймає запит у форматі JSON-RPC через стандартний ввід -2. Обробляє команду initialize повертає результат з версією протоколу -3. Обробляє команду session/new повертає ідентифікатор сесії -4. Обробляє session/prompt: надсилає chunk-нотифікацію з echo-текстом -5. Обробляє команду session/prompt повертає сигнал про завершення ходу - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/crates/agent-server/src/bin/docs/index.md b/crates/agent-server/src/bin/docs/index.md deleted file mode 100644 index cdad685..0000000 --- a/crates/agent-server/src/bin/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: crates/agent-server/src/bin -resource: crates/agent-server/src/bin/ ---- - -| Файл | Тип | -| -------------------------------------- | ----------- | -| [fake-acp-agent.rs](fake-acp-agent.md) | Rust Module | diff --git a/crates/agent-server/src/bin/fake-acp-agent.rs b/crates/agent-server/src/bin/fake-acp-agent.rs deleted file mode 100644 index e46e0e2..0000000 --- a/crates/agent-server/src/bin/fake-acp-agent.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Фейковий ACP-агент для інтеграційних тестів AcpTurnRunner (без мережі й -//! LLM): ndjson JSON-RPC на stdio — відповідає на initialize/session\/new, -//! на session/prompt шле chunk-нотифікацію з echo-текстом і end_turn. - -use std::io::{BufRead, Write}; - -fn main() { - let stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - for line in stdin.lock().lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { - continue; - } - let message: serde_json::Value = match serde_json::from_str(&line) { - Ok(v) => v, - Err(_) => continue, - }; - let id = message["id"].clone(); - let reply = match message["method"].as_str() { - Some("initialize") => vec![serde_json::json!({ - "jsonrpc": "2.0", "id": id, "result": { "protocolVersion": 1 } - })], - Some("session/new") => vec![serde_json::json!({ - "jsonrpc": "2.0", "id": id, "result": { "sessionId": "fake" } - })], - Some("session/prompt") => { - let text = message["params"]["prompt"][0]["text"] - .as_str() - .unwrap_or(""); - vec![ - serde_json::json!({ - "jsonrpc": "2.0", "method": "session/update", - "params": { "sessionId": "fake", "update": { - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": format!("acp: {text}") } } } - }), - serde_json::json!({ - "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } - }), - ] - } - _ => continue, - }; - for frame in reply { - let _ = writeln!(stdout, "{frame}"); - let _ = stdout.flush(); - } - } -} diff --git a/crates/agent-server/src/discovery.rs b/crates/agent-server/src/discovery.rs deleted file mode 100644 index 4c84172..0000000 --- a/crates/agent-server/src/discovery.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Discovery / single-instance (спека runtime.md, «agent-server — один -//! хост-процес на машину»). -//! -//! Сервер пише port-file (`server.port`: port + pid + sha256-хеш токена) -//! і тримає lock-файл; сирий токен — у `server.token` (права 0600), його -//! читає лише той самий користувач (тонкий клієнт на цій машині). Перевірка -//! «живий чи stale» — обовʼязок клієнта: пробний `ClientHello` (спека); -//! stale lock перезаписується. - -use std::fs; -use std::io; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -/// sha256-хеш токена у hex — для port-file (сирий токен туди не пишеться). -pub fn token_hash(token: &str) -> String { - let digest = Sha256::digest(token.as_bytes()); - digest.iter().map(|byte| format!("{byte:02x}")).collect() -} - -/// Вміст `server.port`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PortFile { - pub port: u16, - pub pid: u32, - pub token_hash: String, -} - -/// Файлова discovery-точка в конфігурованій директорії -/// (продакшн — `~/.nitra`, тести — tempdir). -pub struct Discovery { - pub dir: PathBuf, -} - -impl Discovery { - pub fn new(dir: PathBuf) -> Self { - Self { dir } - } - - fn port_path(&self) -> PathBuf { - self.dir.join("server.port") - } - - fn token_path(&self) -> PathBuf { - self.dir.join("server.token") - } - - fn lock_path(&self) -> PathBuf { - self.dir.join("server.lock") - } - - /// Пише port-file + token-файл (0600) + lock. Наявний lock - /// перезаписується — living-перевірку робить клієнт через ClientHello. - pub fn write(&self, port: u16, token: &str) -> io::Result<()> { - fs::create_dir_all(&self.dir)?; - let port_file = PortFile { - port, - pid: std::process::id(), - token_hash: token_hash(token), - }; - fs::write(self.port_path(), serde_json::to_string_pretty(&port_file)?)?; - fs::write(self.token_path(), token)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(self.token_path(), fs::Permissions::from_mode(0o600))?; - } - fs::write(self.lock_path(), std::process::id().to_string())?; - Ok(()) - } - - /// Читає port-file і сирий токен; звіряє хеш (порушення → помилка — - /// хтось підмінив один із файлів). - pub fn read(&self) -> io::Result<(PortFile, String)> { - let port_file: PortFile = serde_json::from_str(&fs::read_to_string(self.port_path())?)?; - let token = fs::read_to_string(self.token_path())?; - if token_hash(&token) != port_file.token_hash { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "token hash mismatch between server.port and server.token", - )); - } - Ok((port_file, token)) - } - - /// Прибирає discovery-файли (акуратне завершення сервера). - pub fn remove(&self) -> io::Result<()> { - for path in [self.port_path(), self.token_path(), self.lock_path()] { - match fs::remove_file(path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Запис → читання: порт/pid/хеш збігаються, токен звіряється хешем. - #[test] - fn write_then_read_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let discovery = Discovery::new(dir.path().to_path_buf()); - discovery.write(4123, "secret-token").unwrap(); - - let (port_file, token) = discovery.read().unwrap(); - assert_eq!(port_file.port, 4123); - assert_eq!(port_file.pid, std::process::id()); - assert_eq!(token, "secret-token"); - assert_eq!(port_file.token_hash, token_hash("secret-token")); - } - - /// Підмінений токен → явна помилка, не тихе підключення. - #[test] - fn tampered_token_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - let discovery = Discovery::new(dir.path().to_path_buf()); - discovery.write(4123, "secret-token").unwrap(); - std::fs::write(dir.path().join("server.token"), "інший").unwrap(); - - let error = discovery.read().unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - } - - /// remove ідемпотентний. - #[test] - fn remove_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - let discovery = Discovery::new(dir.path().to_path_buf()); - discovery.write(1, "t").unwrap(); - discovery.remove().unwrap(); - discovery.remove().unwrap(); - assert!(discovery.read().is_err()); - } -} diff --git a/crates/agent-server/src/docs/approvals_gate.md b/crates/agent-server/src/docs/approvals_gate.md deleted file mode 100644 index 1c4c78a..0000000 --- a/crates/agent-server/src/docs/approvals_gate.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -type: Rust Module -title: approvals_gate.rs -resource: crates/agent-server/src/approvals_gate.rs -docgen: - crc: 6b2295e9 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Цей компонент приймає `ApprovalRequest` від хоста, який посилає його у кімнату. Учасник-approver підписує запит (`request_id`, `approved`, `node_hash`, `run_token`), а хост звіряє цей підпис із публічним ключ-кешем у `relay`. Якщо підпис не знайдений, відбувається відмова. При локальному розробленні з `discovery-token` приймається порожній підпис. - -## Поведінка - -ApprovalGate: Управляє процесом отримання схвалень, зберігаючи очікувані запити та відомі публічні ключі учасників. -register: Реєструє новий запит на схвалення, надаючи механізм для отримання фінального вердикту. -set_pubkeys: Оновлює список відомих публічних ключів учасників та вмикає вимогу надання підпису для наступних схвалень. -resolve: Обробляє отриману відповідь про схвалення, перевіряючи її валідність із збереженими ключами, і завершує відповідний очікуваний запит. - -## Публічний API - -ApprovalGate — поточний стан гату, який включає незавершені запити та кеш публічних ключів пристроїв схвалювачів. -register — фіксує незавершений запит, запускаючи емісію `ApprovalRequest` у сесію; повертає одноразовий результат схвалення. -set_pubkeys — оновлює кеш публічних ключів на основі даних від ретранслятора і вмикає вимогу підпису. -resolve — обробляє відповідь на схвалення. При успіху незавершений запит закривається вердиктом. При помилці (незнайшли запит, недійсний підпис, пошкоджена відповідь) повертається помилка, і незавершений запит залишається активним для інших пристроїв. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-server/src/docs/discovery.md b/crates/agent-server/src/docs/discovery.md deleted file mode 100644 index 94dbd31..0000000 --- a/crates/agent-server/src/docs/discovery.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -type: Rust Module -title: discovery.rs -resource: crates/agent-server/src/discovery.rs -docgen: - crc: c3f1df7b - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл описує файлову discovery-точку для одного agent-server на машині: через `server.port`, `server.token` і `server.lock` він дає thin client знайти процес за контрактом runtime.md. `server.port` містить port, pid і sha256-хеш token; `server.token` зберігає сирий token з правами `0600` і читається лише тим самим користувачем. Перевірка, чи запис живий або stale, — обовʼязок клієнта через пробний `ClientHello`; stale lock перезаписується. Модуль працює fail-safe: перехоплює помилки й не кидає винятків назовні. - -## Поведінка - -- `token_hash` — рахує sha256-хеш токена у hex для запису в `server.port` без сирого токена. -- `PortFile` — описує вміст `server.port`: порт, pid і хеш токена. -- `Discovery` — представляє файлову discovery-точку для одного agent-server у конфігурованій директорії. -- `new` — створює discovery з вказаною директорією. -- `write` — записує `server.port`, `server.token` і `server.lock`; `server.token` зберігає з правами 0600. -- `read` — читає `server.port` і `server.token` та звіряє хеш токена; при розбіжності повертає помилку. -- `remove` — прибирає discovery-файли і не ламається, якщо частини вже немає. - -## Публічний API - -- token_hash — SHA-256 hex від токена для port-file; сам токен туди не записується. -- PortFile — вміст `server.port`. -- Discovery — точка файлового виявлення в налаштованій директорії: у продакшні `~/.nitra`, у тестах `tempdir`. -- new — створює новий discovery-набір для запуску сервера. -- write — записує port-file, token-файл із правами 0600 і lock; існуючий lock замінює, а живість сервера потім звіряє клієнт через ClientHello. -- read — читає port-file і сирий токен, звіряє хеш; якщо файли не узгоджені, повертає помилку про підміну. -- remove — прибирає discovery-файли після завершення сервера. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/crates/agent-server/src/docs/graph.md b/crates/agent-server/src/docs/graph.md deleted file mode 100644 index d9206b7..0000000 --- a/crates/agent-server/src/docs/graph.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -type: Rust Module -title: graph.rs -resource: crates/agent-server/src/graph.rs -docgen: - crc: 85b7be20 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Міст для інтерактивного run вузла за контрактом `runtime.md`: «Інтерактивна сесія = run вузла». Бере CAS claim, створює detached worktree, прив’язує run ref і фіксує хід сесії в `session.jsonl` через виклики `mt-core`; реалізація не реімплементує графовий контракт, а спирається на ту саму основу, яку використовує `@7n/mt` через napi. Життєвий цикл: `attach` → `commit_turn` → `renew` → `done`/`release`, де `done` означає fenced publish, `release` — пауза, а `.nitra/` живе лише в run ref і очищається перед publish, щоб ніколи не потрапити в `main` згідно з `git.md`. API працює fail-safe: перехоплює помилки, для частини з них повертає порожнє значення замість винятку. - -## Поведінка - -- GraphConfig — зберігає конфігурацію для інтерактивного run: tasks-директорію, lease і актора. -- new — створює дефолтну конфігурацію для інтерактивного запуску з коротким lease. -- InteractiveRun — представляє живу інтерактивну сесію вузла з прив’язаним claim, worktree і run ref. -- attach — займає вузол через CAS claim, створює detached worktree від базового стану та публікує run ref. -- commit_turn — фіксує хід сесії через журнал `.nitra/session.jsonl`, а порожній хід лишає без змін. -- renew — поновлює lease поточного claim і повертає ознаку, чи сесію ще утримує цей runner. -- done — готує публікацію: прибирає `.nitra/` з дерева змін, виконує fenced publish і за успіху очищає worktree. -- release — знімає claim, прибирає worktree і лишає run ref як основу для відновлення. - -## Публічний API - -- GraphConfig — конфіг моста між worktree, claim і run ref. -- new — створює новий claim і прив’язаний стартовий стан run. -- InteractiveRun — тримає claim активним під час живого інтерактивного run і матеріалізує worktree. -- attach — підхоплює claim, створює detached worktree від `base_sha` і прив’язує run ref; якщо claim уже забраний, одразу падає з claim-lost. -- commit_turn — фіксує один хід: додає журнал сесії й зміни файлів, потім пушить run ref; якщо змін немає, нічого не робить. -- renew — подовжує lease для того ж token/generation від поточного SHA claim; якщо claim уже втрачено, зупиняє сесію. -- done — завершує `mt done`: прибирає `.nitra/` з індексу, публікує fenced release через rebase на `origin/main` і атомарний push, далі очищає worktree. -- release — ставить сесію на паузу: видаляє claim і прибирає worktree, залишаючи run ref для наступного attach. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-server/src/docs/index.md b/crates/agent-server/src/docs/index.md deleted file mode 100644 index 0cd2168..0000000 --- a/crates/agent-server/src/docs/index.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -type: Directory Index -title: crates/agent-server/src -resource: crates/agent-server/src/ ---- - -| Файл | Тип | -| -------------------------------------- | ----------- | -| [approvals_gate.rs](approvals_gate.md) | Rust Module | -| [discovery.rs](discovery.md) | Rust Module | -| [graph.rs](graph.md) | Rust Module | -| [lib.rs](lib.md) | Rust Module | -| [relay_client.rs](relay_client.md) | Rust Module | -| [runner.rs](runner.md) | Rust Module | -| [session.rs](session.md) | Rust Module | -| [ws.rs](ws.md) | Rust Module | diff --git a/crates/agent-server/src/docs/lib.md b/crates/agent-server/src/docs/lib.md deleted file mode 100644 index 5d2c952..0000000 --- a/crates/agent-server/src/docs/lib.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -type: Rust Module -title: lib.rs -resource: crates/agent-server/src/lib.rs -docgen: - crc: 7d5be41d - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Мінімальний `agent-server` для одного локального вузла в ролі session host протоколу v4: він формує `Envelope` навколо подій `agent-core`, записує їх у `session.jsonl`, відтворює історію за `want_replay_from` і відсікає події за capability-фільтром. Підключення клієнтів і початкове знаходження порту працюють у межах локального контракту v4; graph-операції `claim`, `fenced publish` і `push run ref` тут не реалізуються — їх виконує `mt … --json` окремо за правилом одного коду контракту. - -## Поведінка - -1. Піднімає мінімальний `agent-server` для одного локального вузла з WebSocket-доступом без relay. -2. Збирає події `agent-core` у контрактну обгортку `Envelope` з порядковим номером, часовою міткою та адресацією, щоб усі клієнти бачили однаковий протоколний формат. -3. Веде `session.jsonl` як журнал сесії, щоб зберігати послідовність подій для подальшого відтворення. -4. Розсилає події всім підключеним клієнтам, щоб кожен учасник сесії отримував актуальний стан у реальному часі. -5. За запитом `want_replay_from` віддає реплей подій, щоб новий або відновлений клієнт міг підхопити історію з потрібної точки. -6. Відсіює несумісні можливості через capability-фільтр, щоб у сесію потрапляли лише клієнти з підтримуваним набором функцій. -7. Виконує хендшейк протоколу v4, щоб узгодити режим роботи перед обміном подіями. -8. Знаходить сервер через port-file discovery, щоб локальні інструменти могли під’єднатися без ручного налаштування. -9. Не бере на себе graph-операції на кшталт claim, fenced publish і push run ref: ці дії виконує `mt … --json` за правилом одного коду контракту, а тут залишається лише інтеграційна точка для іншої задачі. -10. Не здійснює запис у файлову систему чи БД як окрему бізнес-дію; його роль — координувати потік подій і доступ до сесії. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-server/src/docs/relay_client.md b/crates/agent-server/src/docs/relay_client.md deleted file mode 100644 index e818ef3..0000000 --- a/crates/agent-server/src/docs/relay_client.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: Rust Module -title: relay_client.rs -resource: crates/agent-server/src/relay_client.rs -docgen: - crc: 64b7eedb - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Міст для host-runtime, що тримає вихідне WS-з’єднання з relay і ретранслює broadcast сесій хоста в relay як `{kind:"envelope", root, envelope}`. На вхід приймає `!from_host` і передає ці кадри в штатну обробку кадру клієнта; `from_host` ставить relay за роллю пристрою, а host-echo bridge ігнорує, щоб не утворювався цикл. Після збоїв виконує reconnect з експоненційним backoff; після відновлення стрічка лишається цілісною через журнал сесій, а replay залишається обов’язком клієнтів, не relay. Компонент працює fail-safe: не кидає винятків назовні, а за певних помилок повертає порожнє значення. Кешування відсутнє. - -## Поведінка - -- RelayBridgeConfig — конфігурює міст до relay: адресу relay, токен host-пристрою та кореневий вузол кімнати задачі. -- spawn_relay_bridge — запускає фоновий міст до relay з автоматичним reconnect і ретрансляцією сесій хоста; fail-safe, не пише у ФС/БД і не кидає помилки назовні. - -Changelog: не перевірено (потрібен `npx @nitra/cursor lint changelog`) - -## Публічний API - -- RelayBridgeConfig — налаштовує міст для підключення до relay. -- spawn_relay_bridge — запускає міст у фоні, тримає його до аборту хоста і відновлює зʼєднання через reconnect із backoff від 1s до 30s. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-server/src/docs/runner.md b/crates/agent-server/src/docs/runner.md deleted file mode 100644 index 7b82fc1..0000000 --- a/crates/agent-server/src/docs/runner.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -type: Rust Module -title: runner.rs -resource: crates/agent-server/src/runner.rs -docgen: - crc: 169199c2 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Виконавці ходу інтерактивної сесії: `UserMessage` клієнта запускає хід, усі події ходу емітяться в сесію (Envelope збирає session host). Транспорт виконавця — **ACP (Agent Client Protocol)**: майбутній `AcpTurnRunner` підключає зовнішній підписочний CLI (claude / codex / cursor / pi) через ACP і мапить `permission-request` на `ApprovalRequest` (ADR `260713-2110`). Власного agent loop/provider у модулі немає. - -## Поведінка - -- **TurnRunner** — трейт одного ходу кімнати: емітить події ходу і повертає відповідь виконавця; `workdir` — робоча директорія ходу (worktree run-а). -- **TurnError** — текстова помилка ходу (транспорт/виконавець повідомляє причину). -- **ScriptedTurnRunner** — скриптований виконавець для тестів транспорту/сесій: на кожен хід віддає наступний текст зі скрипту (`AgentTextDelta` + `AgentTextDone`), без LLM. -- **EchoTurnRunner** — заглушка без LLM: віддзеркалює текст користувача; для demo `attach` без підключеного ACP-виконавця. - -## Гарантії поведінки - -- Модуль не пише у ФС/БД; помилки ходу повертаються значенням `TurnError`, не панікою. diff --git a/crates/agent-server/src/docs/session.md b/crates/agent-server/src/docs/session.md deleted file mode 100644 index 7758a77..0000000 --- a/crates/agent-server/src/docs/session.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -type: Rust Module -title: session.rs -resource: crates/agent-server/src/session.rs -docgen: - crc: a5f3196b - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.97 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Модуль веде інтерактивні сесії як `run` вузла: збирає `Envelope`, призначає їм монотонний `seq` у межах run і працює з `session.jsonl` як append-only журналом подій. Ефемерні події `AgentTextDelta` і `PreviewScreenshot` не журналяться; натомість у журнал потрапляє агрегат `AgentTextDone`. Через `replay_from` сесію можна відновити після рестарту хоста з `session.jsonl`. `SessionHost`, `new`, `get_or_open` і `session_list` керують відкритими сесіями хоста, а `publish`, `subscribe` і `broadcast` забезпечують обмін подіями між учасниками. Модуль fail-safe: помилки не виходять назовні. - -## Поведінка - -- `is_ephemeral` — визначає, чи подія є ефемерною й не має потрапляти в журнал. -- `Session` — тримає стан однієї сесії: `seq`, `run_token`, журнал і шлях до `session.jsonl`. -- `append` — збирає `Envelope`, призначає `seq` і час, журналить неефемерні події та повертає конверт для подальшої розсилки. -- `replay_from` — віддає журнальовані події сесії, починаючи з указаного `seq`, для відновлення після реконекту. -- `SessionHost` — керує набором сесій хоста й спільною broadcast-розсилкою між клієнтами. -- `new` — створює хост сесій і готує директорію стану. -- `get_or_open` — повертає сесію кімнати або ліниво відкриває її з журналу. -- `publish` — додає подію в сесію і одразу розсилає конверт підписникам. -- `subscribe` — підписує на broadcast-потік нових `Envelope`. -- `session_list` — повертає перелік активних сесій для `ServerHello`. -- `replay_from` — збирає журнальовані події всіх сесій, починаючи з указаного `seq`, у стабільному порядку. - -## Публічний API - -- is_ephemeral — позначає події, що йдуть лише через relay/WS і не потрапляють у журнал чи git. -- Session — тримає один run вузла: seq, журнал і `session.jsonl`. -- append — додає конверт із host-поставленими seq і ts, записує неефемерні події та готує broadcast. -- replay_from — повертає журнальовані події з `seq >= from` для replay після reconnect. -- SessionHost — веде реєстр сесій хоста й один спільний broadcast-канал для всіх кімнат; на відправці хост фільтрує за `node_hash` і `capabilities`. -- new — створює host-реєстр і спільний канал для сесій. -- get_or_open — ліниво відкриває кімнатну сесію або відновлює її з журналу. -- publish — додає подію в сесію й розсилає її підключеним клієнтам. -- subscribe — підписує клієнта на broadcast поточної сесії. -- session_list — віддає список активних сесій для `ServerHello.session_list`. -- replay_from — повертає replay усіх журнальованих подій з `seq >= from`, відсортований за ``. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/crates/agent-server/src/docs/ws.md b/crates/agent-server/src/docs/ws.md deleted file mode 100644 index 12bedbe..0000000 --- a/crates/agent-server/src/docs/ws.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: Rust Module -title: ws.rs -resource: crates/agent-server/src/ws.rs -docgen: - crc: cb1ddbb6 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Реалізує WS-транспорт за `runtime.md` для хендшейку v4 і стрічки подій: `router` і `serve` піднімають endpoint, а `AppState` тримає стан обміну між `ClientHello`, `ServerHello` і потоком `Envelope`. На вході перший кадр клієнта має бути `ClientHello`; несумісна версія або невірний токен дають `Event::Error` і закриття з’єднання. Далі клієнтські `Envelope` проходять лише в межах протоколу, при цьому хост ігнорує клієнтські `seq`/`ts` і призначає власні. Для повільних клієнтів, що випали з broadcast-буфера, передбачено replay від `want_replay_from`. Поведінка fail-safe: помилки не виходять назовні, а в окремих випадках повертається порожнє значення (`null`). - -## Поведінка - -- AppState — зберігає стан WS-хоста: сесії, виконання ходів і необов’язковий discovery token для перевірки доступу. -- router — створює router з єдиним WS-ендпоінтом хоста. -- serve — піднімає WS-сервер на вказаній адресі, зокрема підтримує ефемерний порт, і повертає фактичну адресу разом із фоновим handle; помилки біндингу чи запуску не пробиває назовні як exception, а повертає як результат. - -## Публічний API - -- AppState — зберігає сесії, виконавець ходів і токен discovery, який ще очікується. -- router — тримає єдиний host route `/ws`. -- serve — прив’язує адресу, підставляє ефемерний порт для `0` і запускає сервер у фоні. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/agent-server/src/graph.rs b/crates/agent-server/src/graph.rs deleted file mode 100644 index a25886c..0000000 --- a/crates/agent-server/src/graph.rs +++ /dev/null @@ -1,784 +0,0 @@ -//! Міст до графа: інтерактивний run вузла (спека runtime.md, -//! «Інтерактивна сесія = run вузла»; git.md — claim CAS, run ref, fenced -//! publish). -//! -//! Контракт графа НЕ реімплементується: всі операції — виклики `mt-core` -//! (та сама реалізація, яку `@7n/mt` використовує через napi). Життєвий -//! цикл: attach (CAS claim + detached worktree + run ref) → комміти ходів -//! із `session.jsonl` → `done` (fenced publish) або release (пауза). -//! `.nitra/` живе лише в run ref і прибирається перед publish — інваріант -//! git.md: у `main` він не потрапляє ніколи. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use chrono::{Duration, Utc}; -use mt_core::claims::{ - acquire_claim, discover_repo_root, node_hash, release_claim, renew_or_takeover_claim, - tasks_root_relative, ClaimFields, RUN_REF_PREFIX, -}; -use mt_core::publish::{fenced_publish, PublishOutcome, PublishRequest}; -use mt_core::worktree::{create_run_worktree, push_run_ref, remove_run_worktree}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Конфігурація моста. -#[derive(Debug, Clone)] -pub struct GraphConfig { - /// tasks-директорія проєкту (напр. `/mt`). - pub tasks_dir: PathBuf, - /// Lease інтерактивного claim (спека: коротший за автономний; - /// дефолт 0.3.0 — `interactive_claim_lease_sec: 900`). - pub lease_sec: i64, - /// Актор claim-а (інтерактивну сесію веде людина). - pub actor: String, -} - -impl GraphConfig { - pub fn new(tasks_dir: PathBuf) -> Self { - Self { - tasks_dir, - lease_sec: 900, - actor: "human".into(), - } - } -} - -/// Живий інтерактивний run: claim утримується, worktree матеріалізований. -#[derive(Debug)] -pub struct InteractiveRun { - pub node: String, - pub node_hash: String, - /// = run_token сесії (ідентифікатор run ref). - pub token: String, - /// Поточний claim commit (renewal просуває). - pub claim_sha: String, - /// SHA `origin/main` на момент attach — база worktree, незмінна. - pub base_sha: String, - pub worktree: PathBuf, - repo_root: PathBuf, - tasks_root_rel: String, - generation: u64, - lease_sec: i64, - actor: String, - /// Верифіковані approvals цього run-а — матеріалізуються у - /// `## Approvals` синтезованого `run_NNN.md` (access.md). - approvals: Vec, -} - -fn git(dir: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -fn iso(ts: chrono::DateTime) -> String { - ts.format("%Y-%m-%dT%H:%M:%SZ").to_string() -} - -/// Тікет кооперативного handoff (runtime.md, «Міграція сесії між хостами»): -/// ідентифікує старий run ref, з якого новий хост відновлює worktree, і -/// generation, від якої продовжує лічильник claim-а (git.md: «новий хост: -/// create, generation+1» — попри те, що механічно це create-only CAS, -/// бо старий claim уже видалено). Serialize/Deserialize — тікет піде через -/// relay `HandoffRequest`-відповідь у наступній задачі. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HandoffTicket { - pub run_token: String, - pub generation: u64, -} - -/// Спільна реалізація attach/attach_resume: `resume_token` — `None` для -/// звичайного attach (worktree від `origin/main`), `Some(старий token)` — -/// worktree від tip старого run ref (журнал і мідфлайт-правки успадковані). -fn attach_impl( - config: &GraphConfig, - node: &str, - generation: u64, - resume_token: Option<&str>, -) -> Result { - let repo_root = discover_repo_root(&config.tasks_dir)?; - let tasks_root_rel = tasks_root_relative(&repo_root, &config.tasks_dir)?; - let hash = node_hash(&tasks_root_rel, node); - - git(&repo_root, &["fetch", "--quiet", "origin", "main"])?; - let base_sha = git(&repo_root, &["rev-parse", "origin/main"])?; - - let worktree_base = match resume_token { - None => base_sha.clone(), - Some(old_token) => { - let old_run_ref = format!("{RUN_REF_PREFIX}/{hash}/{old_token}"); - git( - &repo_root, - &[ - "fetch", - "--quiet", - "origin", - &format!("+{old_run_ref}:{old_run_ref}"), - ], - ) - .map_err(|e| format!("attach-resume: старий run ref {old_run_ref} недоступний: {e}"))?; - git(&repo_root, &["rev-parse", &old_run_ref])? - } - }; - - let token = Uuid::new_v4().to_string(); - let runner_id = format!("agent-server/{}", std::process::id()); - let run_ref = format!("{RUN_REF_PREFIX}/{hash}/{token}"); - let fields = ClaimFields { - node, - actor: &config.actor, - runner_id: &runner_id, - claimed_at: &iso(Utc::now()), - lease_until: &iso(Utc::now() + Duration::seconds(config.lease_sec)), - token: &token, - generation, - base_sha: &base_sha, - run_ref: &run_ref, - interactive: true, - }; - let claim = acquire_claim(&repo_root, &hash, &fields)?; - if !claim.accepted { - return Err(format!( - "claim-lost: вузол {node} уже утримується іншим runner/сесією" - )); - } - - let worktrees_dir = repo_root.join(".worktrees"); - let worktree = create_run_worktree(&repo_root, &worktrees_dir, &hash, &token, &worktree_base)?; - push_run_ref(&worktree, &hash, &token)?; - - Ok(InteractiveRun { - node: node.to_string(), - node_hash: hash, - token, - claim_sha: claim.commit_sha, - base_sha, - worktree, - repo_root, - tasks_root_rel, - generation, - lease_sec: config.lease_sec, - actor: config.actor.clone(), - approvals: Vec::new(), - }) -} - -/// Attach вузла: CAS claim → detached worktree від `base_sha` → run ref. -/// `accepted: false` CAS-у → явна помилка claim-lost (вузол уже зайнято). -pub fn attach(config: &GraphConfig, node: &str) -> Result { - attach_impl(config, node, 1, None) -} - -/// Відновлення на новому хості після кооперативного `handoff` -/// (runtime.md, кроки 2-3): CAS-create claim (generation = `ticket` + 1) → -/// worktree ЗІ СТАНУ старого run ref (не `origin/main`) — журнал і -/// мідфлайт-правки успадковані → push нового run ref. Недоступний старий -/// run ref (втрачено/типо у тікеті) → явна помилка, не паніка. -pub fn attach_resume( - config: &GraphConfig, - node: &str, - ticket: &HandoffTicket, -) -> Result { - attach_impl(config, node, ticket.generation + 1, Some(&ticket.run_token)) -} - -impl InteractiveRun { - /// Поточна генерація claim-а (fencing token для side effects). - pub fn generation(&self) -> u64 { - self.generation - } - - /// Додає верифікований approval-рядок (пише ws-обробник після - /// успішної перевірки підпису гейтом). - pub fn add_approval(&mut self, line: String) { - self.approvals.push(line); - } - - /// Коміт ходу: журнал сесії (`.nitra/session.jsonl`) + правки файлів → - /// push run ref (recovery/handoff, спека git.md: «кожен хід = коміт + - /// негайний push run ref»). Порожній хід (нічого не змінилось) — no-op. - pub fn commit_turn(&self, session_jsonl: &str, message: &str) -> Result<(), String> { - let nitra_dir = self.worktree.join(".nitra"); - std::fs::create_dir_all(&nitra_dir).map_err(|e| e.to_string())?; - std::fs::write(nitra_dir.join("session.jsonl"), session_jsonl) - .map_err(|e| e.to_string())?; - - git(&self.worktree, &["add", "-A"])?; - let staged = git(&self.worktree, &["status", "--porcelain"])?; - if staged.is_empty() { - return Ok(()); - } - git(&self.worktree, &["commit", "-q", "-m", message])?; - push_run_ref(&self.worktree, &self.node_hash, &self.token) - } - - /// Renewal lease: той самий token/generation, CAS від поточного claim - /// SHA. `Ok(false)` — claim втрачено (takeover-ом), сесію слід зупинити. - pub fn renew(&mut self) -> Result { - let run_ref = format!("{RUN_REF_PREFIX}/{}/{}", self.node_hash, self.token); - let fields = ClaimFields { - node: &self.node, - actor: &self.actor.clone(), - runner_id: &format!("agent-server/{}", std::process::id()), - claimed_at: &iso(Utc::now()), - lease_until: &iso(Utc::now() + Duration::seconds(self.lease_sec)), - token: &self.token.clone(), - generation: self.generation, - base_sha: &self.base_sha.clone(), - run_ref: &run_ref, - interactive: true, - }; - let push = - renew_or_takeover_claim(&self.repo_root, &self.node_hash, &self.claim_sha, &fields)?; - if push.accepted { - self.claim_sha = push.commit_sha; - } - Ok(push.accepted) - } - - /// Синтез контрактних артефактів спроби (graph.md): `run_NNN.md` - /// (actor, result success, `## Approvals` за наявності) і мінімальний - /// `fact_NNN.md`, якщо виконавець не створив власний — без fact вузол - /// після publish не стає resolved. - fn write_run_artifacts(&self) -> Result<(), String> { - let dir = self.worktree.join(&self.tasks_root_rel).join(&self.node); - let nnn = mt_core::nnn::pad_nnn(mt_core::signal::next_run_nnn(&dir)); - - let fact_path = dir.join(format!("fact_{nnn}.md")); - if !fact_path.exists() { - let fact = format!( - "---\nschema_version: 1\ncreated_at: {}\n---\n\n## Summary\n\n\ - Інтерактивний run завершено (mt done); журнал сесії — у run ref.\n", - iso(Utc::now()) - ); - std::fs::write(&fact_path, fact).map_err(|e| e.to_string())?; - } - - let sections = if self.approvals.is_empty() { - "\n".to_string() - } else { - format!("\n## Approvals\n\n{}\n", self.approvals.join("\n")) - }; - mt_core::signal::write_run_fm(&dir, &nnn, &self.actor, "success", §ions, "")?; - Ok(()) - } - - /// `mt done`: гейт `## Check` (контракт graph.md — fail → відмова - /// сигналу, run лишається живим) → синтез `run_NNN.md`/`fact_NNN.md` → - /// стрип `.nitra/` з індексу (інваріант git.md) → fenced publish - /// (rebase на origin/main + atomic push main / видалення claim+run - /// ref). Успіх → worktree прибирається. - pub fn done(&self, retry_max: u32, base_ms: u64) -> Result { - // ## Check вузла ганяється у worktree (cwd = корінь worktree — - // батько tasks-директорії, як у автономного wrapper-а). - let wt_tasks_dir = self.worktree.join(&self.tasks_root_rel); - mt_core::signal::run_check(&wt_tasks_dir.to_string_lossy(), &self.node)?; - - // Remote run ref стоїть на останньому запушеному ході (HEAD ДО - // артефакт/strip-комітів) — саме його очікує force-with-lease. - let run_ref_sha = git(&self.worktree, &["rev-parse", "HEAD"])?; - - self.write_run_artifacts()?; - git(&self.worktree, &["add", "-A"])?; - let staged = git(&self.worktree, &["status", "--porcelain"])?; - if !staged.is_empty() { - git( - &self.worktree, - &[ - "commit", - "-q", - "-m", - &format!("mt: {} run (success)", self.node), - ], - )?; - } - let tracked = git(&self.worktree, &["ls-files", ".nitra"])?; - if !tracked.is_empty() { - git(&self.worktree, &["rm", "-r", "-q", "--cached", ".nitra"])?; - git( - &self.worktree, - &["commit", "-q", "-m", "mt: strip session artifacts"], - )?; - } - let request = PublishRequest { - worktree: &self.worktree, - node_hash: &self.node_hash, - claim_sha: &self.claim_sha, - token: &self.token, - run_ref_sha_before: &run_ref_sha, - }; - let outcome = fenced_publish(&self.repo_root, &request, retry_max, base_ms)?; - if outcome.published { - let _ = remove_run_worktree(&self.repo_root, &self.worktree); - } - // Не published → worktree/run ref лишаються для debug (спека, - // «Failure-сімейство»). - Ok(outcome) - } - - /// Пауза/відпустити: CAS-delete claim + прибрати worktree; run ref - /// лишається (журнал сесії — база відновлення наступного attach). - pub fn release(self) -> Result { - let released = release_claim(&self.repo_root, &self.node_hash, &self.claim_sha)?; - let _ = remove_run_worktree(&self.repo_root, &self.worktree); - Ok(released) - } - - /// Кооперативний handoff (git.md, claim-операція `handoff`; runtime.md, - /// «Міграція сесії між хостами», крок 2): синтезує `run_NNN.md - /// (result: handoff)` → коміт → push run ref БЕЗ стрипу `.nitra/` — - /// повний журнал розмови їде разом (checkpoint-режим із дистильованим - /// summary — окрема задача) → CAS-delete claim. Повертає тікет для - /// `attach_resume` на новому хості. - pub fn handoff(self) -> Result { - let dir = self.worktree.join(&self.tasks_root_rel).join(&self.node); - let nnn = mt_core::nnn::pad_nnn(mt_core::signal::next_run_nnn(&dir)); - mt_core::signal::write_run_fm(&dir, &nnn, &self.actor, "handoff", "\n", "")?; - - git(&self.worktree, &["add", "-A"])?; - let staged = git(&self.worktree, &["status", "--porcelain"])?; - if !staged.is_empty() { - git( - &self.worktree, - &["commit", "-q", "-m", &format!("mt: {} handoff", self.node)], - )?; - } - push_run_ref(&self.worktree, &self.node_hash, &self.token)?; - - let ticket = HandoffTicket { - run_token: self.token.clone(), - generation: self.generation, - }; - release_claim(&self.repo_root, &self.node_hash, &self.claim_sha)?; - let _ = remove_run_worktree(&self.repo_root, &self.worktree); - Ok(ticket) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Герметична фікстура: bare-репо як origin + робочий клон із tasks- - /// директорією `mt/demo` на `main` (патерн mt-core test_support). - struct Fixture { - #[allow(dead_code)] - origin: tempfile::TempDir, - work: tempfile::TempDir, - } - - fn sh(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.local") - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - - impl Fixture { - fn new() -> Self { - let origin = tempfile::tempdir().unwrap(); - sh(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - let work = tempfile::tempdir().unwrap(); - sh(work.path(), &["init", "-q", "-b", "main"]); - std::fs::create_dir_all(work.path().join("mt/demo")).unwrap(); - std::fs::write(work.path().join("mt/demo/task.md"), "## Task\n").unwrap(); - sh(work.path(), &["add", "."]); - sh(work.path(), &["commit", "-q", "-m", "init"]); - sh( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - sh(work.path(), &["push", "-q", "origin", "main"]); - Self { origin, work } - } - - fn config(&self) -> GraphConfig { - GraphConfig::new(self.work.path().join("mt")) - } - - fn remote_refs(&self) -> String { - super::git(self.work.path(), &["ls-remote", "origin"]).unwrap() - } - } - - /// attach: claim ref + run ref на remote, detached worktree від base_sha. - #[test] - fn attach_claims_and_materializes_worktree() { - let fixture = Fixture::new(); - let run = attach(&fixture.config(), "demo").unwrap(); - - assert!(run.worktree.exists()); - let refs = fixture.remote_refs(); - assert!( - refs.contains(&format!("refs/mt/claims/{}", run.node_hash)), - "{refs}" - ); - assert!( - refs.contains(&format!("refs/mt/runs/{}/{}", run.node_hash, run.token)), - "{refs}" - ); - let head = super::git(&run.worktree, &["rev-parse", "HEAD"]).unwrap(); - assert_eq!(head, run.base_sha, "worktree від base_sha (origin/main)"); - - // Claim позначений інтерактивним (0.3.0, ADR 260711-2100). - let claim_yaml = super::git( - Path::new(fixture.origin.path()), - &[ - "show", - &format!("refs/mt/claims/{}:.mt-claim.yml", run.node_hash), - ], - ) - .unwrap(); - assert!(claim_yaml.contains("interactive: true"), "{claim_yaml}"); - } - - /// Другий attach того самого вузла — claim-lost, не системна помилка. - #[test] - fn second_attach_is_claim_lost() { - let fixture = Fixture::new(); - let _held = attach(&fixture.config(), "demo").unwrap(); - let error = attach(&fixture.config(), "demo").unwrap_err(); - assert!(error.contains("claim-lost"), "{error}"); - } - - /// commit_turn пише журнал у run ref; done стрипає .nitra/ і публікує - /// fact у main; claim/run ref прибрані. - #[test] - fn turn_then_done_publishes_without_session_artifacts() { - let fixture = Fixture::new(); - let run = attach(&fixture.config(), "demo").unwrap(); - - // Хід: результатний файл + журнал сесії. - std::fs::write(run.worktree.join("mt/demo/fact_001.md"), "## Summary\nok\n").unwrap(); - run.commit_turn("{\"seq\":0}\n", "mt: demo run 001 (хід 1)") - .unwrap(); - - // Журнал доїхав у run ref. - let run_ref = format!("refs/mt/runs/{}/{}", run.node_hash, run.token); - let journal = super::git( - fixture.work.path(), - &["show", &format!("{run_ref}:.nitra/session.jsonl")], - ); - // ls-remote бачить ref, а show читає локальний — worktree пушить - // напряму в origin; читаємо з origin. - let origin_journal = super::git( - Path::new(fixture.origin.path()), - &["show", &format!("{run_ref}:.nitra/session.jsonl")], - ) - .unwrap(); - assert_eq!(origin_journal, "{\"seq\":0}"); - drop(journal); - - let node_hash = run.node_hash.clone(); - let outcome = run.done(3, 10).unwrap(); - assert!(outcome.published, "{outcome:?}"); - - // main просунувся, fact є, .nitra/ немає, claim/run ref прибрані. - let main_files = super::git( - Path::new(fixture.origin.path()), - &["ls-tree", "-r", "--name-only", "main"], - ) - .unwrap(); - assert!(main_files.contains("mt/demo/fact_001.md"), "{main_files}"); - assert!( - !main_files.contains(".nitra"), - ".nitra/ не мусить потрапити у main: {main_files}" - ); - let refs = fixture.remote_refs(); - assert!( - !refs.contains(&format!("refs/mt/claims/{node_hash}")), - "{refs}" - ); - assert!(!refs.contains("refs/mt/runs/"), "{refs}"); - } - - /// `## Check`-гейт: падаюча перевірка → відмова done (claim/worktree - /// живі); після виправлення той самий run публікується. - #[test] - fn failing_check_blocks_done_until_fixed() { - let fixture = Fixture::new(); - // Вузол із Check: вимагає файл ready у директорії вузла. - std::fs::write( - fixture.work.path().join("mt/demo/task.md"), - "## Task\n\n## Check\n\ntest -f mt/demo/ready\n", - ) - .unwrap(); - sh(fixture.work.path(), &["add", "."]); - sh(fixture.work.path(), &["commit", "-q", "-m", "check"]); - sh(fixture.work.path(), &["push", "-q", "origin", "main"]); - - let run = attach(&fixture.config(), "demo").unwrap(); - run.commit_turn("{}\n", "mt: хід").unwrap(); - - let error = run.done(3, 10).unwrap_err(); - assert!(error.contains("## Check failed"), "{error}"); - assert!( - fixture.remote_refs().contains("refs/mt/claims/"), - "claim живий після відмови Check" - ); - - // Виправлення у worktree → done проходить. - std::fs::write(run.worktree.join("mt/demo/ready"), "ok").unwrap(); - run.commit_turn("{}\n", "mt: виправлення").unwrap(); - let outcome = run.done(3, 10).unwrap(); - assert!(outcome.published, "{outcome:?}"); - } - - /// done синтезує контрактні артефакти: run_001.md з ## Approvals і - /// мінімальний fact_001.md — обидва доїжджають у main. - #[test] - fn done_synthesizes_run_and_fact_artifacts() { - let fixture = Fixture::new(); - let mut run = attach(&fixture.config(), "demo").unwrap(); - run.add_approval( - "- 2026-07-12T00:00:00Z device=phone approved=true request=req-1 signature=ab".into(), - ); - run.commit_turn("{}\n", "mt: хід").unwrap(); - - let outcome = run.done(3, 10).unwrap(); - assert!(outcome.published, "{outcome:?}"); - - let run_md = super::git( - Path::new(fixture.origin.path()), - &["show", "main:mt/demo/run_001.md"], - ) - .unwrap(); - assert!(run_md.starts_with("---\nschema_version: 1"), "{run_md}"); - assert!(run_md.contains("actor: human"), "{run_md}"); - assert!(run_md.contains("result: success"), "{run_md}"); - assert!(run_md.contains("## Approvals"), "{run_md}"); - assert!(run_md.contains("request=req-1"), "{run_md}"); - - let fact_md = super::git( - Path::new(fixture.origin.path()), - &["show", "main:mt/demo/fact_001.md"], - ) - .unwrap(); - assert!(fact_md.contains("## Summary"), "{fact_md}"); - } - - /// Власний fact виконавця з тим самим NNN не перезаписується синтезом. - #[test] - fn executor_fact_is_preserved() { - let fixture = Fixture::new(); - let run = attach(&fixture.config(), "demo").unwrap(); - std::fs::write( - run.worktree.join("mt/demo/fact_001.md"), - "## Summary\n\nвласний fact виконавця\n", - ) - .unwrap(); - run.commit_turn("{}\n", "mt: fact від виконавця").unwrap(); - - assert!(run.done(3, 10).unwrap().published); - - let fact_md = super::git( - Path::new(fixture.origin.path()), - &["show", "main:mt/demo/fact_001.md"], - ) - .unwrap(); - assert!(fact_md.contains("власний fact виконавця"), "{fact_md}"); - } - - /// renew просуває claim SHA і лишає ownership за нами. - #[test] - fn renew_extends_lease() { - let fixture = Fixture::new(); - let mut run = attach(&fixture.config(), "demo").unwrap(); - let before = run.claim_sha.clone(); - assert!(run.renew().unwrap()); - assert_ne!(run.claim_sha, before, "renewal — новий claim commit"); - // Після renewal вузол досі зайнятий. - assert!(attach(&fixture.config(), "demo").is_err()); - } - - /// release: claim знято (вузол знову вільний), run ref лишається. - #[test] - fn release_frees_node_and_keeps_run_ref() { - let fixture = Fixture::new(); - let run = attach(&fixture.config(), "demo").unwrap(); - run.commit_turn("{\"seq\":0}\n", "mt: журнал").unwrap(); - let token = run.token.clone(); - let node_hash = run.node_hash.clone(); - - assert!(run.release().unwrap()); - - let refs = fixture.remote_refs(); - assert!( - !refs.contains(&format!("refs/mt/claims/{node_hash}")), - "{refs}" - ); - assert!( - refs.contains(&format!("refs/mt/runs/{node_hash}/{token}")), - "run ref — база відновлення: {refs}" - ); - // Вузол знову можна attach-нути. - assert!(attach(&fixture.config(), "demo").is_ok()); - } - - /// handoff: run-файл result:handoff з повним журналом (.nitra/ - /// НЕ стрипається — checkpoint-режим поза скоупом), claim знято, - /// worktree прибрано, run ref лишається (база attach_resume). - #[test] - fn handoff_writes_marker_and_frees_claim() { - let fixture = Fixture::new(); - let run = attach(&fixture.config(), "demo").unwrap(); - run.commit_turn("{\"seq\":0}\n", "mt: перший хід").unwrap(); - let node_hash = run.node_hash.clone(); - let old_token = run.token.clone(); - let worktree = run.worktree.clone(); - - let ticket = run.handoff().unwrap(); - - assert_eq!(ticket.run_token, old_token); - assert_eq!(ticket.generation, 1); - assert!(!worktree.exists(), "worktree прибрано після handoff"); - - let refs = fixture.remote_refs(); - assert!( - !refs.contains(&format!("refs/mt/claims/{node_hash}")), - "claim знято: {refs}" - ); - let run_ref = format!("refs/mt/runs/{node_hash}/{old_token}"); - assert!(refs.contains(&run_ref), "run ref лишається: {refs}"); - - let run_md = super::git( - Path::new(fixture.origin.path()), - &["show", &format!("{run_ref}:mt/demo/run_001.md")], - ) - .unwrap(); - assert!(run_md.contains("result: handoff"), "{run_md}"); - let journal = super::git( - Path::new(fixture.origin.path()), - &["show", &format!("{run_ref}:.nitra/session.jsonl")], - ) - .unwrap(); - assert_eq!( - journal, "{\"seq\":0}", - "повний журнал (без checkpoint-стрипу): {journal}" - ); - } - - /// attach_resume: claim CAS-create (generation = old+1), worktree - /// успадковує мідфлайт-правки і журнал старого run ref, новий run ref - /// існує. - #[test] - fn attach_resume_inherits_worktree_and_bumps_generation() { - let fixture = Fixture::new(); - let run = attach(&fixture.config(), "demo").unwrap(); - std::fs::write(run.worktree.join("mt/demo/draft.md"), "мідфлайт").unwrap(); - run.commit_turn("{\"seq\":0}\n", "mt: чернетка").unwrap(); - let ticket = run.handoff().unwrap(); - - let resumed = attach_resume(&fixture.config(), "demo", &ticket).unwrap(); - - assert_eq!(resumed.generation(), ticket.generation + 1); - assert_ne!(resumed.token, ticket.run_token, "новий token сесії"); - assert_eq!( - std::fs::read_to_string(resumed.worktree.join("mt/demo/draft.md")).unwrap(), - "мідфлайт", - "мідфлайт-правка успадкована у новому worktree" - ); - assert_eq!( - std::fs::read_to_string(resumed.worktree.join(".nitra/session.jsonl")).unwrap(), - "{\"seq\":0}\n", - "журнал сесії успадкований" - ); - let refs = fixture.remote_refs(); - assert!( - refs.contains(&format!( - "refs/mt/runs/{}/{}", - resumed.node_hash, resumed.token - )), - "новий run ref: {refs}" - ); - assert!( - refs.contains(&format!("refs/mt/claims/{}", resumed.node_hash)), - "новий claim: {refs}" - ); - } - - /// attach_resume із тікетом на неіснуючий run ref → явна помилка, - /// не паніка. - #[test] - fn attach_resume_missing_run_ref_is_explicit_error() { - let fixture = Fixture::new(); - let ticket = HandoffTicket { - run_token: "no-such-token".into(), - generation: 1, - }; - let error = attach_resume(&fixture.config(), "demo", &ticket).unwrap_err(); - assert!(error.contains("недоступний"), "{error}"); - } - - /// Наскрізно: attach → хід → handoff → attach_resume → done — публікує - /// ту саму серію NNN без розривів (генерація продовжена через handoff). - #[test] - fn full_handoff_cycle_publishes_without_nnn_gap() { - let fixture = Fixture::new(); - let first = attach(&fixture.config(), "demo").unwrap(); - first - .commit_turn("{\"seq\":0}\n", "mt: перший хід") - .unwrap(); - let ticket = first.handoff().unwrap(); - - let second = attach_resume(&fixture.config(), "demo", &ticket).unwrap(); - second - .commit_turn("{\"seq\":0}\n{\"seq\":1}\n", "mt: другий хід") - .unwrap(); - let node_hash = second.node_hash.clone(); - let second_token = second.token.clone(); - let outcome = second.done(3, 10).unwrap(); - assert!(outcome.published, "{outcome:?}"); - - // run_001.md — handoff-маркер першого хосту; run_002.md — success - // від другого. Без розривів NNN попри зміну хоста. - let main_files = super::git( - Path::new(fixture.origin.path()), - &["ls-tree", "-r", "--name-only", "main"], - ) - .unwrap(); - assert!(main_files.contains("mt/demo/run_002.md"), "{main_files}"); - assert!(main_files.contains("mt/demo/fact_002.md"), "{main_files}"); - assert!(!main_files.contains(".nitra"), "{main_files}"); - - let refs = fixture.remote_refs(); - assert!( - !refs.contains(&format!("refs/mt/claims/{node_hash}")), - "claim прибрано: {refs}" - ); - assert!( - !refs.contains(&format!("refs/mt/runs/{node_hash}/{second_token}")), - "новий run ref прибрано fenced publish-ом: {refs}" - ); - // Handoff-run ref першого хосту навмисно лишається (не-checkpoint - // режим не архівує журнал; GC орфанованих run ref-ів після done — - // окрема задача, аналог `mt cleanup`). - assert!( - refs.contains(&format!("refs/mt/runs/{node_hash}/{}", ticket.run_token)), - "{refs}" - ); - } -} diff --git a/crates/agent-server/src/lib.rs b/crates/agent-server/src/lib.rs deleted file mode 100644 index ee23efd..0000000 --- a/crates/agent-server/src/lib.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Мінімальний agent-server (M1: одна машина, локальний WS, без relay) — -//! session host протоколу v4 (спека npm/docs/architecture/runtime.md). -//! -//! Обовʼязки: збірка `Envelope` (seq/ts/адресація) навколо подій ходу -//! виконавця, журнал `session.jsonl`, broadcast клієнтам, реплей за -//! `want_replay_from`, capability-фільтр, хендшейк v4, port-file discovery. -//! Виконавці підключаються через ACP (`TurnRunner`; ADR `260713-2110`). -//! Graph-операції (claim, fenced publish, push run ref) сюди НЕ входять — -//! за правилом одного коду контракту (stack.md) їх виконує `mt … --json`; -//! інтеграція — окрема задача. - -pub mod approvals_gate; -pub mod discovery; -pub mod graph; -pub mod relay_client; -pub mod runner; -pub mod session; -pub mod ws; - -pub use approvals_gate::ApprovalGate; -pub use discovery::{token_hash, Discovery, PortFile}; -pub use graph::{attach, GraphConfig, InteractiveRun}; -pub use relay_client::{spawn_relay_bridge, RelayBridgeConfig}; -pub use runner::{ - AcpTurnRunner, EchoTurnRunner, PermissionFactory, ScriptedTurnRunner, TurnError, TurnRunner, -}; -pub use session::{is_ephemeral, Session, SessionHost}; -pub use ws::{serve, AppState}; diff --git a/crates/agent-server/src/relay_client.rs b/crates/agent-server/src/relay_client.rs deleted file mode 100644 index de74530..0000000 --- a/crates/agent-server/src/relay_client.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Міст до relay — транспорт (в) із runtime.md: хост тримає вихідне -//! WS-зʼєднання до relay і ретранслює кімнату задачі. -//! -//! Вихідний напрям: broadcast сесій хоста → `{kind:"envelope", root, -//! envelope}` у relay (віддалені тонкі клієнти бачать стрічку). Вхідний: -//! кадри `!from_host` (клієнтські події віддалених пристроїв) → штатна -//! обробка кадру клієнта. `from_host` ставить relay за роллю пристрою — -//! host-ехо, що повертається, міст ігнорує (анти-цикл). Reconnect із -//! експоненційним backoff; після реконекту стрічка цілісна через журнал -//! сесій (реплей — обовʼязок клієнтів, не relay). - -use std::sync::Arc; -use std::time::Duration; - -use futures::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::sync::broadcast; -use tokio::task::JoinHandle; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -use crate::ws::{handle_client_frame, AppState}; - -/// Конфіг моста до relay. -#[derive(Debug, Clone)] -pub struct RelayBridgeConfig { - /// Адреса relay (`ws://…` dev / `wss://…` прод). - pub url: String, - /// device_token host-пристрою, зареєстрованого на relay. - pub device_token: String, - /// Кімната = кореневий вузол задачі (access.md). - pub root: String, -} - -/// Стартує міст у фоні; жиє до аборту хоста, падіння зʼєднання лікує -/// reconnect-ом (backoff 1s → 30s). -pub fn spawn_relay_bridge(state: Arc, config: RelayBridgeConfig) -> JoinHandle<()> { - tokio::spawn(async move { - let mut backoff = 1u64; - loop { - if run_bridge(&state, &config).await.is_ok() { - backoff = 1; - } - tokio::time::sleep(Duration::from_secs(backoff)).await; - backoff = (backoff * 2).min(30); - } - }) -} - -/// Одна сесія зʼєднання: hello → subscribe → двонаправлена ретрансляція. -async fn run_bridge(state: &Arc, config: &RelayBridgeConfig) -> Result<(), String> { - let (mut ws, _) = tokio_tungstenite::connect_async(&config.url) - .await - .map_err(|error| error.to_string())?; - - let hello = json!({ "kind": "hello", "device_token": config.device_token }); - ws.send(Message::text(hello.to_string())) - .await - .map_err(|error| error.to_string())?; - let subscribe = json!({ "kind": "subscribe", "root": config.root }); - ws.send(Message::text(subscribe.to_string())) - .await - .map_err(|error| error.to_string())?; - // Pubkey-кеш для перевірки підписів approvals (access.md); разом із - // ним гейт вмикає require_signed. - let pubkeys_request = json!({ "kind": "pubkeys", "root": config.root }); - ws.send(Message::text(pubkeys_request.to_string())) - .await - .map_err(|error| error.to_string())?; - - let mut updates = state.sessions.subscribe(); - loop { - tokio::select! { - update = updates.recv() => match update { - Ok(envelope) => { - let frame = json!({ - "kind": "envelope", - "root": config.root, - "envelope": serde_json::to_value(&envelope).unwrap(), - }); - ws.send(Message::text(frame.to_string())) - .await - .map_err(|error| error.to_string())?; - } - // Випали з буфера — віддалені клієнти доберуть реплеєм у хоста. - Err(broadcast::error::RecvError::Lagged(_)) => {} - Err(broadcast::error::RecvError::Closed) => return Ok(()), - }, - incoming = ws.next() => match incoming { - Some(Ok(Message::Text(text))) => { - handle_incoming(state, text.as_str()).await; - } - Some(Ok(Message::Close(_))) | None => return Ok(()), - Some(Ok(_)) => {} - Some(Err(error)) => return Err(error.to_string()), - }, - } - } -} - -/// Вхідний кадр relay: обробляємо лише envelope БЕЗ `from_host` -/// (клієнтські події віддалених пристроїв); host-ехо і службові кадри -/// (`ok`/`error`) ігноруються. -async fn handle_incoming(state: &Arc, text: &str) { - let Ok(frame) = serde_json::from_str::(text) else { - return; - }; - match frame.get("kind").and_then(Value::as_str) { - Some("pubkeys") => { - update_pubkeys(state, &frame); - return; - } - Some("envelope") => {} - _ => return, - } - if frame - .get("from_host") - .and_then(Value::as_bool) - .unwrap_or(false) - { - return; - } - let Some(envelope) = frame.get("envelope") else { - return; - }; - let device_id = envelope - .get("device_id") - .and_then(Value::as_str) - .and_then(|raw| Uuid::parse_str(raw).ok()); - // Окрема задача: хід агента може чекати ApprovalResponse із relay — - // інлайн-обробка заблокувала б читання наступних кадрів (deadlock). - let state = Arc::clone(state); - let raw_envelope = envelope.to_string(); - tokio::spawn(async move { - handle_client_frame(&state, &raw_envelope, device_id).await; - }); -} - -/// Кадр `pubkeys` від relay → оновлення pubkey-кешу гейту approvals. -/// `pubkey` — hex 32-байтового Ed25519 ключа; непарсибельні записи -/// пропускаються (пристрої без валідного ключа не можуть підписувати). -fn update_pubkeys(state: &Arc, frame: &Value) { - let Some(list) = frame.get("pubkeys").and_then(Value::as_array) else { - return; - }; - let keys = list - .iter() - .filter_map(|entry| { - let device_id = entry - .get("device_id") - .and_then(Value::as_str) - .and_then(|raw| Uuid::parse_str(raw).ok())?; - let hex = entry.get("pubkey").and_then(Value::as_str)?; - let bytes = decode_hex_32(hex)?; - let key = agent_protocol::VerifyingKey::from_bytes(&bytes).ok()?; - Some((device_id, key)) - }) - .collect(); - state.approvals.set_pubkeys(keys); -} - -/// Hex → 32 байти (Ed25519 pubkey); інша довжина/не-hex → None. -fn decode_hex_32(hex: &str) -> Option<[u8; 32]> { - if hex.len() != 64 { - return None; - } - let mut bytes = [0u8; 32]; - for (index, chunk) in hex.as_bytes().chunks(2).enumerate() { - let high = (chunk[0] as char).to_digit(16)?; - let low = (chunk[1] as char).to_digit(16)?; - bytes[index] = ((high << 4) | low) as u8; - } - Some(bytes) -} diff --git a/crates/agent-server/src/runner.rs b/crates/agent-server/src/runner.rs deleted file mode 100644 index 168ecb7..0000000 --- a/crates/agent-server/src/runner.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Виконавці ходу інтерактивної сесії. -//! -//! `UserMessage` клієнта запускає хід агента; всі події ходу емітяться в -//! сесію (Envelope збирає session host). Транспорт виконавця — **ACP (Agent -//! Client Protocol)**: [`AcpTurnRunner`] спавнить ACP-адаптер підписочного -//! CLI (claude / codex / cursor / pi) per-кімнату і мапить -//! `session/request_permission` на approval-гейт (`ApprovalRequest`, -//! ADR `260713-2110`). [`EchoTurnRunner`] — заглушка для demo/CLI і тестів -//! транспорту. - -use std::collections::HashMap; -use std::fmt; -use std::path::Path; -use std::process::Stdio; -use std::sync::Arc; - -use agent_core::{AcpClient, PermissionHandler}; -use agent_protocol::Event; -use async_trait::async_trait; - -/// Помилка ходу виконавця (текстова: транспорт/виконавець повідомляє причину). -#[derive(Debug)] -pub struct TurnError(pub String); - -impl fmt::Display for TurnError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for TurnError {} - -/// Виконавець одного ходу кімнати. `workdir` — робоча директорія ходу -/// (worktree інтерактивного run-а); runner-и без файлових тулів її ігнорують. -#[async_trait] -pub trait TurnRunner: Send + Sync { - async fn run_turn( - &self, - node_hash: &str, - user_text: &str, - workdir: Option<&Path>, - emit: &(dyn Fn(Event) + Send + Sync), - ) -> Result; -} - -/// Фабрика [`PermissionHandler`] для кімнати: хост дає обробник -/// `request_permission`, що знає вузол (роутинг `ApprovalRequest` у правильну -/// кімнату). -pub type PermissionFactory = Arc PermissionHandler + Send + Sync>; - -/// Жива ACP-сесія кімнати: процес адаптера + клієнт + sessionId. -struct AcpRoom { - /// Тримаємо процес живим на весь час кімнати (kill_on_drop). - _child: tokio::process::Child, - client: AcpClient, - session_id: String, -} - -/// Референсний виконавець: зовнішній підписочний CLI через ACP-адаптер. -/// Per-кімнату — окремий процес адаптера (своя історія в сесії агента); -/// `workdir` ходу стає `cwd` ACP-сесії (worktree run-а). -pub struct AcpTurnRunner { - argv: Vec, - permission_factory: Option, - rooms: tokio::sync::Mutex>, -} - -impl AcpTurnRunner { - /// `command` — рядок команди ACP-адаптера (whitespace-токенізація, без - /// shell-метасимволів), напр. `npx claude-code-acp`. - pub fn new(command: &str, permission_factory: Option) -> Self { - Self { - argv: command.split_whitespace().map(str::to_string).collect(), - permission_factory, - rooms: tokio::sync::Mutex::new(HashMap::new()), - } - } - - /// Спавнить адаптер і відкриває ACP-сесію кімнати (initialize + - /// session/new у workdir). - async fn open_room( - &self, - node_hash: &str, - workdir: Option<&Path>, - ) -> Result { - let program = self - .argv - .first() - .ok_or_else(|| TurnError("порожня команда ACP-адаптера".into()))?; - let mut child = tokio::process::Command::new(program) - .args(&self.argv[1..]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .map_err(|e| TurnError(format!("spawn ACP-адаптера {program}: {e}")))?; - let stdin = child - .stdin - .take() - .ok_or_else(|| TurnError("stdin адаптера".into()))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| TurnError("stdout адаптера".into()))?; - let permission = self.permission_factory.as_ref().map(|f| f(node_hash)); - let mut client = AcpClient::new(stdout, stdin, permission); - client - .initialize() - .await - .map_err(|e| TurnError(e.to_string()))?; - // ACP-спека вимагає абсолютний cwd (NewSessionRequest.cwd); без - // workdir (M1 CLI без графа/worktree) беремо cwd поточного процесу — - // деякі адаптери (claude-agent-acp) відкидають "." як невалідний. - let cwd = match workdir { - Some(p) => p.to_string_lossy().into_owned(), - None => std::env::current_dir() - .map_err(|e| TurnError(format!("cwd поточного процесу: {e}")))? - .to_string_lossy() - .into_owned(), - }; - let session_id = client - .new_session(&cwd) - .await - .map_err(|e| TurnError(e.to_string()))?; - Ok(AcpRoom { - _child: child, - client, - session_id, - }) - } -} - -#[async_trait] -impl TurnRunner for AcpTurnRunner { - async fn run_turn( - &self, - node_hash: &str, - user_text: &str, - workdir: Option<&Path>, - emit: &(dyn Fn(Event) + Send + Sync), - ) -> Result { - let mut rooms = self.rooms.lock().await; - if !rooms.contains_key(node_hash) { - let room = self.open_room(node_hash, workdir).await?; - rooms.insert(node_hash.to_string(), room); - } - let room = rooms.get_mut(node_hash).expect("щойно вставлена кімната"); - let session_id = room.session_id.clone(); - room.client - .prompt(&session_id, user_text, emit) - .await - .map_err(|e| TurnError(e.to_string())) - } -} - -/// Скриптований виконавець для тестів транспорту/сесій: на кожен хід -/// віддає наступний текст зі скрипту (емітить `AgentTextDelta` + -/// `AgentTextDone`), не викликаючи жодного LLM. -pub struct ScriptedTurnRunner { - responses: std::sync::Mutex>, -} - -impl ScriptedTurnRunner { - /// Створює runner зі списком відповідей (по одній на хід). - pub fn new(responses: I) -> Self - where - I: IntoIterator, - S: Into, - { - Self { - responses: std::sync::Mutex::new(responses.into_iter().map(Into::into).collect()), - } - } -} - -#[async_trait] -impl TurnRunner for ScriptedTurnRunner { - async fn run_turn( - &self, - _node_hash: &str, - _user_text: &str, - _workdir: Option<&Path>, - emit: &(dyn Fn(Event) + Send + Sync), - ) -> Result { - let text = self - .responses - .lock() - .expect("responses mutex") - .pop_front() - .unwrap_or_default(); - emit(Event::AgentTextDelta { text: text.clone() }); - emit(Event::AgentTextDone {}); - Ok(text) - } -} - -/// Заглушка без LLM: віддзеркалює текст користувача. Для demo `attach` -/// без підключеного ACP-виконавця і для тестів транспорту. -pub struct EchoTurnRunner; - -#[async_trait] -impl TurnRunner for EchoTurnRunner { - async fn run_turn( - &self, - _node_hash: &str, - user_text: &str, - _workdir: Option<&Path>, - emit: &(dyn Fn(Event) + Send + Sync), - ) -> Result { - let text = format!("echo: {user_text}"); - emit(Event::AgentTextDelta { text: text.clone() }); - emit(Event::AgentTextDone {}); - Ok(text) - } -} diff --git a/crates/agent-server/src/session.rs b/crates/agent-server/src/session.rs deleted file mode 100644 index ec21892..0000000 --- a/crates/agent-server/src/session.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! Сесії: збірка `Envelope`, журнал `session.jsonl`, broadcast, реплей -//! (спека runtime.md, «Протокол подій» і «Інтерактивна сесія = run вузла»). -//! -//! `seq` монотонний у межах run і призначається хостом (тримачем claim). -//! Ефемерні події (`AgentTextDelta`, `PreviewScreenshot`) не журналяться — -//! журналиться `AgentTextDone`-агрегат; решта — append-only рядки -//! `session.jsonl`, з якого сесія відновлюється після рестарту хоста. - -use std::collections::HashMap; -use std::fs::{self, OpenOptions}; -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -use agent_protocol::{Envelope, Event}; -use chrono::Utc; -use tokio::sync::broadcast; -use uuid::Uuid; - -/// Ефемерні події: лише relay/WS, ніколи в журнал чи git. -pub fn is_ephemeral(event: &Event) -> bool { - matches!( - event, - Event::AgentTextDelta { .. } | Event::PreviewScreenshot { .. } - ) -} - -/// Одна сесія (run вузла): лічильник seq, журнал, файл `session.jsonl`. -pub struct Session { - pub node_hash: String, - pub run_token: Uuid, - journal_path: PathBuf, - state: Mutex, -} - -struct SessionState { - next_seq: u64, - journal: Vec, -} - -impl Session { - /// Відкриває сесію: якщо `session.jsonl` існує — відновлює журнал і - /// продовжує seq з останнього запису (реплей після рестарту хоста). - fn open(dir: &Path, node_hash: &str) -> std::io::Result { - let journal_path = dir.join(format!("{node_hash}.session.jsonl")); - let mut journal: Vec = Vec::new(); - if journal_path.exists() { - for line in fs::read_to_string(&journal_path)?.lines() { - if let Ok(envelope) = serde_json::from_str::(line) { - journal.push(envelope); - } - } - } - let next_seq = journal.last().map(|envelope| envelope.seq + 1).unwrap_or(0); - let run_token = journal - .last() - .map(|envelope| envelope.run_token) - .unwrap_or_else(Uuid::new_v4); - Ok(Self { - node_hash: node_hash.to_string(), - run_token, - journal_path, - state: Mutex::new(SessionState { next_seq, journal }), - }) - } - - /// Збирає `Envelope` (хост призначає seq і ts), журналить - /// неефемерні події та повертає конверт для broadcast. - pub fn append( - &self, - event: Event, - device_id: Option, - account_id: Option, - ) -> Envelope { - let mut state = self.state.lock().unwrap(); - let envelope = Envelope { - seq: state.next_seq, - ts: Utc::now(), - node_hash: self.node_hash.clone(), - run_token: self.run_token, - device_id, - account_id, - event, - }; - state.next_seq += 1; - if !is_ephemeral(&envelope.event) { - state.journal.push(envelope.clone()); - // Append-only запис; помилка диска не валить сесію — журнал - // лишається в памʼяті, персистентність відновиться наступним записом. - if let Ok(mut file) = OpenOptions::new() - .create(true) - .append(true) - .open(&self.journal_path) - { - let _ = writeln!(file, "{}", serde_json::to_string(&envelope).unwrap()); - } - } - envelope - } - - /// Журнальовані події з `seq >= from` (реплей для реконекту). - pub fn replay_from(&self, from: u64) -> Vec { - self.state - .lock() - .unwrap() - .journal - .iter() - .filter(|envelope| envelope.seq >= from) - .cloned() - .collect() - } -} - -/// Реєстр сесій хоста + один спільний broadcast-канал усіх кімнат -/// (клієнт фільтрує за node_hash/capabilities на боці хоста при відправці). -pub struct SessionHost { - state_dir: PathBuf, - sessions: Mutex>>, - broadcast: broadcast::Sender, -} - -impl SessionHost { - pub fn new(state_dir: PathBuf) -> std::io::Result { - fs::create_dir_all(&state_dir)?; - let (broadcast, _) = broadcast::channel(1024); - Ok(Self { - state_dir, - sessions: Mutex::new(HashMap::new()), - broadcast, - }) - } - - /// Засіває журнал сесії напряму у файл (attach_resume після handoff: - /// новий хост успадковує `.nitra/session.jsonl` вже готовим — той самий - /// формат, що й локальний журнал). Наступний `get_or_open` прочитає - /// його як після рестарту хоста — продовжить seq/run_token природно. - /// Помилка, якщо сесія для цього ключа вже відкрита: живий стан у - /// пам'яті заднім числом не перечитується, сіяти треба ДО першого - /// `get_or_open`. - pub fn seed_journal(&self, node: &str, jsonl: &str) -> std::io::Result<()> { - if self.sessions.lock().unwrap().contains_key(node) { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("seed_journal: сесія {node} вже відкрита"), - )); - } - fs::write(self.state_dir.join(format!("{node}.session.jsonl")), jsonl) - } - - /// Сесія кімнати; створюється (або відновлюється з журналу) ліниво. - pub fn get_or_open(&self, node_hash: &str) -> std::io::Result> { - let mut sessions = self.sessions.lock().unwrap(); - if let Some(session) = sessions.get(node_hash) { - return Ok(std::sync::Arc::clone(session)); - } - let session = std::sync::Arc::new(Session::open(&self.state_dir, node_hash)?); - sessions.insert(node_hash.to_string(), std::sync::Arc::clone(&session)); - Ok(session) - } - - /// Append у сесію + broadcast підключеним клієнтам. - pub fn publish( - &self, - session: &Session, - event: Event, - device_id: Option, - account_id: Option, - ) -> Envelope { - let envelope = session.append(event, device_id, account_id); - let _ = self.broadcast.send(envelope.clone()); - envelope - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.broadcast.subscribe() - } - - /// Активні сесії для `ServerHello.session_list`. - pub fn session_list(&self) -> Vec { - self.sessions - .lock() - .unwrap() - .values() - .map(|session| agent_protocol::SessionInfo { - node_hash: session.node_hash.clone(), - run_token: session.run_token, - }) - .collect() - } - - /// Реплей журнальованих подій усіх сесій із `seq >= from`, - /// стабільно впорядкований за (node_hash, seq). - pub fn replay_from(&self, from: u64) -> Vec { - let sessions = self.sessions.lock().unwrap(); - let mut envelopes: Vec = sessions - .values() - .flat_map(|session| session.replay_from(from)) - .collect(); - envelopes.sort_by(|a, b| (&a.node_hash, a.seq).cmp(&(&b.node_hash, b.seq))); - envelopes - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn host() -> (tempfile::TempDir, SessionHost) { - let dir = tempfile::tempdir().unwrap(); - let host = SessionHost::new(dir.path().to_path_buf()).unwrap(); - (dir, host) - } - - /// seq монотонний; ефемерні події не потрапляють у журнал. - #[test] - fn seq_is_monotonic_and_ephemeral_events_skip_journal() { - let (_dir, host) = host(); - let session = host.get_or_open("room-1").unwrap(); - - let first = session.append( - Event::UserMessage { - text: "привіт".into(), - attachments: vec![], - surface: None, - }, - None, - None, - ); - let delta = session.append(Event::AgentTextDelta { text: "п".into() }, None, None); - let done = session.append(Event::AgentTextDone {}, None, None); - - assert_eq!((first.seq, delta.seq, done.seq), (0, 1, 2)); - let journaled: Vec = session - .replay_from(0) - .iter() - .map(|envelope| envelope.seq) - .collect(); - assert_eq!(journaled, vec![0, 2], "ефемерна дельта не журналиться"); - } - - /// Сесія відновлюється з session.jsonl: журнал, seq і run_token - /// переживають «рестарт хоста». - #[test] - fn session_restores_from_journal_file() { - let dir = tempfile::tempdir().unwrap(); - let (token, last_seq) = { - let host = SessionHost::new(dir.path().to_path_buf()).unwrap(); - let session = host.get_or_open("room-1").unwrap(); - session.append(Event::AgentTextDone {}, None, None); - let last = session.append( - Event::Committed { - commit_hash: "abc".into(), - message: "fix".into(), - }, - None, - None, - ); - (session.run_token, last.seq) - }; - - // «Новий процес» над тим самим state_dir. - let host = SessionHost::new(dir.path().to_path_buf()).unwrap(); - let session = host.get_or_open("room-1").unwrap(); - assert_eq!(session.run_token, token, "run_token успадковано з журналу"); - let next = session.append(Event::AgentTextDone {}, None, None); - assert_eq!(next.seq, last_seq + 1, "seq продовжується, без розривів"); - assert_eq!(session.replay_from(0).len(), 3); - } - - /// publish доставляє конверт підписникам broadcast. - #[tokio::test] - async fn publish_broadcasts_to_subscribers() { - let (_dir, host) = host(); - let session = host.get_or_open("room-1").unwrap(); - let mut receiver = host.subscribe(); - - host.publish(&session, Event::AgentTextDone {}, None, None); - - let received = receiver.recv().await.unwrap(); - assert_eq!(received.node_hash, "room-1"); - assert_eq!(received.event, Event::AgentTextDone {}); - } - - /// seed_journal: сіяний журнал підхоплюється наступним get_or_open — - /// той самий механізм відновлення, що й після рестарту хоста. - #[test] - fn seed_journal_is_picked_up_by_next_open() { - let (_dir, host) = host(); - let seeded = Envelope { - seq: 0, - ts: Utc::now(), - node_hash: "room-1".into(), - run_token: Uuid::from_u128(9), - device_id: None, - account_id: None, - event: Event::UserMessage { - text: "успадковано".into(), - attachments: vec![], - surface: None, - }, - }; - let jsonl = format!("{}\n", serde_json::to_string(&seeded).unwrap()); - host.seed_journal("room-1", &jsonl).unwrap(); - - let session = host.get_or_open("room-1").unwrap(); - assert_eq!(session.run_token, Uuid::from_u128(9)); - assert_eq!(session.replay_from(0), vec![seeded]); - - let next = session.append(Event::AgentTextDone {}, None, None); - assert_eq!(next.seq, 1, "seq продовжується від сіяного журналу"); - } - - /// seed_journal після відкриття сесії — явна помилка (живий стан у - /// пам'яті заднім числом не перечитується). - #[test] - fn seed_journal_after_open_is_rejected() { - let (_dir, host) = host(); - host.get_or_open("room-1").unwrap(); - let error = host.seed_journal("room-1", "").unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); - } -} diff --git a/crates/agent-server/src/ws.rs b/crates/agent-server/src/ws.rs deleted file mode 100644 index 61b7575..0000000 --- a/crates/agent-server/src/ws.rs +++ /dev/null @@ -1,540 +0,0 @@ -//! WS-транспорт: хендшейк v4, стрічка подій, capability-фільтр -//! (спека runtime.md, «Протокол подій» / «Хендшейк» / backpressure). -//! -//! Кадри — JSON: перший від клієнта `ClientHello` (несумісна версія чи -//! невірний токен → `Event::Error` + закриття), відповідь `ServerHello`, -//! далі від клієнта — `Envelope` (host ігнорує клієнтські seq/ts і -//! призначає власні), від хоста — `Envelope` стрічки. Повільний клієнт, -//! що випав із broadcast-буфера, повертається реплеєм за `want_replay_from`. - -use std::collections::HashMap; -use std::io; -use std::net::SocketAddr; -use std::sync::Arc; - -use agent_protocol::{ClientHello, Envelope, Event, ServerHello, PROTOCOL_VERSION}; -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::State; -use axum::response::Response; -use axum::routing::get; -use axum::Router; -use serde::Serialize; -use tokio::sync::broadcast; -use tokio::task::JoinHandle; -use uuid::Uuid; - -use crate::approvals_gate::ApprovalGate; -use crate::graph::{self, GraphConfig, InteractiveRun}; -use crate::runner::TurnRunner; -use crate::session::{Session, SessionHost}; - -/// Стан сервера: сесії + виконавець ходів + очікуваний токен discovery + -/// опційний graph-міст (без нього — транспортний режим, кімнати не -/// прив'язані до вузлів графа). -pub struct AppState { - pub sessions: Arc, - pub runner: Arc, - /// `None` — без перевірки (embedded/in-process клієнт). - pub token: Option, - /// Гейт підписаних approvals (access.md); pubkey-кеш наповнює relay-міст. - pub approvals: Arc, - graph: Option, - /// Активні інтерактивні run-и за node-ключем кімнати. Git-операції - /// швидкі й локальні — виконуються під локом (spawn_blocking — TODO - /// разом із віддаленими remote). - runs: tokio::sync::Mutex>, -} - -impl AppState { - pub fn new(sessions: SessionHost, runner: Arc, token: Option) -> Self { - Self::from_parts( - Arc::new(sessions), - Arc::new(ApprovalGate::default()), - runner, - token, - ) - } - - /// Конструктор зі спільними частинами — коли sessions/gate потрібні - /// runner-фабриці ДО створення AppState (approval-гейт тулів). - pub fn from_parts( - sessions: Arc, - approvals: Arc, - runner: Arc, - token: Option, - ) -> Self { - Self { - sessions, - runner, - token, - approvals, - graph: None, - runs: tokio::sync::Mutex::new(HashMap::new()), - } - } - - /// Mid-run approval-гейт: шле `ApprovalRequest` у кімнату і повертає - /// one-shot із підписаним вердиктом (access.md, перший гейт). - pub fn request_approval( - &self, - node: &str, - action: String, - diff: Option, - ) -> std::io::Result> { - crate::approvals_gate::request_approval(&self.sessions, &self.approvals, node, action, diff) - } - - /// Увімкнути graph-міст: кімната = вузол, UserMessage веде claim/worktree. - pub fn with_graph(mut self, config: GraphConfig) -> Self { - self.graph = Some(config); - self - } - - /// Кооперативний handoff вузла (runtime.md, «Міграція сесії між - /// хостами», крок 2): знімає run з обліку, `InteractiveRun::handoff` - /// пише `run_NNN.md (result: handoff)` і CAS-delete claim; сповіщає - /// сесію `ClaimChanged { holder: None }` — той самий сигнал, що й - /// release (деталь «це handoff, не пауза» лишається в run-файлі). - pub async fn handoff_node(&self, node: &str) -> Result { - let Some(run) = self.runs.lock().await.remove(node) else { - return Err(format!("handoff: вузол {node} без активного run")); - }; - let generation = run.generation(); - let ticket = run.handoff()?; - if let Ok(session) = self.sessions.get_or_open(node) { - self.sessions.publish( - &session, - Event::ClaimChanged { - node_hash: node.to_string(), - holder_device_id: None, - lease_until: None, - generation, - }, - None, - None, - ); - } - Ok(ticket) - } - - /// Відновлення на цьому хості після кооперативного handoff (runtime.md, - /// крок 3): `attach_resume` матеріалізує worktree зі стану старого run - /// ref → журнал `.nitra/session.jsonl` засіває локальну сесію (best - /// effort: помилка сіву не валить resume — сесія просто почне з - /// чистого seq) → run під обліком, renewal запущено. - pub async fn resume_node( - self: &Arc, - node: &str, - ticket: &graph::HandoffTicket, - ) -> Result<(), String> { - let config = self - .graph - .as_ref() - .ok_or_else(|| "resume: graph-міст не увімкнено".to_string())?; - let run = graph::attach_resume(config, node, ticket)?; - - if let Ok(jsonl) = std::fs::read_to_string(run.worktree.join(".nitra/session.jsonl")) { - let _ = self.sessions.seed_journal(node, &jsonl); - } - - let lease_sec = config.lease_sec; - self.runs.lock().await.insert(node.to_string(), run); - spawn_renewal(Arc::clone(self), node.to_string(), lease_sec); - Ok(()) - } -} - -/// Маршрути хоста: єдина точка `/ws`. -pub fn router(state: Arc) -> Router { - Router::new() - .route("/ws", get(ws_handler)) - .with_state(state) -} - -/// Біндить адресу (порт 0 → ефемерний) і запускає сервер у фоні. -pub async fn serve( - state: Arc, - addr: SocketAddr, -) -> io::Result<(SocketAddr, JoinHandle<()>)> { - let listener = tokio::net::TcpListener::bind(addr).await?; - let local_addr = listener.local_addr()?; - let app = router(state); - let handle = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Ok((local_addr, handle)) -} - -async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { - ws.on_upgrade(move |socket| client_connection(socket, state)) -} - -async fn send_json(socket: &mut WebSocket, value: &T) -> Result<(), axum::Error> { - socket - .send(Message::Text(serde_json::to_string(value).unwrap().into())) - .await -} - -/// Відмова на хендшейку: `Event::Error` + коректний Close-кадр. -async fn reject(socket: &mut WebSocket, message: String) { - let _ = send_json(socket, &Event::Error { message }).await; - let _ = socket.send(Message::Close(None)).await; -} - -/// Чи можна доставити подію клієнту з такими capabilities -/// (`PreviewScreenshot` — лише клієнтам із «preview»). -fn allowed(event: &Event, capabilities: &[String]) -> bool { - match event { - Event::PreviewScreenshot { .. } => capabilities.iter().any(|c| c == "preview"), - _ => true, - } -} - -async fn client_connection(mut socket: WebSocket, state: Arc) { - // Хендшейк: перший текстовий кадр мусить бути ClientHello. - let hello: ClientHello = loop { - match socket.recv().await { - Some(Ok(Message::Text(text))) => match serde_json::from_str(text.as_str()) { - Ok(hello) => break hello, - Err(error) => { - reject(&mut socket, format!("invalid ClientHello: {error}")).await; - return; - } - }, - Some(Ok(_)) => continue, - _ => return, - } - }; - if let Some(expected) = &state.token { - if &hello.device_token != expected { - reject(&mut socket, "invalid device token".into()).await; - return; - } - } - if let Err(error) = hello.check_compatibility() { - reject(&mut socket, error.to_string()).await; - return; - } - - // Підписка ДО реплею — щоб не загубити події між ними (дублікати - // клієнт відсіює за seq). - let mut updates = state.sessions.subscribe(); - - if send_json( - &mut socket, - &ServerHello { - protocol_version: PROTOCOL_VERSION, - session_list: state.sessions.session_list(), - }, - ) - .await - .is_err() - { - return; - } - - if let Some(from) = hello.want_replay_from { - for envelope in state.sessions.replay_from(from) { - if allowed(&envelope.event, &hello.client_capabilities) - && send_json(&mut socket, &envelope).await.is_err() - { - return; - } - } - } - - loop { - tokio::select! { - incoming = socket.recv() => match incoming { - Some(Ok(Message::Text(text))) => { - // Кадр обробляється у окремій задачі: хід агента може - // чекати ApprovalResponse із ЦЬОГО Ж зʼєднання — - // інлайн-обробка дала б deadlock. - let state = Arc::clone(&state); - let device_id = hello.device_id; - tokio::spawn(async move { - handle_client_frame(&state, text.as_str(), Some(device_id)).await; - }); - } - Some(Ok(Message::Close(_))) | None => break, - Some(Ok(_)) => {} - Some(Err(_)) => break, - }, - update = updates.recv() => match update { - Ok(envelope) => { - if allowed(&envelope.event, &hello.client_capabilities) - && send_json(&mut socket, &envelope).await.is_err() - { - break; - } - } - // Випав із буфера — журнальовані події клієнт добере реплеєм. - Err(broadcast::error::RecvError::Lagged(_)) => {} - Err(broadcast::error::RecvError::Closed) => break, - }, - } - } -} - -/// Кадр клієнта: Envelope з подією. `UserMessage` запускає хід агента -/// (з graph-мостом — попередньо attach вузла); `DoneSession`/ -/// `ReleaseSession` завершують run; невідомі події ігноруються -/// (forward-compatibility). -pub(crate) async fn handle_client_frame( - state: &Arc, - frame: &str, - device_id: Option, -) { - let Ok(envelope) = serde_json::from_str::(frame) else { - return; - }; - let node = envelope.node_hash.clone(); - let Ok(session) = state.sessions.get_or_open(&node) else { - return; - }; - match envelope.event { - Event::UserMessage { text, .. } => { - handle_user_message( - state, - &session, - &node, - &text, - device_id, - envelope.account_id, - ) - .await; - } - Event::DoneSession {} => handle_done(state, &session, &node).await, - Event::ReleaseSession {} => handle_release(state, &session, &node).await, - Event::ApprovalResponse { - request_id, - approved, - signature, - } => { - match state - .approvals - .resolve(&request_id, approved, &signature, device_id) - { - // Верифікований вердикт журналюється у сесію (аудит-трейл) - // і матеріалізується у run вузла (## Approvals при done). - Ok(verdict) => { - let line = format!( - "- {} device={} approved={verdict} request={request_id} signature={}", - chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"), - device_id - .map(|id| id.to_string()) - .unwrap_or_else(|| "local".into()), - signature - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(), - ); - if let Some(run) = state.runs.lock().await.get_mut(&node) { - run.add_approval(line); - } - state.sessions.publish( - &session, - Event::ApprovalResponse { - request_id, - approved, - signature, - }, - device_id, - envelope.account_id, - ); - } - Err(message) => publish_error(state, &session, message), - } - } - _ => {} - } -} - -fn publish_error(state: &AppState, session: &Session, message: String) { - state - .sessions - .publish(session, Event::Error { message }, None, None); -} - -async fn handle_user_message( - state: &Arc, - session: &Arc, - node: &str, - text: &str, - device_id: Option, - account_id: Option, -) { - state.sessions.publish( - session, - Event::UserMessage { - text: text.to_string(), - attachments: vec![], - surface: None, - }, - device_id, - account_id, - ); - - // Graph-міст: перший хід вузла — attach (CAS claim + worktree + run ref). - let workdir = if let Some(config) = &state.graph { - let mut runs = state.runs.lock().await; - if !runs.contains_key(node) { - match graph::attach(config, node) { - Ok(run) => { - runs.insert(node.to_string(), run); - spawn_renewal(Arc::clone(state), node.to_string(), config.lease_sec); - } - Err(error) => { - // Без claim хід не виконується — вузол зайнято/недоступно. - publish_error(state, session, error); - return; - } - } - } - runs.get(node).map(|run| run.worktree.clone()) - } else { - None - }; - - let sessions = &state.sessions; - let emit = |event: Event| { - sessions.publish(session, event, None, None); - }; - if let Err(error) = state - .runner - .run_turn(node, text, workdir.as_deref(), &emit) - .await - { - publish_error(state, session, error.to_string()); - } - - // Кожен хід — коміт (файли + журнал сесії) → push run ref (git.md). - if state.graph.is_some() { - let journal = journal_jsonl(session); - let runs = state.runs.lock().await; - if let Some(run) = runs.get(node) { - let message = format!("mt: {node} інтерактивний хід"); - if let Err(error) = run.commit_turn(&journal, &message) { - publish_error(state, session, format!("run ref push: {error}")); - } - } - } -} - -/// `mt done`-семантика: `## Check` → strip `.nitra/` → fenced publish → -/// `Committed`. Відмова Check чи системна помилка НЕ знімає run — можна -/// виправити й повторити done; run знімається при published (успіх) або -/// fenced (claim втрачено — retry марний). -async fn handle_done(state: &Arc, session: &Arc, node: &str) { - let mut runs = state.runs.lock().await; - let Some(run) = runs.get(node) else { - publish_error( - state, - session, - format!("done: вузол {node} без активного run"), - ); - return; - }; - match run.done(8, 250) { - Ok(outcome) if outcome.published => { - runs.remove(node); - state.sessions.publish( - session, - Event::Committed { - commit_hash: outcome.result_sha.unwrap_or_default(), - message: format!("mt: {node} done — fact опубліковано"), - }, - None, - None, - ); - } - Ok(outcome) => { - if outcome.fenced { - runs.remove(node); - } - publish_error( - state, - session, - if outcome.fenced { - "done: claim втрачено під час publish — worktree лишився для debug".into() - } else { - "done: конкурентний publish виграв гонку — спробуйте пізніше".into() - }, - ); - } - Err(error) => publish_error(state, session, format!("done: {error}")), - } -} - -/// Пауза: CAS-delete claim; журнал лишається в run ref базою відновлення. -async fn handle_release(state: &Arc, session: &Arc, node: &str) { - let Some(run) = state.runs.lock().await.remove(node) else { - publish_error( - state, - session, - format!("release: вузол {node} без активного run"), - ); - return; - }; - let generation = run.generation(); - match run.release() { - Ok(_) => { - state.sessions.publish( - session, - Event::ClaimChanged { - node_hash: node.to_string(), - holder_device_id: None, - lease_until: None, - generation, - }, - None, - None, - ); - } - Err(error) => publish_error(state, session, format!("release: {error}")), - } -} - -/// Журнал сесії у форматі `session.jsonl` (по рядку на Envelope). -fn journal_jsonl(session: &Session) -> String { - session - .replay_from(0) - .iter() - .map(|envelope| serde_json::to_string(envelope).unwrap()) - .collect::>() - .join("\n") - + "\n" -} - -/// Фоновий renewal lease (~кожну третину lease). Run зник із мапи -/// (done/release) → задача завершується; невдалий renewal → Error у -/// сесію, run прибирається (claim втрачено). -fn spawn_renewal(state: Arc, node: String, lease_sec: i64) { - let period = std::time::Duration::from_secs((lease_sec / 3).max(1) as u64); - tokio::spawn(async move { - let mut interval = tokio::time::interval(period); - interval.tick().await; // перший tick — миттєвий, пропускаємо - loop { - interval.tick().await; - let mut runs = state.runs.lock().await; - let Some(run) = runs.get_mut(&node) else { - return; - }; - match run.renew() { - Ok(true) => {} - Ok(false) | Err(_) => { - runs.remove(&node); - drop(runs); - if let Ok(session) = state.sessions.get_or_open(&node) { - publish_error( - &state, - &session, - format!("claim-lost: lease вузла {node} не подовжено"), - ); - } - return; - } - } - } - }); -} diff --git a/crates/agent-server/tests/acp_runner.rs b/crates/agent-server/tests/acp_runner.rs deleted file mode 100644 index ec85907..0000000 --- a/crates/agent-server/tests/acp_runner.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Інтеграція AcpTurnRunner з реальним child-процесом: фейковий ACP-агент -//! (bin `fake-acp-agent`) на stdio — хід повертає echo-текст і емітить -//! AgentTextDelta/AgentTextDone. - -use std::sync::Mutex; - -use agent_protocol::Event; -use agent_server::{AcpTurnRunner, TurnRunner}; - -#[tokio::test(flavor = "multi_thread")] -async fn acp_turn_runner_spawns_adapter_and_streams_turn() { - let runner = AcpTurnRunner::new(env!("CARGO_BIN_EXE_fake-acp-agent"), None); - let events = Mutex::new(Vec::new()); - let emit = |event: Event| events.lock().unwrap().push(event); - - let first = runner.run_turn("room-1", "раз", None, &emit).await.unwrap(); - let second = runner.run_turn("room-1", "два", None, &emit).await.unwrap(); - - assert_eq!((first.as_str(), second.as_str()), ("end_turn", "end_turn")); - assert_eq!( - *events.lock().unwrap(), - vec![ - Event::AgentTextDelta { - text: "acp: раз".into() - }, - Event::AgentTextDone {}, - Event::AgentTextDelta { - text: "acp: два".into() - }, - Event::AgentTextDone {}, - ] - ); -} diff --git a/crates/agent-server/tests/common/docs/index.md b/crates/agent-server/tests/common/docs/index.md deleted file mode 100644 index 473a883..0000000 --- a/crates/agent-server/tests/common/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: crates/agent-server/tests/common -resource: crates/agent-server/tests/common/ ---- - -| Файл | Тип | -| ---------------- | ----------- | -| [mod.rs](mod.md) | Rust Module | diff --git a/crates/agent-server/tests/common/docs/mod.md b/crates/agent-server/tests/common/docs/mod.md deleted file mode 100644 index 8c2fc2d..0000000 --- a/crates/agent-server/tests/common/docs/mod.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -type: Rust Module -title: mod.rs -resource: crates/agent-server/tests/common/mod.rs -docgen: - crc: 6b45321e - model: omlx/gemma-4-e2b-it-4bit - tier: local-min - score: 100 ---- - -## Огляд - -Огляд -Файл надає спільні WS-хелпери для інтеграційних тестів agent-server, необхідні для підключення клієнта до сервера та читання WebSocket кадрів - -Поведінка - -WsStream -Створює та повертає WebSocketStream для комунікації з сервером - -connect -Підключає WS-клієнта, надсилає ClientHello, чекає ServerHello і повертає стрім - -next_json -Читає текстовий кадр зі стріму, десеріалізує його та повертає - -## Поведінка - -Поведінка - -WsStream -Створює та повертає WebSocketStream для комунікації з сервером. - -connect -Підключає WS-клієнта, надсилає ClientHello, чекає ServerHello і повертає стрім. - -next_json -Читає текстовий кадр зі стріму, десеріалізує його та повертає. - -## Публічний API - -**WsStream** — Створює та керує потоком WebSocket-з'єднання. -**connect** — Ініціює підключення до WS-клієнта, обмінюється `ClientHello` та `ServerHello`, повертає активний стрім. `device_id` використовується для ідентифікації клієнта в сценаріях з мультипідключеннями. -**next_json** — Зчитує наступний текстовий кадр зі стріму як десеріалізований об'єкт `T` з тайм-аутом 10 секунд. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-server/tests/common/mod.rs b/crates/agent-server/tests/common/mod.rs deleted file mode 100644 index 5a04ea5..0000000 --- a/crates/agent-server/tests/common/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Спільні WS-хелпери інтеграційних тестів agent-server: підключення -//! клієнта (ClientHello → ServerHello) і читання кадрів — спільний код -//! тестових бінарників graph_wiring і handoff_ws. - -use agent_protocol::{ClientHello, ServerHello, PROTOCOL_VERSION}; -use futures::{SinkExt, StreamExt}; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -pub type WsStream = - tokio_tungstenite::WebSocketStream>; - -/// Підключає WS-клієнта: шле ClientHello, чекає ServerHello, повертає стрім. -/// `device_id` розрізняє клієнтів у сценаріях із кількома підключеннями. -pub async fn connect(url: &str, device_id: u128) -> WsStream { - let hello = ClientHello { - protocol_version: PROTOCOL_VERSION, - device_id: Uuid::from_u128(device_id), - device_token: String::new(), - client_kind: "cli".into(), - client_capabilities: vec![], - lang: "uk".into(), - want_replay_from: None, - }; - let (mut stream, _) = tokio_tungstenite::connect_async(url).await.unwrap(); - stream - .send(Message::text(serde_json::to_string(&hello).unwrap())) - .await - .unwrap(); - let _: ServerHello = next_json(&mut stream).await; - stream -} - -/// Наступний текстовий кадр стріму як десеріалізований `T` (таймаут 10 с). -pub async fn next_json(stream: &mut WsStream) -> T { - loop { - let message = tokio::time::timeout(std::time::Duration::from_secs(10), stream.next()) - .await - .expect("timeout очікування кадру") - .expect("стрім закрито") - .unwrap(); - if let Message::Text(text) = message { - return serde_json::from_str(text.as_str()).unwrap(); - } - } -} diff --git a/crates/agent-server/tests/docs/acp_runner.md b/crates/agent-server/tests/docs/acp_runner.md deleted file mode 100644 index 0185290..0000000 --- a/crates/agent-server/tests/docs/acp_runner.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -type: Rust Module -title: acp_runner.rs -resource: crates/agent-server/tests/acp_runner.rs -docgen: - crc: 7cf8b886 - model: omlx/gemma-4-e2b-it-4bit - tier: local-min - score: 0 - issues: refusal-filler,best-of-2:retry-lost ---- - -## Огляд - -Будь ласка, надайте текст чорнетки, яку потрібно перевірити. - -## Поведінка - -1. Запускає хід з використанням адаптера до child-процесу -2. Повертає рядок тексту з результату -3. Емітує події AgentTextDelta з текстом -4. Емітує події AgentTextDone для завершення тексту - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-server/tests/docs/graph_wiring.md b/crates/agent-server/tests/docs/graph_wiring.md deleted file mode 100644 index c47779e..0000000 --- a/crates/agent-server/tests/docs/graph_wiring.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -type: Rust Module -title: graph_wiring.rs -resource: crates/agent-server/tests/graph_wiring.rs -docgen: - crc: ec604fa5 - model: openai-codex/gpt-5.4-mini - score: 100 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл інтегрує WS-сесії з graph-мостом для перевірки контракту між `UserMessage`, `DoneSession` і `ReleaseSession`. Тестовий контур працює на bare-репо як origin, використовує MockProvider-агент і реальний WS, щоб зафіксувати поведінку без втрати run ref: перше `UserMessage` прив’язує вузол, `DoneSession` запускає fenced publish, а `ReleaseSession` ставить сесію на паузу й звільняє вузол. - -## Поведінка - -1. Піднімає ізольоване середовище з bare-origin, робочим репозиторієм, вузлом `mt/demo` і WS-сервером із graph-мостом та scripted MockProvider. -2. Приймає перше `UserMessage` як момент захоплення вузла: сесія прив’язується до вузла, а хід агента стартує тільки після успішного attach. -3. Фіксує сесію в журналі окремого run ref, щоб історія звернення зберігалася незалежно від подальшого завершення або паузи. -4. Після завершення ходу через `DoneSession` публікує результат у `main` у fenced-режимі: службові refs прибираються, а `.nitra/` не потрапляє в публікацію. -5. Після `ReleaseSession` знімає блокування з вузла без втрати журналу: claim звільняється, run ref залишається доступним, і вузол можна знову захопити новим `UserMessage`. -6. Якщо вузол уже зайнятий іншим тримачем, завершує спробу помилкою `claim-lost` без виконання ходу. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/crates/agent-server/tests/docs/handoff_ws.md b/crates/agent-server/tests/docs/handoff_ws.md deleted file mode 100644 index 465ad55..0000000 --- a/crates/agent-server/tests/docs/handoff_ws.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: Rust Module -title: handoff_ws.rs -resource: crates/agent-server/tests/handoff_ws.rs -docgen: - crc: b5181e7d - model: openai-codex/gpt-5.4-mini - score: 100 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Цей файл описує контрольований сценарій handoff сесії для кроків 2–3 у `runtime.md` («Міграція сесії між хостами»): дві незалежні `AppState` з окремими `state_dir` симулюють два хости в одному й тому самому git-репозиторії. Після `handoff_node` на хості 1 `resume_node` на хості 2 з тим самим тікетом успадковує журнал і продовжує `seq` без розривів. - -## Поведінка - -1. Піднімає два незалежні хости з окремими `state_dir`, але з одним і тим самим локальним git-репозиторієм, щоб змоделювати міграцію сесії між різними вузлами без зміни робочого дерева. -2. На першому хості відкриває сесію, приймає хід користувача й фіксує завершення агента, після чого переводить вузол у стан handoff. -3. Формує ticket для handoff і перевіряє, що він створений як нова генерація для передачі сесії. -4. На другому хості відновлює той самий вузол за цим ticket і перевіряє, що журнал попереднього хоста доступний локально ще до нового ходу. -5. Запускає новий хід на другому хості та підтверджує, що sequence номер продовжується без розривів після resume. -6. Не перевіряє паралельні гонки між хостами; сценарій навмисно послідовний. -7. Не перевіряє віддалену мережеву міграцію між різними машинами; обидва хости працюють у межах одного локального репозиторію як контрольована симуляція. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/crates/agent-server/tests/docs/index.md b/crates/agent-server/tests/docs/index.md deleted file mode 100644 index fe4eb6a..0000000 --- a/crates/agent-server/tests/docs/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -type: Directory Index -title: crates/agent-server/tests -resource: crates/agent-server/tests/ ---- - -| Файл | Тип | -| -------------------------------------- | ----------- | -| [acp_runner.rs](acp_runner.md) | Rust Module | -| [graph_wiring.rs](graph_wiring.md) | Rust Module | -| [handoff_ws.rs](handoff_ws.md) | Rust Module | -| [relay_bridge.rs](relay_bridge.md) | Rust Module | -| [ws_integration.rs](ws_integration.md) | Rust Module | diff --git a/crates/agent-server/tests/docs/relay_bridge.md b/crates/agent-server/tests/docs/relay_bridge.md deleted file mode 100644 index 5e37e9e..0000000 --- a/crates/agent-server/tests/docs/relay_bridge.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: Rust Module -title: relay_bridge.rs -resource: crates/agent-server/tests/relay_bridge.rs -docgen: - crc: b58a8d9c - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Піднімає тимчасовий `mock-relay` як `tungstenite`-server і тримає міст `agent-server` ↔ `relay` у кадровому протоколі `relay`: віддалений `UserMessage` проходить через хід агента, `host-frames` доходять до `relay`, а `host-echo` повертається назад без створення другого `UserMessage` у журналі сесії. - -## Поведінка - -1. Піднімає тимчасовий mock-relay і з’єднує з ним міст agent-server ↔ relay. -2. Виконує службовий обмін стартовими кадрами: ідентифікація хоста та підписка на потрібний root. -3. Приймає віддалений `UserMessage` через relay і передає його в хід агента. -4. Очікує, що host-кадри результату доїдуть назад у relay: echo користувацького повідомлення, дельта відповіді агента, завершення тексту. -5. Перевіряє, що дані віддаленого пристрою зберігаються в кадрі без спотворення. -6. Подає в relay host-ехо назад у потік і переконується, що міст не запускає повторну обробку. -7. Підтверджує, що в журналі сесії лишається рівно один `UserMessage`, тобто цикл обміну не розкручується повторно. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-server/tests/docs/ws_integration.md b/crates/agent-server/tests/docs/ws_integration.md deleted file mode 100644 index 9ce31af..0000000 --- a/crates/agent-server/tests/docs/ws_integration.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -type: Rust Module -title: ws_integration.rs -resource: crates/agent-server/tests/ws_integration.rs -docgen: - crc: 0fe0a571 - model: openai-codex/gpt-5.5 - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Інтеграційні тести перевіряють WS-транспорт через реальний сервер на ефемерному порту та `tungstenite`-клієнт. Файл існує, щоб підтвердити проходження ходу через `AgentTurnRunner` + `MockProvider` в offline-режимі без зовнішнього провайдера, але з реальною мережевою взаємодією між клієнтом і локальним сервером. - -## Поведінка - -1. Підіймає реальний WebSocket-сервер на локальному ефемерному порту, щоб перевіряти транспортний шар у максимально близькому до робочого режимі без зовнішнього LLM. - -2. Підключає WebSocket-клієнт і виконує handshake із версією протоколу, ідентичністю пристрою, token-авторизацією, мовою та можливістю запросити replay подій. - -3. Перевіряє успішний сценарій розмови: клієнт надсилає повідомлення користувача, сервер повертає подію користувача, текстову відповідь агента та завершення відповіді. - -4. Гарантує, що сервер сам призначає послідовні номери подій і прив’язує повідомлення до пристрою з handshake, а не довіряє клієнтському номеру. - -5. Перевіряє відмову для несумісної версії протоколу: сервер повертає зрозумілу помилку з очікуваною та отриманою версіями, після чого закриває з’єднання. - -6. Перевіряє відмову для неправильного device token на етапі handshake, щоб неавторизований клієнт не міг перейти до обміну подіями. - -7. Перевіряє відновлення після reconnect: сервер повертає список наявних сесій і повторно доставляє журнальовані події з потрібної позиції. - -8. Підтверджує, що ефемерні текстові дельти не потрапляють у replay, а журнальоване завершення відповіді зберігає той самий номер послідовності. - -9. Перевіряє, що після reconnect розмова продовжується в тому самому run без розривів у нумерації подій. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/agent-server/tests/graph_wiring.rs b/crates/agent-server/tests/graph_wiring.rs deleted file mode 100644 index 3835eba..0000000 --- a/crates/agent-server/tests/graph_wiring.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Інтеграція WS-сесій із graph-мостом: attach на першому UserMessage, -//! журнал у run ref, DoneSession → fenced publish, ReleaseSession → пауза. -//! Все герметично: bare-репо як origin, скриптований runner, реальний WS. - -use std::path::Path; -use std::process::Command; -use std::sync::Arc; - -use agent_protocol::{Envelope, Event}; -use agent_server::{serve, AppState, ApprovalGate, GraphConfig, ScriptedTurnRunner, SessionHost}; -use chrono::Utc; -use futures::SinkExt; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -mod common; -use common::next_json; - -fn sh(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.local") - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); -} - -fn sh_out(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); - String::from_utf8_lossy(&out.stdout).trim().to_string() -} - -struct Fixture { - #[allow(dead_code)] - origin: tempfile::TempDir, - work: tempfile::TempDir, - #[allow(dead_code)] - state_dir: tempfile::TempDir, - url: String, -} - -impl Fixture { - /// bare-origin + робочий клон із вузлом `mt/demo` + WS-сервер із - /// graph-мостом і скриптованим runner-ом (по одній відповіді на хід). - async fn start(responses: Vec<&str>) -> Self { - let origin = tempfile::tempdir().unwrap(); - sh(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - let work = tempfile::tempdir().unwrap(); - sh(work.path(), &["init", "-q", "-b", "main"]); - std::fs::create_dir_all(work.path().join("mt/demo")).unwrap(); - std::fs::write(work.path().join("mt/demo/task.md"), "## Task\n").unwrap(); - sh(work.path(), &["add", "."]); - sh(work.path(), &["commit", "-q", "-m", "init"]); - sh( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - sh(work.path(), &["push", "-q", "origin", "main"]); - - let state_dir = tempfile::tempdir().unwrap(); - let sessions = Arc::new(SessionHost::new(state_dir.path().to_path_buf()).unwrap()); - let approvals = Arc::new(ApprovalGate::default()); - let runner = ScriptedTurnRunner::new(responses); - let state = Arc::new( - AppState::from_parts(sessions, approvals, Arc::new(runner), None) - .with_graph(GraphConfig::new(work.path().join("mt"))), - ); - let (addr, _handle) = serve(state, "127.0.0.1:0".parse().unwrap()).await.unwrap(); - Self { - origin, - work, - state_dir, - url: format!("ws://{addr}/ws"), - } - } - - fn remote_refs(&self) -> String { - sh_out(self.work.path(), &["ls-remote", "origin"]) - } -} - -/// WS-клієнт цього тест-бінарника (device_id — довільна константа). -async fn connect(url: &str) -> common::WsStream { - common::connect(url, 7).await -} - -fn client_event(node: &str, event: Event) -> Message { - let envelope = Envelope { - seq: 0, - ts: Utc::now(), - node_hash: node.into(), - run_token: Uuid::from_u128(1), - device_id: None, - account_id: None, - event, - }; - Message::text(serde_json::to_string(&envelope).unwrap()) -} - -fn user_message(node: &str, text: &str) -> Message { - client_event( - node, - Event::UserMessage { - text: text.into(), - attachments: vec![], - surface: None, - }, - ) -} - -// Mid-run approval-гейт тулів пішов разом із власним agent loop -// (ADR 260713-2110): у ACP-виконавців approvals ідуть через -// `permission-request` → `ApprovalRequest` — тести повернуться з ACP-клієнтом. - -/// Повний M1-цикл: UserMessage → attach (claim ref) → хід → журнал у run -/// ref → DoneSession → fenced publish (main без .nitra/, refs прибрані). -#[tokio::test(flavor = "multi_thread")] -async fn user_message_attaches_and_done_publishes() { - let fixture = Fixture::start(vec!["зроблено"]).await; - let mut stream = connect(&fixture.url).await; - - stream.send(user_message("demo", "почни")).await.unwrap(); - let _user: Envelope = next_json(&mut stream).await; - let _delta: Envelope = next_json(&mut stream).await; - let done_event: Envelope = next_json(&mut stream).await; - assert_eq!(done_event.event, Event::AgentTextDone {}); - - // Attach відбувся: claim ref і run ref на remote, журнал у run ref. - // Кадри обробляються у spawned-тасках — коміт журналу завершується - // ПІСЛЯ стріму подій ходу, тому чекаємо з ретраєм. - let mut journal = String::new(); - for _ in 0..50 { - let refs = fixture.remote_refs(); - if let Some(run_ref) = refs - .lines() - .find(|line| line.contains("refs/mt/runs/")) - .and_then(|line| line.split_whitespace().nth(1)) - { - let out = Command::new("git") - .arg("-C") - .arg(fixture.origin.path()) - .args(["show", &format!("{run_ref}:.nitra/session.jsonl")]) - .output() - .unwrap(); - if out.status.success() { - journal = String::from_utf8_lossy(&out.stdout).into_owned(); - break; - } - } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - let refs = fixture.remote_refs(); - assert!(refs.contains("refs/mt/claims/"), "{refs}"); - assert!( - journal.contains("почни"), - "журнал сесії у run ref: {journal}" - ); - - // Done: publish у main, refs прибрані, .nitra/ не протік. - stream - .send(client_event("demo", Event::DoneSession {})) - .await - .unwrap(); - let committed: Envelope = next_json(&mut stream).await; - assert!( - matches!(committed.event, Event::Committed { ref message, .. } if message.contains("done")), - "{committed:?}" - ); - let refs = fixture.remote_refs(); - assert!(!refs.contains("refs/mt/claims/"), "{refs}"); - assert!(!refs.contains("refs/mt/runs/"), "{refs}"); - let main_files = sh_out( - fixture.origin.path(), - &["ls-tree", "-r", "--name-only", "main"], - ); - assert!(!main_files.contains(".nitra"), "{main_files}"); - // Контрактні артефакти спроби синтезовано (graph.md). - assert!(main_files.contains("mt/demo/run_001.md"), "{main_files}"); - assert!(main_files.contains("mt/demo/fact_001.md"), "{main_files}"); -} - -/// ReleaseSession: пауза — claim знято (ClaimChanged без holder-а), -/// run ref лишається; вузол можна attach-нути знову. -#[tokio::test(flavor = "multi_thread")] -async fn release_frees_claim_and_keeps_journal() { - let fixture = Fixture::start(vec!["перший", "після паузи"]).await; - let mut stream = connect(&fixture.url).await; - - stream.send(user_message("demo", "почни")).await.unwrap(); - let _user: Envelope = next_json(&mut stream).await; - let _delta: Envelope = next_json(&mut stream).await; - let _done: Envelope = next_json(&mut stream).await; - - stream - .send(client_event("demo", Event::ReleaseSession {})) - .await - .unwrap(); - let changed: Envelope = next_json(&mut stream).await; - assert!( - matches!( - changed.event, - Event::ClaimChanged { - holder_device_id: None, - .. - } - ), - "{changed:?}" - ); - let refs = fixture.remote_refs(); - assert!(!refs.contains("refs/mt/claims/"), "{refs}"); - assert!(refs.contains("refs/mt/runs/"), "журнал лишився: {refs}"); - - // Повторний UserMessage — новий attach проходить (вузол вільний). - stream.send(user_message("demo", "продовж")).await.unwrap(); - let _user: Envelope = next_json(&mut stream).await; - let delta: Envelope = next_json(&mut stream).await; - assert_eq!( - delta.event, - Event::AgentTextDelta { - text: "після паузи".into() - } - ); - assert!(fixture.remote_refs().contains("refs/mt/claims/")); -} - -/// Вузол, зайнятий іншим тримачем, → Error claim-lost; хід не виконується. -#[tokio::test(flavor = "multi_thread")] -async fn busy_node_yields_claim_lost_error() { - let fixture = Fixture::start(vec!["не має статись"]).await; - // Хтось інший уже тримає claim. - let foreign = - agent_server::graph::attach(&GraphConfig::new(fixture.work.path().join("mt")), "demo") - .unwrap(); - - let mut stream = connect(&fixture.url).await; - stream.send(user_message("demo", "почни")).await.unwrap(); - let _user: Envelope = next_json(&mut stream).await; - let error: Envelope = next_json(&mut stream).await; - assert!( - matches!(error.event, Event::Error { ref message } if message.contains("claim-lost")), - "{error:?}" - ); - drop(foreign); -} diff --git a/crates/agent-server/tests/handoff_ws.rs b/crates/agent-server/tests/handoff_ws.rs deleted file mode 100644 index 9e9f3ad..0000000 --- a/crates/agent-server/tests/handoff_ws.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Кооперативний handoff на рівні AppState/session (runtime.md, «Міграція -//! сесії між хостами», кроки 2-3): дві незалежні `AppState` (окремі -//! `state_dir` — симуляція двох хостів), той самий git-репозиторій. -//! Хід на хості 1 → `handoff_node` → `resume_node` на хості 2 з тим самим -//! тікетом → журнал успадкований, наступний хід продовжує seq без розривів. - -use std::path::Path; -use std::process::Command; -use std::sync::Arc; - -use agent_protocol::{Envelope, Event}; -use agent_server::{serve, AppState, ApprovalGate, GraphConfig, ScriptedTurnRunner, SessionHost}; -use futures::SinkExt; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -mod common; -use common::{next_json, WsStream}; - -fn sh(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "t@t.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "t@t.local") - .output() - .unwrap(); - assert!( - out.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&out.stderr) - ); -} - -/// Bare-origin + робочий клон із вузлом `mt/demo` — спільна координатна -/// точка «двох хостів» (обидва працюють у тому самому локальному клоні; -/// реалістичніше було б два окремі клони, але git-операції йдуть через -/// origin однаково, а тест — послідовний, без гонки між хостами). -struct Fixture { - #[allow(dead_code)] - origin: tempfile::TempDir, - work: tempfile::TempDir, -} - -impl Fixture { - fn new() -> Self { - let origin = tempfile::tempdir().unwrap(); - sh(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - let work = tempfile::tempdir().unwrap(); - sh(work.path(), &["init", "-q", "-b", "main"]); - std::fs::create_dir_all(work.path().join("mt/demo")).unwrap(); - std::fs::write(work.path().join("mt/demo/task.md"), "## Task\n").unwrap(); - sh(work.path(), &["add", "."]); - sh(work.path(), &["commit", "-q", "-m", "init"]); - sh( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - sh(work.path(), &["push", "-q", "origin", "main"]); - Self { origin, work } - } - - fn config(&self) -> GraphConfig { - GraphConfig::new(self.work.path().join("mt")) - } -} - -/// Стартує AppState (свій `state_dir`, скриптований runner) + WS-сервер. -async fn start_host( - fixture: &Fixture, - responses: Vec<&str>, -) -> (Arc, String, tempfile::TempDir) { - let runner = ScriptedTurnRunner::new(responses); - let state_dir = tempfile::tempdir().unwrap(); - let state = Arc::new( - AppState::from_parts( - Arc::new(SessionHost::new(state_dir.path().to_path_buf()).unwrap()), - Arc::new(ApprovalGate::default()), - Arc::new(runner), - None, - ) - .with_graph(fixture.config()), - ); - let (addr, _handle) = serve(Arc::clone(&state), "127.0.0.1:0".parse().unwrap()) - .await - .unwrap(); - (state, format!("ws://{addr}/ws"), state_dir) -} - -/// WS-клієнт цього тест-бінарника (обидва «хости» — той самий device_id 1, -/// як у вихідному сценарії handoff). -async fn connect(url: &str) -> WsStream { - common::connect(url, 1).await -} - -async fn next_matching(stream: &mut WsStream, matches_event: impl Fn(&Event) -> bool) -> Envelope { - loop { - let envelope: Envelope = next_json(stream).await; - if matches_event(&envelope.event) { - return envelope; - } - } -} - -fn user_message(node: &str, text: &str) -> Message { - let envelope = Envelope { - seq: 0, - ts: chrono::Utc::now(), - node_hash: node.into(), - run_token: Uuid::from_u128(1), - device_id: None, - account_id: None, - event: Event::UserMessage { - text: text.into(), - attachments: vec![], - surface: None, - }, - }; - Message::text(serde_json::to_string(&envelope).unwrap()) -} - -/// Наскрізно: хід на хості 1 → handoff_node → resume_node на хості 2 з тим -/// самим тікетом → журнал успадкований (get_or_open бачить хід хоста 1) → -/// наступний хід продовжує seq без розривів. -#[tokio::test(flavor = "multi_thread")] -async fn handoff_then_resume_inherits_journal_and_continues_seq() { - let fixture = Fixture::new(); - - // Хост 1: хід, що завершується AgentTextDone. - let (host1, url1, _dir1) = start_host(&fixture, vec!["перший хост"]).await; - let mut client1 = connect(&url1).await; - client1.send(user_message("demo", "почни")).await.unwrap(); - let user_envelope = - next_matching(&mut client1, |e| matches!(e, Event::UserMessage { .. })).await; - next_matching(&mut client1, |e| matches!(e, Event::AgentTextDone {})).await; - drop(client1); - - let ticket = host1.handoff_node("demo").await.unwrap(); - assert_eq!(ticket.generation, 1); - - // Хост 2: інший AppState (інший state_dir), той самий тікет. - let (host2, url2, _dir2) = start_host(&fixture, vec!["другий хост"]).await; - host2.resume_node("demo", &ticket).await.unwrap(); - - // Журнал хоста 1 успадкований локальною сесією хоста 2 ще ДО будь-якого - // нового ходу. - let inherited = host2.sessions.get_or_open("demo").unwrap().replay_from(0); - assert!( - inherited - .iter() - .any(|e| e.event == user_envelope.event && e.seq == user_envelope.seq), - "{inherited:?}" - ); - let last_inherited_seq = inherited.last().unwrap().seq; - - // Новий хід на хості 2 продовжує seq без розривів. - let mut client2 = connect(&url2).await; - client2.send(user_message("demo", "продовж")).await.unwrap(); - let second_user = next_matching(&mut client2, |e| matches!(e, Event::UserMessage { .. })).await; - assert_eq!( - second_user.seq, - last_inherited_seq + 1, - "seq продовжується без розривів після resume" - ); - next_matching(&mut client2, |e| matches!(e, Event::AgentTextDone {})).await; -} diff --git a/crates/agent-server/tests/relay_bridge.rs b/crates/agent-server/tests/relay_bridge.rs deleted file mode 100644 index 746b6c4..0000000 --- a/crates/agent-server/tests/relay_bridge.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Міст agent-server ↔ relay проти mock-relay (tungstenite-сервер у тесті, -//! кадровий протокол relay): віддалений UserMessage → хід агента → -//! host-кадри доїжджають у relay; host-ехо назад — без зациклення. - -use std::sync::Arc; - -use agent_server::{spawn_relay_bridge, AppState, EchoTurnRunner, RelayBridgeConfig, SessionHost}; -use futures::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::net::TcpListener; -use tokio::sync::mpsc; -use tokio_tungstenite::tungstenite::Message; - -/// Mock-relay: одне зʼєднання; всі отримані кадри — у канал тесту, -/// кадри з каналу тесту — мосту. -async fn mock_relay() -> (String, mpsc::Receiver, mpsc::Sender) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let (received_tx, received_rx) = mpsc::channel::(64); - let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(64); - - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - loop { - tokio::select! { - incoming = ws.next() => match incoming { - Some(Ok(Message::Text(text))) => { - let frame: Value = serde_json::from_str(text.as_str()).unwrap(); - let _ = received_tx.send(frame).await; - } - Some(Ok(_)) => {} - _ => break, - }, - outgoing = outgoing_rx.recv() => match outgoing { - Some(frame) => { - if ws.send(Message::text(frame.to_string())).await.is_err() { - break; - } - } - None => break, - }, - } - } - }); - - (format!("ws://127.0.0.1:{port}"), received_rx, outgoing_tx) -} - -/// Наступний кадр від моста з таймаутом. -async fn next_frame(rx: &mut mpsc::Receiver) -> Value { - tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()) - .await - .expect("timeout очікування кадру від моста") - .expect("канал закрито") -} - -fn remote_user_message(text: &str) -> Value { - json!({ - "kind": "envelope", - "envelope": { - "seq": 0, - "ts": "2026-07-12T00:00:00Z", - "node_hash": "demo", - "run_token": "00000000-0000-0000-0000-000000000001", - "device_id": "00000000-0000-0000-0000-00000000000a", - "event": { "type": "UserMessage", "text": text, "attachments": [] } - } - }) -} - -#[tokio::test(flavor = "multi_thread")] -async fn remote_user_message_runs_turn_and_streams_back() { - let state_dir = tempfile::tempdir().unwrap(); - let state = Arc::new(AppState::new( - SessionHost::new(state_dir.path().to_path_buf()).unwrap(), - Arc::new(EchoTurnRunner), - None, - )); - let (url, mut received, outgoing) = mock_relay().await; - let _bridge = spawn_relay_bridge( - Arc::clone(&state), - RelayBridgeConfig { - url, - device_token: "host-token".into(), - root: "demo".into(), - }, - ); - - // Хендшейк моста: hello з device_token → subscribe кімнати. - let hello = next_frame(&mut received).await; - assert_eq!(hello["kind"], "hello"); - assert_eq!(hello["device_token"], "host-token"); - let subscribe = next_frame(&mut received).await; - assert_eq!(subscribe["kind"], "subscribe"); - assert_eq!(subscribe["root"], "demo"); - let pubkeys_request = next_frame(&mut received).await; - assert_eq!(pubkeys_request["kind"], "pubkeys"); - - // Віддалений клієнт шле UserMessage через relay. - outgoing.send(remote_user_message("привіт")).await.unwrap(); - - // Міст ретранслює host-стрічку: echo UserMessage (seq призначив хост), - // дельта відповіді агента, AgentTextDone. - let user_echo = next_frame(&mut received).await; - assert_eq!(user_echo["kind"], "envelope"); - assert_eq!(user_echo["envelope"]["event"]["type"], "UserMessage"); - assert_eq!(user_echo["envelope"]["seq"], 0); - assert_eq!( - user_echo["envelope"]["device_id"], "00000000-0000-0000-0000-00000000000a", - "device_id віддаленого пристрою збережено" - ); - let delta = next_frame(&mut received).await; - assert_eq!(delta["envelope"]["event"]["type"], "AgentTextDelta"); - assert_eq!(delta["envelope"]["event"]["text"], "echo: привіт"); - let done = next_frame(&mut received).await; - assert_eq!(done["envelope"]["event"]["type"], "AgentTextDone"); - - // Анти-цикл: relay повертає host-ехо (from_host: true) — міст ігнорує; - // у журналі сесії рівно один UserMessage. - let mut echoed = user_echo.clone(); - echoed["from_host"] = json!(true); - outgoing.send(echoed).await.unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - - let session = state.sessions.get_or_open("demo").unwrap(); - let user_messages = session - .replay_from(0) - .iter() - .filter(|envelope| matches!(envelope.event, agent_protocol::Event::UserMessage { .. })) - .count(); - assert_eq!(user_messages, 1, "host-ехо не мусить оброблятись повторно"); -} - -/// Кадр ApprovalResponse віддаленого пристрою (підпис — за протоколом). -fn remote_approval_response( - request_id: &str, - approved: bool, - signature: Vec, - device_id: uuid::Uuid, -) -> Value { - let envelope = agent_protocol::Envelope { - seq: 0, - ts: chrono::Utc::now(), - node_hash: "demo".into(), - run_token: uuid::Uuid::nil(), - device_id: Some(device_id), - account_id: None, - event: agent_protocol::Event::ApprovalResponse { - request_id: request_id.into(), - approved, - signature, - }, - }; - json!({ "kind": "envelope", "envelope": serde_json::to_value(&envelope).unwrap() }) -} - -/// Наскрізний approvals-потік (access.md): pubkeys з relay → ApprovalRequest -/// у кімнату → підписаний ApprovalResponse віддаленого пристрою → вердикт; -/// невалідний підпис → Error у стрічку, запит живий до валідної відповіді. -#[tokio::test(flavor = "multi_thread")] -async fn signed_approval_flow_via_relay() { - let state_dir = tempfile::tempdir().unwrap(); - let state = Arc::new(AppState::new( - SessionHost::new(state_dir.path().to_path_buf()).unwrap(), - Arc::new(EchoTurnRunner), - None, - )); - let (url, mut received, outgoing) = mock_relay().await; - let _bridge = spawn_relay_bridge( - Arc::clone(&state), - RelayBridgeConfig { - url, - device_token: "host-token".into(), - root: "demo".into(), - }, - ); - // hello / subscribe / pubkeys-запит. - for _ in 0..3 { - next_frame(&mut received).await; - } - - // Relay віддає pubkey телефона-approver-а (hex Ed25519). - let phone_key = agent_protocol::SigningKey::from_bytes(&[5u8; 32]); - let phone_device = uuid::Uuid::from_u128(0xF0); - let pubkey_hex: String = phone_key - .verifying_key() - .to_bytes() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - outgoing - .send(json!({ - "kind": "pubkeys", - "root": "demo", - "pubkeys": [{ "device_id": phone_device.to_string(), "pubkey": pubkey_hex }] - })) - .await - .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - // Хост просить approval деструктивної дії. - let verdict = state - .request_approval("demo", "git push origin main".into(), Some("+1 -1".into())) - .unwrap(); - let request_frame = next_frame(&mut received).await; - assert_eq!( - request_frame["envelope"]["event"]["type"], - "ApprovalRequest" - ); - let request_id = request_frame["envelope"]["event"]["request_id"] - .as_str() - .unwrap() - .to_string(); - let run_token: uuid::Uuid = request_frame["envelope"]["run_token"] - .as_str() - .unwrap() - .parse() - .unwrap(); - - // Спершу — зіпсований підпис: Error у стрічці, вердикту немає. - let payload = agent_protocol::ApprovalPayload { - request_id: request_id.clone(), - approved: true, - node_hash: "demo".into(), - run_token, - }; - let mut corrupted = agent_protocol::sign_approval(&phone_key, &payload) - .to_bytes() - .to_vec(); - corrupted[7] ^= 0xFF; - outgoing - .send(remote_approval_response( - &request_id, - true, - corrupted, - phone_device, - )) - .await - .unwrap(); - let error_frame = next_frame(&mut received).await; - assert_eq!(error_frame["envelope"]["event"]["type"], "Error"); - - // Валідний підпис завершує запит. - let signature = agent_protocol::sign_approval(&phone_key, &payload) - .to_bytes() - .to_vec(); - outgoing - .send(remote_approval_response( - &request_id, - true, - signature, - phone_device, - )) - .await - .unwrap(); - let echoed = next_frame(&mut received).await; - assert_eq!( - echoed["envelope"]["event"]["type"], "ApprovalResponse", - "верифікований вердикт журналюється в сесію" - ); - assert_eq!(verdict.await, Ok(true)); -} diff --git a/crates/agent-server/tests/ws_integration.rs b/crates/agent-server/tests/ws_integration.rs deleted file mode 100644 index cf80fa4..0000000 --- a/crates/agent-server/tests/ws_integration.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! Інтеграційні тести WS-транспорту: реальний сервер на ефемерному порту, -//! tungstenite-клієнт, хід через скриптований runner (офлайн). - -use std::sync::Arc; - -use agent_protocol::{ClientHello, Envelope, Event, ServerHello, PROTOCOL_VERSION}; -use agent_server::{serve, AppState, ScriptedTurnRunner, SessionHost}; -use chrono::Utc; -use futures::{SinkExt, StreamExt}; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -type WsStream = - tokio_tungstenite::WebSocketStream>; - -/// Сервер зі скриптованим runner-ом: на кожен хід — наступний текст. -async fn start_server(dir: &tempfile::TempDir, responses: Vec<&str>) -> String { - let runner = ScriptedTurnRunner::new(responses); - let state = Arc::new(AppState::new( - SessionHost::new(dir.path().to_path_buf()).unwrap(), - Arc::new(runner), - Some("test-token".into()), - )); - let (addr, _handle) = serve(state, "127.0.0.1:0".parse().unwrap()).await.unwrap(); - format!("ws://{addr}/ws") -} - -fn hello(version: u32, replay_from: Option) -> ClientHello { - ClientHello { - protocol_version: version, - device_id: Uuid::from_u128(7), - device_token: "test-token".into(), - client_kind: "cli".into(), - client_capabilities: vec!["approvals".into()], - lang: "uk".into(), - want_replay_from: replay_from, - } -} - -async fn connect(url: &str, hello_frame: &ClientHello) -> WsStream { - let (mut stream, _) = tokio_tungstenite::connect_async(url).await.unwrap(); - stream - .send(Message::text(serde_json::to_string(hello_frame).unwrap())) - .await - .unwrap(); - stream -} - -async fn next_json(stream: &mut WsStream) -> T { - loop { - let message = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) - .await - .expect("timeout очікування кадру") - .expect("стрім закрито") - .unwrap(); - if let Message::Text(text) = message { - return serde_json::from_str(text.as_str()).unwrap(); - } - } -} - -fn user_message(node: &str, text: &str) -> Message { - let envelope = Envelope { - seq: 0, - ts: Utc::now(), - node_hash: node.into(), - run_token: Uuid::from_u128(1), - device_id: None, - account_id: None, - event: Event::UserMessage { - text: text.into(), - attachments: vec![], - surface: None, - }, - }; - Message::text(serde_json::to_string(&envelope).unwrap()) -} - -/// Повний хід: хендшейк → UserMessage → стрічка подій ходу з -/// монотонними seq, які призначає хост. -#[tokio::test] -async fn handshake_turn_and_event_stream() { - let dir = tempfile::tempdir().unwrap(); - let url = start_server(&dir, vec!["відповідь агента"]).await; - let mut stream = connect(&url, &hello(PROTOCOL_VERSION, None)).await; - - let server_hello: ServerHello = next_json(&mut stream).await; - assert_eq!(server_hello.protocol_version, PROTOCOL_VERSION); - - stream.send(user_message("demo", "питання")).await.unwrap(); - - let user: Envelope = next_json(&mut stream).await; - let delta: Envelope = next_json(&mut stream).await; - let done: Envelope = next_json(&mut stream).await; - - assert_eq!( - user.event, - Event::UserMessage { - text: "питання".into(), - attachments: vec![], - surface: None - } - ); - assert_eq!( - user.device_id, - Some(Uuid::from_u128(7)), - "адресація від ClientHello" - ); - assert_eq!( - delta.event, - Event::AgentTextDelta { - text: "відповідь агента".into() - } - ); - assert_eq!(done.event, Event::AgentTextDone {}); - assert_eq!( - (user.seq, delta.seq, done.seq), - (0, 1, 2), - "seq призначає хост, монотонно" - ); -} - -/// Несумісна версія протоколу → Error із підказкою, стрім закривається. -#[tokio::test] -async fn incompatible_version_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - let url = start_server(&dir, vec![]).await; - let mut stream = connect(&url, &hello(3, None)).await; - - let error: Event = next_json(&mut stream).await; - assert!( - matches!(error, Event::Error { ref message } if message.contains("v3") && message.contains("v4")), - "{error:?}" - ); - let next = stream.next().await; - assert!( - matches!(next, None | Some(Ok(Message::Close(_)))), - "після відмови зʼєднання закрито, отримано: {next:?}" - ); -} - -/// Невірний device token → відмова на хендшейку. -#[tokio::test] -async fn invalid_token_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - let url = start_server(&dir, vec![]).await; - let mut bad = hello(PROTOCOL_VERSION, None); - bad.device_token = "чужий".into(); - let mut stream = connect(&url, &bad).await; - - let error: Event = next_json(&mut stream).await; - assert!(matches!(error, Event::Error { ref message } if message.contains("token"))); -} - -/// Реконект із want_replay_from: журнальовані події доїжджають повторно -/// (ефемерні дельти — ні), розмова продовжується тим самим run-ом. -#[tokio::test] -async fn reconnect_replays_journaled_events() { - let dir = tempfile::tempdir().unwrap(); - let url = start_server(&dir, vec!["перша", "друга"]).await; - - let mut first = connect(&url, &hello(PROTOCOL_VERSION, None)).await; - let _: ServerHello = next_json(&mut first).await; - first.send(user_message("demo", "раз")).await.unwrap(); - let _user: Envelope = next_json(&mut first).await; - let _delta: Envelope = next_json(&mut first).await; - let done: Envelope = next_json(&mut first).await; - drop(first); // «закрив ноутбук» - - let mut second = connect(&url, &hello(PROTOCOL_VERSION, Some(0))).await; - let server_hello: ServerHello = next_json(&mut second).await; - assert_eq!(server_hello.session_list.len(), 1); - assert_eq!(server_hello.session_list[0].node_hash, "demo"); - - // Реплей: UserMessage + AgentTextDone (дельта ефемерна — не журналиться). - let replay_user: Envelope = next_json(&mut second).await; - let replay_done: Envelope = next_json(&mut second).await; - assert!(matches!(replay_user.event, Event::UserMessage { .. })); - assert_eq!(replay_done.event, Event::AgentTextDone {}); - assert_eq!(replay_done.seq, done.seq, "той самий журнал, ті самі seq"); - - // Розмова продовжується після відновлення. - second.send(user_message("demo", "два")).await.unwrap(); - let user: Envelope = next_json(&mut second).await; - assert_eq!(user.seq, done.seq + 1, "seq продовжується без розривів"); - assert_eq!(user.run_token, replay_done.run_token, "той самий run"); -} diff --git a/crates/mt-cli/Cargo.toml b/crates/mt-cli/Cargo.toml deleted file mode 100644 index 52ea8b1..0000000 --- a/crates/mt-cli/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -# Транзиційний CLI-бінарник mt-scanner (JSON-pipe). Буде видалений, щойно -# @7n/mt повністю перейде на napi-аддон (crates/mt-napi). -[package] -name = "mt-cli" -description = "Transitional mt-scanner CLI binary over mt-core (JSON pipe)" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[[bin]] -name = "mt-scanner" -path = "src/main.rs" - -[dependencies] -mt-core = { path = "../mt-core" } -serde_json.workspace = true diff --git a/crates/mt-cli/src/docs/index.md b/crates/mt-cli/src/docs/index.md deleted file mode 100644 index 067762e..0000000 --- a/crates/mt-cli/src/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: crates/mt-cli/src -resource: crates/mt-cli/src/ ---- - -| Файл | Тип | -| ------------------ | ----------- | -| [main.rs](main.md) | Rust Module | diff --git a/crates/mt-cli/src/docs/main.md b/crates/mt-cli/src/docs/main.md deleted file mode 100644 index 7333b8e..0000000 --- a/crates/mt-cli/src/docs/main.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -type: Rust Module -title: main.rs -resource: crates/mt-cli/src/main.rs -docgen: - crc: b93cd9a2 - model: omlx/gemma-4-e2b-it-4bit - score: 90 ---- - -## Огляд - -Огляд: Цей файл відповідає за виконання команд, пов'язаних зі скануванням, створенням та роботою з завданнями - -## Поведінка - -Поведінка - -1. Приймати аргументи командного рядка та перевіряти їх кількість для визначення команди. -2. Перевіряти, чи надано достатньо аргументів для виконання необхідної операції. -3. Для команди `scan` перевіряти, чи вказано необхідну кількість аргументів, ініціювати сканування завдань з можливістю перевизначення пошуку через аргумент `--worktrees` або автоматичного пошуку через `mt_core::discover_worktrees`. -4. Для команди `create` перевіряти, чи надано достатньо аргументів для створення нової ноди завдання, включаючи ім'я та опції. -5. Для команди `create` парсити опції, такі як `--mode`, `--model-tier`, `--budget_sec`, `--hint` та `--dep`, перевіряючи коректність переданих значень. -6. Для команди `create` перевіряти, чи коректно визначено модальність `--mode` як `agent` або `human`. -7. Для команди `create` перевіряти, чи коректно парсити значення для `--model-tier`, `--budget_sec`, `--hint` та `--dep`. -8. Для команди `scan` перевіряти, чи коректно визначено шляхи до завдань і викликати функцію сканування. -9. Для команди `scan` перевіряти, чи повертає сканування результат у форматі JSON, і виводити його у консоль. -10. Для команди `create` перевіряти, чи коректно викликати функцію створення завдання, і виводити результат у форматі JSON. -11. Для команди `workspaces` перевіряти, чи надано шляхи до робочих просторів, і збирати результати сканування з усіх знайдених директорій. -12. Для команди `workspaces` перевірити, чи якщо не надано директорію, використовується функція `mt_core::find_all_tasks_dirs` для пошуку, і обробляти можливі помилки з цього процесу. -13. При виявленні помилок під час виконання команд, логувати помилку та завершувати виконання з кодом виходу 2. -14. При виявленні помилок парсингу опцій для `create`, негайно виводити помилку та завершувати виконання з кодом виходу 2. -15. При виявленні помилок у парсингу аргументів `--mode` для `create`, негайно виводити помилку та завершувати виконання з кодом виходу 2. -16. При виявленні помилок парсингу `--budget_sec` для `create`, негайно виводити помилку та завершувати виконання з кодом виходу 2. -17. При виявленні помилок у парсингу аргументів для `--dep` для `create`, коректно додавати залежності до списку. -18. Команда `usage` виводити довідку про використання команд. -19. Усі помилки, що виникають у функціях, мають бути перехоплені, щоб уникнути падіння програми. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-cli/src/main.rs b/crates/mt-cli/src/main.rs deleted file mode 100644 index bc8eb0f..0000000 --- a/crates/mt-cli/src/main.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::path::PathBuf; -use std::process; - -fn usage() -> ! { - eprintln!("Usage:"); - eprintln!(" mt-scanner scan [--worktrees a,b,c] — scan tasks, output JSON array"); - eprintln!(" --worktrees: comma-list of active worktree names (overrides git discovery)"); - eprintln!(" mt-scanner workspaces [] — discover workspaces, output JSON array"); - eprintln!( - " mt-scanner create [flags] — create a task node, output JSON" - ); - eprintln!(" [--mode agent|human] [--model-tier MIN|AVG|MAX] [--budget-sec N] [--hint ] [--dep ]..."); - process::exit(1); -} - -/// Parses `create` flags after ` `. Unknown flags are ignored. -fn parse_create_opts(args: &[String]) -> mt_core::CreateOpts { - let mut opts = mt_core::CreateOpts::default(); - let mut i = 0; - while i < args.len() { - match args[i].as_str() { - "--mode" => { - opts.mode = match args.get(i + 1).map(String::as_str) { - Some("agent") => Some(mt_core::Mode::Agent), - Some("human") => Some(mt_core::Mode::Human), - _ => { - eprintln!("Error: --mode must be agent|human"); - process::exit(2); - } - }; - i += 1; - } - "--model-tier" => { - opts.model_tier = args.get(i + 1).cloned(); - i += 1; - } - "--budget-sec" => { - opts.budget_sec = args.get(i + 1).and_then(|s| s.parse().ok()); - i += 1; - } - "--hint" => { - opts.hint = args.get(i + 1).cloned(); - i += 1; - } - "--dep" => { - if let Some(v) = args.get(i + 1) { - opts.deps.push(v.clone()); - } - i += 1; - } - _ => {} - } - i += 1; - } - opts -} - -/// Parses an optional `--worktrees a,b,c` flag. Returns None if the flag is absent, -/// Some(vec) (possibly empty) when present. -fn parse_worktrees_arg(args: &[String]) -> Option> { - let pos = args.iter().position(|a| a == "--worktrees")?; - let raw = args.get(pos + 1).map(String::as_str).unwrap_or(""); - Some( - raw.split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(), - ) -} - -fn main() { - let args: Vec = std::env::args().collect(); - if args.len() < 2 { - usage(); - } - - match args[1].as_str() { - "scan" => { - if args.len() < 3 { - usage(); - } - let tasks_dir = args[2].clone(); - // --worktrees overrides discovery; otherwise discover via git from tasks_dir. - let worktrees = parse_worktrees_arg(&args) - .unwrap_or_else(|| mt_core::discover_worktrees(&PathBuf::from(&tasks_dir))); - match mt_core::scan_tasks(tasks_dir, worktrees) { - Ok(nodes) => println!("{}", serde_json::to_string_pretty(&nodes).unwrap()), - Err(e) => { - eprintln!("Error: {e}"); - process::exit(2); - } - } - } - "create" => { - if args.len() < 4 { - usage(); - } - let tasks_dir = args[2].clone(); - let name = args[3].clone(); - let opts = parse_create_opts(&args[4..]); - match mt_core::create_task(tasks_dir, name, opts) { - Ok(outcome) => println!( - "{}", - serde_json::to_string_pretty(&outcome.to_cli_json()).unwrap() - ), - Err(e) => { - eprintln!("Error: {e}"); - process::exit(2); - } - } - } - "workspaces" => { - // Accept multiple roots: `workspaces ` scans each and merges. - // No dir → discover from cwd (back-compat). - let workspaces = if args.len() >= 3 { - args[2..] - .iter() - .flat_map(|d| mt_core::find_all_tasks_dirs_from(&PathBuf::from(d))) - .collect() - } else { - match mt_core::find_all_tasks_dirs() { - Ok(ws) => ws, - Err(e) => { - eprintln!("Error: {e}"); - process::exit(2); - } - } - }; - println!("{}", serde_json::to_string_pretty(&workspaces).unwrap()); - } - _ => usage(), - } -} diff --git a/crates/mt-core/Cargo.toml b/crates/mt-core/Cargo.toml deleted file mode 100644 index 322ff60..0000000 --- a/crates/mt-core/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "mt-core" -description = "Core library for @7n/mt task graphs: scan, create, derived states" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -name = "mt_core" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -chrono.workspace = true -sha2.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/mt-core/src/artifacts.rs b/crates/mt-core/src/artifacts.rs deleted file mode 100644 index 90c3584..0000000 --- a/crates/mt-core/src/artifacts.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Version-chain артефакти вузла (§4 файловий контракт mt.md). -//! -//! Read-модель для GUI-timeline і CLI: перелік файлів `task/plan/run/fact/…` -//! з ключовими полями frontmatter, у детермінованому порядку chain: -//! `task.md` → NNN-групи (plan → run → fact → аудит-цикл) → термінальні маркери. - -use std::fs; -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::frontmatter::parse_front_matter; -use crate::validate_name; - -/// Тип артефакта у директорії вузла. Serde-імена збігаються з файловими -/// префіксами (kebab-case). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ArtifactKind { - Task, - Plan, - PlanApproved, - PlanRejected, - Run, - Fact, - PendingAudit, - AuditResult, - Clarification, - Amended, - Unresolvable, - RunSummary, -} - -/// Один артефакт вузла з витягом frontmatter-полів, потрібних для timeline. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeArtifact { - pub file: String, - pub kind: ArtifactKind, - /// NNN version chain; немає у `task`/`unresolvable`/`run-summary`. - pub nnn: Option, - pub created_at: Option, - pub actor: Option, - /// run: success|failed|progress-timeout|…; audit-result: success|failed. - pub result: Option, - /// plan: atomic|composite. - pub decision: Option, - pub wall_sec: Option, - pub cost_usd: Option, - pub tokens_in: Option, - pub tokens_out: Option, -} - -/// `(prefix, kind, rank)` для файлів форми `.md`; rank — -/// порядок усередині однієї NNN-групи (план → виконання → аудит-цикл). -const NNN_KINDS: [(&str, ArtifactKind, u8); 9] = [ - ("plan_", ArtifactKind::Plan, 1), - ("plan-rejected_", ArtifactKind::PlanRejected, 2), - ("plan-approved_", ArtifactKind::PlanApproved, 3), - ("run_", ArtifactKind::Run, 4), - ("fact_", ArtifactKind::Fact, 5), - ("pending-audit_", ArtifactKind::PendingAudit, 6), - ("clarification_", ArtifactKind::Clarification, 7), - ("amended_", ArtifactKind::Amended, 8), - ("audit-result_", ArtifactKind::AuditResult, 9), -]; - -/// Класифікує ім'я файлу як артефакт вузла: `(kind, nnn, rank)`. -/// `None` — не артефакт (прапори `a.md`/`h.md`, чернетки, довільні файли). -fn classify(file: &str) -> Option<(ArtifactKind, Option, u8)> { - match file { - "task.md" => return Some((ArtifactKind::Task, None, 0)), - "unresolvable.md" => return Some((ArtifactKind::Unresolvable, None, 10)), - "run-summary.md" => return Some((ArtifactKind::RunSummary, None, 11)), - _ => {} - } - for (prefix, kind, rank) in NNN_KINDS { - let Some(rest) = file.strip_prefix(prefix) else { - continue; - }; - let Some(digits) = rest.strip_suffix(".md") else { - continue; - }; - if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) { - return Some((kind, digits.parse().ok(), rank)); - } - } - None -} - -fn fm_str(fm: &Value, key: &str) -> Option { - fm.get(key).and_then(Value::as_str).map(String::from) -} - -/// Перелік артефактів вузла `node_path` (відносно `tasks_dir`), відсортований -/// у порядку chain: `task.md` → за NNN (у групі — за rank) → термінальні. -pub fn list_node_artifacts(tasks_dir: &str, node_path: &str) -> Result, String> { - validate_name(node_path)?; - let dir = Path::new(tasks_dir).join(node_path); - let entries = fs::read_dir(&dir).map_err(|e| format!("read_dir {}: {e}", dir.display()))?; - - let mut out: Vec<(u64, u8, NodeArtifact)> = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| e.to_string())?; - if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let Some((kind, nnn, rank)) = classify(&name) else { - continue; - }; - let fm = fs::read_to_string(entry.path()) - .map(|c| parse_front_matter(&c)) - .unwrap_or(Value::Null); - // Групувальний ключ: task — перед chain, термінальні маркери — після. - let group = match kind { - ArtifactKind::Task => 0, - ArtifactKind::Unresolvable | ArtifactKind::RunSummary => u64::MAX, - _ => nnn.unwrap_or(0), - }; - out.push(( - group, - rank, - NodeArtifact { - file: name, - kind, - nnn, - created_at: fm_str(&fm, "created_at"), - actor: fm_str(&fm, "actor"), - result: fm_str(&fm, "result"), - decision: fm_str(&fm, "decision"), - wall_sec: fm.get("wall_sec").and_then(Value::as_u64), - cost_usd: fm.get("cost_usd").and_then(Value::as_f64), - tokens_in: fm.get("tokens_in").and_then(Value::as_u64), - tokens_out: fm.get("tokens_out").and_then(Value::as_u64), - }, - )); - } - out.sort_by(|a, b| (a.0, a.1, &a.2.file).cmp(&(b.0, b.1, &b.2.file))); - Ok(out.into_iter().map(|(_, _, a)| a).collect()) -} - -/// Безпечне читання одного артефакта вузла. `file` мусить класифікуватись як -/// артефакт — allowlist разом із [`validate_name`] гарантує, що шлях не -/// виходить за межі директорії вузла. -pub fn read_node_artifact(tasks_dir: &str, node_path: &str, file: &str) -> Result { - validate_name(node_path)?; - if classify(file).is_none() { - return Err(format!("not a node artifact: {file:?}")); - } - let path = Path::new(tasks_dir).join(node_path).join(file); - fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display())) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn write(dir: &Path, name: &str, content: &str) { - fs::write(dir.join(name), content).unwrap(); - } - - fn fixture() -> tempfile::TempDir { - let tmp = tempfile::tempdir().unwrap(); - let node = tmp.path().join("analyze"); - fs::create_dir_all(node.join("deps")).unwrap(); - write( - &node, - "task.md", - "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\n---\n\n## Task\n", - ); - write(&node, "a.md", "schema_version: 1\n"); - write( - &node, - "plan_001.md", - "---\nschema_version: 1\ndecision: atomic\n---\n", - ); - write( - &node, - "run_001.md", - "---\nschema_version: 1\nactor: agent\nresult: failed\nwall_sec: 120\ncost_usd: 0.15\n---\n", - ); - write( - &node, - "run_002.md", - "---\nschema_version: 1\nactor: agent\nresult: success\nwall_sec: 300\n---\n", - ); - write( - &node, - "fact_002.md", - "---\nschema_version: 1\n---\n\n## Summary\n", - ); - write( - &node, - "pending-audit_002.md", - "---\nschema_version: 1\nactor: agent\n---\n", - ); - write( - &node, - "audit-result_002.md", - "---\nschema_version: 1\nactor: auditor\nresult: success\n---\n", - ); - write(&node, "run-draft.md", "## Completed\n"); - tmp - } - - #[test] - fn lists_chain_in_order_and_parses_frontmatter() { - let tmp = fixture(); - let arts = list_node_artifacts(&tmp.path().to_string_lossy(), "analyze").unwrap(); - let files: Vec<&str> = arts.iter().map(|a| a.file.as_str()).collect(); - assert_eq!( - files, - [ - "task.md", - "plan_001.md", - "run_001.md", - "run_002.md", - "fact_002.md", - "pending-audit_002.md", - "audit-result_002.md", - ] - ); - let run1 = &arts[2]; - assert_eq!(run1.kind, ArtifactKind::Run); - assert_eq!(run1.nnn, Some(1)); - assert_eq!(run1.result.as_deref(), Some("failed")); - assert_eq!(run1.wall_sec, Some(120)); - assert_eq!(run1.cost_usd, Some(0.15)); - let audit = arts.last().unwrap(); - assert_eq!(audit.kind, ArtifactKind::AuditResult); - assert_eq!(audit.actor.as_deref(), Some("auditor")); - } - - #[test] - fn skips_flags_drafts_and_dirs() { - let tmp = fixture(); - let arts = list_node_artifacts(&tmp.path().to_string_lossy(), "analyze").unwrap(); - assert!(arts - .iter() - .all(|a| a.file != "a.md" && a.file != "run-draft.md")); - } - - #[test] - fn read_artifact_allows_only_classified_files() { - let tmp = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - assert!(read_node_artifact(&root, "analyze", "task.md").is_ok()); - assert!(read_node_artifact(&root, "analyze", "a.md").is_err()); - assert!(read_node_artifact(&root, "analyze", "../analyze/task.md").is_err()); - assert!(read_node_artifact(&root, "../x", "task.md").is_err()); - } - - #[test] - fn terminal_markers_sort_last() { - let tmp = fixture(); - let node = tmp.path().join("analyze"); - write(&node, "unresolvable.md", "---\nschema_version: 1\n---\n"); - write( - &node, - "run-summary.md", - "---\nschema_version: 1\nactor: wrapper\n---\n", - ); - let arts = list_node_artifacts(&tmp.path().to_string_lossy(), "analyze").unwrap(); - let tail: Vec<&str> = arts.iter().rev().take(2).map(|a| a.file.as_str()).collect(); - assert_eq!(tail, ["run-summary.md", "unresolvable.md"]); - } -} diff --git a/crates/mt-core/src/claims.rs b/crates/mt-core/src/claims.rs deleted file mode 100644 index 7380dcc..0000000 --- a/crates/mt-core/src/claims.rs +++ /dev/null @@ -1,580 +0,0 @@ -//! Remote execution claims (спека mt.md, «Authoritative execution claim»). -//! -//! Ownership вузла живе у GitHub custom refs `refs/mt/claims/`; -//! claim ref вказує на commit із `.mt-claim.yml`. Модуль дає read-модель: -//! node-hash, читання remote claims через git CLI і зіставлення з вузлами. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -use crate::frontmatter::parse_yaml; - -/// Префікс claim refs (дефолт `.mt.json` → `claim_ref_prefix`). -pub const CLAIM_REF_PREFIX: &str = "refs/mt/claims"; - -/// `node-hash` = перші 20 hex символів SHA-256 від `\0`. -/// `tasks_root` — канонічний шлях tasks-директорії відносно git root (напр. -/// `mt` або `packages/api/mt`), `node_path` — вузол відносно tasks root. -pub fn node_hash(tasks_root: &str, node_path: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(tasks_root.as_bytes()); - hasher.update([0u8]); - hasher.update(node_path.as_bytes()); - let digest = hasher.finalize(); - let mut hex = String::with_capacity(20); - for byte in digest.iter() { - if hex.len() >= 20 { - break; - } - hex.push_str(&format!("{byte:02x}")); - } - hex.truncate(20); - hex -} - -/// Префікс run refs (спека: `refs/mt/runs//`). -pub const RUN_REF_PREFIX: &str = "refs/mt/runs"; - -/// Git top-level, що містить `tasks_dir` (`git rev-parse --show-toplevel`). -pub fn discover_repo_root(tasks_dir: &Path) -> Result { - let out = git(tasks_dir, &["rev-parse", "--show-toplevel"])?; - Ok(PathBuf::from(out.trim())) -} - -/// Канонічний шлях `tasks_dir` відносно `repo_root`, POSIX-нормалізований -/// (`\` → `/`) — вхід для [`node_hash`] (спека: `\0`). -pub fn tasks_root_relative(repo_root: &Path, tasks_dir: &Path) -> Result { - let repo_root = repo_root - .canonicalize() - .map_err(|e| format!("repo root {}: {e}", repo_root.display()))?; - let tasks_dir = tasks_dir - .canonicalize() - .map_err(|e| format!("tasks dir {}: {e}", tasks_dir.display()))?; - let rel = tasks_dir - .strip_prefix(&repo_root) - .map_err(|_| "tasks dir escapes its git repository".to_string())?; - Ok(rel.to_string_lossy().replace('\\', "/")) -} - -/// Один запис `git ls-remote origin 'refs/mt/claims/*'`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RemoteClaimRef { - pub node_hash: String, - pub sha: String, -} - -/// Парсить вивід `git ls-remote` (рядки `\t`), лишаючи claim refs. -pub fn parse_ls_remote(output: &str, prefix: &str) -> Vec { - let mut refs = Vec::new(); - for line in output.lines() { - let Some((sha, name)) = line.split_once('\t') else { - continue; - }; - let Some(hash) = name.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) else { - continue; - }; - if !sha.is_empty() && !hash.is_empty() && !hash.contains('/') { - refs.push(RemoteClaimRef { - node_hash: hash.to_string(), - sha: sha.to_string(), - }); - } - } - refs -} - -/// Розібраний `.mt-claim.yml` claim-коміта. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ClaimInfo { - pub node_hash: String, - /// `node:` з claim-файлу (шлях відносно tasks root; інформативний). - pub node: Option, - pub actor: Option, - pub runner_id: Option, - pub lease_until: Option, - /// Lease прострочений (з урахуванням grace) → derived-стан `stalled`. - pub expired: bool, - /// Інтерактивна сесія тримає claim; відсутнє поле (старі claim-и 0.2.x) - /// → `false` (ADR 260711-2100). - pub interactive: bool, -} - -fn yaml_str(v: &Value, key: &str) -> Option { - v.get(key).and_then(Value::as_str).map(String::from) -} - -/// Чи прострочений lease: `lease_until + grace_sec ≤ now`. Непарсибельний -/// або відсутній `lease_until` вважаємо простроченим (консервативно). -pub fn lease_expired(lease_until: Option<&str>, grace_sec: i64, now: DateTime) -> bool { - let Some(until) = lease_until.and_then(|s| DateTime::parse_from_rfc3339(s).ok()) else { - return true; - }; - until.with_timezone(&Utc) + chrono::Duration::seconds(grace_sec) <= now -} - -/// Будує [`ClaimInfo`] з YAML-вмісту `.mt-claim.yml`. -pub fn parse_claim(node_hash: &str, yaml: &str, grace_sec: i64, now: DateTime) -> ClaimInfo { - let v = parse_yaml(yaml); - let lease_until = yaml_str(&v, "lease_until"); - ClaimInfo { - node_hash: node_hash.to_string(), - node: yaml_str(&v, "node"), - actor: yaml_str(&v, "actor"), - runner_id: yaml_str(&v, "runner_id"), - expired: lease_expired(lease_until.as_deref(), grace_sec, now), - lease_until, - interactive: v - .get("interactive") - .and_then(Value::as_bool) - .unwrap_or(false), - } -} - -fn git(repo: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).into_owned()) -} - -/// Поля `.mt-claim.yml`, які runner контролює при acquire/renew/takeover. -pub struct ClaimFields<'a> { - pub node: &'a str, - pub actor: &'a str, - pub runner_id: &'a str, - pub claimed_at: &'a str, - pub lease_until: &'a str, - pub token: &'a str, - pub generation: u64, - /// SHA `origin/main` на момент першого claim — фіксується назавжди, - /// незалежно від наступних renewal/takeover (parent першого коміту). - pub base_sha: &'a str, - pub run_ref: &'a str, - /// Інтерактивна сесія (attach) замість автономного run-а (0.3.0, - /// git.md «Claim»: `token = session_id`, коротший lease; політики - /// watchdog/бюджетів мʼякші). ADR 260711-2100. - pub interactive: bool, -} - -fn claim_yaml(f: &ClaimFields) -> String { - format!( - "schema_version: 1\nnode: {}\nactor: {}\nrunner_id: {}\nclaimed_at: {}\n\ - lease_until: {}\ntoken: {}\ngeneration: {}\nbase_sha: {}\nrun_ref: {}\ninteractive: {}\n", - f.node, - f.actor, - f.runner_id, - f.claimed_at, - f.lease_until, - f.token, - f.generation, - f.base_sha, - f.run_ref, - f.interactive - ) -} - -/// Як [`git`], але пише `stdin` у дочірній процес (для `hash-object`/`mktree`). -fn git_stdin(repo: &Path, args: &[&str], stdin: &str) -> Result { - use std::io::Write; - use std::process::Stdio; - - let mut child = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - child - .stdin - .take() - .expect("stdin piped") - .write_all(stdin.as_bytes()) - .map_err(|e| format!("git {}: write stdin: {e}", args.join(" ")))?; - let out = child - .wait_with_output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -/// Пише claim-коміт (blob → tree → commit-tree) без checkout/індексу — -/// придатне для headless runner без робочого дерева проєкту. `parent` — -/// `base_sha` для першого claim, попередній claim-коміт для renew/takeover -/// (спека: «Новий claim commit має parent = попередній claim commit»). -fn create_claim_commit(repo: &Path, parent: &str, fields: &ClaimFields) -> Result { - let blob_sha = git_stdin(repo, &["hash-object", "-w", "--stdin"], &claim_yaml(fields))?; - let tree_entry = format!("100644 blob {blob_sha}\t.mt-claim.yml\n"); - let tree_sha = git_stdin(repo, &["mktree"], &tree_entry)?; - let message = format!("mt: claim {}", fields.node); - let commit_sha = git( - repo, - &["commit-tree", &tree_sha, "-p", parent, "-m", &message], - )?; - Ok(commit_sha.trim().to_string()) -} - -/// Підсумок CAS-push claim ref. `accepted: false` — інший runner виграв -/// гонку (нормальний результат гонки, не помилка транспорту/мережі). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClaimPush { - pub accepted: bool, - pub commit_sha: String, -} - -/// Розрізняє "інший runner виграв гонку" (force-with-lease rejection) від -/// системної помилки (мережа/автентифікація) за текстом stderr git push. -fn is_lease_rejection(stderr: &str) -> bool { - stderr.contains("stale info") - || stderr.contains("[rejected]") - || stderr.contains("already exists") - || stderr.contains("fetch first") -} - -fn push_claim_ref( - repo: &Path, - node_hash: &str, - new_sha: &str, - expected: Option<&str>, -) -> Result { - let refname = format!("{CLAIM_REF_PREFIX}/{node_hash}"); - let lease = format!("--force-with-lease={refname}:{}", expected.unwrap_or("")); - let refspec = format!("{new_sha}:{refname}"); - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(["push", &lease, "origin", &refspec]) - .output() - .map_err(|e| format!("git push claim: {e}"))?; - if out.status.success() { - return Ok(true); - } - let stderr = String::from_utf8_lossy(&out.stderr); - if is_lease_rejection(&stderr) { - return Ok(false); - } - Err(format!("git push claim: {}", stderr.trim())) -} - -/// Create-only CAS (спека, крок 3): приймається лише якщо `refs/mt/claims/` -/// на remote ще не існує. Лише accepted push дає право створити worktree. -pub fn acquire_claim( - repo: &Path, - node_hash: &str, - fields: &ClaimFields, -) -> Result { - let commit_sha = create_claim_commit(repo, fields.base_sha, fields)?; - let accepted = push_claim_ref(repo, node_hash, &commit_sha, None)?; - Ok(ClaimPush { - accepted, - commit_sha, - }) -} - -/// Renewal/takeover (спека, крок 5): CAS лише з exact `old_claim_sha`. Той -/// самий виклик покриває і renewal (той самий `token`/`generation` у `fields`), -/// і takeover (новий `token`, `generation + 1`) — розрізняє лише вміст `fields`. -pub fn renew_or_takeover_claim( - repo: &Path, - node_hash: &str, - old_claim_sha: &str, - fields: &ClaimFields, -) -> Result { - let commit_sha = create_claim_commit(repo, old_claim_sha, fields)?; - let accepted = push_claim_ref(repo, node_hash, &commit_sha, Some(old_claim_sha))?; - Ok(ClaimPush { - accepted, - commit_sha, - }) -} - -/// CAS-видалення claim ref після fenced publish — лише якщо runner досі -/// власник exact `claim_sha`. `accepted: false` не є помилкою: означає, що -/// claim вже загублено (takeover), publish цього runner-а мав бути fenced. -pub fn release_claim(repo: &Path, node_hash: &str, claim_sha: &str) -> Result { - let refname = format!("{CLAIM_REF_PREFIX}/{node_hash}"); - let lease = format!("--force-with-lease={refname}:{claim_sha}"); - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(["push", &lease, "origin", &format!(":{refname}")]) - .output() - .map_err(|e| format!("git push --delete claim: {e}"))?; - if out.status.success() { - return Ok(true); - } - let stderr = String::from_utf8_lossy(&out.stderr); - if is_lease_rejection(&stderr) { - return Ok(false); - } - Err(format!("git push --delete claim: {}", stderr.trim())) -} - -/// Читає remote claims: `ls-remote` → fetch claim refs → `.mt-claim.yml` з -/// кожного claim-коміта. `grace_sec` — буфер перед takeover (`claim_grace_sec`). -pub fn fetch_remote_claims(repo_root: &Path, grace_sec: i64) -> Result, String> { - let ls = git( - repo_root, - &["ls-remote", "origin", &format!("{CLAIM_REF_PREFIX}/*")], - )?; - let refs = parse_ls_remote(&ls, CLAIM_REF_PREFIX); - if refs.is_empty() { - return Ok(Vec::new()); - } - // Custom refs не покриваються стандартним refspec — тягнемо явно (спека). - git( - repo_root, - &[ - "fetch", - "--quiet", - "origin", - &format!("+{CLAIM_REF_PREFIX}/*:{CLAIM_REF_PREFIX}/*"), - ], - )?; - let now = Utc::now(); - let mut claims = Vec::new(); - for r in refs { - let yaml = git(repo_root, &["show", &format!("{}:.mt-claim.yml", r.sha)])?; - claims.push(parse_claim(&r.node_hash, &yaml, grace_sec, now)); - } - Ok(claims) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{output, TestRepo}; - use chrono::TimeZone; - - fn fields<'a>(node: &'a str, token: &'a str, base_sha: &'a str) -> ClaimFields<'a> { - ClaimFields { - node, - actor: "agent", - runner_id: "test-runner/1", - claimed_at: "2026-06-09T10:00:00Z", - lease_until: "2030-01-01T00:00:00Z", - token, - generation: 1, - base_sha, - run_ref: "refs/mt/runs/deadbeef/tok", - interactive: false, - } - } - - /// `interactive:` пишеться у claim YAML і читається назад; відсутність - /// поля (старі claim-и 0.2.x) → false (ADR 260711-2100). - #[test] - fn interactive_field_roundtrips_and_defaults_to_false() { - let now = chrono::Utc.with_ymd_and_hms(2026, 7, 11, 12, 0, 0).unwrap(); - let mut f = fields("research/analyze", "t1", "abc"); - f.interactive = true; - let yaml = claim_yaml(&f); - assert!(yaml.contains("interactive: true"), "{yaml}"); - assert!(parse_claim("h", &yaml, 0, now).interactive); - - // Старий claim без поля — консервативний false. - let legacy = "schema_version: 1\nnode: x\nlease_until: 2030-01-01T00:00:00Z\n"; - assert!(!parse_claim("h", legacy, 0, now).interactive); - } - - #[test] - fn acquire_is_create_only_second_attempt_rejected() { - let repo = TestRepo::new(); - let base = repo.main_sha(); - let hash = node_hash("mt", "research/analyze"); - - let first = acquire_claim( - repo.work.path(), - &hash, - &fields("research/analyze", "t1", &base), - ) - .unwrap(); - assert!(first.accepted); - - // Другий CAS-push з тим самим create-only lease (expect empty) — - // ref уже існує, тож приймається лише один. - let second = acquire_claim( - repo.work.path(), - &hash, - &fields("research/analyze", "t2", &base), - ) - .unwrap(); - assert!(!second.accepted); - } - - #[test] - fn renew_with_correct_sha_accepted_wrong_sha_rejected() { - let repo = TestRepo::new(); - let base = repo.main_sha(); - let hash = node_hash("mt", "research/analyze"); - let first = acquire_claim( - repo.work.path(), - &hash, - &fields("research/analyze", "t1", &base), - ) - .unwrap(); - - let renewed = renew_or_takeover_claim( - repo.work.path(), - &hash, - &first.commit_sha, - &fields("research/analyze", "t1", &base), - ) - .unwrap(); - assert!(renewed.accepted); - assert_ne!(renewed.commit_sha, first.commit_sha); - - // Ланцюг claim-комітів: parent нового = попередній claim-коміт (не main). - let parent = output( - repo.work.path(), - &["rev-parse", &format!("{}^", renewed.commit_sha)], - ); - assert_eq!(parent, first.commit_sha); - - // Застаріле знання SHA (гонка вже пройшла) → CAS відхиляє. - let stale = renew_or_takeover_claim( - repo.work.path(), - &hash, - &first.commit_sha, - &fields("research/analyze", "t3", &base), - ) - .unwrap(); - assert!(!stale.accepted); - } - - #[test] - fn release_requires_exact_sha_then_ref_gone() { - let repo = TestRepo::new(); - let base = repo.main_sha(); - let hash = node_hash("mt", "research/analyze"); - let claim = acquire_claim( - repo.work.path(), - &hash, - &fields("research/analyze", "t1", &base), - ) - .unwrap(); - - // Застарілий SHA — release відхиляється, ref лишається. - assert!(!release_claim( - repo.work.path(), - &hash, - "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - ) - .unwrap()); - let ls = git( - repo.work.path(), - &["ls-remote", "origin", &format!("{CLAIM_REF_PREFIX}/{hash}")], - ) - .unwrap(); - assert!(!ls.trim().is_empty()); - - assert!(release_claim(repo.work.path(), &hash, &claim.commit_sha).unwrap()); - let ls = git( - repo.work.path(), - &["ls-remote", "origin", &format!("{CLAIM_REF_PREFIX}/{hash}")], - ) - .unwrap(); - assert!(ls.trim().is_empty()); - } - - #[test] - fn discovers_repo_root_and_relative_tasks_dir() { - let repo = TestRepo::new(); - let tasks_dir = repo.work.path().join("mt"); - std::fs::create_dir_all(&tasks_dir).unwrap(); - - let root = discover_repo_root(&tasks_dir).unwrap(); - assert_eq!( - root.canonicalize().unwrap(), - repo.work.path().canonicalize().unwrap() - ); - assert_eq!(tasks_root_relative(&root, &tasks_dir).unwrap(), "mt"); - } - - #[test] - fn fetch_remote_claims_reads_back_what_acquire_wrote() { - let repo = TestRepo::new(); - let base = repo.main_sha(); - let hash = node_hash("mt", "research/analyze"); - acquire_claim( - repo.work.path(), - &hash, - &fields("research/analyze", "t1", &base), - ) - .unwrap(); - - let claims = fetch_remote_claims(repo.work.path(), 60).unwrap(); - assert_eq!(claims.len(), 1); - assert_eq!(claims[0].node_hash, hash); - assert_eq!(claims[0].node.as_deref(), Some("research/analyze")); - assert_eq!(claims[0].runner_id.as_deref(), Some("test-runner/1")); - assert!(!claims[0].expired); - } - - #[test] - fn node_hash_is_20_hex_and_stable() { - let h = node_hash("mt", "research/analyze"); - assert_eq!(h.len(), 20); - assert!(h.bytes().all(|b| b.is_ascii_hexdigit())); - assert_eq!(h, node_hash("mt", "research/analyze")); - assert_ne!(h, node_hash("mt", "research")); - // Роздільник \0 розрізняє межу root/path. - assert_ne!(node_hash("mt/a", "b"), node_hash("mt", "a/b")); - } - - #[test] - fn parses_ls_remote_output() { - let out = "abc123\trefs/mt/claims/deadbeefdeadbeefdead\n\ - ffff00\trefs/mt/runs/x/y\n\ - 012345\trefs/heads/main\n"; - let refs = parse_ls_remote(out, CLAIM_REF_PREFIX); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].node_hash, "deadbeefdeadbeefdead"); - assert_eq!(refs[0].sha, "abc123"); - } - - #[test] - fn claim_expiry_uses_grace() { - let now = Utc.with_ymd_and_hms(2026, 6, 9, 11, 0, 0).unwrap(); - assert!(!lease_expired(Some("2026-06-09T11:00:30Z"), 60, now)); - assert!(lease_expired(Some("2026-06-09T10:58:00Z"), 60, now)); - assert!(lease_expired(Some("not-a-date"), 60, now)); - assert!(lease_expired(None, 60, now)); - } - - #[test] - fn parses_claim_yaml() { - let yaml = "schema_version: 1\nnode: research/analyze\nactor: agent\n\ - runner_id: server-1/4821\nclaimed_at: 2026-06-09T10:00:00Z\n\ - lease_until: 2026-06-09T11:00:00Z\ntoken: t\ngeneration: 1\n"; - let now = Utc.with_ymd_and_hms(2026, 6, 9, 10, 30, 0).unwrap(); - let c = parse_claim("deadbeef", yaml, 60, now); - assert_eq!(c.node.as_deref(), Some("research/analyze")); - assert_eq!(c.actor.as_deref(), Some("agent")); - assert_eq!(c.runner_id.as_deref(), Some("server-1/4821")); - assert!(!c.expired); - } -} diff --git a/crates/mt-core/src/config.rs b/crates/mt-core/src/config.rs deleted file mode 100644 index dc6f1ae..0000000 --- a/crates/mt-core/src/config.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Конфігурація `.mt.json`: дефолти + merge (порт `npm/lib/core/config.mjs`). -//! -//! Читання файлів лишається на боці викликача (JS-обгортка зберігає ін'єкції -//! `exists`/`readFile`); сюди приходить лише сирий текст `.mt.json` або `None`. -//! -//! Пріоритет ефективного конфігу вузла (спадання): `plan_NNN.md` frontmatter > -//! `.mt-override.json` > `task.md` frontmatter > `.mt.json` > дефолти. - -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; - -use crate::frontmatter::parse_front_matter; - -/// Дефолтні значення конфігурації — 1:1 з JS `CONFIG_DEFAULTS` -/// (порядок ключів значущий: JS-об'єкт зберігає порядок вставки). -pub fn config_defaults() -> Value { - serde_json::json!({ - "mt_dir": "./mt", - "worktrees_dir": "./.worktrees", - "warn_worktrees_above": 4, - "max_worktrees": 8, - "default_budget_sec": 1800, - "default_mode": "human", - "default_model_tier": "AVG", - "budget_hard_sec_multiplier": 3, - "progress_timeout_sec": 300, - "agent_concurrency": 5, - "claim_lease_sec": 3600, - "claim_grace_sec": 60, - "publish_retry_max": 8, - "publish_retry_base_ms": 250, - "stale_worktree_min": 30, - "system_prompt": ".mt/system-prompt.md" - }) -} - -/// Зливає сирий текст `.mt.json` з дефолтами (JS `loadConfig` без FS). -/// `None` / невалідний JSON / не-об'єкт → чисті дефолти. Модельна -/// конфігурація виконавців — НЕ тут: вона user-level, через ENV -/// (`MT_AGENT_CLI` / `MT_CLOUD_AGENT_CLIS` / `MT_AGENT_CLI_MODEL_MAP`). -pub fn merge_config(raw: Option<&str>) -> Value { - let defaults = config_defaults(); - let Some(raw) = raw else { - return defaults; - }; - let Ok(Value::Object(overrides)) = serde_json::from_str::(raw) else { - return defaults; - }; - - let Value::Object(mut merged) = defaults else { - unreachable!("config_defaults is an object"); - }; - for (k, v) in overrides { - merged.insert(k, v); - } - Value::Object(merged) -} - -/// Плоский merge JSON-об'єктів: ключі `over` поверх `base` (не-об'єкти ігноруються). -fn overlay(base: &mut Map, over: &Value) { - if let Value::Object(o) = over { - for (k, v) in o { - base.insert(k.clone(), v.clone()); - } - } -} - -/// Ефективний конфіг вузла (spec-пріоритет): дефолти ← `.mt.json` ← -/// `task.md` frontmatter ← `.mt-override.json` ← `plan_NNN.md` frontmatter. -/// Кожен аргумент — сирий текст відповідного файлу, якщо він існує. -pub fn effective_config( - mt_json: Option<&str>, - task_md: Option<&str>, - mt_override_json: Option<&str>, - plan_md: Option<&str>, -) -> Value { - let Value::Object(mut merged) = merge_config(mt_json) else { - unreachable!("merge_config returns an object"); - }; - if let Some(text) = task_md { - overlay(&mut merged, &parse_front_matter(text)); - } - if let Some(raw) = mt_override_json { - if let Ok(v) = serde_json::from_str::(raw) { - overlay(&mut merged, &v); - } - } - if let Some(text) = plan_md { - overlay(&mut merged, &parse_front_matter(text)); - } - Value::Object(merged) -} - -/// Канонізує тир моделі: uppercase (`MIN` | `AVG` | `MAX`); порожнє → `""` -/// (порт JS `normalizeModelTier`). -pub fn normalize_model_tier(tier: &str) -> String { - tier.to_uppercase() -} - -/// Конфігурація виконавців — **user-level, з ENV** (runtime.md «Підписочні -/// CLI-виконавці»): вона спільна для всіх репозиторіїв користувача і тому НЕ -/// живе у repo-scoped `.mt.json`. Порт JS `loadAgentCliEnv`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentCliEnv { - /// Дефолтний CLI (`MT_AGENT_CLI`): claude | codex | cursor | pi. - pub agent_cli: String, - /// Каскад хмарних CLI (`MT_CLOUD_AGENT_CLIS`, comma-separated). - pub cloud_agent_clis: Vec, - /// JSON-мапа «CLI → тир → модель» (`MT_AGENT_CLI_MODEL_MAP`). - pub model_map: Value, -} - -impl Default for AgentCliEnv { - fn default() -> Self { - Self { - agent_cli: "claude".to_string(), - cloud_agent_clis: Vec::new(), - model_map: Value::Object(Map::new()), - } - } -} - -/// Будує [`AgentCliEnv`] через getter змінних середовища (ін'єкція для -/// тестів; продакшн — [`agent_cli_env_from_process`]). Невалідний або -/// не-об'єктний `MT_AGENT_CLI_MODEL_MAP` → порожня мапа. -pub fn load_agent_cli_env(get: impl Fn(&str) -> Option) -> AgentCliEnv { - let model_map = get("MT_AGENT_CLI_MODEL_MAP") - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .filter(Value::is_object) - .unwrap_or_else(|| Value::Object(Map::new())); - let agent_cli = get("MT_AGENT_CLI") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| "claude".to_string()) - .to_lowercase(); - let cloud_agent_clis = get("MT_CLOUD_AGENT_CLIS") - .unwrap_or_default() - .split(',') - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(); - AgentCliEnv { - agent_cli, - cloud_agent_clis, - model_map, - } -} - -/// [`AgentCliEnv`] зі змінних середовища поточного процесу. -pub fn agent_cli_env_from_process() -> AgentCliEnv { - load_agent_cli_env(|k| std::env::var(k).ok()) -} - -/// Резолвить конкретну модель тиру для підписочного CLI: MIN/AVG/MAX → -/// `model_map[][]`. Немає мапінгу → `None`: CLI резолвить модель -/// сам, тир лишається hint-ом env `MT_MODEL_TIER` (порт JS `resolveModelForCli`). -pub fn resolve_model_for_cli( - cli_env: &AgentCliEnv, - agent_cli: &str, - model_tier: &str, -) -> Option { - cli_env - .model_map - .get(agent_cli) - .and_then(|m| m.get(normalize_model_tier(model_tier))) - .and_then(Value::as_str) - .map(String::from) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_when_no_raw() { - let cfg = merge_config(None); - assert_eq!(cfg["mt_dir"], "./mt"); - assert_eq!(cfg["system_prompt"], ".mt/system-prompt.md"); - assert!(cfg.get("tasks_dir").is_none()); - } - - #[test] - fn defaults_on_invalid_json() { - assert_eq!(merge_config(Some("not json {")), config_defaults()); - assert_eq!(merge_config(Some("[1,2]")), config_defaults()); - } - - #[test] - fn merges_overrides_and_keeps_defaults() { - let cfg = merge_config(Some(r#"{"mt_dir":"./my-tasks","max_worktrees":12}"#)); - assert_eq!(cfg["mt_dir"], "./my-tasks"); - assert_eq!(cfg["max_worktrees"], 12); - assert_eq!(cfg["worktrees_dir"], "./.worktrees"); - } - - #[test] - fn no_model_keys_in_defaults() { - // Модельна конфігурація виконавців — user-level ENV, не .mt.json. - let cfg = merge_config(None); - assert!(cfg.get("model_map").is_none()); - assert!(cfg.get("claude_model").is_none()); - assert!(cfg.get("audit_model").is_none()); - } - - #[test] - fn agent_cli_env_defaults_and_parsing() { - let env = load_agent_cli_env(|_| None); - assert_eq!(env.agent_cli, "claude"); - assert!(env.cloud_agent_clis.is_empty()); - assert!(env.model_map.as_object().unwrap().is_empty()); - - let env = load_agent_cli_env(|k| match k { - "MT_AGENT_CLI" => Some("CODEX".to_string()), - "MT_CLOUD_AGENT_CLIS" => Some(" Codex, cursor ,,".to_string()), - "MT_AGENT_CLI_MODEL_MAP" => Some(r#"{"codex":{"AVG":"gpt-5.6-terra"}}"#.to_string()), - _ => None, - }); - assert_eq!(env.agent_cli, "codex"); - assert_eq!(env.cloud_agent_clis, ["codex", "cursor"]); - assert_eq!( - resolve_model_for_cli(&env, "codex", "avg").as_deref(), - Some("gpt-5.6-terra") - ); - // Немає мапінгу → None: CLI резолвить модель сам. - assert_eq!(resolve_model_for_cli(&env, "cursor", "AVG"), None); - } - - #[test] - fn agent_cli_env_invalid_model_map_is_empty() { - let env = load_agent_cli_env(|k| match k { - "MT_AGENT_CLI_MODEL_MAP" => Some("[not an object]".to_string()), - _ => None, - }); - assert!(env.model_map.as_object().unwrap().is_empty()); - assert_eq!(resolve_model_for_cli(&env, "claude", "MAX"), None); - } - - #[test] - fn effective_config_priority_chain() { - let cfg = effective_config( - Some(r#"{"default_budget_sec": 100, "progress_timeout_sec": 60}"#), - Some("---\ndefault_budget_sec: 200\nhint: atomic\n---\n"), - Some(r#"{"default_budget_sec": 300}"#), - Some("---\ndefault_budget_sec: 400\n---\n"), - ); - // plan_NNN > .mt-override.json > task.md > .mt.json - assert_eq!(cfg["default_budget_sec"], 400); - assert_eq!(cfg["hint"], "atomic"); - assert_eq!(cfg["progress_timeout_sec"], 60); - assert_eq!(cfg["mt_dir"], "./mt"); - } - - #[test] - fn effective_config_without_layers_is_merge_config() { - assert_eq!(effective_config(None, None, None, None), config_defaults()); - } -} diff --git a/crates/mt-core/src/directory.rs b/crates/mt-core/src/directory.rs deleted file mode 100644 index b5b89a3..0000000 --- a/crates/mt-core/src/directory.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Directory: мапінг handle → PII (`.mt/directory.json`, git-ignored). -//! -//! PII-політика (operations.md): у git-файлах вузлів живуть лише handles -//! (`assignee: vkozlov`, `owner: olena`, `from`/`to` ескалацій) — email та -//! імʼя людини лишаються поза історією, у локальному `.mt/directory.json` -//! і на relay (`accounts.email`). Цей модуль — канонічний парсер файлу; -//! читання ФС лишається на боці викликача (як у `config`). -//! -//! Формат — плоский обʼєкт: значення або рядок-email, або обʼєкт -//! `{ "email": "...", "name": "..." }`: -//! -//! ```json -//! { -//! "vkozlov": "v.kozlov@example.com", -//! "olena": { "email": "olena@example.com", "name": "Олена" } -//! } -//! ``` - -use std::collections::HashMap; - -use serde_json::Value; - -/// Канонічний шлях directory-файлу відносно кореня репо. -pub const DIRECTORY_PATH: &str = ".mt/directory.json"; - -/// PII одного handle: email — ключ мапінгу на relay-акаунт, імʼя — display. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DirectoryEntry { - pub email: String, - pub name: Option, -} - -/// Розбирає сирий текст `.mt/directory.json` у мапінг handle → PII. -/// Відсутній файл (`None`), битий JSON чи не-обʼєкт → порожній мапінг -/// (нерозмічена directory — штатний стан, не помилка). Невалідні значення -/// (без email) пропускаються. -pub fn parse_directory(raw: Option<&str>) -> HashMap { - let Some(raw) = raw else { - return HashMap::new(); - }; - let Ok(Value::Object(entries)) = serde_json::from_str::(raw) else { - return HashMap::new(); - }; - entries - .into_iter() - .filter_map(|(handle, value)| { - let entry = match value { - Value::String(email) if !email.trim().is_empty() => DirectoryEntry { - email: email.trim().to_string(), - name: None, - }, - Value::Object(fields) => DirectoryEntry { - email: fields.get("email")?.as_str()?.trim().to_string(), - name: fields - .get("name") - .and_then(Value::as_str) - .map(str::to_string), - }, - _ => return None, - }; - (!entry.email.is_empty()).then_some((handle, entry)) - }) - .collect() -} - -/// Email за handle-ом (None — handle поза directory: емітер не може -/// резолвити адресний push, події їдуть без `to_account_id`). -pub fn resolve_email<'a>( - directory: &'a HashMap, - handle: &str, -) -> Option<&'a str> { - directory.get(handle).map(|entry| entry.email.as_str()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_string_and_object_entries() { - let raw = r#"{ - "vkozlov": "v.kozlov@example.com", - "olena": { "email": " olena@example.com ", "name": "Олена" }, - "broken": { "name": "без email" }, - "empty": " " - }"#; - let directory = parse_directory(Some(raw)); - assert_eq!(directory.len(), 2); - assert_eq!( - resolve_email(&directory, "vkozlov"), - Some("v.kozlov@example.com") - ); - assert_eq!( - directory.get("olena"), - Some(&DirectoryEntry { - email: "olena@example.com".into(), - name: Some("Олена".into()) - }) - ); - assert_eq!(resolve_email(&directory, "broken"), None); - } - - #[test] - fn missing_or_invalid_file_is_empty_mapping() { - assert!(parse_directory(None).is_empty()); - assert!(parse_directory(Some("не json")).is_empty()); - assert!(parse_directory(Some("[1,2]")).is_empty()); - } -} diff --git a/crates/mt-core/src/docs/artifacts.md b/crates/mt-core/src/docs/artifacts.md deleted file mode 100644 index 298d09e..0000000 --- a/crates/mt-core/src/docs/artifacts.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -type: Rust Module -title: artifacts.rs -resource: crates/mt-core/src/artifacts.rs -docgen: - crc: 3302314d - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Файл відповідає за обробку артефактів вузла для формування ланцюжка. Використовується для збору, сортування та зчитування полів з файлів, що відповідають контракту Version-chain. - -## Поведінка - -Поведінка - -ArtifactKind Класифікує ім'я файлу як артефакт вузла - -NodeArtifact Створює структуру для зберігання витягнутих полів frontmatter артефакта вузла - -list_node_artifacts Збирає артефакти вузла з директорії та сортує їх за порядком chain - -read_node_artifact Зчитує вміст артефакта вузла за вказаним шляхом - -## Публічний API - -Зрозумів. Я буду писати лаконічну ПОВЕДІНКОВУ документацію у стилі «назва — що робить» українською, без вступів, висновків, сигнатур, типів чи параметрів. - -Надайте мені код, який потрібно переписати. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/claims.md b/crates/mt-core/src/docs/claims.md deleted file mode 100644 index ef58822..0000000 --- a/crates/mt-core/src/docs/claims.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -type: Rust Module -title: claims.rs -resource: crates/mt-core/src/claims.rs -docgen: - crc: e001c819 - model: omlx/gemma-4-e2b-it-4bit - score: 95 ---- - -## Огляд - -Файл надає інструменти для роботи з remote execution claims, забезпечуючи механізми для визначення та керування правами володіння вузлами через git refs. - -## Поведінка - -node_hash: генерує 20-символьний хеш SHA-256 з поєднання `tasks_root` та `node_path`. -discover_repo_root: знаходить кореневий каталог репозиторію за допомогою `git rev-parse --show-toplevel`. -tasks_root_relative: обчислює канонічний шлях `tasks_dir` відносно `repo_root`, нормалізуючи POSIX-роздільники. -RemoteClaimRef: структура для зберігання хешу вузла та SHA. -parse_ls_remote: парсить вивід `git ls-remote` для вилучення `RemoteClaimRef`. -ClaimInfo: структура для зберігання розпарсених даних з `.mt-claim.yml` включаючи стан прострочення. -lease_expired: перевіряє, чи прострочений термін дії ліцензії з урахуванням простроки (grace period). -parse_claim: будує `ClaimInfo` з YAML-вмісту `.mt-claim.yml`. -ClaimFields: структура для зберігання полів, які контролює runner, включаючи бейз-хеш та посилання на першу коміт. -ClaimPush: структура для збереження результату CAS-push, включаючи статус прийняття. -acquire_claim: створює новий claim-коміт і намагається опублікувати його через `git push`. -renew_or_takeover_claim: створює новий claim-коміт на основі попереднього, використовуючи `old_claim_sha` для авторизації. -release_claim: намагається видалити (delete) claim-референс, якщо він належить поточному власнику. -fetch_remote_claims: зчитує remote claims через `git ls-remote`, виконує `fetch` та парсить YAML для генерації `ClaimInfo`. - -## Публічний API - -**node_hash** — 20-символьний хеш SHA-256 з `\0`. -**discover_repo_root** — кореневий каталог репозиторію через `git rev-parse --show-toplevel`. -**RemoteClaimRef**, **ClaimInfo**, **ClaimFields**, **ClaimPush** — структури для представлення claim-даних. -**parse_ls_remote**, **parse_claim** — парсинг `git ls-remote` і `.mt-claim.yml` у `ClaimInfo`. -**lease_expired** — прострочення lease з урахуванням grace period. -**acquire_claim**, **renew_or_takeover_claim**, **release_claim** — CAS-цикл claim-коміту: створення, поновлення/перехоплення за `old_claim_sha`, видалення за `claim_sha`. -**fetch_remote_claims** — читає всі remote claims (`fetch` + парсинг кожного claim-коміту). - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/config.md b/crates/mt-core/src/docs/config.md deleted file mode 100644 index d988f30..0000000 --- a/crates/mt-core/src/docs/config.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: Rust Module -title: config.rs -resource: crates/mt-core/src/config.rs -docgen: - crc: ac6de95a - model: omlx/gemma-4-e2b-it-4bit - score: 90 ---- - -## Огляд - -Файл відповідає за конфігурацію проекту через злиття різних рівнів конфігураційних файлів. Мета — забезпечити отримання ефективної конфігурації вузла, враховуючи пріоритет файлів від дефолтних до оверрайди. - -## Поведінка - -config_defaults: генерує дефолтні значення конфігурації для проекту. -merge_config: зливає сирий текст `.mt.json` з дефолтними значеннями. -effective_config: створює ефективну конфігурацію вузла з урахуванням пріоритету файлів. - -## Публічний API - -**config_defaults** — дефолтні значення конфігурації. -**merge_config** — злиття сирого тексту `.mt.json` з дефолтними значеннями. -**effective_config** — ефективна конфігурація вузла з урахуванням пріоритету завантаження файлів. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/mt-core/src/docs/directory.md b/crates/mt-core/src/docs/directory.md deleted file mode 100644 index b309c63..0000000 --- a/crates/mt-core/src/docs/directory.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: Rust Module -title: directory.rs -resource: crates/mt-core/src/directory.rs -docgen: - crc: 5ce11be5 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Канонічний парсер вмісту `.mt/directory.json`: перетворює flat-мапінг `handle → PII` у структуру для подальшого використання в коді, щоб у git-файлах лишалися лише handles, а email та імʼя людини зберігалися поза історією. Для рішень, що спираються на цей формат, орієнтиром є конфіг `directory.json`. Публічний `resolve_email` повертає email для заданого handle або `null`, якщо запису немає чи вхідні дані не дають однозначного результату. Для частини некоректних або неповних даних модуль теж повертає порожнє значення замість винятку. - -## Поведінка - -- DirectoryEntry — зберігає PII для одного handle: email як основний ідентифікатор для relay та optional display name. -- parse_directory — перетворює вміст `.mt/directory.json` на мапінг handle → PII; відсутній або невалідний вміст дає порожній мапінг. -- resolve_email — повертає email для заданого handle або `null`, якщо handle відсутній у directory. - -## Публічний API - -- DirectoryEntry — запис directory для одного handle: email як ключ relay-акаунта, name як display-імʼя -- parse_directory — читає `.mt/directory.json` і будує мапінг handle → PII; якщо файла нема, JSON зламаний або корінь не обʼєкт, повертає порожній мапінг; записи без email ігнорує -- resolve_email — повертає email для handle; якщо handle не знайдено, віддає `None`, тож адресний push без `to_account_id` не формується - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/frontmatter.md b/crates/mt-core/src/docs/frontmatter.md deleted file mode 100644 index af9122a..0000000 --- a/crates/mt-core/src/docs/frontmatter.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: Rust Module -title: frontmatter.rs -resource: crates/mt-core/src/frontmatter.rs -docgen: - crc: 0cc51d2c - model: omlx/gemma-4-e2b-it-4bit - tier: local-min - score: 100 ---- - -## Огляд - -Файл відповідає за парсинг та серіалізацію YAML front-matter для task-файлів. Мета — гарантувати 1:1 ідентичність вихідного байта між JS-версією та функцією `serialize_yaml`, зберігаючи порядок вставки ключів. - -## Поведінка - -parse_front_matter: парсить YAML front-matter з markdown-тексту. -parse_yaml: парсить чистий YAML-блок (без `---`-маркерів). -get_body: повертає тіло документа (без front-matter, з обрізаним лівим whitespace). -build_markdown: будує markdown-файл із front-matter і тілом: `---\n\n---\n\n`. - -## Публічний API - -**parse_front_matter** — YAML front-matter з markdown-тексту. -**parse_yaml** — чистий YAML-блок без `---`-маркерів. -**get_body** — тіло документа без front-matter. -**build_markdown** — складання markdown-файлу з front-matter і тілом. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/index.md b/crates/mt-core/src/docs/index.md deleted file mode 100644 index d542776..0000000 --- a/crates/mt-core/src/docs/index.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -type: Directory Index -title: crates/mt-core/src -resource: crates/mt-core/src/ ---- - -| Файл | Тип | -| ---------------------------------- | ----------- | -| [artifacts.rs](artifacts.md) | Rust Module | -| [claims.rs](claims.md) | Rust Module | -| [config.rs](config.md) | Rust Module | -| [directory.rs](directory.md) | Rust Module | -| [frontmatter.rs](frontmatter.md) | Rust Module | -| [ledger.rs](ledger.md) | Rust Module | -| [lib.rs](lib.md) | Rust Module | -| [lifecycle.rs](lifecycle.md) | Rust Module | -| [nnn.rs](nnn.md) | Rust Module | -| [orchestrate.rs](orchestrate.md) | Rust Module | -| [publish.rs](publish.md) | Rust Module | -| [runner.rs](runner.md) | Rust Module | -| [signal.rs](signal.md) | Rust Module | -| [spawn.rs](spawn.md) | Rust Module | -| [test_support.rs](test_support.md) | Rust Module | -| [worktree.rs](worktree.md) | Rust Module | diff --git a/crates/mt-core/src/docs/ledger.md b/crates/mt-core/src/docs/ledger.md deleted file mode 100644 index b963170..0000000 --- a/crates/mt-core/src/docs/ledger.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -type: Rust Module -title: ledger.rs -resource: crates/mt-core/src/ledger.rs -docgen: - crc: 62dee7b9 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл призначений для збору та агрегування метрик часу та токенів з усіх вузлів воркспейсу. Файл фіксує сировину для звітності, агрегуючи дані `wall_sec`, `tokens_in`, `tokens_out` та `cost_usd` з усіх записів `run_NNN.md` для забезпечення GUI-аналітики на Фазі 4. - -## Поведінка - -Поведінка -LedgerEntry: Агрегує метрики одного вузла. -CostLedger: Зберігає агреговані метрики по всьому воркспейсу. -build_cost_ledger: Будує ledger сканом усього дерева воркспейсу та `run_NNN.md` кожного вузла. - -## Публічний API - -Зрозуміло. Я готовий писати лаконічну поведінкову документацію у стилі «назва — що робить», без зайвих деталей, сигнатур чи типів. - -Надайте мені код, який потрібно переписати. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/lib.md b/crates/mt-core/src/docs/lib.md deleted file mode 100644 index 0c95a82..0000000 --- a/crates/mt-core/src/docs/lib.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -type: Rust Module -title: lib.rs -resource: crates/mt-core/src/lib.rs -docgen: - crc: fb050001 - model: omlx/gemma-4-e2b-it-4bit - score: 85 ---- - -## Огляд - -Огляд: Функції для роботи зі станом завдань, вузлами, робочими просторами та керуванням активацією записів. Служить для ініціалізації, сканування, валідації та створення елементів у системі завдань. - -## Поведінка - -TaskState повертає перерахування стану вузла. -TaskNode представляє вузол задачі. -WorkspaceInfo представляє інформацію про робочу область. -write_executor_flag записує прапор виконавця в файлі. -Mode повертає режим виконання. -CreateOpts приймає опції створення вузла. -CreateOutcome повертає результат створення вузла. -to_cli_json серіалізує CreateOutcome у формат JSON. -sanitize трансформує назву. -sanitize_branch трансформує назву. -scan_tasks сканує директорію завдань і повертає дерево вузлів. -discover_worktrees виявляє активні git-worktree. -parse_worktree_list парсить вивід git worktree list. -find_all_tasks_dirs_from знаходить директорії робочих просторів. -find_all_tasks_dirs знаходить усі директорії завдань. -find_tasks_dir знаходить першу директорію завдань. -validate_name валідує ім'я вузла. -create_task створює новий вузол задачі. - -## Публічний API - -Будь ласка, надайте код, який потрібно переписати у формат поведінкової документації. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. -- Кешує результати в межах одного прогону. diff --git a/crates/mt-core/src/docs/lifecycle.md b/crates/mt-core/src/docs/lifecycle.md deleted file mode 100644 index b578ec0..0000000 --- a/crates/mt-core/src/docs/lifecycle.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -type: Rust Module -title: lifecycle.rs -resource: crates/mt-core/src/lifecycle.rs -docgen: - crc: 58b5fe2f - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Overview -Файл відповідає за управління життєвим циклом вузла через мутації `mt invalidate` та `mt kill` для архівування та видалення даних. - -Behavior -child_nodes: Отримує директорію і повертає список імен вузлів, які містять `task.md`. -invalidate: Архівує version chain вузла та каскадно архівує всіх нащадків. -kill: Архівує весь вузол з нащадками у директорію історії і видаляє директорію. - -## Поведінка - -Поведінка - -child_nodes: Отримує директорію і повертає список імен вузлів, які містять `task.md`. - -invalidate: Архівує version chain вузла та каскадно архівує всіх нащадків. - -kill: Архівує весь вузол з нащадками у директорію історії і видаляє директорію. - -## Публічний API - -Я готовий. Надайте мені код, який потрібно переписати згідно з цими інструкціями. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/nnn.md b/crates/mt-core/src/docs/nnn.md deleted file mode 100644 index c449745..0000000 --- a/crates/mt-core/src/docs/nnn.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: Rust Module -title: nnn.rs -resource: crates/mt-core/src/nnn.rs -docgen: - crc: adfcda5d - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Overview -Файл відповідає за нумерацію артефактів задач, зокрема `run_`, `plan_`, `fact_`, `pending-audit_` та `audit-result_`. Забезпечує форматування числового значення у форматі `NNN` з ведучими нулями до трьох цифр для внутрішньої нумерації артефактів. - -## Поведінка - -Поведінка -pad_nnn Форматує число як NNN-рядок з ведучими нулями до трьох цифр -next_run_nnn Розраховує наступну нумерацію для файлів run_ -next_plan_nnn Розраховує наступну нумерацію для файлів plan_ -latest_fact_nnn Повертає максимальне NNN для файлів fact_ -latest_pending_audit_nnn Повертає максимальне NNN для файлів pending-audit_ -latest_audit_result_nnn Повертає максимальне NNN для файлів audit-result_ - -## Публічний API - -Зрозумів. Я буду писати лаконічну ПОВЕДІНКОВУ документацію до коду українською, у форматі «назва — що робить», без вступів, висновків, сигнатур, типів чи параметрів, використовуючи лише зазначені назви. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/orchestrate.md b/crates/mt-core/src/docs/orchestrate.md deleted file mode 100644 index fe2ed1e..0000000 --- a/crates/mt-core/src/docs/orchestrate.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -type: Rust Module -title: orchestrate.rs -resource: crates/mt-core/src/orchestrate.rs -docgen: - crc: ff3c6653 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд - -Файл реалізує оркестрацію `run --auto` для одноразового проходу по агентах. Він ініціює пошук `waiting` вузлів, сортує їх за метрикою, яка включає кількість нащадків, дедлайн та час створення, а потім виконує черговий прохід через `agent_concurrency`. - -Поведінка - -AutoResult: повертає результат одного проходу -sort_for_auto: сортує `waiting` вузли за кількістю нащадків, `deadline` та `created_at` -run_auto: виконує черговий прохід `waiting` вузлів через `concurrency`, перехоплює помилки, не використовує кешування - -## Поведінка - -Поведінка - -AutoResult: повертає результат одного проходу -sort_for_auto: сортує waiting вузли за кількістю нащадків, deadline та created_at -run_auto: прогон waiting вузлів чергами через concurrency. перехоплює помилки. не використовує кешування. - -## Публічний API - -**AutoResult** — підсумок одного проходу в `run_auto`. -**sort_for_auto** — сортує `waiting`-вузли: спочатку ті, що мають більше "розблокованих" нащадків (загальний count); далі — ті, що мають найближчий `deadline` (якщо `deadline` відсутній — в кінці групи); далі — час `created_at` (якщо `created_то` відсутній — в кінці). -**run_auto** — один прохід оркестратора, який прогодує `waiting` агенти чергами через `concurrency` (кожен вузол — окремий потік через `run_node`). Вузли, що перейшли у стан `preflight` (гонка/зникла умова), не підбираються в межах цього виклику, що гарантує термінацію. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/publish.md b/crates/mt-core/src/docs/publish.md deleted file mode 100644 index c47b4fb..0000000 --- a/crates/mt-core/src/docs/publish.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: Rust Module -title: publish.rs -resource: crates/mt-core/src/publish.rs -docgen: - crc: e621ff5a - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл реалізує протокол закритого публікування (Fenced publish protocol), який забезпечує атомарне відправлення результату worktree у репозиторій `main` через рефетч/rebase та повторні спроби з експоненційним відкатом (exponential backoff+jitter). - -Поведінка -PublishRequest: приймає запит на публікацію з посиланням на worktree та хеші. -PublishOutcome: повертає результат фінальної операції пушу, включаючи стан fencing та кількість спроб. -fenced_publish: виконує atomic push з ребазом worktree на origin/main та перевіркою fencing. - -## Поведінка - -Поведінка -PublishRequest: приймає запит на публікацію з посиланням на worktree та хеші. -PublishOutcome: повертає результат фінальної операції пушу, включаючи стан fencing та кількість спроб. -fenced_publish: виконує atomic push з ребазом worktree на origin/main та перевіркою fencing. - -## Публічний API - -Зрозумів. Я перепишу наданий список у потрібному стилі, як технічний письменник, що пише лаконічну поведінкову документацію українською мовою, без зайвих деталей, сигнатур чи типів. - -Ось переписаний список: - -- PublishRequest — приймає запит на публікацію. -- PublishOutcome — повертає результат публікації. `published: false` означає, що fencing/conflict (claim втрачено або конкурентний publish виграв гонку), а не системну помилку. -- fenced_publish — виконує три кроки: `fetch main + claim ref` → `rebase worktree на origin/main` → перевірка fencing (claim ref усе ще exact SHA) → `atomic multi-ref push` (main + видалення claim/run ref). Повторне виконання з експоненційним backoff+jitter при відхиленні push-у. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/runner.md b/crates/mt-core/src/docs/runner.md deleted file mode 100644 index 5316034..0000000 --- a/crates/mt-core/src/docs/runner.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -type: Rust Module -title: runner.rs -resource: crates/mt-core/src/runner.rs -docgen: - crc: a9df4181 - model: omlx/gemma-4-e2b-it-4bit - score: 95 ---- - -## Огляд - -Огляд -Файл Real-wrapper (спека `mt.md`, «Wrapper-скрипт») забезпечує контроль над процесом запуску, що трансформує CAS claim у detached worktree, ініціює роботу агента через watchdog, фіксує результат та забезпечує fenced publish. - -Поведінка -RunPlan -Створює план запуску для агента, включаючи бюджети та команди. - -RunOutcome -Повертає підсумок результату виконання завдання. - -preflight -Перевіряє готовність вузла, залежностей та станів прав доступу. - -run_node -Запускає агента, супроводжує його виконання та публікує результат. - -## Поведінка - -Поведінка - -RunPlan -Створює план запуску для агента, включаючи бюджети та команди. - -RunOutcome -Повертає підсумок результату виконання завдання. - -preflight -Перевіряє готовність вузла, залежностей та станів прав доступу. - -run_node -Запускає агента, супроводжує його виконання та публікує результат. - -## Публічний API - -Зрозумів. Я готовий писати лаконічну ПОВЕДІНКОВУ документацію у стилі «ЩО і НАВІЩО» для коду, використовуючи надані вами константи та суворі обмеження щодо формату. - -Надайте мені код, який потрібно переписати. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/signal.md b/crates/mt-core/src/docs/signal.md deleted file mode 100644 index 5859bac..0000000 --- a/crates/mt-core/src/docs/signal.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -type: Rust Module -title: signal.rs -resource: crates/mt-core/src/signal.rs -docgen: - crc: dc1e4917 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Файл відповідає за обгортку виконавця для управління станом завершення роботи, включаючи запис фактів, результатів виконання та керування аудитами. Забезпечує послідовне проходження етапів виконання вузла та агрегацію результатів вгору. - -CheckResult Результат однієї команди -SignalOutcome Результат сигналу done/audit записані файли та пропагація вгору -next_run_nnn Обчислює NNN наступної спроби count + 1 -check_commands Витягує команди секції ## Check з task.md -run_check Проганяє ## Check з task.md повертає `Vec` -write_fact Пише fact_NNN.md з обов'язковим ## Summary -write_run Пише run_NNN.md -write_run_fm Пише run_NNN.md з додатковими frontmatter-рядками -done Записує run_NNN (success) та створює необхідні компоненти -audit Записує run_NNN (success) та відкриває аудит-цикл pending-audit_NNN.md -failed Пише run_NNN (failed) без fact -propagate_composite Виконує composite-агрегацію вгору якщо всі діти resolved - -## Поведінка - -Поведінка - -CheckResult Результат однієї команди ## Check -SignalOutcome Результат сигналу done/audit записані файли та пропагація вгору -next_run_nnn Обчислює NNN наступної спроби count + 1 -check_commands Витягує команди секції ## Check з task.md -run_check Проганяє ## Check з task.md повертає `Vec` -write_fact Пише fact_NNN.md з обов'язковим ## Summary -write_run Пише run_NNN.md -write_run_fm Пише run_NNN.md з додатковими frontmatter-рядками -done Записує run_NNN (success) та створює необхідні компоненти -audit Записує run_NNN (success) та відкриває аудит-цикл pending-audit_NNN.md -failed Пише run_NNN (failed) без fact -propagate_composite Виконує composite-агрегацію вгору якщо всі діти resolved - -## Публічний API - -Як технічний письменник, я готовий переписати ваш список відповідно до ваших вимог. - ---- - -CheckResult — результат однієї команди `## Check`. -SignalOutcome — результат сигналу done/audit: записані файли + пропагація вгору. -next_run_nnn — NNN наступної спроби: `count + 1` (спека, «NNN source»). -check_commands — витягує команди секції `## Check` task.md: кожен непорожній рядок — shell-команда, `#` — коментар. -run_check — проганяє `## Check` (cwd = project root — батько tasks_dir). Будь-який ненульовий exit → `Err` з виводом команд; сигнал відхиляється. -write_fact — пише `fact_NNN.md` (NNN наступної спроби) з обов'язковим `## Summary`. -write_run — формулює стисло з наміру файлу. -write_run_fm — як `write_run`, але з додатковими frontmatter-рядками (wall_sec тощо). -done — `mt done`: fact існує → `## Check` → `run_NNN (success)` → агрегація вгору. -audit — `mt audit`: як done, але відкриває аудит-цикл (`pending-audit_NNN.md`). -failed — `mt failed`: `run_NNN (failed)` без fact; секції Completed/Blockers/Next Attempt обов'язкові (інваріант файлу — джерело діагностики ретраїв). - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/spawn.md b/crates/mt-core/src/docs/spawn.md deleted file mode 100644 index bad1b02..0000000 --- a/crates/mt-core/src/docs/spawn.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -type: Rust Module -title: spawn.rs -resource: crates/mt-core/src/spawn.rs -docgen: - crc: 87a4a6fc - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Overview -Файл надає інструменти для створення та валідації підграфових вузлів плану. Використовується для структурованого управління станом плану через процедури узгодження та відхилення. - -Поведінка - -ChildSpec -Визначає специфікацію вузла з обов'язковим полем `mode`. - -PlanReview -Читає модель поточного плану та його дочірні вузли. - -SpawnOutcome -Повертає результат операції `spawn_approve`, включаючи назви матеріалізованих дітей. - -parse_children -Витягує структури з секції `children` у YAML-підмножині. - -plan_review -Витягує актуальний план та його дочірні вузли з файлу. - -spawn_approve -Валідує специфікацію, створює нові вузли та записує у `plan-approved_NNN.md`. - -spawn_reject -Записує у файл `plan-rejected_NNN.md` з зазначенням причини відхилення. - -set_executor -Перемикає виконавця вузла, записуючи потрібний прапорець для роботи. - -## Поведінка - -**ChildSpec** -Специфікація однієї дитини з визначенням `mode` як обов'язкового поля. - -**PlanReview** -Read-модель актуального плану та його дочірніх вузлів. - -**SpawnOutcome** -Результат операції `spawn_approve`, включає назви матеріалізованих дітей. - -**parse_children** -Парсер підмножини YAML для вилучення структур з `children` секції. - -**plan_review** -Витягує актуальний план та його дочірніх вузлів з файлу. - -**spawn_approve** -Валідує специфікацію, матеріалізує дітей та записує `plan-approved_NNN.md`. - -**spawn_reject** -Записує `plan-rejected_NNN.md` з вказаною причиною. - -**set_executor** -Перемикає виконавця вузла, записуючи необхідний прапор для роботи. - -## Публічний API - -ChildSpec — визначає специфікацію однієї дитини з `## Children` (спека: mode — обов'язковий). -PlanReview — читає `plan-review` для GUI: актуальний план і його `## Children`. -SpawnOutcome — повертає результат `spawn_approve`. -parse_children — парсить підмножини YAML для `children:` — список об'єктів зі скалярами, інлайн-масивами та блоковими скалярами `|` (частковий парсер frontmatter списки об'єктів не підтримує). -plan_review — читає `plan_review` вузла: актуальний план + розібрані `## Children`. -spawn_approve — валідує `## Children` актуального плану, матеріалізує дітей (task.md + прапор + deps/) і записує `plan-approved_NNN.md`. -spawn_reject — записує `plan-rejected_NNN.md`; вражений вузол переводиться у стан `waiting`, наступний план отримує причину. -set_executor — перемикає виконавця вузла: записує `a.md`/`h.md`, видаляє протилежний прапор. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/docs/test_support.md b/crates/mt-core/src/docs/test_support.md deleted file mode 100644 index 6f72568..0000000 --- a/crates/mt-core/src/docs/test_support.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: Rust Module -title: test_support.rs -resource: crates/mt-core/src/test_support.rs -docgen: - crc: e16fdf9e - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд. Файл створює версійні фіксації для тестування. Використовується bare-репозиторій як `origin` та звичайний клон на `main`. Призначений для ізольованого тестування функціоналу. - -## Поведінка - -Поведінка - -run запускає git-команду з заданими аргументами в вказаній директорії. -output запускає git-команду і повертає обточений stdout. -TestRepo структура зберігає bare-репозиторій у origin та робочий клон у work. -new ініціалізує bare-репозиторій та робочий клон для тестування. -main_sha повертає хеш комміту `main` з робочого клону. - -## Публічний API - -Зрозумів. Я перепишу список у потрібному стилі, виконуючи роль технічного письменника, який описує поведінку коду лаконічно та у стилі «ЩО і НАВІЩО». - -Надайте мені список, який потрібно переписати. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/crates/mt-core/src/docs/worktree.md b/crates/mt-core/src/docs/worktree.md deleted file mode 100644 index 0114f1b..0000000 --- a/crates/mt-core/src/docs/worktree.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -type: Rust Module -title: worktree.rs -resource: crates/mt-core/src/worktree.rs -docgen: - crc: 0e45c74f - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл керує операціями з іменуванням, пошуком, створення, прив'язки та видалення detached worktree для виконання завдань, використовуючи git-операції та різноманітні хеші. - -Поведінка -make_worktree_name Створює ім'я worktree з назви та епохи у форматі `-`. - -find_worktree_match Знаходить перший запис з entries, що починається з префікса або дорівнює префіксу. - -create_run_worktree Створює detached worktree від base_sha у `worktrees_dir/-` за допомогою git worktree add --detach. - -push_run_ref Публікує локальний run ref у `refs/mt//` як поточний HEAD worktree. - -delete_run_ref Видаляє remote run ref, використовуючи --force-with-lease для безпечного видалення. - -remove_run_worktree Видаляє worktree після завершення спроби, використовуючи git worktree remove --force. - -## Поведінка - -Поведінка - -make_worktree_name Створює ім'я worktree з назви та епохи в форматі `-`. - -find_worktree_match Знаходить перший запис з entries, що починається з префікса або дорівнює префіксу. - -create_run_worktree Створює detached worktree від base_sha у `worktrees_dir/-` за допомогою git worktree add --detach. - -push_run_ref Публікує локальний run ref у `refs/mt//` як поточний HEAD worktree. - -delete_run_ref Видаляє remote run ref, використовуючи --force-with-lease для безпечного видалення. - -remove_run_worktree Видаляє worktree після завершення спроби, використовуючи git worktree remove --force. - -## Публічний API - -**make_worktree_name** — генерує ім'я для worktree: `-`. -**find_worktree_match** — знаходить перший запис з `entries`, що починається з `-`. -**create_run_worktree** — створює detached worktree від `base_sha` у ``worktrees_dir/-`` (спека: `git worktree add --detach .worktrees/- `). Worktree ізольований від живого робочого дерева. -**push_run_ref** — публікує локальний run ref для recovery/handoff (спека, крок 5: `refs/mt/runs//` ← поточний HEAD worktree). -**delete_run_ref** — видаляє remote run ref (після успішного publish або при cleanup невдалої спроби; `--force-with-lease` — лише якщо ref усе ще на очікуваному SHA). -**remove_run_worktree** — прибирає worktree після завершення спроби (success — завжди; failure — залишається для debug за рішенням викликача, спека «Failure-сімейство»). - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-core/src/frontmatter.rs b/crates/mt-core/src/frontmatter.rs deleted file mode 100644 index d5a3dde..0000000 --- a/crates/mt-core/src/frontmatter.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! YAML front-matter parser/serializer для mt task-файлів. -//! -//! Порт `npm/lib/core/frontmatter.mjs` 1:1 — включно з «дивними» кутовими -//! випадками парсера, бо JS-обгортка тепер делегує сюди, а вихід -//! `serialize_yaml` має лишатися **байт-у-байт** ідентичним JS-версії. -//! Ключі зберігають порядок вставки (`serde_json` із `preserve_order`). - -use serde_json::{Map, Value}; - -/// Спецсимволи YAML, що вимагають лапок (JS `YAML_SPECIAL_RE`). -const YAML_SPECIAL: &[char] = &[':', '#', '[', ']', '{', '}', ',', '\n']; - -/// Розбиває текст на `(кінець збігу front-matter, внутрішній блок)`. -/// Еквівалент JS `/^---\r?\n([\s\S]*?)\r?\n---/`. -fn split_frontmatter(text: &str) -> Option<(usize, &str)> { - let rest = text.strip_prefix("---")?; - let nl_len = if rest.starts_with("\r\n") { - 2 - } else if rest.starts_with('\n') { - 1 - } else { - return None; - }; - let after_open = &rest[nl_len..]; - let idx = after_open.find("\n---")?; - let inner_end = if after_open[..idx].ends_with('\r') { - idx - 1 - } else { - idx - }; - let match_end = 3 + nl_len + idx + 4; - Some((match_end, &after_open[..inner_end])) -} - -/// Парсить YAML front-matter з markdown-тексту. Без front-matter → порожній об'єкт. -pub fn parse_front_matter(text: &str) -> Value { - match split_frontmatter(text) { - Some((_, inner)) => Value::Object(parse_yaml_block(inner)), - None => Value::Object(Map::new()), - } -} - -/// Парсить чистий YAML-блок (без `---`-маркерів) — напр. `.mt-claim.yml`. -pub fn parse_yaml(text: &str) -> Value { - Value::Object(parse_yaml_block(text)) -} - -/// Повертає тіло документа (без front-matter, з обрізаним лівим whitespace). -pub fn get_body(text: &str) -> String { - match split_frontmatter(text) { - Some((end, _)) => text[end..].trim_start().to_string(), - None => text.to_string(), - } -} - -/// Кількість пробілів на початку рядка. -fn get_indent(line: &str) -> usize { - line.bytes().take_while(|b| *b == b' ').count() -} - -/// JS `line.slice(n)` по символах (для нормалізації відступу вкладених блоків). -fn slice_chars(line: &str, n: usize) -> String { - line.chars().skip(n).collect() -} - -fn parse_yaml_block(block: &str) -> Map { - let lines: Vec<&str> = block - .split('\n') - .map(|l| l.strip_suffix('\r').unwrap_or(l)) - .collect(); - let mut result = Map::new(); - let mut i = 0; - - while i < lines.len() { - let line = lines[i]; - if line.trim().is_empty() || line.trim_start().starts_with('#') { - i += 1; - continue; - } - - if get_indent(line) > 0 { - // Верхній рівень — пропускаємо «бродячі» дочірні рядки. - i += 1; - continue; - } - - let Some(colon_idx) = line.find(':') else { - i += 1; - continue; - }; - - let key = line[..colon_idx].trim().to_string(); - let raw_val = line[colon_idx + 1..].trim(); - - if !raw_val.is_empty() { - result.insert(key, parse_scalar(raw_val)); - i += 1; - continue; - } - - // Значення відсутнє після ':' — дивимось наступні рядки. - i += 1; - if i >= lines.len() { - result.insert(key, Value::Null); - continue; - } - - let next_line = lines[i]; - if next_line.trim().is_empty() { - result.insert(key, Value::Null); - continue; - } - - let next_indent = get_indent(next_line); - if next_indent == 0 { - result.insert(key, Value::Null); - continue; - } - - if next_line.trim_start().starts_with("- ") { - // Список. - let mut arr = vec![]; - while i < lines.len() { - let l = lines[i]; - if l.trim().is_empty() { - i += 1; - continue; - } - if get_indent(l) == 0 { - break; - } - let t = l.trim_start(); - if let Some(item) = t.strip_prefix("- ") { - arr.push(parse_scalar(item.trim())); - } - i += 1; - } - result.insert(key, Value::Array(arr)); - } else { - // Вкладений об'єкт: нормалізуємо відступ (видаляємо перший рівень). - let mut child_lines = vec![]; - while i < lines.len() { - let l = lines[i]; - if l.trim().is_empty() { - i += 1; - continue; - } - if get_indent(l) == 0 { - break; - } - child_lines.push(slice_chars(l, next_indent)); - i += 1; - } - result.insert( - key, - Value::Object(parse_yaml_block(&child_lines.join("\n"))), - ); - } - } - - result -} - -/// JS `Number(s)` для обрізаного непорожнього рядка → serde-число. -/// `Infinity`/`NaN` не представні в JSON → `None` (значення лишиться рядком). -fn js_number(s: &str) -> Option { - let parse_radix = |digits: &str, radix: u32| -> Option { - if digits.is_empty() { - return None; - } - u128::from_str_radix(digits, radix).ok().map(|v| v as f64) - }; - let lower = s.get(..2).map(str::to_ascii_lowercase); - let n: f64 = match lower.as_deref() { - Some("0x") => parse_radix(&s[2..], 16)?, - Some("0o") => parse_radix(&s[2..], 8)?, - Some("0b") => parse_radix(&s[2..], 2)?, - _ => { - // Rust приймає "inf"/"nan" — JS Number ні (лише "Infinity", який пропускаємо). - if s.chars() - .any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E')) - { - return None; - } - s.parse().ok()? - } - }; - if !n.is_finite() { - return None; - } - // Цілі в безпечному діапазоні зберігаємо як int — серіалізація як у JS String(n). - if n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_992.0 { - return Some(Value::from(n as i64)); - } - serde_json::Number::from_f64(n).map(Value::Number) -} - -/// Парсить скалярне значення: булеве, null, число, лапки, або рядок. -fn parse_scalar(s: &str) -> Value { - match s { - "true" => return Value::Bool(true), - "false" => return Value::Bool(false), - "null" | "~" => return Value::Null, - _ => {} - } - if let Some(n) = js_number(s) { - return n; - } - // Знімаємо лапки. - let chars: Vec = s.chars().collect(); - if chars.len() >= 2 { - let (first, last) = (chars[0], chars[chars.len() - 1]); - if (first == '"' && last == '"') || (first == '\'' && last == '\'') { - return Value::String(chars[1..chars.len() - 1].iter().collect()); - } - } - Value::String(s.to_string()) -} - -/// Серіалізує об'єкт у YAML-рядок (без `---` маркерів). Байт-у-байт як JS -/// `serializeYaml`: scalar, масиви (` - item`), вкладені об'єкти. -pub fn serialize_yaml(obj: &Value, indent_level: usize) -> String { - let indent = " ".repeat(indent_level); - let mut lines: Vec = vec![]; - - if let Value::Object(map) = obj { - for (key, val) in map { - match val { - Value::Null => lines.push(format!("{indent}{key}:")), - Value::Array(items) => { - lines.push(format!("{indent}{key}:")); - for item in items { - lines.push(format!("{indent} - {}", serialize_scalar(item))); - } - } - Value::Object(_) => { - lines.push(format!("{indent}{key}:")); - lines.push(serialize_yaml(val, indent_level + 1)); - } - _ => lines.push(format!("{indent}{key}: {}", serialize_scalar(val))), - } - } - } - - lines.join("\n") -} - -/// Серіалізує скалярне значення у рядок (JS `serializeScalar` + `String(val)`). -fn serialize_scalar(val: &Value) -> String { - match val { - Value::String(s) => { - if s.contains(YAML_SPECIAL) || s.trim() != s { - format!("\"{}\"", s.replace('"', "\\\"")) - } else { - s.clone() - } - } - Value::Bool(b) => b.to_string(), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - i.to_string() - } else if let Some(u) = n.as_u64() { - u.to_string() - } else { - format!("{}", n.as_f64().unwrap_or(f64::NAN)) - } - } - Value::Null => "null".to_string(), - other => other.to_string(), - } -} - -/// Будує markdown-файл із front-matter і тілом: `---\n\n---\n\n`. -pub fn build_markdown(fm: &Value, body: &str) -> String { - let yaml = serialize_yaml(fm, 0); - ["---", &yaml, "---", "", body].join("\n") -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn parses_simple_frontmatter() { - let fm = parse_front_matter("---\nschema_version: 1\nhint: atomic\n---\n\nbody"); - assert_eq!(fm, json!({"schema_version": 1, "hint": "atomic"})); - } - - #[test] - fn no_frontmatter_gives_empty_object() { - assert_eq!(parse_front_matter("just text"), json!({})); - assert_eq!(get_body("just text"), "just text"); - } - - #[test] - fn get_body_strips_frontmatter_and_leading_ws() { - assert_eq!(get_body("---\na: 1\n---\n\n## Body\n"), "## Body\n"); - } - - #[test] - fn parses_lists_and_nested_objects() { - let text = - "---\nskills:\n - bash\n - write-files\nexecutor:\n mode: agent\n tier: MAX\n---\n"; - let fm = parse_front_matter(text); - assert_eq!( - fm, - json!({ - "skills": ["bash", "write-files"], - "executor": {"mode": "agent", "tier": "MAX"} - }) - ); - } - - #[test] - fn parses_scalars_like_js() { - let fm = parse_front_matter( - "---\nnum: 42\nfloat: 1.5\nyes: true\nno: false\nnil: null\ntilde: ~\nquoted: \"a: b\"\n---\n", - ); - assert_eq!( - fm, - json!({ - "num": 42, "float": 1.5, "yes": true, "no": false, - "nil": null, "tilde": null, "quoted": "a: b" - }) - ); - } - - #[test] - fn crlf_frontmatter() { - let fm = parse_front_matter("---\r\na: 1\r\n---\r\nbody"); - assert_eq!(fm, json!({"a": 1})); - } - - #[test] - fn serialize_yaml_matches_js_bytes() { - let obj = json!({ - "schema_version": 1, - "created_at": "2026-06-14T00:00:00Z", - "budget_sec": 1800, - "hint": "atomic", - "note": null, - "skills": ["bash", "write-files"], - "nested": {"a": 1, "b": "x y"} - }); - // Часові мітки містять ':' → JS-версія теж бере їх у лапки. - assert_eq!( - serialize_yaml(&obj, 0), - "schema_version: 1\ncreated_at: \"2026-06-14T00:00:00Z\"\nbudget_sec: 1800\nhint: atomic\nnote:\nskills:\n - bash\n - write-files\nnested:\n a: 1\n b: x y" - ); - } - - #[test] - fn serialize_quotes_special_chars() { - let obj = json!({"a": "x: y", "b": " pad ", "c": "q\"q"}); - assert_eq!( - serialize_yaml(&obj, 0), - "a: \"x: y\"\nb: \" pad \"\nc: q\"q" - ); - } - - #[test] - fn build_markdown_layout() { - let md = build_markdown(&json!({"a": 1}), "body\n"); - assert_eq!(md, "---\na: 1\n---\n\nbody\n"); - } - - #[test] - fn roundtrip_parse_serialize() { - let src = "schema_version: 1\ncreated_at: \"2026-06-14T00:00:00Z\"\nresult: success"; - let fm = parse_front_matter(&format!("---\n{src}\n---\n")); - assert_eq!(serialize_yaml(&fm, 0), src); - } -} diff --git a/crates/mt-core/src/ledger.rs b/crates/mt-core/src/ledger.rs deleted file mode 100644 index f6696c8..0000000 --- a/crates/mt-core/src/ledger.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Cost/time ledger (спека mt.md, run-frontmatter `wall_sec`/`tokens_in`/ -//! `tokens_out`/`cost_usd` — «сировина для звітності»). Агрегує всі -//! `run_NNN.md` по графу воркспейсу: per-node і сумарний підсумок для -//! GUI-аналітики (Фаза 4). - -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -use crate::artifacts::{list_node_artifacts, ArtifactKind}; -use crate::{discover_worktrees, scan_tasks, TaskNode}; - -/// Агреговані метрики одного вузла (сума по всіх його `run_NNN.md`). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct LedgerEntry { - pub path: String, - pub runs: u64, - pub wall_sec: u64, - pub cost_usd: f64, - pub tokens_in: u64, - pub tokens_out: u64, -} - -/// Підсумок воркспейсу: per-node записи (найдорожчі за `wall_sec` — першими) -/// + сумарний рядок `total` по всьому графу. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CostLedger { - pub nodes: Vec, - pub total: LedgerEntry, -} - -fn flatten<'a>(nodes: &'a [TaskNode], out: &mut Vec<&'a TaskNode>) { - for node in nodes { - out.push(node); - flatten(&node.children, out); - } -} - -fn add(acc: &mut LedgerEntry, entry: &LedgerEntry) { - acc.runs += entry.runs; - acc.wall_sec += entry.wall_sec; - acc.cost_usd += entry.cost_usd; - acc.tokens_in += entry.tokens_in; - acc.tokens_out += entry.tokens_out; -} - -/// Будує ledger сканом усього дерева воркспейсу + `run_NNN.md` кожного вузла. -/// Вузли без жодного run-артефакту (ще не запускались) — пропускаються. -pub fn build_cost_ledger(tasks_dir: &str) -> Result { - let worktrees = discover_worktrees(Path::new(tasks_dir)); - let tree = scan_tasks(tasks_dir.to_string(), worktrees)?; - let mut all = Vec::new(); - flatten(&tree, &mut all); - - let mut nodes = Vec::new(); - let mut total = LedgerEntry::default(); - for node in all { - let artifacts = list_node_artifacts(tasks_dir, &node.path).unwrap_or_default(); - let mut entry = LedgerEntry { - path: node.path.clone(), - ..Default::default() - }; - for artifact in &artifacts { - if artifact.kind != ArtifactKind::Run { - continue; - } - entry.runs += 1; - entry.wall_sec += artifact.wall_sec.unwrap_or(0); - entry.cost_usd += artifact.cost_usd.unwrap_or(0.0); - entry.tokens_in += artifact.tokens_in.unwrap_or(0); - entry.tokens_out += artifact.tokens_out.unwrap_or(0); - } - if entry.runs > 0 { - add(&mut total, &entry); - nodes.push(entry); - } - } - nodes.sort_by(|a, b| { - b.wall_sec - .cmp(&a.wall_sec) - .then(b.cost_usd.total_cmp(&a.cost_usd)) - }); - total.path = "TOTAL".to_string(); - - Ok(CostLedger { nodes, total }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn write_run(dir: &std::path::Path, nnn: &str, wall_sec: u64, cost_usd: Option) { - let cost_line = cost_usd - .map(|c| format!("cost_usd: {c}\n")) - .unwrap_or_default(); - fs::write( - dir.join(format!("run_{nnn}.md")), - format!( - "---\nschema_version: 1\nactor: agent\nresult: success\nwall_sec: {wall_sec}\n{cost_line}tokens_in: 100\ntokens_out: 20\n---\n" - ), - ) - .unwrap(); - } - - fn node(tmp: &std::path::Path, path: &str) -> std::path::PathBuf { - let dir = tmp.join(path); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("task.md"), - "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\n---\n\n## Task\n", - ) - .unwrap(); - fs::write(dir.join("a.md"), "schema_version: 1\n").unwrap(); - dir - } - - #[test] - fn aggregates_runs_per_node_and_total() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - let a = node(&root, "a"); - write_run(&a, "001", 100, Some(0.5)); - write_run(&a, "002", 50, Some(0.1)); - let b = node(&root, "b"); - write_run(&b, "001", 300, None); - - let ledger = build_cost_ledger(&root.to_string_lossy()).unwrap(); - assert_eq!(ledger.nodes.len(), 2); - // b дорожчий за wall_sec (300 > 150) — сортування спадне. - assert_eq!(ledger.nodes[0].path, "b"); - assert_eq!(ledger.nodes[0].wall_sec, 300); - assert_eq!(ledger.nodes[0].runs, 1); - assert_eq!(ledger.nodes[1].path, "a"); - assert_eq!(ledger.nodes[1].runs, 2); - assert_eq!(ledger.nodes[1].wall_sec, 150); - assert!((ledger.nodes[1].cost_usd - 0.6).abs() < 1e-9); - - assert_eq!(ledger.total.runs, 3); - assert_eq!(ledger.total.wall_sec, 450); - assert!((ledger.total.cost_usd - 0.6).abs() < 1e-9); - assert_eq!(ledger.total.tokens_in, 300); - assert_eq!(ledger.total.tokens_out, 60); - } - - #[test] - fn node_without_runs_is_excluded() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - node(&root, "untouched"); - let ledger = build_cost_ledger(&root.to_string_lossy()).unwrap(); - assert!(ledger.nodes.is_empty()); - assert_eq!(ledger.total.runs, 0); - } - - #[test] - fn composite_children_included_recursively() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - let parent = node(&root, "parent"); - write_run(&parent, "001", 10, None); - let child = node(&root, "parent/child"); - write_run(&child, "001", 20, None); - - let ledger = build_cost_ledger(&root.to_string_lossy()).unwrap(); - let paths: Vec<&str> = ledger.nodes.iter().map(|e| e.path.as_str()).collect(); - assert!(paths.contains(&"parent")); - assert!(paths.contains(&"parent/child")); - assert_eq!(ledger.total.wall_sec, 30); - } -} diff --git a/crates/mt-core/src/lib.rs b/crates/mt-core/src/lib.rs deleted file mode 100644 index 447ca41..0000000 --- a/crates/mt-core/src/lib.rs +++ /dev/null @@ -1,1646 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -pub mod artifacts; -pub mod claims; -pub mod config; -pub mod directory; -pub mod frontmatter; -pub mod ledger; -pub mod lifecycle; -pub mod nnn; -pub mod orchestrate; -pub mod publish; -pub mod runner; -pub mod signal; -pub mod spawn; -#[cfg(test)] -mod test_support; -pub mod worktree; - -// ── Types ───────────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum TaskState { - Unassigned, - Pending, // h.md exists - Waiting, // a.md exists, deps resolved - Blocked, // a.md exists, deps not resolved - PlanReview, // composite plan awaiting human approval - Spawned, // children materialized, not all resolved - Running, // running__until_ sentinel present - Stalled, // remote claim lease expired (needs claim refs — not derived by local scan) - PendingAudit, // open audit cycle - Resolved, // accepted fact exists - Failed, // failed_streak >= agent_retry_max - Unresolvable, // unresolvable.md exists (terminal) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskNode { - pub id: String, - pub path: String, - pub state: TaskState, - pub deps: Vec, - pub mode: String, - pub budget_sec: Option, - pub budget_hard_sec: Option, - pub deadline: Option, - pub hint: Option, - pub created_at: Option, - pub children: Vec, - pub is_composite: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceInfo { - pub label: String, - pub path: String, -} - -/// Виконавець вузла. Істина — прапор-файл `a.md`/`h.md`, не поле frontmatter. -/// Пише прапор виконавця (`a.md` або `h.md`) і видаляє протилежний — -/// інваріант «рівно один прапор» (§4.3). Повертає ім'я записаного файлу. -pub fn write_executor_flag( - task_dir: &Path, - mode: Mode, - model_tier: &str, - skills: &[String], - qualification: Option<&str>, -) -> Result<&'static str, String> { - match mode { - Mode::Agent => { - let skill_lines = skills - .iter() - .map(|s| format!("- {s}")) - .collect::>() - .join("\n"); - let content = format!("## Model tier\n\n{model_tier}\n\n## Skills\n\n{skill_lines}\n"); - write_atomic(&task_dir.join("a.md"), &content)?; - let _ = fs::remove_file(task_dir.join("h.md")); - Ok("a.md") - } - Mode::Human => { - let content = match qualification { - Some(q) => format!("## Qualification\n\n{q}\n"), - None => "## Qualification\n\n\n" - .to_string(), - }; - write_atomic(&task_dir.join("h.md"), &content)?; - let _ = fs::remove_file(task_dir.join("a.md")); - Ok("h.md") - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Mode { - Agent, - Human, -} - -/// Опції створення вузла. `None`-поля резолвляться з `.mt.json` (default_*). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CreateOpts { - #[serde(default)] - pub mode: Option, - #[serde(default)] - pub model_tier: Option, - #[serde(default)] - pub budget_sec: Option, - #[serde(default)] - pub hint: Option, - #[serde(default)] - pub deps: Vec, - #[serde(default)] - pub skills: Option>, - /// Текст місії дитини (спека: `## Children` → `task:`); без нього — шаблон. - #[serde(default)] - pub task: Option, - /// Для mode: human — кваліфікація виконавця (`## Children` → `qualification:`). - #[serde(default)] - pub qualification: Option, -} - -/// Результат `create_task`. CLI серіалізує через [`CreateOutcome::to_cli_json`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CreateOutcome { - Created { - name: String, - task_path: String, - flag: String, - deps: Vec, - }, - Exists { - name: String, - task_path: String, - }, -} - -impl CreateOutcome { - /// Плаский JSON-контракт CLI (§3.2 spec): поле `created: bool`. - pub fn to_cli_json(&self) -> serde_json::Value { - match self { - CreateOutcome::Created { - name, - task_path, - flag, - deps, - } => serde_json::json!({ - "created": true, - "name": name, - "task_path": task_path, - "flag": flag, - "deps": deps, - }), - CreateOutcome::Exists { name, task_path } => serde_json::json!({ - "created": false, - "reason": "exists", - "name": name, - "task_path": task_path, - }), - } - } -} - -// ── Frontmatter ─────────────────────────────────────────────────────────────── - -#[derive(Default)] -struct Frontmatter { - created_at: Option, - budget_sec: Option, - budget_hard_sec: Option, - deadline: Option, - hint: Option, -} - -fn parse_frontmatter(content: &str) -> Frontmatter { - let mut fm = Frontmatter::default(); - let lines: Vec<&str> = content.lines().collect(); - if lines.is_empty() || lines[0].trim() != "---" { - return fm; - } - let end = lines[1..] - .iter() - .position(|l| l.trim() == "---") - .map(|i| i + 1) - .unwrap_or(lines.len()); - for line in &lines[1..end] { - let t = line.trim(); - if t == "---" { - break; - } - if let Some(pos) = t.find(':') { - let key = t[..pos].trim(); - let val = t[pos + 1..].trim(); - match key { - "created_at" => fm.created_at = Some(val.to_string()), - "budget_sec" => fm.budget_sec = val.parse().ok(), - "budget_hard_sec" => fm.budget_hard_sec = val.parse().ok(), - "deadline" => fm.deadline = Some(val.to_string()), - "hint" => fm.hint = Some(val.to_string()), - _ => {} - } - } - } - fm -} - -// ── NNN helpers ─────────────────────────────────────────────────────────────── - -fn max_nnn(dir: &Path, prefix: &str, suffix: &str) -> u64 { - fs::read_dir(dir) - .ok() - .into_iter() - .flatten() - .flatten() - .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) - .filter_map(|e| { - let n = e.file_name(); - let s = n.to_string_lossy(); - if s.starts_with(prefix) && s.ends_with(suffix) { - s[prefix.len()..s.len() - suffix.len()].parse::().ok() - } else { - None - } - }) - .max() - .unwrap_or(0) -} - -fn failed_streak(dir: &Path) -> u64 { - max_nnn(dir, "run_", ".md").saturating_sub(max_nnn(dir, "fact_", ".md")) -} - -// ── State detection ─────────────────────────────────────────────────────────── - -// Reads result: field from audit-result frontmatter (only exception to name-based state rule). -fn audit_result_success(path: &Path) -> bool { - let Ok(content) = fs::read_to_string(path) else { - return false; - }; - let lines: Vec<&str> = content.lines().collect(); - if lines.first().map(|l| l.trim()) != Some("---") { - return false; - } - let end = lines[1..] - .iter() - .position(|l| l.trim() == "---") - .map(|i| i + 1) - .unwrap_or(lines.len()); - for line in &lines[1..end] { - if let Some(val) = line.trim().strip_prefix("result:") { - return val.trim() == "success"; - } - } - false -} - -#[derive(PartialEq)] -enum FactState { - None, - PendingAudit, - Resolved, -} - -// Accepted-fact state from the LATEST fact NNN only (mirrors JS getAcceptedFactState). -// A non-latest open audit cycle does not block resolution. -fn accepted_fact_state(dir: &Path) -> FactState { - let nnn = max_nnn(dir, "fact_", ".md"); - if nnn == 0 { - return FactState::None; - } - let nnn_s = format!("{nnn:03}"); - if !dir.join(format!("pending-audit_{nnn_s}.md")).exists() { - return FactState::Resolved; - } - let result_path = dir.join(format!("audit-result_{nnn_s}.md")); - if !result_path.exists() { - return FactState::PendingAudit; - } - // Audit completed: success → resolved; failed → fall through (None). - if audit_result_success(&result_path) { - FactState::Resolved - } else { - FactState::None - } -} - -// Local runtime marker running__until_ (mirrors JS RUNNING_MARKER_RE /^running_\d+_until_/). -fn has_running_marker(dir: &Path) -> bool { - fs::read_dir(dir).ok().is_some_and(|entries| { - entries.flatten().any(|e| { - e.file_type().map(|t| t.is_file()).unwrap_or(false) - && is_running_marker(&e.file_name().to_string_lossy()) - }) - }) -} - -fn is_running_marker(name: &str) -> bool { - let Some(rest) = name.strip_prefix("running_") else { - return false; - }; - let Some(idx) = rest.find("_until_") else { - return false; - }; - idx > 0 && rest[..idx].bytes().all(|b| b.is_ascii_digit()) -} - -// Sanitize task name for worktree comparison (mirrors JS sanitizeTaskName: [^A-Za-z0-9_-] → '-'). -// NOTE: must stay in sync with sanitizeTaskName in npm/lib/core/state.mjs (shared test vectors). -pub fn sanitize(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' || c == '-' { - c - } else { - '-' - } - }) - .collect() -} - -/// Нормалізує ім'я гілки до безпечного імені директорії у .worktrees/. -/// Правило: не-alphanum/не-_/не-- → '-', consecutive '-' → '-', trim leading/trailing '-'. -/// ⚠️ Логіку синхронізовано з JS `sanitizeBranch` у `@7n/mt` (npm/lib/commands/worktree.mjs). -pub fn sanitize_branch(branch: &str) -> String { - let s: String = branch - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' || c == '-' { - c - } else { - '-' - } - }) - .collect(); - let s = re_collapse_dashes(&s); - s.trim_matches('-').to_string() -} - -fn re_collapse_dashes(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut last_dash = false; - for c in s.chars() { - if c == '-' { - if !last_dash { - result.push('-'); - } - last_dash = true; - } else { - result.push(c); - last_dash = false; - } - } - result -} - -// running if an active worktree name starts with the sanitized node path. -fn worktree_matches(path: &str, worktrees: &[String]) -> bool { - let prefix = sanitize(path); - !prefix.is_empty() && worktrees.iter().any(|wt| wt.starts_with(&prefix)) -} - -fn plan_decision(dir: &Path, nnn: u64) -> Option { - let content = fs::read_to_string(dir.join(format!("plan_{nnn:03}.md"))).ok()?; - let lines: Vec<&str> = content.lines().collect(); - if lines.first()?.trim() != "---" { - return None; - } - let end = lines[1..] - .iter() - .position(|l| l.trim() == "---") - .map(|i| i + 1) - .unwrap_or(lines.len()); - for line in &lines[1..end] { - if let Some(val) = line.trim().strip_prefix("decision:") { - return Some(val.trim().to_string()); - } - } - None -} - -// plan-review / spawned for composite plans (mirrors JS getCompositePlanState). -fn composite_plan_state(dir: &Path, children: &[TaskNode]) -> Option { - let nnn = max_nnn(dir, "plan_", ".md"); - if nnn == 0 { - return None; - } - if plan_decision(dir, nnn).as_deref() != Some("composite") { - return None; - } - let nnn_s = format!("{nnn:03}"); - let approved = dir.join(format!("plan-approved_{nnn_s}.md")).exists(); - let rejected = dir.join(format!("plan-rejected_{nnn_s}.md")).exists(); - if !approved && !rejected { - return Some(TaskState::PlanReview); - } - if approved && !children.is_empty() { - return Some(TaskState::Spawned); - } - None -} - -// Priority per spec / JS deriveNodeState: -// pending-audit > resolved > unresolvable > running > plan-review > spawned > -// waiting/failed > pending > unassigned. (stalled needs remote — skipped in local scan.) -fn detect_state( - dir: &Path, - path: &str, - children: &[TaskNode], - agent_retry_max: u64, - worktrees: &[String], -) -> TaskState { - // 1 + 2. pending-audit / resolved — accepted fact on the latest NNN. - match accepted_fact_state(dir) { - FactState::PendingAudit => return TaskState::PendingAudit, - FactState::Resolved => return TaskState::Resolved, - FactState::None => {} - } - // 3. unresolvable — terminal marker file. - if dir.join("unresolvable.md").exists() { - return TaskState::Unresolvable; - } - // 4. running — local marker or an active worktree matching this node. - if has_running_marker(dir) || worktree_matches(path, worktrees) { - return TaskState::Running; - } - // 5 + 6. plan-review / spawned — composite plan without approve, or approved with children. - if let Some(st) = composite_plan_state(dir, children) { - return st; - } - // 7. waiting / failed — a.md = agent executor; failed once streak exhausted. - if dir.join("a.md").exists() { - if failed_streak(dir) >= agent_retry_max { - return TaskState::Failed; - } - return TaskState::Waiting; // may be upgraded to Blocked in post-processing - } - // 8. pending — h.md = human executor. - if dir.join("h.md").exists() { - return TaskState::Pending; - } - // 9. unassigned — no executor. - TaskState::Unassigned -} - -// ── Blocked post-processing ─────────────────────────────────────────────────── - -fn build_state_map(nodes: &[TaskNode], map: &mut HashMap) { - for node in nodes { - map.insert(node.path.clone(), node.state.clone()); - build_state_map(&node.children, map); - } -} - -fn apply_blocked(nodes: &mut [TaskNode], state_map: &HashMap) { - for node in nodes.iter_mut() { - if node.state == TaskState::Waiting && !node.deps.is_empty() { - let blocked = node.deps.iter().any(|dep_id| { - state_map - .get(dep_id) - .is_none_or(|s| *s != TaskState::Resolved) - }); - if blocked { - node.state = TaskState::Blocked; - } - } - if !node.children.is_empty() { - apply_blocked(&mut node.children, state_map); - } - } -} - -// ── Deps ────────────────────────────────────────────────────────────────────── - -fn collect_deps(deps_root: &Path, current: &Path, result: &mut Vec) { - let Ok(entries) = fs::read_dir(current) else { - return; - }; - let mut entries: Vec<_> = entries.flatten().collect(); - entries.sort_by_key(|e| e.file_name()); - for entry in entries { - let path = entry.path(); - if path.is_dir() { - collect_deps(deps_root, &path, result); - } else if path.extension().and_then(|e| e.to_str()) == Some("md") { - if let Ok(rel) = path.strip_prefix(deps_root) { - let dep_str = rel.to_string_lossy().replace('\\', "/"); - let dep_id = dep_str.strip_suffix(".md").unwrap_or(&dep_str).to_string(); - result.push(dep_id); - } - } - } -} - -fn read_deps_dir(node_dir: &Path) -> Vec { - let deps_dir = node_dir.join("deps"); - if !deps_dir.is_dir() { - return vec![]; - } - let mut result = vec![]; - collect_deps(&deps_dir, &deps_dir, &mut result); - result -} - -// ── Node scanner ────────────────────────────────────────────────────────────── - -// Spec «Монорепо»: scan пропускає `.gitignore`d, приховані (`.`) та -// `node_modules`/`target`/`dist`/`build` директорії. -fn scan_skip_dir(name: &str, ignore_patterns: &[String]) -> bool { - name.starts_with('.') - || matches!(name, "node_modules" | "target" | "dist" | "build") - || dir_is_gitignored(name, ignore_patterns) -} - -fn scan_dir( - dir: &Path, - tasks_root: &Path, - agent_retry_max: u64, - worktrees: &[String], - inherited_ignores: &[String], -) -> Option { - if !dir.join("task.md").exists() { - return None; - } - - let content = fs::read_to_string(dir.join("task.md")).unwrap_or_default(); - let fm = parse_frontmatter(&content); - - let mode = if dir.join("a.md").exists() { - "agent" - } else if dir.join("h.md").exists() { - "human" - } else { - "unassigned" - }; - - let deps = read_deps_dir(dir); - - // Scan children; skip history/ and other non-node dirs (no task.md = None), - // plus hidden/denylisted/.gitignore'd dirs per spec «Монорепо». - let ignores = load_gitignore(dir, inherited_ignores); - let mut children: Vec = Vec::new(); - if let Ok(entries) = fs::read_dir(dir) { - let mut subdirs: Vec<_> = entries - .flatten() - .filter(|e| { - e.file_type().map(|t| t.is_dir()).unwrap_or(false) - && !scan_skip_dir(&e.file_name().to_string_lossy(), &ignores) - }) - .collect(); - subdirs.sort_by_key(|e| e.file_name()); - for sub in subdirs { - if let Some(child) = scan_dir( - &sub.path(), - tasks_root, - agent_retry_max, - worktrees, - &ignores, - ) { - children.push(child); - } - } - } - - let is_composite = !children.is_empty(); - let id = dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - let path = dir - .strip_prefix(tasks_root) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|_| id.clone()); - let state = detect_state(dir, &path, &children, agent_retry_max, worktrees); - - Some(TaskNode { - id, - path, - state, - deps, - mode: mode.to_string(), - budget_sec: fm.budget_sec, - budget_hard_sec: fm.budget_hard_sec, - deadline: fm.deadline, - hint: fm.hint, - created_at: fm.created_at, - children, - is_composite, - }) -} - -// ── Workspace discovery ─────────────────────────────────────────────────────── - -fn find_git_root(start: &Path) -> Option { - let mut current = start; - loop { - if current.join(".git").exists() { - return Some(current.to_path_buf()); - } - current = current.parent()?; - } -} - -fn workspace_label(git_root: &Path, workspace_dir: &Path) -> String { - if workspace_dir == git_root { - git_root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("root") - .to_string() - } else { - workspace_dir - .strip_prefix(git_root) - .map(|p| p.to_string_lossy().replace('\\', "/")) - .unwrap_or_else(|_| workspace_dir.to_string_lossy().into_owned()) - } -} - -fn load_gitignore(dir: &Path, inherited: &[String]) -> Vec { - let mut patterns = inherited.to_vec(); - if let Ok(content) = fs::read_to_string(dir.join(".gitignore")) { - for line in content.lines() { - let l = line.trim(); - if !l.is_empty() && !l.starts_with('#') && !l.starts_with('!') { - patterns.push(l.trim_end_matches('/').trim_start_matches('/').to_string()); - } - } - } - patterns -} - -fn glob_match_name(pattern: &str, name: &str) -> bool { - match pattern.split_once('*') { - Some((prefix, suffix)) => { - name.starts_with(prefix) - && name.ends_with(suffix) - && name.len() >= prefix.len() + suffix.len() - } - None => name == pattern, - } -} - -fn dir_is_gitignored(name: &str, patterns: &[String]) -> bool { - patterns.iter().any(|p| glob_match_name(p, name)) -} - -fn has_task_nodes(dir: &Path) -> bool { - fs::read_dir(dir).ok().is_some_and(|entries| { - entries.flatten().any(|e| { - e.file_type().map(|t| t.is_dir()).unwrap_or(false) && e.path().join("task.md").exists() - }) - }) -} - -fn scan_for_workspaces( - current: &Path, - git_root: &Path, - result: &mut Vec, - depth: u8, - inherited_ignores: &[String], -) { - if depth > 6 { - return; - } - - let ignores = load_gitignore(current, inherited_ignores); - - let mt_config = current.join(".mt.json"); - if mt_config.exists() { - let mt_dir = fs::read_to_string(&mt_config) - .ok() - .and_then(|c| serde_json::from_str::(&c).ok()) - .and_then(|v| { - v.get("mt_dir") - .and_then(|v| v.as_str()) - .map(|s| current.join(s)) - }) - .unwrap_or_else(|| current.join("mt")); - if mt_dir.is_dir() && has_task_nodes(&mt_dir) { - result.push(WorkspaceInfo { - label: workspace_label(git_root, current), - path: mt_dir.to_string_lossy().into_owned(), - }); - return; - } - } - - for dirname in &["mt", "tasks"] { - let candidate = current.join(dirname); - if candidate.is_dir() && has_task_nodes(&candidate) { - result.push(WorkspaceInfo { - label: workspace_label(git_root, current), - path: candidate.to_string_lossy().into_owned(), - }); - return; - } - } - - let Ok(entries) = fs::read_dir(current) else { - return; - }; - let mut subdirs: Vec<_> = entries - .flatten() - .filter(|e| { - let name = e.file_name(); - let n = name.to_string_lossy(); - e.file_type().map(|t| t.is_dir()).unwrap_or(false) - && !n.starts_with('.') - && !matches!(n.as_ref(), "node_modules" | "target" | "dist" | "build") - && !dir_is_gitignored(&n, &ignores) - }) - .collect(); - subdirs.sort_by_key(|e| e.file_name()); - for sub in subdirs { - scan_for_workspaces(&sub.path(), git_root, result, depth + 1, &ignores); - } -} - -fn read_agent_retry_max(project_root: &Path) -> u64 { - fs::read_to_string(project_root.join(".mt.json")) - .ok() - .and_then(|c| serde_json::from_str::(&c).ok()) - .and_then(|v| v.get("agent_retry_max").and_then(|v| v.as_u64())) - .unwrap_or(3) -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -/// Сканує `tasks_dir` і повертає дерево вузлів. -/// -/// `worktrees` — імена активних git-worktree (останній компонент шляху). Вузол, чий -/// sanitized-шлях є префіксом імені активного worktree, отримує стан `running`. -pub fn scan_tasks(tasks_dir: String, worktrees: Vec) -> Result, String> { - let dir = PathBuf::from(&tasks_dir); - if !dir.exists() { - return Err(format!("Directory not found: {tasks_dir}")); - } - let project_root = dir.parent().unwrap_or(&dir).to_path_buf(); - let agent_retry_max = read_agent_retry_max(&project_root); - - // .gitignore успадковується з project root у tasks-dir і далі вглиб дерева. - let root_ignores = load_gitignore(&project_root, &[]); - let ignores = load_gitignore(&dir, &root_ignores); - - let mut entries: Vec<_> = fs::read_dir(&dir) - .map_err(|e| e.to_string())? - .flatten() - .filter(|e| { - e.file_type().map(|t| t.is_dir()).unwrap_or(false) - && !scan_skip_dir(&e.file_name().to_string_lossy(), &ignores) - }) - .collect(); - entries.sort_by_key(|e| e.file_name()); - - let mut nodes: Vec = entries - .iter() - .filter_map(|e| scan_dir(&e.path(), &dir, agent_retry_max, &worktrees, &ignores)) - .collect(); - - // Post-processing: mark Waiting → Blocked where deps are not yet resolved. - let mut state_map = HashMap::new(); - build_state_map(&nodes, &mut state_map); - apply_blocked(&mut nodes, &state_map); - - Ok(nodes) -} - -/// Виявляє активні git-worktree через `git worktree list --porcelain` із `start_dir`. -/// Повертає імена (останній компонент шляху кожного worktree). Помилка git → порожньо. -pub fn discover_worktrees(start: &Path) -> Vec { - let output = std::process::Command::new("git") - .args(["worktree", "list", "--porcelain"]) - .current_dir(start) - .output(); - match output { - Ok(out) if out.status.success() => { - parse_worktree_list(&String::from_utf8_lossy(&out.stdout)) - } - _ => vec![], - } -} - -/// Парсить `git worktree list --porcelain` → імена worktree (останній компонент шляху). -pub fn parse_worktree_list(output: &str) -> Vec { - output - .lines() - .filter_map(|line| { - let path = line.strip_prefix("worktree ")?.trim(); - let name = path.rsplit(['/', '\\']).next().unwrap_or(""); - if name.is_empty() { - None - } else { - Some(name.to_string()) - } - }) - .collect() -} - -/// Знаходить усі mt/ директорії у репо, починаючи від `start_dir`. -pub fn find_all_tasks_dirs_from(start_dir: &Path) -> Vec { - let git_root = find_git_root(start_dir).unwrap_or_else(|| start_dir.to_path_buf()); - let mut result = vec![]; - scan_for_workspaces(&git_root, &git_root, &mut result, 0, &[]); - result -} - -/// Знаходить усі mt/ директорії у репо від поточного cwd. -pub fn find_all_tasks_dirs() -> Result, String> { - let cwd = std::env::current_dir().map_err(|e| e.to_string())?; - Ok(find_all_tasks_dirs_from(&cwd)) -} - -/// Знаходить першу tasks-директорію, ідучи вгору від cwd. -pub fn find_tasks_dir() -> Result { - let cwd = std::env::current_dir().map_err(|e| e.to_string())?; - let mut dir: &Path = &cwd; - let mut depth = 0u8; - loop { - let mt_config = dir.join(".mt.json"); - if mt_config.exists() { - if let Ok(content) = fs::read_to_string(&mt_config) { - if let Ok(v) = serde_json::from_str::(&content) { - if let Some(td) = v.get("mt_dir").and_then(|v| v.as_str()) { - let full = dir.join(td); - if full.is_dir() { - return Ok(full.to_string_lossy().into_owned()); - } - } - } - } - } - let config_path = dir.join(".n-cursor.json"); - if config_path.exists() { - if let Ok(content) = fs::read_to_string(&config_path) { - if let Ok(v) = serde_json::from_str::(&content) { - if let Some(td) = v.get("tasks_dir").and_then(|v| v.as_str()) { - let full = dir.join(td); - if full.is_dir() { - return Ok(full.to_string_lossy().into_owned()); - } - } - } - } - } - for dirname in &["mt", "tasks"] { - let candidate = dir.join(dirname); - if candidate.is_dir() && has_task_nodes(&candidate) { - return Ok(candidate.to_string_lossy().into_owned()); - } - } - depth += 1; - if depth >= 8 { - break; - } - match dir.parent() { - Some(p) => dir = p, - None => break, - } - } - Err("Could not auto-detect tasks directory.".to_string()) -} - -// ── Task creation (write-side) ───────────────────────────────────────────────── - -/// Валідує id вузла (§8 spec). Дозволені сегменти `[a-z0-9-]+`, роздільник `/`. -/// Відхиляє порожні/`.`/`..` сегменти, провідний/кінцевий `/`, traversal. -/// На відміну від [`sanitize`], НЕ виправляє — повертає `Err`. -pub fn validate_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("name must not be empty".to_string()); - } - if name.starts_with('/') || name.ends_with('/') { - return Err(format!("name must not start or end with '/': {name:?}")); - } - for seg in name.split('/') { - if seg.is_empty() { - return Err(format!("name has an empty segment: {name:?}")); - } - if seg == "." || seg == ".." { - return Err(format!("name segment must not be '.' or '..': {name:?}")); - } - if !seg - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - { - // Текст синхронізовано з JS validateTaskName (npm/lib/core/state.mjs). - return Err(format!( - "name segment {seg:?} must match [a-z0-9-]: {name:?}" - )); - } - } - Ok(()) -} - -struct CreateDefaults { - mode: Mode, - model_tier: String, - budget_sec: u64, -} - -fn read_create_defaults(project_root: &Path) -> CreateDefaults { - let v = fs::read_to_string(project_root.join(".mt.json")) - .ok() - .and_then(|c| serde_json::from_str::(&c).ok()); - let mode = v - .as_ref() - .and_then(|v| v.get("default_mode").and_then(|x| x.as_str())) - .map(|s| { - if s == "agent" { - Mode::Agent - } else { - Mode::Human - } - }) - .unwrap_or(Mode::Human); - let model_tier = v - .as_ref() - .and_then(|v| v.get("default_model_tier").and_then(|x| x.as_str())) - .unwrap_or("AVG") - .to_string(); - let budget_sec = v - .as_ref() - .and_then(|v| v.get("default_budget_sec").and_then(|x| x.as_u64())) - .unwrap_or(1800); - CreateDefaults { - mode, - model_tier, - budget_sec, - } -} - -/// Найвищий неіснуючий предок `dir` (для відкату — що саме ми створимо). -fn first_missing_ancestor(dir: &Path) -> Option { - if dir.exists() { - return None; - } - let mut candidate = dir.to_path_buf(); - while let Some(parent) = candidate.parent() { - if parent.exists() { - return Some(candidate); - } - candidate = parent.to_path_buf(); - } - Some(candidate) -} - -/// Атомарний запис: tmp-файл у тій самій директорії → rename (§13/§11.1). -fn write_atomic(path: &Path, content: &str) -> Result<(), String> { - let dir = path.parent().ok_or("path has no parent directory")?; - let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); - let tmp = dir.join(format!(".{fname}.tmp")); - fs::write(&tmp, content).map_err(|e| e.to_string())?; - fs::rename(&tmp, path).map_err(|e| e.to_string()) -} - -const TASK_BODY: &str = "\n## Task\n\n\n\n## Done when\n\n\n\n## Check\n\n\n\n## Inputs\n\n\n"; - -/// Створює вузол задачі з шаблонного контракту (§4 spec): `/task.md`, -/// прапор виконавця (`a.md`/`h.md`), опційні `deps/.md`. -/// -/// Ідемпотентно: якщо `task.md` уже існує — повертає `Exists`, нічого не пише. -/// Атомарно: при частковій відмові прибирає щойно створену гілку директорій. -pub fn create_task( - tasks_dir: String, - name: String, - opts: CreateOpts, -) -> Result { - validate_name(&name)?; - - let tasks_root = PathBuf::from(&tasks_dir); - let project_root = tasks_root.parent().unwrap_or(&tasks_root).to_path_buf(); - let defaults = read_create_defaults(&project_root); - - let task_dir = tasks_root.join(&name); - let task_path_rel = format!("{name}/task.md"); - let task_md = task_dir.join("task.md"); - - // Ідемпотентність (§2.5): існуючий вузол не чіпаємо. - if task_md.exists() { - return Ok(CreateOutcome::Exists { - name, - task_path: task_path_rel, - }); - } - - let mode = opts.mode.unwrap_or(defaults.mode); - let model_tier = opts.model_tier.unwrap_or(defaults.model_tier); - let budget_sec = opts.budget_sec.unwrap_or(defaults.budget_sec); - let hint = opts.hint.unwrap_or_else(|| "atomic".to_string()); - let skills = opts - .skills - .unwrap_or_else(|| vec!["bash".to_string(), "write-files".to_string()]); - - // Гілка директорій, яку ми створимо — для відкату при частковій відмові. - let rollback_root = first_missing_ancestor(&task_dir); - - let build = || -> Result { - fs::create_dir_all(&task_dir).map_err(|e| e.to_string())?; - - // schema_version ПЕРШИМ полем (інваріант docs/mt.md); лише нові файли (§2.8). - let created_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let frontmatter = format!( - "---\nschema_version: 1\ncreated_at: {created_at}\nbudget_sec: {budget_sec}\nhint: {hint}\n---\n" - ); - let body = match &opts.task { - Some(text) => format!( - "\n## Task\n\n{}\n\n## Done when\n\n\n\n## Check\n\n\n\n## Inputs\n\n\n", - text.trim_end() - ), - None => TASK_BODY.to_string(), - }; - write_atomic(&task_md, &format!("{frontmatter}{body}"))?; - - // Прапор виконавця — рівно один (§4.3). - let flag = write_executor_flag( - &task_dir, - mode, - &model_tier, - &skills, - opts.qualification.as_deref(), - )?; - - // Залежності — порожні файли-ребра deps/.md (§4.4). - if !opts.deps.is_empty() { - let deps_dir = task_dir.join("deps"); - fs::create_dir_all(&deps_dir).map_err(|e| e.to_string())?; - for dep in &opts.deps { - let dep_file = deps_dir.join(format!("{dep}.md")); - if let Some(parent) = dep_file.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - write_atomic(&dep_file, "")?; - } - } - - Ok(CreateOutcome::Created { - name: name.clone(), - task_path: task_path_rel.clone(), - flag: flag.to_string(), - deps: opts.deps.clone(), - }) - }; - - let result = build(); - if result.is_err() { - if let Some(root) = rollback_root { - let _ = fs::remove_dir_all(&root); - } - } - result -} - -// ── Tests ─────────────────────────────────────────────────────────────────────── -// Reproduce the authoritative cases from npm/lib/tests/state.test.mjs (the JS suite -// these replace), plus worktree→running and sanitize vectors. - -#[cfg(test)] -mod tests { - - #[test] - fn test_sanitize_branch() { - assert_eq!(sanitize_branch("feat/my-feature"), "feat-my-feature"); - assert_eq!(sanitize_branch("main"), "main"); - assert_eq!(sanitize_branch("feature/fix:bug"), "feature-fix-bug"); - assert_eq!(sanitize_branch("-leading"), "leading"); - assert_eq!(sanitize_branch("trailing-"), "trailing"); - assert_eq!(sanitize_branch("double//slash"), "double-slash"); - assert_eq!(sanitize_branch("a b c"), "a-b-c"); - } - - use super::*; - use std::fs; - use tempfile::tempdir; - - /// Builds /mt// with `files` (name, content), scans, returns that node's state. - /// `.mt.json` (with optional agent_retry_max) lives in the project root (parent of mt/). - fn state_of( - node: &str, - files: &[(&str, &str)], - worktrees: &[&str], - retry: Option, - ) -> TaskState { - let root = tempdir().unwrap(); - if let Some(m) = retry { - fs::write( - root.path().join(".mt.json"), - format!("{{\"agent_retry_max\": {m}}}"), - ) - .unwrap(); - } - let tasks_root = root.path().join("mt"); - let node_dir = tasks_root.join(node); - fs::create_dir_all(&node_dir).unwrap(); - for (name, content) in files { - fs::write(node_dir.join(name), content).unwrap(); - } - let wt: Vec = worktrees.iter().map(|s| (*s).to_string()).collect(); - let nodes = scan_tasks(tasks_root.to_string_lossy().into_owned(), wt).unwrap(); - find_node(&nodes, node) - .expect("node not found") - .state - .clone() - } - - fn find_node<'a>(nodes: &'a [TaskNode], path: &str) -> Option<&'a TaskNode> { - for n in nodes { - if n.path == path { - return Some(n); - } - if let Some(found) = find_node(&n.children, path) { - return Some(found); - } - } - None - } - - const COMPOSITE: &str = "---\nschema_version: 1\ndecision: composite\n---\n"; - const ATOMIC: &str = "---\nschema_version: 1\ndecision: atomic\n---\n"; - - // ── unassigned / pending / waiting ── - #[test] - fn unassigned_when_no_executor() { - assert_eq!( - state_of("task", &[("task.md", "")], &[], None), - TaskState::Unassigned - ); - } - #[test] - fn pending_with_h_md() { - assert_eq!( - state_of("task", &[("task.md", ""), ("h.md", "")], &[], None), - TaskState::Pending - ); - assert_eq!( - state_of( - "task", - &[("task.md", ""), ("h.md", ""), ("plan_001.md", "")], - &[], - None - ), - TaskState::Pending - ); - } - #[test] - fn waiting_with_a_md() { - assert_eq!( - state_of("task", &[("task.md", ""), ("a.md", "")], &[], None), - TaskState::Waiting - ); - } - #[test] - fn waiting_when_streak_below_max() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("run_001.md", ""), - ("run_002.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Waiting); // streak 2 < 3 - } - - // ── failed ── - #[test] - fn failed_when_streak_reaches_max() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("run_001.md", ""), - ("run_002.md", ""), - ("run_003.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Failed); // streak 3 >= 3 - } - #[test] - fn failed_with_custom_retry_max_1() { - let files = [("task.md", ""), ("a.md", ""), ("run_001.md", "")]; - assert_eq!(state_of("task", &files, &[], Some(1)), TaskState::Failed); - } - #[test] - fn not_failed_when_fact_resets_streak() { - // fact_001 with no pending-audit → resolved (checked before failed) - let files = [ - ("task.md", ""), - ("a.md", ""), - ("run_001.md", ""), - ("fact_001.md", ""), - ("run_002.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Resolved); - } - - // ── unresolvable ── - #[test] - fn unresolvable_marker() { - assert_eq!( - state_of( - "task", - &[("task.md", ""), ("a.md", ""), ("unresolvable.md", "")], - &[], - None - ), - TaskState::Unresolvable - ); - } - - // ── running: marker + worktree ── - #[test] - fn running_marker() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("running_4821_until_1234567890", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Running); - } - #[test] - fn running_worktree_match() { - let files = [("task.md", ""), ("a.md", "")]; - assert_eq!( - state_of("my-task", &files, &["my-task-1234567890"], None), - TaskState::Running - ); - } - #[test] - fn not_running_when_worktree_mismatch() { - let files = [("task.md", ""), ("a.md", "")]; - assert_eq!( - state_of("my-task", &files, &["other-task-1234567890"], None), - TaskState::Waiting - ); - } - #[test] - fn running_worktree_nested_cross_level() { - // research/analyze node, worktree "research-analyze-" - let root = tempdir().unwrap(); - let analyze = root.path().join("mt/research/analyze"); - fs::create_dir_all(&analyze).unwrap(); - fs::write(root.path().join("mt/research/task.md"), "").unwrap(); - fs::write(analyze.join("task.md"), "").unwrap(); - fs::write(analyze.join("a.md"), "").unwrap(); - let nodes = scan_tasks( - root.path().join("mt").to_string_lossy().into_owned(), - vec!["research-analyze-1234567890".to_string()], - ) - .unwrap(); - assert_eq!( - find_node(&nodes, "research/analyze").unwrap().state, - TaskState::Running - ); - } - - // ── plan-review / spawned ── - #[test] - fn plan_review_composite_unapproved() { - let files = [("task.md", ""), ("a.md", ""), ("plan_001.md", COMPOSITE)]; - assert_eq!(state_of("task", &files, &[], None), TaskState::PlanReview); - } - #[test] - fn atomic_plan_is_waiting_not_review() { - let files = [("task.md", ""), ("a.md", ""), ("plan_001.md", ATOMIC)]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Waiting); - } - #[test] - fn composite_approved_without_children_falls_to_waiting() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("plan_001.md", COMPOSITE), - ("plan-approved_001.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Waiting); - } - #[test] - fn plan_review_for_human_composite() { - let files = [("task.md", ""), ("h.md", ""), ("plan_001.md", COMPOSITE)]; - assert_eq!(state_of("task", &files, &[], None), TaskState::PlanReview); - } - #[test] - fn spawned_when_composite_approved_with_children() { - let root = tempdir().unwrap(); - let parent = root.path().join("mt/parent"); - let child = parent.join("child"); - fs::create_dir_all(&child).unwrap(); - fs::write(parent.join("task.md"), "").unwrap(); - fs::write(parent.join("a.md"), "").unwrap(); - fs::write(parent.join("plan_001.md"), COMPOSITE).unwrap(); - fs::write(parent.join("plan-approved_001.md"), "").unwrap(); - fs::write(child.join("task.md"), "").unwrap(); - let nodes = scan_tasks( - root.path().join("mt").to_string_lossy().into_owned(), - vec![], - ) - .unwrap(); - assert_eq!( - find_node(&nodes, "parent").unwrap().state, - TaskState::Spawned - ); - } - - // ── pending-audit / resolved (latest fact only) ── - #[test] - fn pending_audit_open_cycle() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("fact_001.md", ""), - ("pending-audit_001.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::PendingAudit); - } - #[test] - fn resolved_when_newer_fact_supersedes_audit() { - // fact_001 has open audit, but fact_002 (latest) has none → resolved - let files = [ - ("task.md", ""), - ("a.md", ""), - ("fact_001.md", ""), - ("pending-audit_001.md", ""), - ("fact_002.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Resolved); - } - #[test] - fn resolved_plain_fact() { - assert_eq!( - state_of( - "task", - &[("task.md", ""), ("a.md", ""), ("fact_001.md", "")], - &[], - None - ), - TaskState::Resolved - ); - } - #[test] - fn resolved_audit_success() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("fact_001.md", ""), - ("pending-audit_001.md", ""), - ("audit-result_001.md", "---\nresult: success\n---\n"), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Resolved); - } - #[test] - fn audit_failed_falls_through_to_waiting() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("fact_001.md", ""), - ("pending-audit_001.md", ""), - ("audit-result_001.md", "---\nresult: failed\n---\n"), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Waiting); - } - - // ── priority chain ── - #[test] - fn resolved_over_unresolvable() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("fact_001.md", ""), - ("unresolvable.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Resolved); - } - #[test] - fn pending_audit_over_resolved() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("fact_001.md", ""), - ("pending-audit_001.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::PendingAudit); - } - #[test] - fn unresolvable_over_running_marker() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("running_1_until_9999999999", ""), - ("unresolvable.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Unresolvable); - } - #[test] - fn running_over_plan_review() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("plan_001.md", COMPOSITE), - ("running_1_until_9999999999", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Running); - } - #[test] - fn unresolvable_over_failed() { - let files = [ - ("task.md", ""), - ("a.md", ""), - ("run_001.md", ""), - ("run_002.md", ""), - ("run_003.md", ""), - ("unresolvable.md", ""), - ]; - assert_eq!(state_of("task", &files, &[], None), TaskState::Unresolvable); - } - - // ── blocked post-processing ── - #[test] - fn waiting_with_unresolved_dep_becomes_blocked() { - let root = tempdir().unwrap(); - let mt = root.path().join("mt"); - let a = mt.join("a"); - let b = mt.join("b"); - fs::create_dir_all(a.join("deps")).unwrap(); - fs::create_dir_all(&b).unwrap(); - // a depends on b; b is unassigned (not resolved) → a blocked - fs::write(a.join("task.md"), "").unwrap(); - fs::write(a.join("a.md"), "").unwrap(); - fs::write(a.join("deps/b.md"), "").unwrap(); - fs::write(b.join("task.md"), "").unwrap(); - let nodes = scan_tasks(mt.to_string_lossy().into_owned(), vec![]).unwrap(); - assert_eq!(find_node(&nodes, "a").unwrap().state, TaskState::Blocked); - assert_eq!(find_node(&nodes, "a").unwrap().deps, vec!["b".to_string()]); - } - - // ── scan skips gitignored/hidden/denylisted dirs (spec «Монорепо») ── - #[test] - fn scan_skips_gitignored_and_denylisted_dirs() { - let root = tempdir().unwrap(); - let mt = root.path().join("mt"); - // .gitignore у project root: mt/scratch ігнорується - fs::write(root.path().join(".gitignore"), "scratch\n").unwrap(); - for (dir, tracked) in [ - ("visible", true), - ("scratch", false), // gitignored - ("node_modules", false), // denylist - (".hidden", false), // hidden - ] { - let d = mt.join(dir); - fs::create_dir_all(&d).unwrap(); - fs::write(d.join("task.md"), "").unwrap(); - let _ = tracked; - } - let nodes = scan_tasks(mt.to_string_lossy().into_owned(), vec![]).unwrap(); - let paths: Vec<_> = nodes.iter().map(|n| n.path.as_str()).collect(); - assert_eq!(paths, vec!["visible"]); - } - - #[test] - fn scan_skips_gitignored_child_dirs() { - let root = tempdir().unwrap(); - let parent = root.path().join("mt/parent"); - fs::create_dir_all(parent.join("tmp-cache")).unwrap(); - fs::write(parent.join("task.md"), "").unwrap(); - fs::write(parent.join(".gitignore"), "tmp-*\n").unwrap(); - fs::write(parent.join("tmp-cache/task.md"), "").unwrap(); - let nodes = scan_tasks( - root.path().join("mt").to_string_lossy().into_owned(), - vec![], - ) - .unwrap(); - assert!(find_node(&nodes, "parent").is_some()); - assert!(find_node(&nodes, "parent/tmp-cache").is_none()); - assert!(!find_node(&nodes, "parent").unwrap().is_composite); - } - - // ── sanitize vectors (must match JS sanitizeTaskName) ── - #[test] - fn sanitize_vectors() { - assert_eq!(sanitize("research/collect data"), "research-collect-data"); - assert_eq!(sanitize("my-task_01"), "my-task_01"); - assert_eq!(sanitize(""), ""); - } - - // ── worktree list parsing ── - #[test] - fn parse_worktree_list_extracts_names() { - let out = "worktree /repo\nHEAD abc\n\nworktree /repo/.worktrees/my-task-123\nHEAD def\n"; - assert_eq!( - parse_worktree_list(out), - vec!["repo".to_string(), "my-task-123".to_string()] - ); - } - - // ── create_task (write-side) ── - - /// /mt with optional .mt.json defaults in the project root; returns (root, mt_dir). - fn create_repo(mt_json: Option<&str>) -> (tempfile::TempDir, String) { - let root = tempdir().unwrap(); - if let Some(j) = mt_json { - fs::write(root.path().join(".mt.json"), j).unwrap(); - } - let mt = root.path().join("mt"); - fs::create_dir_all(&mt).unwrap(); - let mt_dir = mt.to_string_lossy().into_owned(); - (root, mt_dir) - } - - #[test] - fn create_writes_task_flag_and_frontmatter() { - let (root, mt) = create_repo(None); - let opts = CreateOpts { - mode: Some(Mode::Human), - ..Default::default() - }; - let outcome = create_task(mt.clone(), "demo".to_string(), opts).unwrap(); - match outcome { - CreateOutcome::Created { - name, - task_path, - flag, - deps, - } => { - assert_eq!(name, "demo"); - assert_eq!(task_path, "demo/task.md"); - assert_eq!(flag, "h.md"); - assert!(deps.is_empty()); - } - _ => panic!("expected Created"), - } - let task_md = fs::read_to_string(root.path().join("mt/demo/task.md")).unwrap(); - // schema_version must be the FIRST frontmatter field. - assert!( - task_md.starts_with("---\nschema_version: 1\n"), - "got: {task_md}" - ); - assert!(task_md.contains("\nbudget_sec: 1800\n")); // default - assert!(task_md.contains("\nhint: atomic\n")); - // No mode/executor/deps fields in frontmatter (§2.6/§2.7). - assert!(!task_md.contains("mode:")); - assert!(!task_md.contains("executor")); - assert!(!task_md.contains("\ndeps:")); - // Секції task.md — контракт graph.md (## Task / ## Done when / ## Check / ## Inputs). - assert!(task_md.contains("## Task")); - assert!(task_md.contains("## Done when")); - assert!(task_md.contains("## Check")); // машинний done/audit-гейт (signal.rs) - assert!(task_md.contains("## Inputs")); - assert!(!task_md.contains("## Mission")); // старий канон docs/mt.md більше не пишемо - // h.md created, a.md not. - assert!(root.path().join("mt/demo/h.md").exists()); - assert!(!root.path().join("mt/demo/a.md").exists()); - } - - #[test] - fn create_agent_writes_a_md_with_tier() { - let (root, mt) = create_repo(None); - let opts = CreateOpts { - mode: Some(Mode::Agent), - model_tier: Some("MAX".to_string()), - ..Default::default() - }; - let outcome = create_task(mt, "agentic".to_string(), opts).unwrap(); - assert!(matches!(&outcome, CreateOutcome::Created { flag, .. } if flag == "a.md")); - let a = fs::read_to_string(root.path().join("mt/agentic/a.md")).unwrap(); - assert!(a.contains("## Model tier\n\nMAX\n"), "got: {a}"); - assert!(a.contains("## Skills")); - assert!(a.contains("- bash")); - assert!(!root.path().join("mt/agentic/h.md").exists()); - } - - #[test] - fn create_is_idempotent() { - let (root, mt) = create_repo(None); - create_task(mt.clone(), "demo".to_string(), CreateOpts::default()).unwrap(); - let before = fs::read_to_string(root.path().join("mt/demo/task.md")).unwrap(); - let again = create_task(mt, "demo".to_string(), CreateOpts::default()).unwrap(); - assert!(matches!(again, CreateOutcome::Exists { .. })); - let after = fs::read_to_string(root.path().join("mt/demo/task.md")).unwrap(); - assert_eq!(before, after); // not rewritten - } - - #[test] - fn create_nested_name_recursive_mkdir() { - let (root, mt) = create_repo(None); - create_task( - mt, - "research/collect-data".to_string(), - CreateOpts::default(), - ) - .unwrap(); - assert!(root - .path() - .join("mt/research/collect-data/task.md") - .exists()); - } - - #[test] - fn create_dep_writes_empty_edge_file() { - let (root, mt) = create_repo(None); - let opts = CreateOpts { - deps: vec!["upstream".to_string()], - ..Default::default() - }; - let outcome = create_task(mt, "downstream".to_string(), opts).unwrap(); - assert!(matches!(&outcome, CreateOutcome::Created { deps, .. } if deps == &["upstream"])); - let edge = root.path().join("mt/downstream/deps/upstream.md"); - assert!(edge.exists()); - assert_eq!(fs::read_to_string(&edge).unwrap(), ""); - } - - #[test] - fn create_resolves_defaults_from_mt_json() { - let (root, mt) = create_repo(Some( - "{\"default_mode\":\"agent\",\"default_model_tier\":\"MIN\",\"default_budget_sec\":42}", - )); - let outcome = create_task(mt, "d".to_string(), CreateOpts::default()).unwrap(); - assert!(matches!(&outcome, CreateOutcome::Created { flag, .. } if flag == "a.md")); - let task_md = fs::read_to_string(root.path().join("mt/d/task.md")).unwrap(); - assert!(task_md.contains("\nbudget_sec: 42\n")); - let a = fs::read_to_string(root.path().join("mt/d/a.md")).unwrap(); - assert!(a.contains("MIN")); - } - - // ── shared name-validation vectors (Rust ↔ JS, see name-vectors.json) ── - #[test] - fn validate_name_shared_vectors() { - let raw = include_str!("../../../npm/lib/tests/fixtures/name-vectors.json"); - let v: serde_json::Value = serde_json::from_str(raw).unwrap(); - for name in v["valid"].as_array().unwrap() { - let n = name.as_str().unwrap(); - assert!(validate_name(n).is_ok(), "expected VALID: {n:?}"); - } - for name in v["invalid"].as_array().unwrap() { - let n = name.as_str().unwrap(); - assert!(validate_name(n).is_err(), "expected INVALID: {n:?}"); - } - } - - #[test] - fn create_rejects_traversal_name() { - let (_root, mt) = create_repo(None); - assert!(create_task(mt, "../escape".to_string(), CreateOpts::default()).is_err()); - } -} diff --git a/crates/mt-core/src/lifecycle.rs b/crates/mt-core/src/lifecycle.rs deleted file mode 100644 index 15f08df..0000000 --- a/crates/mt-core/src/lifecycle.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Lifecycle-мутації вузла: `mt invalidate` та `mt kill` (спека mt.md). -//! -//! Файловий рівень (без git-протоколу — fenced publish прийде з фазою git): -//! - invalidate: архівує version chain у `history/-invalidate/`, нова -//! chain стартує з NNN=001; каскад вниз по нащадках; без sentinel-файлів — -//! стан derived з відсутності `fact_*.md`. -//! - kill: якщо піддерево вузла (сам вузол + нащадки) не має жодного -//! run-артефакту (chain-файли, `run-summary.md`, `history/`) — вузол -//! видаляється назавжди (не було що архівувати, помилково створений -//! вузол); інакше архівується у `/.history/-kill-/` -//! і прибирається директорія; каскад повний за визначенням (піддерево). - -use std::fs; -use std::path::Path; - -use chrono::Utc; - -use crate::validate_name; - -/// Префікси файлів version chain, які archive-ує invalidate (§ mt invalidate). -const CHAIN_PREFIXES: [&str; 6] = [ - "fact_", - "run_", - "pending-audit_", - "audit-result_", - "clarification_", - "amended_", -]; - -fn is_chain_file(name: &str) -> bool { - if name == "unresolvable.md" { - return true; // термінальний маркер — частина chain, архівується разом - } - CHAIN_PREFIXES - .iter() - .any(|p| name.strip_prefix(p).is_some_and(|r| r.ends_with(".md"))) -} - -fn timestamp() -> String { - Utc::now().format("%Y%m%d-%H%M%S").to_string() -} - -/// Архівує version chain одного вузла (без рекурсії). Повертає `true`, -/// якщо було що архівувати. -fn archive_chain(dir: &Path, ts: &str) -> Result { - let mut chain = Vec::new(); - for entry in fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if entry.file_type().map(|t| t.is_file()).unwrap_or(false) && is_chain_file(&name) { - chain.push(name); - } - } - // run-summary.md видаляється (нова chain — нова історія), не архівується. - let _ = fs::remove_file(dir.join("run-summary.md")); - if chain.is_empty() { - return Ok(false); - } - let archive = dir.join("history").join(format!("{ts}-invalidate")); - fs::create_dir_all(&archive).map_err(|e| e.to_string())?; - for name in &chain { - fs::rename(dir.join(name), archive.join(name)).map_err(|e| e.to_string())?; - } - Ok(true) -} - -/// Дочірні вузли (директорії з `task.md`); `history/` і приховані — пропуск. -pub(crate) fn child_nodes(dir: &Path) -> Vec { - let mut out = Vec::new(); - let Ok(entries) = fs::read_dir(dir) else { - return out; - }; - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - if name.starts_with('.') || name == "history" || name == "deps" { - continue; - } - let path = entry.path(); - if path.is_dir() && path.join("task.md").is_file() { - out.push(name); - } - } - out -} - -/// `mt invalidate `: архівує chain вузла і (cascade) всіх нащадків. -/// Повертає шляхи вузлів (відносно tasks root), де chain було архівовано. -pub fn invalidate(tasks_dir: &str, node_path: &str, cascade: bool) -> Result, String> { - validate_name(node_path)?; - let dir = Path::new(tasks_dir).join(node_path); - if !dir.join("task.md").is_file() { - return Err(format!("node not found: {node_path}")); - } - let ts = timestamp(); - let mut archived = Vec::new(); - invalidate_rec(&dir, node_path, &ts, cascade, &mut archived)?; - Ok(archived) -} - -fn invalidate_rec( - dir: &Path, - node_path: &str, - ts: &str, - cascade: bool, - archived: &mut Vec, -) -> Result<(), String> { - if archive_chain(dir, ts)? { - archived.push(node_path.to_string()); - } - if !cascade { - return Ok(()); - } - for child in child_nodes(dir) { - invalidate_rec( - &dir.join(&child), - &format!("{node_path}/{child}"), - ts, - cascade, - archived, - )?; - } - Ok(()) -} - -/// Чи має вузол (без рекурсії в нащадків) артефакти запуску: chain-файли, -/// `run-summary.md`, або `history/` (архів попередніх invalidate). -fn has_run_artifacts_here(dir: &Path) -> bool { - if dir.join("run-summary.md").is_file() || dir.join("history").is_dir() { - return true; - } - let Ok(entries) = fs::read_dir(dir) else { - return false; - }; - entries.flatten().any(|entry| { - entry.file_type().map(|t| t.is_file()).unwrap_or(false) - && is_chain_file(&entry.file_name().to_string_lossy()) - }) -} - -/// Чи має піддерево вузла (сам вузол + всі нащадки) бодай один run-артефакт. -fn has_run_artifacts(dir: &Path) -> bool { - has_run_artifacts_here(dir) - || child_nodes(dir) - .iter() - .any(|c| has_run_artifacts(&dir.join(c))) -} - -/// `mt kill ` (файловий рівень): якщо піддерево вузла ще не мало -/// жодного запуску — видаляє його назавжди; інакше архівує весь вузол -/// з нащадками у `/.history/-kill-/` і прибирає -/// директорію. Повертає `.history/` (архівовано) або -/// `deleted:` (видалено без історії). -pub fn kill(tasks_dir: &str, node_path: &str) -> Result { - validate_name(node_path)?; - let root = Path::new(tasks_dir); - let dir = root.join(node_path); - if !dir.join("task.md").is_file() { - return Err(format!("node not found: {node_path}")); - } - if !has_run_artifacts(&dir) { - fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; - return Ok(format!("deleted:{node_path}")); - } - let archive_name = format!("{}-kill-{}", timestamp(), node_path.replace('/', "-")); - let history = root.join(".history"); - fs::create_dir_all(&history).map_err(|e| e.to_string())?; - let target = history.join(&archive_name); - fs::rename(&dir, &target).map_err(|e| e.to_string())?; - Ok(format!(".history/{archive_name}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture() -> tempfile::TempDir { - let tmp = tempfile::tempdir().unwrap(); - let node = tmp.path().join("research"); - let child = node.join("analyze"); - fs::create_dir_all(&child).unwrap(); - for (dir, files) in [ - ( - &node, - vec![ - "task.md", - "a.md", - "plan_001.md", - "run_001.md", - "fact_001.md", - "run-summary.md", - ], - ), - ( - &child, - vec![ - "task.md", - "a.md", - "run_001.md", - "fact_001.md", - "audit-result_001.md", - ], - ), - ] { - for f in files { - fs::write(dir.join(f), "x").unwrap(); - } - } - tmp - } - - #[test] - fn invalidate_archives_chain_and_cascades() { - let tmp = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - let archived = invalidate(&root, "research", true).unwrap(); - assert_eq!(archived, ["research", "research/analyze"]); - - let node = tmp.path().join("research"); - // task/plan/прапор лишаються; chain-файли поїхали в history/. - assert!(node.join("task.md").is_file()); - assert!(node.join("plan_001.md").is_file()); - assert!(node.join("a.md").is_file()); - assert!(!node.join("fact_001.md").exists()); - assert!(!node.join("run_001.md").exists()); - assert!(!node.join("run-summary.md").exists()); - let hist = fs::read_dir(node.join("history")) - .unwrap() - .next() - .unwrap() - .unwrap(); - assert!(hist.path().join("fact_001.md").is_file()); - // Дитина теж: audit-файл у архіві. - assert!(!node.join("analyze/audit-result_001.md").exists()); - } - - #[test] - fn invalidate_no_cascade_keeps_children() { - let tmp = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - let archived = invalidate(&root, "research", false).unwrap(); - assert_eq!(archived, ["research"]); - assert!(tmp.path().join("research/analyze/fact_001.md").is_file()); - } - - #[test] - fn kill_moves_subtree_to_history() { - let tmp = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - let archive = kill(&root, "research").unwrap(); - assert!(archive.starts_with(".history/")); - assert!(archive.ends_with("-kill-research")); - assert!(!tmp.path().join("research").exists()); - let archived_root = tmp.path().join(&archive); - assert!(archived_root.join("task.md").is_file()); - assert!(archived_root.join("analyze/fact_001.md").is_file()); - } - - #[test] - fn kill_missing_node_errors() { - let tmp = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - assert!(kill(&root, "nope").is_err()); - assert!(kill(&root, "../escape").is_err()); - } - - #[test] - fn kill_deletes_fresh_node_without_run_history() { - let tmp = tempfile::tempdir().unwrap(); - let node = tmp.path().join("draft"); - fs::create_dir_all(&node).unwrap(); - fs::write(node.join("task.md"), "x").unwrap(); - fs::write(node.join("plan_001.md"), "x").unwrap(); - - let root = tmp.path().to_string_lossy().into_owned(); - let result = kill(&root, "draft").unwrap(); - assert_eq!(result, "deleted:draft"); - assert!(!node.exists()); - assert!(!tmp.path().join(".history").exists()); - } - - #[test] - fn kill_archives_when_only_a_descendant_has_run_history() { - let tmp = tempfile::tempdir().unwrap(); - let node = tmp.path().join("draft"); - let child = node.join("sub"); - fs::create_dir_all(&child).unwrap(); - fs::write(node.join("task.md"), "x").unwrap(); - fs::write(child.join("task.md"), "x").unwrap(); - fs::write(child.join("run_001.md"), "x").unwrap(); - - let root = tmp.path().to_string_lossy().into_owned(); - let archive = kill(&root, "draft").unwrap(); - assert!(archive.starts_with(".history/")); - assert!(!node.exists()); - assert!(tmp.path().join(&archive).join("sub/run_001.md").is_file()); - } -} diff --git a/crates/mt-core/src/nnn.rs b/crates/mt-core/src/nnn.rs deleted file mode 100644 index 0803b85..0000000 --- a/crates/mt-core/src/nnn.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! NNN-нумерація артефактів задач (`run_NNN.md`, `fact_NNN.md`, …). -//! -//! Чисті функції над списком імен файлів — FS лишається на боці викликача -//! (JS-обгортки зберігають ін'єкцію `readdirSync`). Семантика 1:1 із -//! `npm/lib/core/nnn.mjs`: NNN — рядок із ведучими нулями до 3 цифр. - -/// Форматує число як NNN-рядок: `1` → `"001"`. -pub fn pad_nnn(n: u64) -> String { - format!("{n:03}") -} - -/// Чи відповідає ім'я шаблону `<цифри>` (мінімум одна цифра). -fn nnn_of(name: &str, prefix: &str, suffix: &str) -> Option { - let rest = name.strip_prefix(prefix)?; - let digits = rest.strip_suffix(suffix)?; - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - digits.parse().ok() -} - -/// Максимальний NNN серед файлів шаблону, або 0. -fn max_nnn(files: &[String], prefix: &str, suffix: &str) -> u64 { - files - .iter() - .filter_map(|f| nnn_of(f, prefix, suffix)) - .max() - .unwrap_or(0) -} - -/// Наступний NNN для `run_NNN.md`: `count(run_*.md) + 1`. -pub fn next_run_nnn(files: &[String]) -> String { - let count = files - .iter() - .filter(|f| nnn_of(f, "run_", ".md").is_some()) - .count() as u64; - pad_nnn(count + 1) -} - -/// Наступний NNN для `plan_NNN.md`: `max(plan_*.md) + 1`. -pub fn next_plan_nnn(files: &[String]) -> String { - pad_nnn(max_nnn(files, "plan_", ".md") + 1) -} - -/// Найвищий NNN серед `fact_NNN.md`, або `None`. -pub fn latest_fact_nnn(files: &[String]) -> Option { - match max_nnn(files, "fact_", ".md") { - 0 => None, - m => Some(pad_nnn(m)), - } -} - -/// Найвищий NNN серед `pending-audit_NNN.md`, або `None`. -pub fn latest_pending_audit_nnn(files: &[String]) -> Option { - match max_nnn(files, "pending-audit_", ".md") { - 0 => None, - m => Some(pad_nnn(m)), - } -} - -/// Найвищий NNN серед `audit-result_NNN.md`, або `None`. -pub fn latest_audit_result_nnn(files: &[String]) -> Option { - match max_nnn(files, "audit-result_", ".md") { - 0 => None, - m => Some(pad_nnn(m)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn files(names: &[&str]) -> Vec { - names.iter().map(|s| (*s).to_string()).collect() - } - - #[test] - fn pad_nnn_vectors() { - assert_eq!(pad_nnn(1), "001"); - assert_eq!(pad_nnn(42), "042"); - assert_eq!(pad_nnn(1000), "1000"); - } - - #[test] - fn next_run_counts_files() { - assert_eq!(next_run_nnn(&files(&[])), "001"); - // Рахує кількість, не max: пропуски в нумерації не заповнює. - assert_eq!(next_run_nnn(&files(&["run_001.md", "run_005.md"])), "003"); - assert_eq!(next_run_nnn(&files(&["run_x.md", "fact_001.md"])), "001"); - } - - #[test] - fn next_plan_uses_max() { - assert_eq!(next_plan_nnn(&files(&[])), "001"); - assert_eq!( - next_plan_nnn(&files(&["plan_001.md", "plan_005.md"])), - "006" - ); - } - - #[test] - fn latest_helpers() { - assert_eq!(latest_fact_nnn(&files(&[])), None); - assert_eq!( - latest_fact_nnn(&files(&["fact_001.md", "fact_003.md"])), - Some("003".to_string()) - ); - assert_eq!( - latest_pending_audit_nnn(&files(&["pending-audit_002.md"])), - Some("002".to_string()) - ); - assert_eq!( - latest_audit_result_nnn(&files(&["audit-result_007.md"])), - Some("007".to_string()) - ); - } - - #[test] - fn rejects_non_digit_middles() { - assert_eq!(latest_fact_nnn(&files(&["fact_+1.md", "fact_.md"])), None); - } -} diff --git a/crates/mt-core/src/orchestrate.rs b/crates/mt-core/src/orchestrate.rs deleted file mode 100644 index 50b531c..0000000 --- a/crates/mt-core/src/orchestrate.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Оркестратор `run --auto` — локальний одноразовий прохід (спека mt.md, -//! «Оркестрація»): знаходить усі `waiting` агентські вузли, сортує (leaf-и що -//! розблоковують найбільше нащадків — першими, потім nearest deadline, потім -//! `created_at`) і прогонить чергами по `agent_concurrency` через -//! [`crate::runner::run_node`]. -//! -//! **Спрощення проти повної спеки:** батчинг замість continuous backfill — -//! один прохід бере до `concurrency` вузлів, чекає завершення всієї партії, -//! пересканує і формує наступну. Достатньо для solo-машини (Фаза 3, крок 2); -//! remote claims і `mt watch` periodic rescan — окрема фаза. - -use std::collections::HashSet; -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -use crate::runner::run_node; -use crate::{discover_worktrees, scan_tasks, TaskNode, TaskState}; - -/// Підсумок однієї спроби в межах `run_auto`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AutoResult { - pub path: String, - /// success | failed | budget-exceeded | progress-timeout | error - pub result: String, - /// Заповнено лише для `result: "error"` (preflight відмовив запуск). - pub error: Option, -} - -fn walk<'a>(nodes: &'a [TaskNode], out: &mut Vec<&'a TaskNode>) { - for node in nodes { - out.push(node); - walk(&node.children, out); - } -} - -/// Плаский список усіх вузлів воркспейсу (для підрахунку залежностей і -/// вибірки waiting-агентських). -fn flatten(nodes: &[TaskNode]) -> Vec<&TaskNode> { - let mut out = Vec::new(); - walk(nodes, &mut out); - out -} - -/// Сортує `waiting`-вузли: більше "розблокованих" нащадків (dependents -/// count по всьому графу) — раніше; далі nearest `deadline` (без нього — -/// в кінець групи); далі `created_at` (без нього — в кінець). -pub fn sort_for_auto<'a>(all: &[&'a TaskNode], waiting: Vec<&'a TaskNode>) -> Vec<&'a TaskNode> { - let dependents_count = |path: &str| -> usize { - all.iter() - .filter(|n| n.deps.iter().any(|d| d == path)) - .count() - }; - let mut sorted = waiting; - sorted.sort_by(|a, b| { - let by_dependents = dependents_count(&b.path).cmp(&dependents_count(&a.path)); - if by_dependents != std::cmp::Ordering::Equal { - return by_dependents; - } - let by_deadline = match (&a.deadline, &b.deadline) { - (Some(x), Some(y)) => x.cmp(y), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - }; - if by_deadline != std::cmp::Ordering::Equal { - return by_deadline; - } - match (&a.created_at, &b.created_at) { - (Some(x), Some(y)) => x.cmp(y), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.path.cmp(&b.path), - } - }); - sorted -} - -/// Один прохід оркестратора: до вичерпання waiting-агентських вузлів прогонить -/// їх чергами по `concurrency` (кожен вузол — окремий потік через -/// [`run_node`]). Вузол, що впав на preflight (гонка/зникла умова), більше не -/// підбирається в межах цього виклику — гарантує термінацію. -pub fn run_auto(tasks_dir: &str, concurrency: usize) -> Result, String> { - let mut results = Vec::new(); - let mut skip: HashSet = HashSet::new(); - let concurrency = concurrency.max(1); - - loop { - let worktrees = discover_worktrees(Path::new(tasks_dir)); - let tree = scan_tasks(tasks_dir.to_string(), worktrees)?; - let all = flatten(&tree); - let waiting: Vec<&TaskNode> = all - .iter() - .copied() - .filter(|n| { - n.mode == "agent" && n.state == TaskState::Waiting && !skip.contains(&n.path) - }) - .collect(); - if waiting.is_empty() { - break; - } - - let batch: Vec = sort_for_auto(&all, waiting) - .into_iter() - .take(concurrency) - .map(|n| n.path.clone()) - .collect(); - if batch.is_empty() { - break; - } - - let handles: Vec<_> = batch - .into_iter() - .map(|path| { - let dir = tasks_dir.to_string(); - std::thread::spawn(move || (path.clone(), run_node(&dir, &path))) - }) - .collect(); - - for handle in handles { - let (path, outcome) = handle - .join() - .unwrap_or_else(|_| (String::new(), Err("run thread panicked".to_string()))); - match outcome { - Ok(o) => results.push(AutoResult { - path, - result: o.result, - error: None, - }), - Err(e) => { - skip.insert(path.clone()); - results.push(AutoResult { - path, - result: "error".to_string(), - error: Some(e), - }); - } - } - } - } - Ok(results) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn node(path: &str, mode: &str, state: TaskState, deps: &[&str]) -> TaskNode { - TaskNode { - id: path.rsplit('/').next().unwrap_or(path).to_string(), - path: path.to_string(), - state, - deps: deps.iter().map(|s| s.to_string()).collect(), - mode: mode.to_string(), - budget_sec: None, - budget_hard_sec: None, - deadline: None, - hint: None, - created_at: None, - children: Vec::new(), - is_composite: false, - } - } - - #[test] - fn sorts_by_dependents_then_deadline_then_created_at() { - let mut c = node("c", "agent", TaskState::Waiting, &[]); - c.deadline = Some("2026-07-10T00:00:00Z".to_string()); - let mut a = node("a", "agent", TaskState::Waiting, &[]); // 2 dependents - a.created_at = Some("2026-07-01T00:00:00Z".to_string()); - let b = node("b", "agent", TaskState::Waiting, &[]); // 1 dependent, no deadline/created_at - let dependent1 = node("d1", "agent", TaskState::Blocked, &["a"]); - let dependent2 = node("d2", "agent", TaskState::Blocked, &["a"]); - let dependent3 = node("d3", "agent", TaskState::Blocked, &["b"]); - - let all_owned = [ - a.clone(), - b.clone(), - c.clone(), - dependent1, - dependent2, - dependent3, - ]; - let all_refs: Vec<&TaskNode> = all_owned.iter().collect(); - let waiting = vec![&all_owned[2], &all_owned[1], &all_owned[0]]; // c, b, a (shuffled) - - let sorted = sort_for_auto(&all_refs, waiting); - let paths: Vec<&str> = sorted.iter().map(|n| n.path.as_str()).collect(); - // a: 2 dependents → перший; b: 1 dependent → другий; c: 0 dependents → останній. - assert_eq!(paths, ["a", "b", "c"]); - } - - #[test] - fn deadline_breaks_tie_before_created_at() { - let mut x = node("x", "agent", TaskState::Waiting, &[]); - x.created_at = Some("2026-01-01T00:00:00Z".to_string()); - let mut y = node("y", "agent", TaskState::Waiting, &[]); - y.deadline = Some("2026-01-01T00:00:00Z".to_string()); - let all = [x, y]; - let refs: Vec<&TaskNode> = all.iter().collect(); - let sorted = sort_for_auto(&refs, refs.clone()); - // y має deadline (навіть пізніший created_at за замовчуванням None) → раніше x. - assert_eq!(sorted[0].path, "y"); - } - - #[test] - fn run_auto_terminates_on_repeated_preflight_error() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - // Scan бачить Waiting (deps не враховують budget), але runner::preflight - // відмовляє на budget_hard_sec: 0 (validation error) — розбіжність - // між derived-станом і preflight, яку має покривати skip-set. - let dir = root.join("stuck"); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("task.md"), - "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\nbudget_hard_sec: 0\n---\n\n## Task\n", - ) - .unwrap(); - fs::write(dir.join("a.md"), "schema_version: 1\n").unwrap(); - - let root_s = root.to_string_lossy().into_owned(); - let results = run_auto(&root_s, 5).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].path, "stuck"); - assert_eq!(results[0].result, "error"); - assert!(results[0] - .error - .as_deref() - .unwrap() - .contains("budget_hard_sec")); - } - - #[test] - fn run_auto_empty_workspace_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - fs::create_dir_all(&root).unwrap(); - let results = run_auto(&root.to_string_lossy(), 5).unwrap(); - assert!(results.is_empty()); - } -} diff --git a/crates/mt-core/src/publish.rs b/crates/mt-core/src/publish.rs deleted file mode 100644 index a20d779..0000000 --- a/crates/mt-core/src/publish.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! Fenced publish protocol (спека mt.md, «Fenced publish protocol») — -//! atomic multi-ref push результату worktree у `main`, з рефетчем/rebase і -//! retry через exponential backoff+jitter. Використовується агентом і -//! аудитором однаково; тут — генерична реалізація над готовим worktree. - -use std::path::Path; -use std::process::Command; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use serde::{Deserialize, Serialize}; - -use crate::claims::{CLAIM_REF_PREFIX, RUN_REF_PREFIX}; - -fn git(repo: &Path, args: &[&str]) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -fn git_status(repo: &Path, args: &[&str]) -> Result<(bool, String), String> { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - Ok(( - out.status.success(), - String::from_utf8_lossy(&out.stderr).trim().to_string(), - )) -} - -/// Вхід одного fenced-publish запиту. -pub struct PublishRequest<'a> { - /// Detached worktree з готовим результатом (агент/аудитор уже закомітив). - pub worktree: &'a Path, - pub node_hash: &'a str, - /// Exact claim SHA, яким цей runner володіє — fencing-перевірка. - pub claim_sha: &'a str, - pub token: &'a str, - /// Очікуваний SHA run ref перед publish (зазвичай той, що запушено при - /// створенні worktree). - pub run_ref_sha_before: &'a str, -} - -/// Підсумок публікації. `published: false` без `Err` — fencing/conflict -/// (claim втрачено або конкурентний publish виграв гонку), не системна помилка. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PublishOutcome { - pub published: bool, - /// `true` — рейс програно чи claim втрачено (retry марний, публікація - /// зупиняється); `false` — вичерпано `retry_max` спроб при звичайних - /// race-відхиленнях (можна повторити пізніше). - pub fenced: bool, - pub result_sha: Option, - pub attempts: u32, -} - -fn is_lease_rejection(stderr: &str) -> bool { - stderr.contains("stale info") || stderr.contains("[rejected]") || stderr.contains("fetch first") -} - -/// Псевдо-джиттер без зовнішньої залежності `rand`: молодші біти системного -/// часу в наносекундах — достатньо для розсіювання конкурентних retry. -fn jitter_ms(spread_ms: u64) -> u64 { - if spread_ms == 0 { - return 0; - } - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - u64::from(nanos) % spread_ms -} - -/// Fenced publish (спека, кроки 1–3): fetch main + claim ref → rebase -/// worktree на `origin/main` → перевірка fencing (claim ref усе ще exact -/// SHA) → atomic multi-ref push (main + CAS-видалення claim/run ref). -/// Retry з exponential backoff+jitter при race-відхиленні push-у. -pub fn fenced_publish( - repo_root: &Path, - req: &PublishRequest, - retry_max: u32, - base_ms: u64, -) -> Result { - let claim_ref = format!("{CLAIM_REF_PREFIX}/{}", req.node_hash); - let run_ref = format!("{RUN_REF_PREFIX}/{}/{}", req.node_hash, req.token); - - for attempt in 0..retry_max.max(1) { - git(repo_root, &["fetch", "--quiet", "origin", "main"])?; - // Custom ref — явний fetch (спека: стандартний refspec його не покриває). - let (claim_fetch_ok, _) = git_status( - repo_root, - &[ - "fetch", - "--quiet", - "origin", - &format!("+{claim_ref}:{claim_ref}"), - ], - )?; - if !claim_fetch_ok { - // Claim ref зник (звільнено/протух і прибрано) — без claim publish - // неможливий: fencing failed, не системна помилка. - return Ok(PublishOutcome { - published: false, - fenced: true, - result_sha: None, - attempts: attempt + 1, - }); - } - - // Fencing: claim ref усе ще на exact SHA цього runner-а. - let current_claim_sha = git(repo_root, &["rev-parse", &claim_ref])?; - if current_claim_sha != req.claim_sha { - return Ok(PublishOutcome { - published: false, - fenced: true, - result_sha: None, - attempts: attempt + 1, - }); - } - - let main_sha_before = git(repo_root, &["rev-parse", "origin/main"])?; - - // Rebase worktree на origin/main. Конфлікт → merge-conflict (термінально - // для цієї спроби; викликач фіксує result: merge-conflict, без retry тут). - let (rebase_ok, rebase_err) = git_status(req.worktree, &["rebase", "origin/main"])?; - if !rebase_ok { - let _ = git_status(req.worktree, &["rebase", "--abort"]); - return Err(format!("rebase conflict on publish: {rebase_err}")); - } - - let result_sha = git(req.worktree, &["rev-parse", "HEAD"])?; - - let lease_main = format!("--force-with-lease=refs/heads/main:{main_sha_before}"); - let lease_claim = format!("--force-with-lease={claim_ref}:{}", req.claim_sha); - let lease_run = format!("--force-with-lease={run_ref}:{}", req.run_ref_sha_before); - let out = Command::new("git") - .arg("-C") - .arg(req.worktree) - .args([ - "push", - "--atomic", - &lease_main, - &lease_claim, - &lease_run, - "origin", - &format!("{result_sha}:refs/heads/main"), - &format!(":{claim_ref}"), - &format!(":{run_ref}"), - ]) - .output() - .map_err(|e| format!("git push --atomic: {e}"))?; - - if out.status.success() { - // Best-effort: fast-forward локальний main, якщо саме на ньому і - // це чистий ff (щоб GUI live-скан одразу бачив опублікований - // результат, а не чекав ручного pull — не критично при невдачі). - let _ = sync_local_main(repo_root, &result_sha); - return Ok(PublishOutcome { - published: true, - fenced: false, - result_sha: Some(result_sha), - attempts: attempt + 1, - }); - } - - let stderr = String::from_utf8_lossy(&out.stderr); - if !is_lease_rejection(&stderr) { - return Err(format!("git push --atomic: {}", stderr.trim())); - } - // Race програно — backoff+jitter, наступна ітерація перечитує стан. - let backoff = base_ms.saturating_mul(1u64 << attempt.min(16)); - std::thread::sleep(Duration::from_millis(backoff + jitter_ms(base_ms))); - } - - Ok(PublishOutcome { - published: false, - fenced: false, - result_sha: None, - attempts: retry_max, - }) -} - -/// Best-effort ff-only синхронізація локального `main` після власного -/// publish — щоб живий working tree (яке бачить FS-watcher GUI) одразу -/// відобразило результат без ручного `git pull`. Мовчки ігнорує невдачу -/// (інша гілка, брудне дерево, конфлікт) — дані вже в `origin/main`, -/// це лише питання видимості локально. -fn sync_local_main(repo_root: &Path, result_sha: &str) -> Result<(), String> { - let current_branch = git(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])?; - if current_branch != "main" { - return Ok(()); - } - let (ok, _) = git_status(repo_root, &["merge", "--ff-only", result_sha])?; - if !ok { - return Ok(()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::claims::{acquire_claim, node_hash, ClaimFields}; - use crate::test_support::{output, TestRepo}; - use crate::worktree::{create_run_worktree, push_run_ref}; - - fn setup(repo: &TestRepo) -> (String, crate::claims::ClaimPush, std::path::PathBuf) { - let base = repo.main_sha(); - let hash = node_hash("mt", "research/analyze"); - let fields = ClaimFields { - node: "research/analyze", - actor: "agent", - runner_id: "test/1", - claimed_at: "2026-06-09T10:00:00Z", - lease_until: "2030-01-01T00:00:00Z", - token: "tok1", - generation: 1, - base_sha: &base, - run_ref: "refs/mt/runs/x/tok1", - interactive: false, - }; - let claim = acquire_claim(repo.work.path(), &hash, &fields).unwrap(); - assert!(claim.accepted); - - let worktrees_dir = tempfile::tempdir().unwrap(); - // Тримаємо TempDir живим через leak — тест короткий, ок для fixture. - let worktrees_dir = Box::leak(Box::new(worktrees_dir)); - let wt = create_run_worktree(repo.work.path(), worktrees_dir.path(), &hash, "tok1", &base) - .unwrap(); - push_run_ref(&wt, &hash, "tok1").unwrap(); - - (hash, claim, wt) - } - - #[test] - fn publishes_result_atomically_and_updates_local_main() { - let repo = TestRepo::new(); - let (hash, claim, wt) = setup(&repo); - let base = repo.main_sha(); - - std::fs::write(wt.join("result.txt"), "done").unwrap(); - crate::test_support::run(&wt, &["add", "."]); - crate::test_support::run(&wt, &["commit", "-q", "-m", "mt: result"]); - - let req = PublishRequest { - worktree: &wt, - node_hash: &hash, - claim_sha: &claim.commit_sha, - token: "tok1", - run_ref_sha_before: &base, - }; - let outcome = fenced_publish(repo.work.path(), &req, 8, 10).unwrap(); - assert!(outcome.published); - assert!(!outcome.fenced); - assert!(outcome.result_sha.is_some()); - - // main на remote просунувся; claim/run ref прибрані. - let remote_main = output( - repo.work.path(), - &["ls-remote", "origin", "refs/heads/main"], - ); - assert!(remote_main.contains(outcome.result_sha.as_ref().unwrap())); - let claims_left = output( - repo.work.path(), - &["ls-remote", "origin", "refs/mt/claims/*"], - ); - assert!(claims_left.is_empty()); - let runs_left = output(repo.work.path(), &["ls-remote", "origin", "refs/mt/runs/*"]); - assert!(runs_left.is_empty()); - - // Локальний main (той самий work-клон, HEAD на main) синхронізовано. - assert!(repo.work.path().join("result.txt").is_file()); - } - - #[test] - fn fenced_when_claim_lost_to_takeover() { - let repo = TestRepo::new(); - let (hash, claim, wt) = setup(&repo); - let base = repo.main_sha(); - - // Інший runner перехопив claim (takeover) конкурентно. - let fields2 = ClaimFields { - node: "research/analyze", - actor: "agent", - runner_id: "test/2", - claimed_at: "2026-06-09T10:05:00Z", - lease_until: "2030-01-01T00:00:00Z", - token: "tok2", - generation: 2, - base_sha: &base, - run_ref: "refs/mt/runs/x/tok2", - interactive: false, - }; - crate::claims::renew_or_takeover_claim( - repo.work.path(), - &hash, - &claim.commit_sha, - &fields2, - ) - .unwrap(); - - std::fs::write(wt.join("result.txt"), "done").unwrap(); - crate::test_support::run(&wt, &["add", "."]); - crate::test_support::run(&wt, &["commit", "-q", "-m", "mt: result"]); - - let req = PublishRequest { - worktree: &wt, - node_hash: &hash, - claim_sha: &claim.commit_sha, // застарілий SHA — програний claim - token: "tok1", - run_ref_sha_before: &base, - }; - let outcome = fenced_publish(repo.work.path(), &req, 3, 5).unwrap(); - assert!(!outcome.published); - assert!(outcome.fenced); - } -} diff --git a/crates/mt-core/src/runner.rs b/crates/mt-core/src/runner.rs deleted file mode 100644 index 5e1d16e..0000000 --- a/crates/mt-core/src/runner.rs +++ /dev/null @@ -1,1243 +0,0 @@ -//! Run-wrapper (спека mt.md, «Wrapper-скрипт») — git-режим за замовчуванням: -//! CAS claim → detached worktree від `origin/main` → spawn виконавця у -//! worktree → watchdog (hard budget → SIGKILL, progress-timeout за mtime) → -//! підсумок через [`crate::signal`] (fact є і `## Check` пройдено → done/audit -//! і composite вгору, інакше failed із секціями з `run-draft.md`) → коміт -//! worktree → fenced publish. -//! -//! Виконавці — **підписочні CLI**, єдиний agent-шлях (`claude` | `codex` | -//! `cursor` | `pi`, runtime.md «Підписочні CLI-виконавці»; точку розширення -//! `node_executor` видалено — PR #48): конфіг — user-level ENV -//! ([`crate::config::AgentCliEnv`]), per-node override — `a.md` «## Agent -//! cli»; вичерпані ліміти підписки → каскад `MT_CLOUD_AGENT_CLIS`. Retry -//! ladder (`## Retry ladder` у `a.md` або дефолт -//! base/diagnose-first/alternative-approach) резолвить стратегію спроби та -//! ескалацію model_tier MIN→AVG→MAX. -//! -//! Вимагає git-репозиторій з `origin`, до якого є push-доступ (claims/publish -//! — реальні мутації спільного remote). Rejected claim / merge-conflict / -//! вичерпаний publish-retry → `Err` (нормальний "інший runner виграв", не -//! системний збій) — викликач (`orchestrate::run_auto`) додає вузол у -//! skip-set цього проходу й переходить до інших. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant, SystemTime}; - -use serde::{Deserialize, Serialize}; - -use crate::claims::{ - acquire_claim, discover_repo_root, node_hash, tasks_root_relative, ClaimFields, -}; -use crate::config::{ - agent_cli_env_from_process, merge_config, normalize_model_tier, resolve_model_for_cli, - AgentCliEnv, -}; -use crate::frontmatter::parse_front_matter; -use crate::nnn::pad_nnn; -use crate::publish::{fenced_publish, PublishRequest}; -use crate::signal::{self, next_run_nnn, write_run_fm}; -use crate::worktree::{create_run_worktree, push_run_ref, remove_run_worktree}; -use crate::{accepted_fact_state, validate_name, FactState}; - -/// Підтримувані підписочні CLI-виконавці (порядок — лише для повідомлень). -pub const AGENT_CLIS: [&str; 4] = ["claude", "codex", "cursor", "pi"]; - -/// Порядок model_tier для ескалації драбиною (позиційний зсув, cap на MAX). -const MODEL_TIER_ORDER: [&str; 3] = ["MIN", "AVG", "MAX"]; - -/// Щабель драбини ретраїв: стратегія + зсув тиру. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LadderStep { - pub strategy: String, - pub model_tier_delta: usize, -} - -/// Драбина ретраїв за замовчуванням (graph.md «Retry ladder»): 1 — base; -/// 2 — diagnose-first; 3 — alternative-approach (+1 model_tier). -fn default_retry_ladder() -> Vec { - ["base", "diagnose-first", "alternative-approach"] - .into_iter() - .map(|strategy| LadderStep { - strategy: strategy.to_string(), - model_tier_delta: usize::from(strategy == "alternative-approach"), - }) - .collect() -} - -/// План запуску після preflight — бюджети, NNN, щабель ретраю, виконавець. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RunPlan { - pub nnn: u64, - pub attempt: u64, - pub budget_sec: u64, - pub budget_hard_sec: u64, - pub progress_timeout_sec: u64, - /// Ефективний тир MIN/AVG/MAX (після ескалації щаблем драбини). - pub model_tier: String, - /// Стратегія щабля драбини (`MT_RETRY_STRATEGY`). - pub retry_strategy: String, - /// Підписочний CLI вузла: `a.md` «## Agent cli» → env `MT_AGENT_CLI` → claude. - pub agent_cli: String, -} - -/// Підсумок спроби (файли вже опубліковані в `origin/main`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RunOutcome { - /// success | failed | progress-timeout | budget-exceeded - pub result: String, - pub run_file: String, - pub fact_file: Option, - pub wall_sec: u64, - /// Фактичний CLI після каскаду (None — всі кандидати вичерпали ліміти). - pub agent_cli: Option, - pub propagated: Vec, -} - -fn node_dir(tasks_dir: &str, node_path: &str) -> Result { - validate_name(node_path)?; - let dir = Path::new(tasks_dir).join(node_path); - if !dir.join("task.md").is_file() { - return Err(format!("node not found: {node_path}")); - } - Ok(dir) -} - -fn fm_u64(v: &serde_json::Value, key: &str) -> Option { - v.get(key).and_then(serde_json::Value::as_u64) -} - -/// Непорожні рядки секції `## ` прапора `a.md` — спільний -/// markdown-конвент прапорів виконавця («## Model tier», «## Retry ladder», -/// «## Agent cli»). Немає a.md/секції/рядків → None. -fn read_flag_section(dir: &Path, title_lower: &str) -> Option<Vec<String>> { - let content = fs::read_to_string(dir.join("a.md")).ok()?; - let mut lines = content.lines(); - lines.find(|l| l.trim().to_lowercase() == title_lower)?; - let values: Vec<String> = lines - .take_while(|l| !l.trim_start().starts_with("##")) - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(String::from) - .collect(); - (!values.is_empty()).then_some(values) -} - -/// Парсить рядки секції «## Retry ladder» у драбину (буліт/рядок на щабель). -/// Щабель "alternative-approach" завжди несе `model_tier_delta: 1` (graph.md). -fn parse_retry_ladder(lines: &[String]) -> Option<Vec<LadderStep>> { - let steps: Vec<LadderStep> = lines - .iter() - .map(|l| l.trim_start_matches(['-', '*']).trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .map(|strategy| LadderStep { - model_tier_delta: usize::from(strategy == "alternative-approach"), - strategy, - }) - .collect(); - (!steps.is_empty()).then_some(steps) -} - -/// Щабель драбини для номера спроби; коротша драбина — останній щабель -/// повторюється (graph.md). -fn resolve_retry_step(attempt: u64, ladder: &[LadderStep]) -> &LadderStep { - let idx = (attempt.max(1) - 1).min(ladder.len() as u64 - 1) as usize; - &ladder[idx] -} - -/// Підвищує model_tier на `delta` позицій MIN→AVG→MAX (cap на MAX). -/// Невідомий tier або delta=0 → без змін. -fn bump_model_tier(tier: &str, delta: usize) -> String { - if delta == 0 { - return tier.to_string(); - } - match MODEL_TIER_ORDER.iter().position(|t| *t == tier) { - Some(idx) => MODEL_TIER_ORDER[(idx + delta).min(MODEL_TIER_ORDER.len() - 1)].to_string(), - None => tier.to_string(), - } -} - -/// argv підписочного CLI: команда + аргументи headless-запуску. Модель -/// передається лише за наявності мапінгу (`MT_AGENT_CLI_MODEL_MAP`); без неї -/// CLI резолвить модель сам. Невідомий CLI → None. -/// -/// Прапори звірені живим спайком 2026-07-14 (claude 2.1.193, codex 0.142.5, -/// cursor-agent 2026.07.01, pi 0.80.3): у claude немає `--no-session` -/// (є `--no-session-persistence`), у codex exec немає `--full-auto` -/// (пісочниця — `--sandbox workspace-write`, сесія — `--ephemeral`). -fn build_agent_cli_argv( - cli: &str, - model: Option<&str>, - prompt: &str, -) -> Option<(String, Vec<String>)> { - let mut args: Vec<String> = Vec::new(); - let cmd = match cli { - "claude" => { - if let Some(m) = model { - args.extend(["--model".into(), m.into()]); - } - args.extend([ - "--no-session-persistence".into(), - "-p".into(), - prompt.into(), - ]); - "claude" - } - "codex" => { - args.push("exec".into()); - if let Some(m) = model { - args.extend(["-m".into(), m.into()]); - } - args.extend([ - "--sandbox".into(), - "workspace-write".into(), - "--ephemeral".into(), - prompt.into(), - ]); - "codex" - } - "cursor" => { - if let Some(m) = model { - args.extend(["--model".into(), m.into()]); - } - args.extend(["--print".into(), "--force".into(), prompt.into()]); - "cursor-agent" - } - "pi" => { - if let Some(m) = model { - args.extend(["--model".into(), m.into()]); - } - args.extend(["--no-session".into(), "-p".into(), prompt.into()]); - "pi" - } - _ => return None, - }; - Some((cmd.to_string(), args)) -} - -/// Порядок каскаду: `[обраний agent_cli, ...cloud_agent_clis]` без дублів -/// (невідомі імена лишаються — спавн їх пропустить). -fn cascade_order(agent_cli: &str, cloud: &[String]) -> Vec<String> { - let mut order = vec![agent_cli.to_string()]; - for cli in cloud { - if !order.contains(cli) { - order.push(cli.clone()); - } - } - order -} - -/// Чи схожий результат CLI на вичерпані ліміти підписки: ненульовий exit і -/// rate-limit-маркер у виводі (best-effort текстова евристика — до -/// структурованих ACP-помилок, ADR 260713-2110). -fn is_rate_limited(exit_ok: bool, output: &str) -> bool { - if exit_ok { - return false; - } - let t = output.to_lowercase(); - if [ - "too many requests", - "usage limit", - "quota exceeded", - "quota reached", - ] - .iter() - .any(|m| t.contains(m)) - { - return true; - } - // rate.?limit — до одного символу між словами. - let squashed: String = t.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); - if squashed.contains("ratelimit") { - return true; - } - // \b429\b — «429» без цифр по сусідству. - let bytes = t.as_bytes(); - t.match_indices("429").any(|(i, _)| { - let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit(); - let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit(); - before_ok && after_ok - }) -} - -/// Headless-промпт agent-шляху — спільний для всіх підписочних CLI. -/// -/// Місія **вкладається** у промпт (тіло `task.md` без frontmatter): непряме -/// «прочитай task.md» — заважке для слабких локальних моделей (тертя M0, -/// dogfood 2026-07-15: gemma-2B через pi виконує пряму інструкцію, але -/// губиться на meta-prompt). `plan_*.md` лишаються за посиланням — вони -/// опційні і можуть бути великими. -fn build_agent_prompt(task_path: &str, node_dir: &Path, nnn: &str, budget_sec: u64) -> String { - let task_body = fs::read_to_string(node_dir.join("task.md")) - .map(|content| { - let trimmed = content.trim_start(); - match trimmed.strip_prefix("---") { - Some(rest) => rest - .split_once("\n---") - .map(|(_, body)| body.trim_start_matches('\n').to_string()) - .unwrap_or(content.clone()), - None => content.clone(), - } - }) - .unwrap_or_default(); - format!( - "You are executing task: {task_path}\nWorking directory: {}\nRun NNN: {nnn}\nBudget: {budget_sec}s\n\n\ - The task (from task.md):\n\n{task_body}\n\n\ - Execute the task above in the current directory (read plan_*.md if present).\n\n\ - MANDATORY FINAL STEP: create the file fact_{nnn}.md in the current directory. \ - Without fact_{nnn}.md the run counts as FAILED even if everything else is done. Example content:\n\n\ - ## Summary\n\n<one sentence describing the result>", - node_dir.display() - ) -} - -/// Preflight за спекою: a.md, deps resolved, без відкритого аудиту, вузол не -/// running; бюджети — task.md > .mt.json > дефолти; виконавець — a.md-прапори, -/// далі ENV, далі дефолти. Суто локальні перевірки (без git) — дешевий гейт -/// перед дорожчим claim acquisition. -pub fn preflight(tasks_dir: &str, node_path: &str) -> Result<RunPlan, String> { - preflight_env(tasks_dir, node_path, &agent_cli_env_from_process()) -} - -/// Як [`preflight`], але з явним конфігом виконавців (ін'єкція для тестів -/// і викликачів, що вже прочитали ENV). -pub fn preflight_env( - tasks_dir: &str, - node_path: &str, - cli_env: &AgentCliEnv, -) -> Result<RunPlan, String> { - let dir = node_dir(tasks_dir, node_path)?; - if !dir.join("a.md").is_file() { - return Err("вузол без a.md — runner запускає лише агентські вузли".to_string()); - } - if crate::has_running_marker(&dir) { - return Err("вузол уже running (є running_* маркер)".to_string()); - } - match accepted_fact_state(&dir) { - FactState::PendingAudit => { - return Err("відкритий аудит-цикл — retry заблоковано".to_string()) - } - FactState::Resolved => return Err("вузол уже resolved".to_string()), - FactState::None => {} - } - for dep in crate::read_deps_dir(&dir) { - let dep_dir = Path::new(tasks_dir).join(&dep); - if !dep_dir.join("task.md").is_file() { - return Err(format!("blocked-invalid-dep: {dep}")); - } - if accepted_fact_state(&dep_dir) != FactState::Resolved { - return Err(format!("blocked: {dep} не resolved")); - } - } - - let task_fm = fs::read_to_string(dir.join("task.md")) - .map(|c| parse_front_matter(&c)) - .unwrap_or(serde_json::Value::Null); - let project_root = Path::new(tasks_dir) - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - let config = fs::read_to_string(project_root.join(".mt.json")) - .ok() - .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()) - .unwrap_or(serde_json::Value::Null); - - let budget_sec = fm_u64(&task_fm, "budget_sec") - .or_else(|| fm_u64(&config, "default_budget_sec")) - .unwrap_or(1800); - let multiplier = fm_u64(&config, "budget_hard_sec_multiplier").unwrap_or(3); - let budget_hard_sec = fm_u64(&task_fm, "budget_hard_sec") - .or_else(|| fm_u64(&config, "default_budget_hard_sec")) - .unwrap_or(budget_sec * multiplier); - if budget_hard_sec == 0 { - return Err( - "budget_hard_sec: 0 → validation error (hard limit не вимикається)".to_string(), - ); - } - let progress_timeout_sec = fm_u64(&task_fm, "progress_timeout_sec") - .or_else(|| fm_u64(&config, "progress_timeout_sec")) - .unwrap_or(300); - - let nnn = next_run_nnn(&dir); - let last_fact = crate::max_nnn(&dir, "fact_", ".md"); - let attempt = nnn.saturating_sub(last_fact).max(1); - - // Істина model_tier — прапор a.md; fallback: executor.model_tier у - // frontmatter (старі вузли) → default_model_tier із .mt.json → AVG. - let tier_flag = read_flag_section(&dir, "## model tier").map(|v| v[0].clone()); - let executor_tier = task_fm - .get("executor") - .and_then(|e| e.get("model_tier")) - .and_then(serde_json::Value::as_str) - .map(String::from); - let config_tier = config - .get("default_model_tier") - .and_then(serde_json::Value::as_str) - .map(String::from); - let base_tier = normalize_model_tier( - &tier_flag - .or(executor_tier) - .or(config_tier) - .unwrap_or_else(|| "AVG".to_string()), - ); - - let ladder = read_flag_section(&dir, "## retry ladder") - .and_then(|lines| parse_retry_ladder(&lines)) - .unwrap_or_else(default_retry_ladder); - let step = resolve_retry_step(attempt, &ladder); - let model_tier = bump_model_tier(&base_tier, step.model_tier_delta); - let retry_strategy = step.strategy.clone(); - - let agent_cli = read_flag_section(&dir, "## agent cli") - .map(|v| v[0].clone()) - .unwrap_or_else(|| cli_env.agent_cli.clone()) - .to_lowercase(); - // Fail-fast до claim/worktree: невідомий CLI — помилка конфігурації. - if !AGENT_CLIS.contains(&agent_cli.as_str()) { - return Err(format!( - "невідомий agent_cli \"{agent_cli}\" — підтримується: {}", - AGENT_CLIS.join(", ") - )); - } - - Ok(RunPlan { - nnn, - attempt, - budget_sec, - budget_hard_sec, - progress_timeout_sec, - model_tier, - retry_strategy, - agent_cli, - }) -} - -/// Останній mtime у піддереві (для progress-watchdog). -fn latest_mtime(dir: &Path) -> SystemTime { - let mut latest = SystemTime::UNIX_EPOCH; - let mut stack = vec![dir.to_path_buf()]; - while let Some(d) = stack.pop() { - let Ok(entries) = fs::read_dir(&d) else { - continue; - }; - for entry in entries.flatten() { - if let Ok(meta) = entry.metadata() { - if let Ok(m) = meta.modified() { - if m > latest { - latest = m; - } - } - if meta.is_dir() { - stack.push(entry.path()); - } - } - } - } - latest -} - -/// Секція `## <name>` з markdown-тексту (для run-draft.md). -fn md_section(text: &str, name: &str) -> Option<String> { - let header = format!("## {name}"); - let mut inside = false; - let mut out = Vec::new(); - for line in text.lines() { - if line.trim() == header { - inside = true; - continue; - } - if inside { - if line.starts_with("## ") { - break; - } - out.push(line); - } - } - let s = out.join("\n").trim().to_string(); - (!s.is_empty()).then_some(s) -} - -fn git(dir: &Path, args: &[&str]) -> Result<String, String> { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -fn iso_now() -> String { - chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} - -fn iso_plus(sec: i64) -> String { - (chrono::Utc::now() + chrono::Duration::seconds(sec)) - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} - -/// Псевдо-унікальний токен спроби без залежності `uuid` (час + pid). -fn fresh_token() -> String { - let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0); - format!("{nanos:x}-{}", std::process::id()) -} - -fn worktrees_dir_path(repo_root: &Path, config: &serde_json::Value) -> PathBuf { - let raw = config - .get("worktrees_dir") - .and_then(serde_json::Value::as_str) - .unwrap_or("./.worktrees"); - let rel = raw.strip_prefix("./").unwrap_or(raw); - if Path::new(rel).is_absolute() { - PathBuf::from(rel) - } else { - repo_root.join(rel) - } -} - -/// Комітить усі зміни worktree (fact/run/plan/тощо); "нема що комітити" — -/// не помилка (виконавець теоретично міг не лишити diff). -fn commit_worktree(worktree: &Path, message: &str) -> Result<(), String> { - git(worktree, &["add", "-A"])?; - let status = git(worktree, &["status", "--porcelain"])?; - if status.is_empty() { - return Ok(()); - } - let out = Command::new("git") - .arg("-C") - .arg(worktree) - .args(["commit", "-q", "-m", message]) - .env("GIT_AUTHOR_NAME", "mt-runner") - .env("GIT_AUTHOR_EMAIL", "mt-runner@localhost") - .env("GIT_COMMITTER_NAME", "mt-runner") - .env("GIT_COMMITTER_EMAIL", "mt-runner@localhost") - .output() - .map_err(|e| format!("git commit: {e}"))?; - if !out.status.success() { - return Err(format!( - "git commit: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(()) -} - -/// Результат одного спавну під watchdog-ом. -struct WatchedOutcome { - /// budget-exceeded | progress-timeout (None — процес завершився сам). - kill_reason: Option<&'static str>, - exit_ok: bool, - /// stdout + stderr разом (для rate-limit евристики). - combined: String, -} - -/// Спавнить процес і супроводжує його watchdog-ом: hard budget → SIGKILL, -/// progress-timeout за mtime `watch_dir`. stdout/stderr — у тимчасові файли -/// (щоб не блокувати pipe і не лишати слідів у worktree). Локальний -/// running-маркер у `live_dir` — observability для сканера (НЕ lock). -fn spawn_watched( - mut cmd: Command, - watch_dir: &Path, - live_dir: &Path, - budget_hard_sec: u64, - progress_timeout_sec: u64, -) -> Result<WatchedOutcome, String> { - let capture_base = std::env::temp_dir().join(format!("mt-run-{}", fresh_token())); - let stdout_path = capture_base.with_extension("out"); - let stderr_path = capture_base.with_extension("err"); - let stdout_file = fs::File::create(&stdout_path).map_err(|e| e.to_string())?; - let stderr_file = fs::File::create(&stderr_path).map_err(|e| e.to_string())?; - - let started = Instant::now(); - let started_unix = chrono::Utc::now().timestamp(); - let mut child = cmd - .stdout(Stdio::from(stdout_file)) - .stderr(Stdio::from(stderr_file)) - .spawn() - .map_err(|e| format!("spawn виконавця: {e}"))?; - - let marker = live_dir.join(format!( - "running_{}_until_{}", - child.id(), - started_unix + budget_hard_sec as i64 - )); - let _ = fs::write(&marker, ""); - - let mut kill_reason: Option<&'static str> = None; - let mut exit_ok = false; - let mut baseline_mtime = latest_mtime(watch_dir); - let mut baseline_at = Instant::now(); - loop { - match child.try_wait().map_err(|e| e.to_string())? { - Some(status) => { - exit_ok = status.success(); - break; - } - None => { - if started.elapsed().as_secs() > budget_hard_sec { - let _ = child.kill(); - kill_reason = Some("budget-exceeded"); - let _ = child.wait(); - break; - } - let m = latest_mtime(watch_dir); - if m > baseline_mtime { - baseline_mtime = m; - baseline_at = Instant::now(); - } else if baseline_at.elapsed().as_secs() > progress_timeout_sec { - let _ = child.kill(); - kill_reason = Some("progress-timeout"); - let _ = child.wait(); - break; - } - std::thread::sleep(Duration::from_millis(500)); - } - } - } - let _ = fs::remove_file(&marker); - - let stdout = fs::read_to_string(&stdout_path).unwrap_or_default(); - let stderr = fs::read_to_string(&stderr_path).unwrap_or_default(); - let _ = fs::remove_file(&stdout_path); - let _ = fs::remove_file(&stderr_path); - Ok(WatchedOutcome { - kill_reason, - exit_ok, - combined: format!("{stdout}\n{stderr}"), - }) -} - -/// Запускає виконавця вузла, супроводжує спробу до кінця і публікує результат -/// через fenced publish. **Блокуючий** — викликач (napi/CLI) сам вирішує потік. -pub fn run_node(tasks_dir: &str, node_path: &str) -> Result<RunOutcome, String> { - run_node_env(tasks_dir, node_path, &agent_cli_env_from_process()) -} - -/// Як [`run_node`], але з явним конфігом виконавців (ін'єкція для тестів). -pub fn run_node_env( - tasks_dir: &str, - node_path: &str, - cli_env: &AgentCliEnv, -) -> Result<RunOutcome, String> { - let plan = preflight_env(tasks_dir, node_path, cli_env)?; - - let repo_root = discover_repo_root(Path::new(tasks_dir))?; - let tasks_root_rel = tasks_root_relative(&repo_root, Path::new(tasks_dir))?; - let hash = node_hash(&tasks_root_rel, node_path); - - let raw_config = fs::read_to_string(repo_root.join(".mt.json")).ok(); - let config = merge_config(raw_config.as_deref()); - let claim_lease_sec = fm_u64(&config, "claim_lease_sec").unwrap_or(3600) as i64; - let publish_retry_max = fm_u64(&config, "publish_retry_max").unwrap_or(8) as u32; - let publish_retry_base_ms = fm_u64(&config, "publish_retry_base_ms").unwrap_or(250); - - git(&repo_root, &["fetch", "--quiet", "origin", "main"])?; - let base_sha = git(&repo_root, &["rev-parse", "origin/main"])?; - - let token = fresh_token(); - let runner_id = format!("mt-runner/{}", std::process::id()); - let run_ref = format!("refs/mt/runs/{hash}/{token}"); - let claimed_at = iso_now(); - let lease_until = iso_plus(claim_lease_sec); - let fields = ClaimFields { - node: node_path, - actor: "agent", - runner_id: &runner_id, - claimed_at: &claimed_at, - lease_until: &lease_until, - token: &token, - generation: 1, - base_sha: &base_sha, - run_ref: &run_ref, - interactive: false, - }; - let claim = acquire_claim(&repo_root, &hash, &fields)?; - if !claim.accepted { - return Err("claim-lost: інший runner уже володіє цим вузлом".to_string()); - } - - let worktrees_dir = worktrees_dir_path(&repo_root, &config); - let worktree = create_run_worktree(&repo_root, &worktrees_dir, &hash, &token, &base_sha)?; - push_run_ref(&worktree, &hash, &token)?; - - let wt_tasks_dir = worktree.join(&tasks_root_rel); - let wt_tasks_dir_str = wt_tasks_dir.to_string_lossy().into_owned(); - let dir = wt_tasks_dir.join(node_path); - let dir_str = dir.to_string_lossy().into_owned(); - let nnn_s = pad_nnn(plan.nnn); - let live_dir = node_dir(tasks_dir, node_path)?; - - let started = Instant::now(); - let started_iso = iso_now(); - // ENV-контракт виконавця (runtime.md «Контракт команди-екзекутора»). - let base_envs: Vec<(String, String)> = vec![ - ("MT_RUN_NNN".into(), nnn_s.clone()), - ("MT_ATTEMPT".into(), plan.attempt.to_string()), - ("MT_RETRY_STRATEGY".into(), plan.retry_strategy.clone()), - ("MT_BUDGET_SEC".into(), plan.budget_sec.to_string()), - ( - "MT_HARD_BUDGET_SEC".into(), - plan.budget_hard_sec.to_string(), - ), - ("MT_STARTED_AT".into(), started_iso.clone()), - ("MT_TASK_PATH".into(), node_path.to_string()), - ("MT_NODE_DIR".into(), dir_str.clone()), - ( - "MT_WORKTREE".into(), - worktree.to_string_lossy().into_owned(), - ), - ("MT_RUN_TOKEN".into(), token.clone()), - ("MT_MODEL_TIER".into(), plan.model_tier.clone()), - ("MT_AGENT_CLI".into(), plan.agent_cli.clone()), - ("MT_CLAIM_TOKEN".into(), token.clone()), - ("MT_CLAIM_GENERATION".into(), "1".into()), - ]; - - // Єдиний agent-шлях — підписочний CLI з каскадом по хмарних підписках - // за rate-limit (node_executor видалено — PR #48). - let mut used_agent_cli: Option<String> = None; - let watched: Option<WatchedOutcome> = { - let prompt = build_agent_prompt(node_path, &dir, &nnn_s, plan.budget_sec); - let mut outcome = None; - for cli in cascade_order(&plan.agent_cli, &cli_env.cloud_agent_clis) { - let model = resolve_model_for_cli(cli_env, &cli, &plan.model_tier); - let Some((prog, args)) = build_agent_cli_argv(&cli, model.as_deref(), &prompt) else { - continue; // невідоме ім'я у каскаді — пропускаємо - }; - let mut cmd = Command::new(prog); - cmd.args(args).current_dir(&dir); - cmd.envs(base_envs.iter().cloned()); - cmd.env("MT_AGENT_CLI", &cli); - let w = spawn_watched( - cmd, - &dir, - &live_dir, - plan.budget_hard_sec, - plan.progress_timeout_sec, - )?; - // Watchdog-kill — термінальний; rate-limit → наступний кандидат. - if w.kill_reason.is_some() || !is_rate_limited(w.exit_ok, &w.combined) { - used_agent_cli = Some(cli); - outcome = Some(w); - break; - } - } - outcome // None — усі CLI каскаду вичерпали ліміти підписки - }; - - let wall_sec = started.elapsed().as_secs(); - let cli_fm = used_agent_cli - .as_ref() - .map(|c| format!("agent_cli: {c}\n")) - .unwrap_or_default(); - let extra_fm = format!("{cli_fm}wall_sec: {wall_sec}\n"); - - let fact_file = format!("fact_{nnn_s}.md"); - let kill_reason = watched.as_ref().and_then(|w| w.kill_reason); - - let has_fact = dir.join(&fact_file).is_file(); - let (result, run_file, out_fact_file, propagated) = if kill_reason.is_none() && has_fact { - let policy_required = fs::read_to_string(dir.join("task.md")) - .map(|c| parse_front_matter(&c)) - .ok() - .and_then(|fm| { - fm.get("audit") - .and_then(serde_json::Value::as_str) - .map(|s| s == "required") - }) - .unwrap_or(false); - let signaled = if policy_required { - signal::audit_fm(&wt_tasks_dir_str, node_path, "agent", &extra_fm) - } else { - signal::done_fm(&wt_tasks_dir_str, node_path, "agent", &extra_fm) - }; - match signaled { - Ok(out) => ( - "success".to_string(), - out.run_file, - Some(out.fact_file), - out.propagated, - ), - Err(check_err) => { - // Fact без пройденого ## Check не публікується — інакше вузол - // хибно стане resolved (accepted_fact_state рахує лише файли). - let _ = fs::remove_file(dir.join(&fact_file)); - let sections = format!( - "\n## Completed\n\nfact записано, але ## Check не пройшов (fact відкликано)\n\n## Blockers\n\n{check_err}\n\n## Next Attempt\n\nвиправити і повторити done\n" - ); - let run_file = write_run_fm(&dir, &nnn_s, "agent", "failed", §ions, &extra_fm)?; - ("failed".to_string(), run_file, None, Vec::new()) - } - } - } else { - let draft = fs::read_to_string(dir.join("run-draft.md")).unwrap_or_default(); - let result = kill_reason.unwrap_or("failed").to_string(); - let default_blockers = if watched.is_none() { - "усі CLI каскаду вичерпали ліміти підписки".to_string() - } else { - format!("процес завершився без fact ({result})") - }; - let completed = - md_section(&draft, "Completed").unwrap_or_else(|| "невідомо (draft відсутній)".into()); - let blockers = md_section(&draft, "Blockers").unwrap_or(default_blockers); - let next = md_section(&draft, "Next Attempt") - .unwrap_or_else(|| "діагностувати попередній ран".into()); - // Діагностика провалу не губиться: хвіст виводу виконавця (який уже - // читається для rate-limit-детекту) — у run-файл (тертя M0 №5). - let output_tail = watched - .as_ref() - .map(|w| { - let tail: Vec<&str> = w.combined.trim().lines().rev().take(15).collect(); - tail.into_iter().rev().collect::<Vec<_>>().join("\n") - }) - .filter(|t| !t.is_empty()) - .map(|t| format!("\n## Executor output tail\n\n```text\n{t}\n```\n")) - .unwrap_or_default(); - let sections = format!( - "\n## Completed\n\n{completed}\n\n## Blockers\n\n{blockers}\n\n## Next Attempt\n\n{next}\n{output_tail}" - ); - let run_file = write_run_fm(&dir, &nnn_s, "agent", &result, §ions, &extra_fm)?; - (result, run_file, None, Vec::new()) - }; - - commit_worktree( - &worktree, - &format!("mt: {node_path} run {nnn_s} ({result})"), - )?; - - let publish_req = PublishRequest { - worktree: &worktree, - node_hash: &hash, - claim_sha: &claim.commit_sha, - token: &token, - run_ref_sha_before: &base_sha, - }; - let publish = fenced_publish( - &repo_root, - &publish_req, - publish_retry_max, - publish_retry_base_ms, - )?; - - if !publish.published { - // Worktree/run ref лишаються для debug (спека, «Failure-сімейство» / - // «Orphan worktree») — не видаляємо, наступний runner чи людина - // розбереться. Claim теж не чіпаємо: якщо fenced — він уже не наш. - return Err(if publish.fenced { - "claim-lost: втрачено ownership під час виконання, publish скасовано".to_string() - } else { - "publish: вичерпано retry — конкурентний publish виграв гонку, спробуйте пізніше" - .to_string() - }); - } - - // Успішна публікація — worktree більше не потрібен. - let _ = remove_run_worktree(&repo_root, &worktree); - - Ok(RunOutcome { - result, - run_file, - fact_file: out_fact_file, - wall_sec, - agent_cli: used_agent_cli, - propagated, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::TestRepo; - - const TASK: &str = "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\nbudget_sec: 5\nbudget_hard_sec: 2\nprogress_timeout_sec: 60\n---\n\n## Task\n\nx\n"; - - /// Пише task.md/a.md на диск, без git — для тестів `preflight()` - /// (суто файлова логіка, git-репо не потрібне). - fn node_files_only(tmp: &Path, path: &str) { - let dir = tmp.join(path); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("task.md"), TASK).unwrap(); - fs::write(dir.join("a.md"), "schema_version: 1\n").unwrap(); - } - - /// Як [`node_files_only`], але комітить і пушить у `origin/main` — - /// потрібно для `run_node()`: worktree чекаутиться саме з `origin/main`. - fn node(tmp: &Path, path: &str) { - node_files_only(tmp, path); - crate::test_support::run(tmp, &["add", "."]); - crate::test_support::run(tmp, &["commit", "-q", "-m", &format!("add {path}")]); - crate::test_support::run(tmp, &["push", "-q", "origin", "main"]); - } - - /// Тіло фейкового `claude`, що пише валідний fact поточної спроби - /// (cwd шима — директорія вузла у worktree, NNN — з env). - const FAKE_CLI_WRITES_FACT: &str = r#"printf -- '---\nschema_version: 1\n---\n\n## Summary\n\nok\n' > "fact_${MT_RUN_NNN}.md""#; - - fn env_default() -> AgentCliEnv { - AgentCliEnv::default() - } - - #[test] - fn preflight_blocks_unresolved_deps_and_running() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - node_files_only(&root, "a"); - node_files_only(&root, "b"); - fs::create_dir_all(root.join("b/deps")).unwrap(); - fs::write(root.join("b/deps/a.md"), "").unwrap(); - let r = root.to_string_lossy().into_owned(); - - assert!(preflight_env(&r, "b", &env_default()) - .unwrap_err() - .contains("blocked: a")); - fs::write(root.join("a/running_1_until_9999999999"), "").unwrap(); - assert!(preflight_env(&r, "a", &env_default()) - .unwrap_err() - .contains("running")); - } - - #[test] - fn preflight_resolves_executor_flags_and_ladder() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - node_files_only(&root, "solo"); - fs::write( - root.join("solo/a.md"), - "## Model tier\n\nAVG\n\n## Agent cli\n\ncursor\n", - ) - .unwrap(); - let r = root.to_string_lossy().into_owned(); - - // attempt=1 — базовий щабель. - let plan = preflight_env(&r, "solo", &env_default()).unwrap(); - assert_eq!(plan.agent_cli, "cursor"); - assert_eq!(plan.model_tier, "AVG"); - assert_eq!(plan.retry_strategy, "base"); - - // failed_streak=2 → attempt=3 → alternative-approach ескалює AVG→MAX. - fs::write(root.join("solo/run_001.md"), "---\nresult: failed\n---\n").unwrap(); - fs::write(root.join("solo/run_002.md"), "---\nresult: failed\n---\n").unwrap(); - let plan = preflight_env(&r, "solo", &env_default()).unwrap(); - assert_eq!(plan.attempt, 3); - assert_eq!(plan.retry_strategy, "alternative-approach"); - assert_eq!(plan.model_tier, "MAX"); - } - - #[test] - fn preflight_short_ladder_repeats_last_step() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - node_files_only(&root, "solo"); - fs::write( - root.join("solo/a.md"), - "## Model tier\n\nAVG\n\n## Retry ladder\n\n- base\n- diagnose-first\n", - ) - .unwrap(); - fs::write(root.join("solo/run_001.md"), "---\nresult: failed\n---\n").unwrap(); - fs::write(root.join("solo/run_002.md"), "---\nresult: failed\n---\n").unwrap(); - let r = root.to_string_lossy().into_owned(); - - let plan = preflight_env(&r, "solo", &env_default()).unwrap(); - assert_eq!(plan.attempt, 3); - // Коротша драбина — останній щабель повторюється, без ескалації тиру. - assert_eq!(plan.retry_strategy, "diagnose-first"); - assert_eq!(plan.model_tier, "AVG"); - } - - #[test] - fn preflight_rejects_unknown_agent_cli_fail_fast() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("mt"); - node_files_only(&root, "solo"); - let r = root.to_string_lossy().into_owned(); - let cli_env = AgentCliEnv { - agent_cli: "gemini".to_string(), - ..AgentCliEnv::default() - }; - let err = preflight_env(&r, "solo", &cli_env).unwrap_err(); - assert!(err.contains("невідомий agent_cli \"gemini\"")); - } - - #[test] - fn agent_cli_argv_per_cli_with_and_without_model() { - let (cmd, args) = build_agent_cli_argv("codex", None, "p").unwrap(); - assert_eq!(cmd, "codex"); - assert_eq!( - args, - ["exec", "--sandbox", "workspace-write", "--ephemeral", "p"] - ); - let (cmd, args) = build_agent_cli_argv("codex", Some("gpt-5.6-terra"), "p").unwrap(); - assert_eq!(cmd, "codex"); - assert_eq!( - args, - [ - "exec", - "-m", - "gpt-5.6-terra", - "--sandbox", - "workspace-write", - "--ephemeral", - "p" - ] - ); - let (cmd, args) = build_agent_cli_argv("cursor", None, "p").unwrap(); - assert_eq!(cmd, "cursor-agent"); - assert_eq!(args, ["--print", "--force", "p"]); - let (cmd, args) = build_agent_cli_argv("claude", Some("opus"), "p").unwrap(); - assert_eq!(cmd, "claude"); - assert_eq!( - args, - ["--model", "opus", "--no-session-persistence", "-p", "p"] - ); - let (cmd, args) = build_agent_cli_argv("pi", None, "p").unwrap(); - assert_eq!(cmd, "pi"); - assert_eq!(args, ["--no-session", "-p", "p"]); - assert!(build_agent_cli_argv("gemini", None, "p").is_none()); - } - - #[test] - fn rate_limit_heuristic_and_cascade_order() { - assert!(is_rate_limited(false, "Rate limit exceeded")); - assert!(is_rate_limited(false, "usage limit reached for your plan")); - assert!(is_rate_limited(false, "HTTP 429 Too Many Requests")); - assert!(is_rate_limited(false, "quota exceeded")); - // Успішний exit або не-лімітна помилка каскад не запускають. - assert!(!is_rate_limited(true, "rate limit")); - assert!(!is_rate_limited(false, "syntax error in generated patch")); - assert!(!is_rate_limited(false, "id 14290 not found")); - - let cloud = vec!["codex".to_string(), "cursor".to_string()]; - assert_eq!(cascade_order("codex", &cloud), ["codex", "cursor"]); - assert_eq!( - cascade_order("claude", &cloud), - ["claude", "codex", "cursor"] - ); - } - - #[test] - fn run_success_publishes_fact_to_origin_main() { - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - node(&root, "solo"); - let r = root.to_string_lossy().into_owned(); - with_path_shims(&[("claude", FAKE_CLI_WRITES_FACT)], || { - let out = run_node_env(&r, "solo", &env_default()).unwrap(); - assert_eq!(out.result, "success"); - assert_eq!(out.fact_file.as_deref(), Some("fact_001.md")); - assert_eq!(out.agent_cli.as_deref(), Some("claude")); - }); - assert!(!crate::has_running_marker(&root.join("solo"))); - - // Опубліковано в origin/main: claim/run ref прибрані, коміт на remote. - let claims = crate::test_support::output( - repo.work.path(), - &["ls-remote", "origin", "refs/mt/claims/*"], - ); - assert!(claims.is_empty()); - // Локальний main (той самий work-клон) підхопив публікацію. - assert!(root.join("solo/fact_001.md").is_file()); - let run = fs::read_to_string(root.join("solo/run_001.md")).unwrap(); - assert!(run.contains("result: success")); - assert!(run.contains("agent_cli: claude")); - } - - #[test] - fn hard_budget_kills_and_publishes_failure_run() { - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - node(&root, "slow"); - let r = root.to_string_lossy().into_owned(); - let mut out = None; - with_path_shims(&[("claude", "sleep 30")], || { - out = Some(run_node_env(&r, "slow", &env_default()).unwrap()); - }); - assert_eq!(out.unwrap().result, "budget-exceeded"); - let run = fs::read_to_string(root.join("slow/run_001.md")).unwrap(); - assert!(run.contains("result: budget-exceeded")); - assert!(run.contains("wall_sec:")); - assert!(!root.join("slow/fact_001.md").exists()); - } - - #[test] - fn failure_takes_sections_from_draft_and_publishes() { - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - node(&root, "fail"); - let r = root.to_string_lossy().into_owned(); - let draft_cli = r#"printf -- '## Completed\n\nполовина\n\n## Blockers\n\nнемає доступу\n\n## Next Attempt\n\nдати ключ\n' > run-draft.md; exit 1"#; - let mut result = String::new(); - with_path_shims(&[("claude", draft_cli)], || { - result = run_node_env(&r, "fail", &env_default()).unwrap().result; - }); - assert_eq!(result, "failed"); - let run = fs::read_to_string(root.join("fail/run_001.md")).unwrap(); - assert!(run.contains("немає доступу")); - assert!(run.contains("дати ключ")); - } - - #[test] - fn failed_check_revokes_fact_and_publishes_failed_run() { - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - let dir = root.join("gated"); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("task.md"), - "---\nschema_version: 1\nbudget_sec: 5\nbudget_hard_sec: 2\n---\n\n## Task\n\nx\n\n## Check\n\nfalse\n", - ) - .unwrap(); - fs::write(dir.join("a.md"), "schema_version: 1\n").unwrap(); - crate::test_support::run(repo.work.path(), &["add", "."]); - crate::test_support::run(repo.work.path(), &["commit", "-q", "-m", "add gated"]); - crate::test_support::run(repo.work.path(), &["push", "-q", "origin", "main"]); - - let r = root.to_string_lossy().into_owned(); - let mut result = String::new(); - with_path_shims(&[("claude", FAKE_CLI_WRITES_FACT)], || { - result = run_node_env(&r, "gated", &env_default()).unwrap().result; - }); - assert_eq!(result, "failed"); - // Fact відкликано — вузол не стає хибно resolved. - assert!(!root.join("gated/fact_001.md").exists()); - let run = fs::read_to_string(root.join("gated/run_001.md")).unwrap(); - assert!(run.contains("result: failed")); - assert!(run.contains("## Check")); - } - - #[test] - fn rejected_claim_when_node_already_claimed() { - // Claim відхиляється ДО спавну виконавця — фейковий CLI не потрібен. - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - node(&root, "solo"); - let r = root.to_string_lossy().into_owned(); - - let hash = node_hash("mt", "solo"); - let base = repo.main_sha(); - let fields = ClaimFields { - node: "solo", - actor: "agent", - runner_id: "other/1", - claimed_at: &iso_now(), - lease_until: &iso_plus(3600), - token: "already-there", - generation: 1, - base_sha: &base, - run_ref: "refs/mt/runs/x/already-there", - interactive: false, - }; - acquire_claim(repo.work.path(), &hash, &fields).unwrap(); - - let err = run_node_env(&r, "solo", &env_default()).unwrap_err(); - assert!(err.contains("claim-lost")); - } - - /// Каскадні тести спавнять фейкові CLI через PATH-шими — серіалізуємо - /// мутацію PATH процесу. - static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Тимчасовий bin-каталог із фейковими CLI, prepended до PATH. - fn with_path_shims(shims: &[(&str, &str)], f: impl FnOnce()) { - let _guard = PATH_LOCK.lock().unwrap(); - let bin = tempfile::tempdir().unwrap(); - for (name, body) in shims { - let p = bin.path().join(name); - fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); - } - } - let orig = std::env::var("PATH").unwrap_or_default(); - std::env::set_var("PATH", format!("{}:{orig}", bin.path().display())); - f(); - std::env::set_var("PATH", orig); - } - - #[test] - fn cascade_falls_over_to_next_cloud_cli_on_rate_limit() { - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - node(&root, "solo"); - let r = root.to_string_lossy().into_owned(); - let cli_env = AgentCliEnv { - agent_cli: "codex".to_string(), - cloud_agent_clis: vec!["codex".to_string(), "cursor".to_string()], - ..AgentCliEnv::default() - }; - with_path_shims( - &[ - ( - "codex", - "echo 'Rate limit exceeded, try again later' >&2; exit 1", - ), - ( - "cursor-agent", - r#"printf -- '---\nschema_version: 1\n---\n\n## Summary\n\nok\n' > "fact_${MT_RUN_NNN}.md""#, - ), - ], - || { - let out = run_node_env(&r, "solo", &cli_env).unwrap(); - assert_eq!(out.result, "success"); - assert_eq!(out.agent_cli.as_deref(), Some("cursor")); - }, - ); - let run = fs::read_to_string(root.join("solo/run_001.md")).unwrap(); - assert!(run.contains("agent_cli: cursor")); - assert!(run.contains("result: success")); - } - - #[test] - fn cascade_exhausted_or_plain_error_paths() { - let repo = TestRepo::new(); - let root = repo.work.path().join("mt"); - node(&root, "solo"); - let r = root.to_string_lossy().into_owned(); - - // Усі кандидати rate-limited → failed без fact. - let cli_env = AgentCliEnv { - agent_cli: "codex".to_string(), - cloud_agent_clis: vec!["cursor".to_string()], - ..AgentCliEnv::default() - }; - with_path_shims( - &[ - ("codex", "echo 'usage limit reached for your plan'; exit 1"), - ("cursor-agent", "echo 'quota exceeded'; exit 1"), - ], - || { - let out = run_node_env(&r, "solo", &cli_env).unwrap(); - assert_eq!(out.result, "failed"); - assert!(out.agent_cli.is_none()); - }, - ); - let run = fs::read_to_string(root.join("solo/run_001.md")).unwrap(); - assert!(run.contains("вичерпали ліміти підписки")); - - // Не-лімітна помилка НЕ каскадує: перший кандидат фіксується як - // фактичний CLI, наступний не викликається. - let marker = repo.work.path().join("cursor-called"); - let marker_cmd = format!("touch {}", marker.display()); - with_path_shims( - &[ - ("codex", "echo 'syntax error in generated patch'; exit 1"), - ("cursor-agent", marker_cmd.as_str()), - ], - || { - let out = run_node_env(&r, "solo", &cli_env).unwrap(); - assert_eq!(out.result, "failed"); - assert_eq!(out.agent_cli.as_deref(), Some("codex")); - assert!(!marker.exists()); - }, - ); - } -} diff --git a/crates/mt-core/src/signal.rs b/crates/mt-core/src/signal.rs deleted file mode 100644 index 7e3fb87..0000000 --- a/crates/mt-core/src/signal.rs +++ /dev/null @@ -1,533 +0,0 @@ -//! Сигнали виконавця `mt done | audit | failed` — файловий рівень wrapper-а -//! (спека mt.md, «Два етапи виконання вузла» і «Composite fact»). -//! -//! Потік: виконавець пише `fact_NNN.md` (NNN наступної спроби) → `done`/`audit` -//! проганяє `## Check` з task.md → wrapper пише `run_NNN.md`; `audit` додатково -//! відкриває аудит-цикл (`pending-audit_NNN.md`). `failed` пише run без fact -//! («дірка» в нумерації). Після успішного done — composite-агрегація вгору: -//! всі діти resolved → синтетична пара run/fact батька (actor: wrapper). - -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use serde::{Deserialize, Serialize}; - -use crate::frontmatter::{get_body, parse_front_matter}; -use crate::lifecycle::child_nodes; -use crate::nnn::pad_nnn; -use crate::{accepted_fact_state, validate_name, write_atomic, FactState}; - -/// Результат однієї команди `## Check`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CheckResult { - pub command: String, - pub exit_code: i32, - pub output: String, -} - -/// Результат сигналу done/audit: записані файли + пропагація вгору. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignalOutcome { - pub run_file: String, - pub fact_file: String, - /// Для audit — файл відкритого аудит-циклу. - pub pending_audit_file: Option<String>, - /// Батьки, що отримали синтетичну пару run/fact (composite-агрегація). - pub propagated: Vec<String>, -} - -fn node_dir(tasks_dir: &str, node_path: &str) -> Result<PathBuf, String> { - validate_name(node_path)?; - let dir = Path::new(tasks_dir).join(node_path); - if !dir.join("task.md").is_file() { - return Err(format!("node not found: {node_path}")); - } - Ok(dir) -} - -fn now_iso() -> String { - chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} - -/// NNN наступної спроби: `count(run_*.md) + 1` (спека, «NNN source»). -pub fn next_run_nnn(dir: &Path) -> u64 { - let count = fs::read_dir(dir) - .map(|entries| { - entries - .flatten() - .filter(|e| { - let name = e.file_name().to_string_lossy().into_owned(); - name.strip_prefix("run_") - .and_then(|r| r.strip_suffix(".md")) - .is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) - }) - .count() - }) - .unwrap_or(0); - count as u64 + 1 -} - -/// Витягує команди секції `## Check` task.md: кожен непорожній рядок — -/// shell-команда, `#` — коментар. -pub fn check_commands(task_md: &str) -> Vec<String> { - let body = get_body(task_md); - let mut commands = Vec::new(); - let mut inside = false; - for line in body.lines() { - if line.trim() == "## Check" { - inside = true; - continue; - } - if inside { - if line.starts_with("## ") { - break; - } - let t = line.trim(); - if !t.is_empty() && !t.starts_with('#') && !t.starts_with("<!--") { - commands.push(t.to_string()); - } - } - } - commands -} - -/// Проганяє `## Check` (cwd = project root — батько tasks_dir). Будь-який -/// ненульовий exit → `Err` з виводом команд; сигнал відхиляється. -pub fn run_check(tasks_dir: &str, node_path: &str) -> Result<Vec<CheckResult>, String> { - let dir = node_dir(tasks_dir, node_path)?; - let task_md = fs::read_to_string(dir.join("task.md")).map_err(|e| e.to_string())?; - // Контракт graph.md: `## Check` ганяється у директорії вузла (артефакти - // вузла — поряд із task.md); командам, яким потрібен корінь репо, - // додається власний cwd-еквівалент у самому рядку Check. - let cwd = dir.as_path(); - let mut results = Vec::new(); - for command in check_commands(&task_md) { - let out = Command::new("sh") - .arg("-c") - .arg(&command) - .current_dir(cwd) - .output() - .map_err(|e| format!("## Check `{command}`: {e}"))?; - let mut output = String::from_utf8_lossy(&out.stdout).into_owned(); - output.push_str(&String::from_utf8_lossy(&out.stderr)); - let exit_code = out.status.code().unwrap_or(-1); - results.push(CheckResult { - command: command.clone(), - exit_code, - output: output.trim().to_string(), - }); - if exit_code != 0 { - let last = results.last().unwrap(); - return Err(format!( - "## Check failed: `{}` → exit {}\n{}", - last.command, last.exit_code, last.output - )); - } - } - Ok(results) -} - -/// Пише `fact_NNN.md` (NNN наступної спроби) з обов'язковим `## Summary`. -pub fn write_fact( - tasks_dir: &str, - node_path: &str, - summary: &str, - extra_body: Option<&str>, -) -> Result<String, String> { - let dir = node_dir(tasks_dir, node_path)?; - if summary.trim().is_empty() { - return Err("## Summary обов'язковий для fact".to_string()); - } - let nnn = pad_nnn(next_run_nnn(&dir)); - let fact_file = format!("fact_{nnn}.md"); - let mut content = format!( - "---\nschema_version: 1\ncreated_at: {}\n---\n\n## Summary\n\n{}\n", - now_iso(), - summary.trim() - ); - if let Some(extra) = extra_body { - if !extra.trim().is_empty() { - content.push('\n'); - content.push_str(extra.trim()); - content.push('\n'); - } - } - write_atomic(&dir.join(&fact_file), &content)?; - Ok(fact_file) -} - -pub(crate) fn write_run( - dir: &Path, - nnn: &str, - actor: &str, - result: &str, - sections: &str, -) -> Result<String, String> { - write_run_fm(dir, nnn, actor, result, sections, "") -} - -/// Як [`write_run`], але з додатковими frontmatter-рядками (wall_sec тощо). -pub fn write_run_fm( - dir: &Path, - nnn: &str, - actor: &str, - result: &str, - sections: &str, - extra_fm: &str, -) -> Result<String, String> { - let run_file = format!("run_{nnn}.md"); - let content = format!( - "---\nschema_version: 1\ncreated_at: {}\nactor: {actor}\nresult: {result}\n{extra_fm}---\n{sections}", - now_iso() - ); - write_atomic(&dir.join(&run_file), &content)?; - Ok(run_file) -} - -/// Політика аудиту вузла: frontmatter `audit:` task.md (required|optional|off). -fn audit_policy(dir: &Path) -> String { - fs::read_to_string(dir.join("task.md")) - .ok() - .map(|c| parse_front_matter(&c)) - .and_then(|fm| { - fm.get("audit") - .and_then(serde_json::Value::as_str) - .map(String::from) - }) - .unwrap_or_else(|| "optional".to_string()) -} - -fn signal_success( - tasks_dir: &str, - node_path: &str, - actor: &str, - with_audit: bool, - extra_fm: &str, -) -> Result<SignalOutcome, String> { - let dir = node_dir(tasks_dir, node_path)?; - let policy = audit_policy(&dir); - if !with_audit && policy == "required" { - return Err("audit: required — вузол приймає лише сигнал audit".to_string()); - } - if with_audit && policy == "off" { - return Err("audit: off — аудит для цього вузла вимкнено".to_string()); - } - - let nnn_num = next_run_nnn(&dir); - let nnn = pad_nnn(nnn_num); - let fact_file = format!("fact_{nnn}.md"); - if !dir.join(&fact_file).is_file() { - return Err(format!("{fact_file} відсутній — спершу запишіть fact")); - } - - run_check(tasks_dir, node_path)?; - - let sections = format!("\n## Ref\n\nref: {fact_file}\n"); - let run_file = write_run_fm(&dir, &nnn, actor, "success", §ions, extra_fm)?; - - let pending_audit_file = if with_audit { - let pa = format!("pending-audit_{nnn}.md"); - write_atomic( - &dir.join(&pa), - &format!( - "---\nschema_version: 1\ncreated_at: {}\nactor: {actor}\n---\n", - now_iso() - ), - )?; - Some(pa) - } else { - None - }; - - // Аудит відкритий → вузол не resolved → пропагація вгору не запускається. - let propagated = if with_audit { - Vec::new() - } else { - propagate_composite(tasks_dir, node_path)? - }; - - Ok(SignalOutcome { - run_file, - fact_file, - pending_audit_file, - propagated, - }) -} - -/// `mt done`: fact існує → `## Check` → `run_NNN (success)` → агрегація вгору. -pub fn done(tasks_dir: &str, node_path: &str, actor: &str) -> Result<SignalOutcome, String> { - signal_success(tasks_dir, node_path, actor, false, "") -} - -/// Як [`done`], але з додатковими frontmatter-рядками `run_NNN.md` -/// (runner фіксує `agent_cli`, `wall_sec` тощо). -pub fn done_fm( - tasks_dir: &str, - node_path: &str, - actor: &str, - extra_fm: &str, -) -> Result<SignalOutcome, String> { - signal_success(tasks_dir, node_path, actor, false, extra_fm) -} - -/// `mt audit`: як done, але відкриває аудит-цикл (`pending-audit_NNN.md`). -pub fn audit(tasks_dir: &str, node_path: &str, actor: &str) -> Result<SignalOutcome, String> { - signal_success(tasks_dir, node_path, actor, true, "") -} - -/// Як [`audit`], але з додатковими frontmatter-рядками `run_NNN.md`. -pub fn audit_fm( - tasks_dir: &str, - node_path: &str, - actor: &str, - extra_fm: &str, -) -> Result<SignalOutcome, String> { - signal_success(tasks_dir, node_path, actor, true, extra_fm) -} - -/// `mt failed`: `run_NNN (failed)` без fact; секції Completed/Blockers/Next -/// Attempt обов'язкові (інваріант файлу — джерело діагностики ретраїв). -pub fn failed( - tasks_dir: &str, - node_path: &str, - actor: &str, - completed: &str, - blockers: &str, - next_attempt: &str, -) -> Result<String, String> { - let dir = node_dir(tasks_dir, node_path)?; - for (name, value) in [ - ("Completed", completed), - ("Blockers", blockers), - ("Next Attempt", next_attempt), - ] { - if value.trim().is_empty() { - return Err(format!("## {name} обов'язковий при failed")); - } - } - let nnn = pad_nnn(next_run_nnn(&dir)); - let sections = format!( - "\n## Completed\n\n{}\n\n## Blockers\n\n{}\n\n## Next Attempt\n\n{}\n", - completed.trim(), - blockers.trim(), - next_attempt.trim() - ); - write_run(&dir, &nnn, actor, "failed", §ions) -} - -/// Перше речення `## Summary` останнього fact вузла (для агрегації батька). -fn latest_fact_summary(dir: &Path) -> Option<(String, String)> { - let nnn = crate::max_nnn(dir, "fact_", ".md"); - if nnn == 0 { - return None; - } - let file = format!("fact_{nnn:03}.md"); - let content = fs::read_to_string(dir.join(&file)).ok()?; - let body = get_body(&content); - let mut inside = false; - for line in body.lines() { - if line.trim() == "## Summary" { - inside = true; - continue; - } - if inside { - let t = line.trim(); - if t.starts_with("## ") { - break; - } - if !t.is_empty() { - return Some((file, t.to_string())); - } - } - } - Some((file, String::new())) -} - -/// Composite-агрегація вгору (спека, «Composite fact»): якщо всі діти батька -/// resolved — wrapper пише синтетичну пару run/fact батька; рекурсивно далі. -/// Повертає шляхи батьків, що отримали синтетичну пару. -pub fn propagate_composite(tasks_dir: &str, node_path: &str) -> Result<Vec<String>, String> { - let mut propagated = Vec::new(); - let mut current = node_path.to_string(); - - while let Some((parent_path, _)) = current.rsplit_once('/') { - let parent_dir = Path::new(tasks_dir).join(parent_path); - if !parent_dir.join("task.md").is_file() { - break; - } - // Батько вже resolved або не має дітей → зупинка. - if accepted_fact_state(&parent_dir) == FactState::Resolved { - break; - } - let children = child_nodes(&parent_dir); - if children.is_empty() { - break; - } - let all_resolved = children - .iter() - .all(|c| accepted_fact_state(&parent_dir.join(c)) == FactState::Resolved); - if !all_resolved { - break; - } - - // export: false діти (з ## Children approved-плану) не потрапляють у ## children. - let exported: Vec<&String> = { - let excluded: Vec<String> = crate::spawn::plan_review(tasks_dir, parent_path) - .map(|r| { - r.children - .iter() - .filter(|c| !c.export) - .map(|c| c.id.clone()) - .collect() - }) - .unwrap_or_default(); - children.iter().filter(|c| !excluded.contains(c)).collect() - }; - - let mut summaries = Vec::new(); - let mut refs = Vec::new(); - for child in &exported { - if let Some((fact_file, summary)) = latest_fact_summary(&parent_dir.join(child)) { - if !summary.is_empty() { - summaries.push(summary); - } - refs.push(format!("- {child}: ref: {child}/{fact_file}")); - } - } - - let nnn = pad_nnn(next_run_nnn(&parent_dir)); - write_run( - &parent_dir, - &nnn, - "wrapper", - "success", - &format!("\n## Reasoning\n\nагрегація дітей\n\n## Ref\n\nref: fact_{nnn}.md\n"), - )?; - let fact_content = format!( - "---\nschema_version: 1\ncreated_at: {}\n---\n\n## Summary\n\n{}\n\n## children\n\n{}\n", - now_iso(), - summaries.join(" "), - refs.join("\n") - ); - write_atomic(&parent_dir.join(format!("fact_{nnn}.md")), &fact_content)?; - - propagated.push(parent_path.to_string()); - current = parent_path.to_string(); - } - Ok(propagated) -} - -#[cfg(test)] -mod tests { - use super::*; - - const TASK_WITH_CHECK: &str = "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\n---\n\n## Task\n\nx\n\n## Check\n\n# коментар\ntrue\n\n## Inputs\n"; - - fn node(tmp: &Path, path: &str, task_md: &str) { - let dir = tmp.join(path); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("task.md"), task_md).unwrap(); - } - - #[test] - fn check_commands_skips_comments_and_stops_at_next_section() { - let commands = check_commands(TASK_WITH_CHECK); - assert_eq!(commands, ["true"]); - assert!(check_commands("---\nschema_version: 1\n---\n\n## Task\n").is_empty()); - } - - #[test] - fn done_requires_fact_then_writes_run() { - let tmp = tempfile::tempdir().unwrap(); - node(tmp.path(), "solo", TASK_WITH_CHECK); - let root = tmp.path().to_string_lossy().into_owned(); - - assert!(done(&root, "solo", "human") - .unwrap_err() - .contains("fact_001")); - - write_fact(&root, "solo", "Зроблено 42 речі.", None).unwrap(); - let out = done(&root, "solo", "human").unwrap(); - assert_eq!(out.run_file, "run_001.md"); - assert_eq!(out.fact_file, "fact_001.md"); - let run = fs::read_to_string(tmp.path().join("solo/run_001.md")).unwrap(); - assert!(run.contains("actor: human")); - assert!(run.contains("result: success")); - assert!(run.contains("ref: fact_001.md")); - } - - #[test] - fn failing_check_rejects_signal() { - let tmp = tempfile::tempdir().unwrap(); - let task = TASK_WITH_CHECK.replace("true", "exit 3"); - node(tmp.path(), "solo", &task); - let root = tmp.path().to_string_lossy().into_owned(); - write_fact(&root, "solo", "s", None).unwrap(); - let err = done(&root, "solo", "human").unwrap_err(); - assert!(err.contains("exit 3")); - assert!(!tmp.path().join("solo/run_001.md").exists()); - } - - #[test] - fn audit_opens_cycle_and_required_blocks_done() { - let tmp = tempfile::tempdir().unwrap(); - let task = TASK_WITH_CHECK.replace("---\n\n## Task", "audit: required\n---\n\n## Task"); - node(tmp.path(), "solo", &task); - let root = tmp.path().to_string_lossy().into_owned(); - write_fact(&root, "solo", "s", None).unwrap(); - - assert!(done(&root, "solo", "human") - .unwrap_err() - .contains("required")); - let out = audit(&root, "solo", "human").unwrap(); - assert_eq!( - out.pending_audit_file.as_deref(), - Some("pending-audit_001.md") - ); - assert!(out.propagated.is_empty()); - } - - #[test] - fn failed_requires_sections_and_leaves_gap() { - let tmp = tempfile::tempdir().unwrap(); - node(tmp.path(), "solo", TASK_WITH_CHECK); - let root = tmp.path().to_string_lossy().into_owned(); - assert!(failed(&root, "solo", "human", "", "b", "n").is_err()); - let run = failed( - &root, - "solo", - "human", - "зроблено половину", - "впс", - "розбити на батчі", - ) - .unwrap(); - assert_eq!(run, "run_001.md"); - assert!(!tmp.path().join("solo/fact_001.md").exists()); - } - - #[test] - fn done_propagates_composite_up() { - let tmp = tempfile::tempdir().unwrap(); - node(tmp.path(), "root", TASK_WITH_CHECK); - node(tmp.path(), "root/a", TASK_WITH_CHECK); - node(tmp.path(), "root/b", TASK_WITH_CHECK); - let root = tmp.path().to_string_lossy().into_owned(); - - write_fact(&root, "root/a", "A готово.", None).unwrap(); - let out_a = done(&root, "root/a", "agent").unwrap(); - assert!(out_a.propagated.is_empty()); // b ще не resolved - - write_fact(&root, "root/b", "B готово.", None).unwrap(); - let out_b = done(&root, "root/b", "agent").unwrap(); - assert_eq!(out_b.propagated, ["root"]); - - let fact = fs::read_to_string(tmp.path().join("root/fact_001.md")).unwrap(); - assert!(fact.contains("A готово. B готово.") || fact.contains("B готово. A готово.")); - assert!(fact.contains("- a: ref: a/fact_001.md")); - assert!(fact.contains("- b: ref: b/fact_001.md")); - let run = fs::read_to_string(tmp.path().join("root/run_001.md")).unwrap(); - assert!(run.contains("actor: wrapper")); - } -} diff --git a/crates/mt-core/src/spawn.rs b/crates/mt-core/src/spawn.rs deleted file mode 100644 index 1ed18d7..0000000 --- a/crates/mt-core/src/spawn.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! Протокол spawn — plan-review рішення (спека mt.md, «Протокол spawn»). -//! -//! `## Children` актуального `plan_NNN.md` — єдине джерело структури підграфу. -//! `spawn_approve` валідує специфікацію і матеріалізує дітей + `plan-approved_NNN.md`; -//! `spawn_reject` пише `plan-rejected_NNN.md` із причиною. Без approve — -//! жодних дочірніх вузлів (правило легітимності). - -use std::collections::HashSet; -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::nnn::pad_nnn; -use crate::{create_task, validate_name, write_atomic, CreateOpts, Mode}; - -/// Специфікація однієї дитини з `## Children` (спека: mode — обов'язковий). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ChildSpec { - pub id: String, - pub mode: Option<String>, - pub model_tier: Option<String>, - pub skills: Vec<String>, - pub qualification: Option<String>, - pub budget_sec: Option<u64>, - /// `export: false` → дитина не потрапляє у `## children` fact батька. - pub export: bool, - /// Сусіди — голий id; cross-level — шлях відносно tasks root. - pub deps: Vec<String>, - pub task: Option<String>, -} - -/// Read-модель plan-review для GUI: актуальний план і його `## Children`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PlanReview { - pub plan_file: String, - pub nnn: u64, - pub decision: Option<String>, - pub decided: bool, - pub children: Vec<ChildSpec>, -} - -/// Результат `spawn_approve`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SpawnOutcome { - pub approved_file: String, - pub children: Vec<String>, -} - -fn node_dir(tasks_dir: &str, node_path: &str) -> Result<PathBuf, String> { - validate_name(node_path)?; - let dir = Path::new(tasks_dir).join(node_path); - if !dir.join("task.md").is_file() { - return Err(format!("node not found: {node_path}")); - } - Ok(dir) -} - -/// Актуальний план: `plan_NNN.md` з max NNN. -fn latest_plan(dir: &Path) -> Option<(u64, String, String)> { - let mut best: Option<(u64, String)> = None; - for entry in fs::read_dir(dir).ok()?.flatten() { - let name = entry.file_name().to_string_lossy().into_owned(); - let Some(digits) = name - .strip_prefix("plan_") - .and_then(|r| r.strip_suffix(".md")) - else { - continue; - }; - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - continue; - } - let n: u64 = digits.parse().ok()?; - if best.as_ref().is_none_or(|(b, _)| n > *b) { - best = Some((n, name)); - } - } - let (nnn, file) = best?; - let content = fs::read_to_string(dir.join(&file)).ok()?; - Some((nnn, file, content)) -} - -/// Вирізає тіло секції `## Children` (до наступного `## `-заголовка). -fn children_section(plan: &str) -> Option<String> { - let mut lines = plan.lines(); - let mut section = Vec::new(); - let mut inside = false; - for line in lines.by_ref() { - if line.trim() == "## Children" { - inside = true; - continue; - } - if inside { - if line.starts_with("## ") { - break; - } - section.push(line); - } - } - if section.is_empty() { - return None; - } - // Fenced-блок усередині секції → беремо його вміст; інакше секцію цілком. - let text = section.join("\n"); - if let Some(start) = text.find("```") { - let after = &text[start..]; - let body_start = after.find('\n')? + start + 1; - let body = &text[body_start..]; - let end = body.find("```").unwrap_or(body.len()); - return Some(body[..end].to_string()); - } - Some(text) -} - -/// Інлайн-масив `[a, b]` → елементи; порожній `[]` → порожньо. -fn parse_inline_list(v: &str) -> Vec<String> { - let inner = v.trim().trim_start_matches('[').trim_end_matches(']'); - inner - .split(',') - .map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string()) - .filter(|s| !s.is_empty()) - .collect() -} - -fn indent_of(line: &str) -> usize { - line.bytes().take_while(|b| *b == b' ').count() -} - -/// Парсер підмножини YAML для `children:` — список об'єктів зі скалярами, -/// інлайн-масивами та блоковими скалярами `|` (частковий парсер frontmatter -/// списки об'єктів не підтримує). -pub fn parse_children(yaml: &str) -> Result<Vec<ChildSpec>, String> { - let lines: Vec<&str> = yaml.lines().collect(); - let mut children: Vec<ChildSpec> = Vec::new(); - let mut i = 0; - - // Пропускаємо все до ключа children: - while i < lines.len() && lines[i].trim() != "children:" { - i += 1; - } - if i >= lines.len() { - return Err("## Children: ключ `children:` не знайдено".to_string()); - } - i += 1; - - let mut item_indent = None; - while i < lines.len() { - let line = lines[i]; - if line.trim().is_empty() || line.trim_start().starts_with('#') { - i += 1; - continue; - } - let indent = indent_of(line); - let trimmed = line.trim_start(); - - if let Some(rest) = trimmed.strip_prefix("- ") { - // Новий елемент списку. - item_indent = Some(indent); - children.push(ChildSpec { - id: String::new(), - mode: None, - model_tier: None, - skills: Vec::new(), - qualification: None, - budget_sec: None, - export: true, - deps: Vec::new(), - task: None, - }); - apply_field( - children.last_mut().unwrap(), - rest, - &lines, - &mut i, - indent + 2, - )?; - } else if item_indent.is_some_and(|ii| indent > ii) { - let Some(child) = children.last_mut() else { - return Err("## Children: поле поза елементом списку".to_string()); - }; - apply_field(child, trimmed, &lines, &mut i, indent)?; - } else { - break; // вихід із блоку children - } - i += 1; - } - Ok(children) -} - -/// Застосовує один рядок `key: value` до дитини; для `task: |` збирає -/// блоковий скаляр (рядки з більшим відступом), пересуваючи курсор `i`. -fn apply_field( - child: &mut ChildSpec, - field: &str, - lines: &[&str], - i: &mut usize, - field_indent: usize, -) -> Result<(), String> { - let Some((key, raw)) = field.split_once(':') else { - return Err(format!( - "## Children: очікував `key: value`, отримав {field:?}" - )); - }; - let key = key.trim(); - let value = raw.split('#').next().unwrap_or("").trim().to_string(); - - if value == "|" || value == "|-" { - // Блоковий скаляр: рядки з відступом > field_indent. - let mut block = Vec::new(); - while *i + 1 < lines.len() { - let next = lines[*i + 1]; - if !next.trim().is_empty() && indent_of(next) <= field_indent { - break; - } - block.push(next.trim().to_string()); - *i += 1; - } - let text = block.join("\n").trim().to_string(); - if key == "task" { - child.task = Some(text); - } - return Ok(()); - } - - match key { - "id" => child.id = value, - "mode" => child.mode = Some(value), - "model_tier" => child.model_tier = Some(value), - "qualification" => child.qualification = Some(value), - "task" => child.task = Some(value), - "budget_sec" => { - child.budget_sec = Some( - value - .parse() - .map_err(|_| format!("## Children: budget_sec не число: {value:?}"))?, - ) - } - "export" => child.export = value != "false", - "skills" => child.skills = parse_inline_list(&value), - "deps" => child.deps = parse_inline_list(&value), - "audit" => {} // приймаємо без обробки: create_task поки не пише audit - _ => {} // невідомі поля — толерантно ігноруємо - } - Ok(()) -} - -/// Read-модель plan-review вузла: актуальний план + розібрані `## Children`. -pub fn plan_review(tasks_dir: &str, node_path: &str) -> Result<PlanReview, String> { - let dir = node_dir(tasks_dir, node_path)?; - let (nnn, plan_file, content) = - latest_plan(&dir).ok_or_else(|| format!("no plan_NNN.md in {node_path}"))?; - let fm = crate::frontmatter::parse_front_matter(&content); - let decision = fm - .get("decision") - .and_then(serde_json::Value::as_str) - .map(String::from); - let children = match children_section(&content) { - Some(yaml) => parse_children(&yaml)?, - None => Vec::new(), - }; - let nnn_s = pad_nnn(nnn); - let decided = dir.join(format!("plan-approved_{nnn_s}.md")).exists() - || dir.join(format!("plan-rejected_{nnn_s}.md")).exists(); - Ok(PlanReview { - plan_file, - nnn, - decision, - decided, - children, - }) -} - -/// Валідація `## Children` за спекою: id/naming, mode per-child, deps -/// існують (сусід у списку або cross-level вузол на диску), циклів немає. -fn validate_children(tasks_dir: &str, children: &[ChildSpec]) -> Result<(), String> { - if children.is_empty() { - return Err("## Children порожня — немає що матеріалізувати".to_string()); - } - let ids: HashSet<&str> = children.iter().map(|c| c.id.as_str()).collect(); - if ids.len() != children.len() { - return Err("## Children: дублікати id".to_string()); - } - for child in children { - validate_name(&child.id)?; - if child.id.contains('/') { - return Err(format!("child id must be a single segment: {:?}", child.id)); - } - match child.mode.as_deref() { - Some("agent") | Some("human") => {} - Some(other) => return Err(format!("child {:?}: невалідний mode {other:?}", child.id)), - None => return Err(format!("child {:?}: mode обов'язковий per-child", child.id)), - } - for dep in &child.deps { - let sibling = ids.contains(dep.as_str()); - let cross = Path::new(tasks_dir).join(dep).join("task.md").is_file(); - if !sibling && !cross { - return Err(format!("child {:?}: dep {dep:?} не існує", child.id)); - } - } - } - // Цикли серед сусідів: DFS по sibling-ребрах. - fn dfs<'a>( - id: &'a str, - children: &'a [ChildSpec], - visiting: &mut HashSet<&'a str>, - done: &mut HashSet<&'a str>, - ) -> Result<(), String> { - if done.contains(id) { - return Ok(()); - } - if !visiting.insert(id) { - return Err(format!("## Children: цикл через {id:?}")); - } - if let Some(child) = children.iter().find(|c| c.id == id) { - for dep in &child.deps { - if children.iter().any(|c| c.id == *dep) { - dfs(dep, children, visiting, done)?; - } - } - } - visiting.remove(id); - done.insert(id); - Ok(()) - } - let mut done = HashSet::new(); - for child in children { - dfs(&child.id, children, &mut HashSet::new(), &mut done)?; - } - Ok(()) -} - -fn guard_undecided(dir: &Path, nnn: u64) -> Result<String, String> { - let nnn_s = pad_nnn(nnn); - if dir.join(format!("plan-approved_{nnn_s}.md")).exists() { - return Err(format!("plan {nnn_s} вже approved")); - } - if dir.join(format!("plan-rejected_{nnn_s}.md")).exists() { - return Err(format!("plan {nnn_s} вже rejected")); - } - Ok(nnn_s) -} - -fn decision_frontmatter() -> String { - let created_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - format!("---\nschema_version: 1\ncreated_at: {created_at}\n---\n") -} - -/// `mt spawn --approve`: валідує `## Children` актуального плану, -/// матеріалізує дітей (task.md + прапор + deps/) і пише `plan-approved_NNN.md`. -pub fn spawn_approve(tasks_dir: &str, node_path: &str) -> Result<SpawnOutcome, String> { - let dir = node_dir(tasks_dir, node_path)?; - let review = plan_review(tasks_dir, node_path)?; - if review.decision.as_deref() != Some("composite") { - return Err(format!( - "актуальний план {} не composite — spawn не застосовний", - review.plan_file - )); - } - let nnn_s = guard_undecided(&dir, review.nnn)?; - validate_children(tasks_dir, &review.children)?; - - let mut created = Vec::new(); - for child in &review.children { - let deps = child - .deps - .iter() - .map(|dep| { - if dep.contains('/') { - dep.clone() // cross-level: шлях відносно tasks root - } else { - format!("{node_path}/{dep}") // сусід: повний шлях - } - }) - .collect(); - let mode = match child.mode.as_deref() { - Some("human") => Mode::Human, - _ => Mode::Agent, - }; - create_task( - tasks_dir.to_string(), - format!("{node_path}/{}", child.id), - CreateOpts { - mode: Some(mode), - model_tier: child.model_tier.clone(), - budget_sec: child.budget_sec, - hint: None, - deps, - skills: (!child.skills.is_empty()).then(|| child.skills.clone()), - task: child.task.clone(), - qualification: child.qualification.clone(), - }, - )?; - created.push(child.id.clone()); - } - - let approved_file = format!("plan-approved_{nnn_s}.md"); - let list = created - .iter() - .map(|id| format!("- {id}")) - .collect::<Vec<_>>() - .join("\n"); - write_atomic( - &dir.join(&approved_file), - &format!("{}\n## Children\n\n{list}\n", decision_frontmatter()), - )?; - Ok(SpawnOutcome { - approved_file, - children: created, - }) -} - -/// `mt spawn --reject --reason`: пише `plan-rejected_NNN.md`; вузол -/// derived-повертається у `waiting`, наступний план бачить причину. -pub fn spawn_reject(tasks_dir: &str, node_path: &str, reason: &str) -> Result<String, String> { - let dir = node_dir(tasks_dir, node_path)?; - let (nnn, _, _) = latest_plan(&dir).ok_or_else(|| format!("no plan_NNN.md in {node_path}"))?; - let nnn_s = guard_undecided(&dir, nnn)?; - let rejected_file = format!("plan-rejected_{nnn_s}.md"); - write_atomic( - &dir.join(&rejected_file), - &format!( - "{}\n## Reason\n\n{}\n", - decision_frontmatter(), - reason.trim() - ), - )?; - Ok(rejected_file) -} - -/// Перемикає виконавця вузла: пише `a.md`/`h.md`, видаляє протилежний прапор. -pub fn set_executor( - tasks_dir: &str, - node_path: &str, - mode: Mode, - model_tier: Option<&str>, - skills: Option<&[String]>, - qualification: Option<&str>, -) -> Result<String, String> { - let dir = node_dir(tasks_dir, node_path)?; - let default_skills = ["bash".to_string(), "write-files".to_string()]; - let flag = crate::write_executor_flag( - &dir, - mode, - model_tier.unwrap_or("AVG"), - skills.unwrap_or(&default_skills), - qualification, - )?; - Ok(flag.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const PLAN: &str = "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\ndecision: composite\n---\n\n## Context\n\nx\n\n## Children\n\n```yaml\nchildren:\n - id: collect-data\n mode: agent\n model_tier: AVG\n skills: [bash, web-search]\n budget_sec: 1800\n deps: []\n task: |\n Зібрати дані з API за Q4\n - id: analyze\n mode: human\n qualification: senior analyst\n export: false\n deps: [collect-data]\n task: Перевірити аномалії\n```\n\n## Risks\n\ny\n"; - - fn fixture() -> (tempfile::TempDir, String) { - let tmp = tempfile::tempdir().unwrap(); - let node = tmp.path().join("research"); - fs::create_dir_all(&node).unwrap(); - fs::write( - node.join("task.md"), - "---\nschema_version: 1\ncreated_at: 2026-06-06T10:00:00Z\nbudget_sec: 600\n---\n\n## Task\n", - ) - .unwrap(); - fs::write(node.join("plan_001.md"), PLAN).unwrap(); - (tmp, "research".to_string()) - } - - #[test] - fn parses_children_specs() { - let review_yaml = children_section(PLAN).unwrap(); - let children = parse_children(&review_yaml).unwrap(); - assert_eq!(children.len(), 2); - assert_eq!(children[0].id, "collect-data"); - assert_eq!(children[0].skills, ["bash", "web-search"]); - assert_eq!(children[0].budget_sec, Some(1800)); - assert_eq!( - children[0].task.as_deref(), - Some("Зібрати дані з API за Q4") - ); - assert!(children[0].export); - assert_eq!(children[1].mode.as_deref(), Some("human")); - assert!(!children[1].export); - assert_eq!(children[1].deps, ["collect-data"]); - } - - #[test] - fn approve_materializes_children_and_writes_sentinel() { - let (tmp, node) = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - let out = spawn_approve(&root, &node).unwrap(); - assert_eq!(out.children, ["collect-data", "analyze"]); - - let collect = tmp.path().join("research/collect-data"); - assert!(collect.join("task.md").is_file()); - assert!(collect.join("a.md").is_file()); - let task = fs::read_to_string(collect.join("task.md")).unwrap(); - assert!(task.contains("Зібрати дані з API за Q4")); - - let analyze = tmp.path().join("research/analyze"); - assert!(analyze.join("h.md").is_file()); - // Сусідній dep матеріалізовано повним шляхом від tasks root. - assert!(analyze.join("deps/research/collect-data.md").is_file()); - - assert!(tmp.path().join("research/plan-approved_001.md").is_file()); - // Повторний approve → відмова. - assert!(spawn_approve(&root, &node).is_err()); - } - - #[test] - fn reject_writes_reason_and_blocks_second_decision() { - let (tmp, node) = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - let file = spawn_reject(&root, &node, "занадто дрібна декомпозиція").unwrap(); - assert_eq!(file, "plan-rejected_001.md"); - let content = fs::read_to_string(tmp.path().join("research").join(file)).unwrap(); - assert!(content.contains("занадто дрібна декомпозиція")); - assert!(spawn_approve(&root, &node).is_err()); - } - - #[test] - fn validation_rejects_missing_mode_and_cycles() { - let no_mode = parse_children("children:\n - id: a\n").unwrap(); - assert!(validate_children("/nonexistent", &no_mode).is_err()); - - let cyclic = parse_children( - "children:\n - id: a\n mode: agent\n deps: [b]\n - id: b\n mode: agent\n deps: [a]\n", - ) - .unwrap(); - let err = validate_children("/nonexistent", &cyclic).unwrap_err(); - assert!(err.contains("цикл")); - } - - #[test] - fn set_executor_switches_flags() { - let (tmp, node) = fixture(); - let root = tmp.path().to_string_lossy().into_owned(); - assert_eq!( - set_executor(&root, &node, Mode::Agent, Some("MAX"), None, None).unwrap(), - "a.md" - ); - assert!(tmp.path().join("research/a.md").is_file()); - assert_eq!( - set_executor(&root, &node, Mode::Human, None, None, Some("senior")).unwrap(), - "h.md" - ); - assert!(tmp.path().join("research/h.md").is_file()); - assert!(!tmp.path().join("research/a.md").exists()); - } -} diff --git a/crates/mt-core/src/test_support.rs b/crates/mt-core/src/test_support.rs deleted file mode 100644 index bb2d684..0000000 --- a/crates/mt-core/src/test_support.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Герметичні git-фікстури для тестів `claims`/`publish` — bare-репозиторій -//! як "origin" + звичайний клон з одним комітом на `main`. Тільки для тестів -//! (`#[cfg(test)]`), нуль впливу на реальний runtime. -#![cfg(test)] - -use std::path::Path; -use std::process::Command; - -/// Запускає git-команду в `dir`, панікує з stderr при ненульовому exit-коді. -pub fn run(dir: &Path, args: &[&str]) { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_AUTHOR_NAME", "test") - .env("GIT_AUTHOR_EMAIL", "test@test.local") - .env("GIT_COMMITTER_NAME", "test") - .env("GIT_COMMITTER_EMAIL", "test@test.local") - .output() - .unwrap_or_else(|e| panic!("git {args:?}: {e}")); - if !out.status.success() { - panic!( - "git {args:?} failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } -} - -/// Як [`run`], але повертає trimmed stdout. -pub fn output(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap_or_else(|e| panic!("git {args:?}: {e}")); - if !out.status.success() { - panic!( - "git {args:?} failed: {}", - String::from_utf8_lossy(&out.stderr) - ); - } - String::from_utf8_lossy(&out.stdout).trim().to_string() -} - -/// Bare "origin" + звичайний клон із одним комітом на `main`, віддалений -/// `origin` уже додано і запушено. Робочий клон — контекст для plumbing-команд -/// (claim-коміти пишуться без touching робочого дерева/індексу). -pub struct TestRepo { - /// Тримає bare-репозиторій живим на диску (remote URL — file-шлях); - /// поле не читається напряму після конструктора, лише продовжує TempDir. - #[allow(dead_code)] - pub origin: tempfile::TempDir, - pub work: tempfile::TempDir, -} - -impl TestRepo { - pub fn new() -> Self { - let origin = tempfile::tempdir().unwrap(); - run(origin.path(), &["init", "--bare", "-q", "-b", "main"]); - - let work = tempfile::tempdir().unwrap(); - run(work.path(), &["init", "-q", "-b", "main"]); - std::fs::write(work.path().join("README.md"), "x").unwrap(); - run(work.path(), &["add", "."]); - run(work.path(), &["commit", "-q", "-m", "init"]); - run( - work.path(), - &["remote", "add", "origin", origin.path().to_str().unwrap()], - ); - run(work.path(), &["push", "-q", "origin", "main"]); - - Self { origin, work } - } - - pub fn main_sha(&self) -> String { - output(self.work.path(), &["rev-parse", "main"]) - } -} diff --git a/crates/mt-core/src/worktree.rs b/crates/mt-core/src/worktree.rs deleted file mode 100644 index a0630fd..0000000 --- a/crates/mt-core/src/worktree.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Іменування, матчінг і provisioning worktree для задач (порт чистої частини -//! `npm/lib/core/worktree.mjs` + git-операції run-wrapper-а, спека «Wrapper- -//! скрипт», крок 5: detached worktree від зафіксованого `base_sha`). - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use crate::claims::RUN_REF_PREFIX; -use crate::sanitize; - -fn git(repo: &Path, args: &[&str]) -> Result<String, String> { - let out = Command::new("git") - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| format!("git {}: {e}", args.join(" ")))?; - if !out.status.success() { - return Err(format!( - "git {}: {}", - args.join(" "), - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - -/// Префікс worktree для задачі: `sanitize(task_path.replace('/', '-'))`. -fn worktree_prefix(task_path: &str) -> String { - sanitize(&task_path.replace('/', "-")) -} - -/// Ім'я worktree для задачі: `<sanitized-path>-<epoch-сек>`. -pub fn make_worktree_name(task_path: &str, epoch_sec: u64) -> String { - format!("{}-{epoch_sec}", worktree_prefix(task_path)) -} - -/// Знаходить перший запис із `entries`, що належить задачі: -/// точний збіг із префіксом або `<prefix>-...`. -pub fn find_worktree_match(entries: &[String], task_path: &str) -> Option<String> { - let prefix = worktree_prefix(task_path); - let dashed = format!("{prefix}-"); - entries - .iter() - .find(|name| name.starts_with(&dashed) || **name == prefix) - .cloned() -} - -/// Створює detached worktree від `base_sha` у `worktrees_dir/<node-hash>-<token>` -/// (спека: `git worktree add --detach .worktrees/<node-hash>-<token> <base_sha>`). -/// Не checkout-ить `main` — worktree ізольований від живого робочого дерева. -pub fn create_run_worktree( - repo_root: &Path, - worktrees_dir: &Path, - node_hash: &str, - token: &str, - base_sha: &str, -) -> Result<PathBuf, String> { - let path = worktrees_dir.join(format!("{node_hash}-{token}")); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - git( - repo_root, - &[ - "worktree", - "add", - "--detach", - &path.to_string_lossy(), - base_sha, - ], - )?; - Ok(path) -} - -/// Публікує локальний run ref для recovery/handoff (спека, крок 5): -/// `refs/mt/runs/<node-hash>/<token>` ← поточний HEAD worktree. -pub fn push_run_ref(repo_root: &Path, node_hash: &str, token: &str) -> Result<(), String> { - let refname = format!("{RUN_REF_PREFIX}/{node_hash}/{token}"); - git(repo_root, &["push", "origin", &format!("HEAD:{refname}")])?; - Ok(()) -} - -/// Видаляє remote run ref (після успішного publish або при cleanup невдалої -/// спроби; `--force-with-lease` — лише якщо ref усе ще на очікуваному SHA). -pub fn delete_run_ref( - repo_root: &Path, - node_hash: &str, - token: &str, - expected_sha: &str, -) -> Result<bool, String> { - let refname = format!("{RUN_REF_PREFIX}/{node_hash}/{token}"); - let lease = format!("--force-with-lease={refname}:{expected_sha}"); - let out = Command::new("git") - .arg("-C") - .arg(repo_root) - .args(["push", &lease, "origin", &format!(":{refname}")]) - .output() - .map_err(|e| format!("git push --delete run ref: {e}"))?; - if out.status.success() { - return Ok(true); - } - let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("stale info") || stderr.contains("[rejected]") { - return Ok(false); - } - Err(format!("git push --delete run ref: {}", stderr.trim())) -} - -/// Прибирає worktree після завершення спроби (success — завжди; failure — -/// лишається для debug за рішенням викликача, спека «Failure-сімейство»). -pub fn remove_run_worktree(repo_root: &Path, path: &Path) -> Result<(), String> { - git( - repo_root, - &["worktree", "remove", "--force", &path.to_string_lossy()], - )?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::TestRepo; - - #[test] - fn creates_detached_worktree_from_base_sha() { - let repo = TestRepo::new(); - let base = repo.main_sha(); - let worktrees_dir = tempfile::tempdir().unwrap(); - let path = create_run_worktree( - repo.work.path(), - worktrees_dir.path(), - "deadbeef", - "tok1", - &base, - ) - .unwrap(); - assert!(path.join("README.md").is_file()); - let head = crate::test_support::output(&path, &["rev-parse", "HEAD"]); - assert_eq!(head, base); - // Detached — не на гілці: `--abbrev-ref HEAD` повертає літерал "HEAD". - let branch = crate::test_support::output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]); - assert_eq!(branch, "HEAD"); - } - - #[test] - fn push_and_delete_run_ref_round_trip() { - let repo = TestRepo::new(); - let worktrees_dir = tempfile::tempdir().unwrap(); - let base = repo.main_sha(); - let path = create_run_worktree( - repo.work.path(), - worktrees_dir.path(), - "deadbeef", - "tok1", - &base, - ) - .unwrap(); - // push_run_ref читає HEAD поточного репо (work), тож пушимо з worktree-контексту. - push_run_ref(&path, "deadbeef", "tok1").unwrap(); - - let ls = crate::test_support::output( - repo.work.path(), - &[ - "ls-remote", - "origin", - &format!("{RUN_REF_PREFIX}/deadbeef/tok1"), - ], - ); - assert!(!ls.is_empty()); - - assert!(delete_run_ref(repo.work.path(), "deadbeef", "tok1", &base).unwrap()); - let ls = crate::test_support::output( - repo.work.path(), - &[ - "ls-remote", - "origin", - &format!("{RUN_REF_PREFIX}/deadbeef/tok1"), - ], - ); - assert!(ls.is_empty()); - } - - #[test] - fn remove_worktree_cleans_up_directory() { - let repo = TestRepo::new(); - let base = repo.main_sha(); - let worktrees_dir = tempfile::tempdir().unwrap(); - let path = create_run_worktree( - repo.work.path(), - worktrees_dir.path(), - "deadbeef", - "tok1", - &base, - ) - .unwrap(); - assert!(path.is_dir()); - remove_run_worktree(repo.work.path(), &path).unwrap(); - assert!(!path.exists()); - } - - #[test] - fn make_name_sanitizes_and_appends_epoch() { - assert_eq!( - make_worktree_name("research/collect data", 1234567890), - "research-collect-data-1234567890" - ); - assert_eq!(make_worktree_name("my-task_01", 5), "my-task_01-5"); - } - - #[test] - fn find_match_prefers_first_entry() { - let entries = vec![ - "other-task-1".to_string(), - "my-task-100".to_string(), - "my-task-200".to_string(), - ]; - assert_eq!( - find_worktree_match(&entries, "my-task"), - Some("my-task-100".to_string()) - ); - } - - #[test] - fn find_match_exact_or_dashed_only() { - let entries = vec!["my-task".to_string(), "my-taskish-1".to_string()]; - assert_eq!( - find_worktree_match(&entries, "my-task"), - Some("my-task".to_string()) - ); - assert_eq!( - find_worktree_match(&["my-taskish-1".to_string()], "my-task"), - None - ); - } -} diff --git a/crates/mt-napi/.cargo/mutants.toml b/crates/mt-napi/.cargo/mutants.toml deleted file mode 100644 index 71ac6a7..0000000 --- a/crates/mt-napi/.cargo/mutants.toml +++ /dev/null @@ -1,7 +0,0 @@ -# .cargo/mutants.toml — universal cargo-mutants baseline (test.mdc). -# Цей baseline нейтральний: він не робить припущень про framework/app shell, -# не виключає platform glue, generated wrappers або binary entrypoints. -# Framework-specific tuning (Tauri, Capacitor тощо) належить відповідним -# правилам — вони без дублювання доповнюють цей файл, не перетирають його. -# cargo-mutants має робочі defaults; цей файл — стартова точка для customization. -# Документація: https://mutants.rs/ diff --git a/crates/mt-napi/CHANGELOG.md b/crates/mt-napi/CHANGELOG.md deleted file mode 100644 index 94a146f..0000000 --- a/crates/mt-napi/CHANGELOG.md +++ /dev/null @@ -1,64 +0,0 @@ -# Changelog - -## [0.3.1] - 2026-07-21 - -### Changed - -- chore(deps): n-taze bump — 6 minor/patch (npm), 0 major (Rust unchanged) - -## [0.3.0] - 2026-07-15 - -### Added - -- Биндінг `killNode` — `mt kill` тепер іде через `mt-core lifecycle::kill` (одна імплементація контракту: вузол без run-історії видаляється, з історією — архів у `.history/`) - -## [0.2.1] - 2026-07-14 - -### Changed - -- feat(mt): Rust-порт run-оркестрації до паритету; run.mjs — тонкий клієнт mt-core (#47) - -## [0.2.0] - 2026-07-14 - -### Removed - -- Модельні ключі (model_map / claude_model / audit_model) видалені з дефолтів .mt.json — конфігурація виконавців іде з user-level ENV (ADR 260713-2110) - -## [0.1.5] - 2026-07-11 - -### Changed - -- ⬆️ chore: @nitra/cursor ^14.25.1 (авто-оновлення pre-commit hook) -- 📝 chore: mt-napi changelog entry - -## [0.1.4] - 2026-07-11 - -### Changed - -- ⬆️ chore: @nitra/cursor ^14.25.1 (авто-оновлення pre-commit hook) -- 📝 chore: mt-napi changelog entry - -## [0.1.3] - 2026-07-08 - -### Changed - -- 📝 docs: файлові доки для 47 кодових файлів (doc-files беклог) (#16) - -## [0.1.2] - 2026-07-08 - -### Changed - -- release: @7n/mt@0.8.0 - -## [0.1.1] - 2026-07-07 - -### Changed - -- release: @7n/mt@0.8.0 -- chore: package.json — type module + engines (js.mdc канон); vitest/stryker/jsconfig конфіги (T0-автофікси) + файлові доки - -## [0.1.0] - 2026-07-04 - -### Added - -- Новий napi v3 addon: биндинги mt-core для Node/Bun (darwin-arm64, linux-x64) diff --git a/crates/mt-napi/Cargo.toml b/crates/mt-napi/Cargo.toml deleted file mode 100644 index b22e1ce..0000000 --- a/crates/mt-napi/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "mt-napi" -description = "napi-rs addon exposing mt-core to @7n/mt (Node.js/Bun)" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -name = "mt_napi" -crate-type = ["cdylib"] - -[dependencies] -mt-core = { path = "../mt-core" } -napi = { version = "3", default-features = false, features = ["napi8", "serde-json"] } -napi-derive = "3" -serde_json.workspace = true - -[build-dependencies] -napi-build = "2" diff --git a/crates/mt-napi/build.rs b/crates/mt-napi/build.rs deleted file mode 100644 index 0f1b010..0000000 --- a/crates/mt-napi/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - napi_build::setup(); -} diff --git a/crates/mt-napi/docs/build.md b/crates/mt-napi/docs/build.md deleted file mode 100644 index 4473fcb..0000000 --- a/crates/mt-napi/docs/build.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -type: Rust Module -title: build.rs -resource: crates/mt-napi/build.rs -docgen: - crc: 90c626cb - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Overview -Файл ініціалізує середовище через функцію `napi_build::setup` - -## Поведінка - -Поведінка - -1. Ініціалізація середовища через `napi_build::setup` - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/crates/mt-napi/docs/index.md b/crates/mt-napi/docs/index.md deleted file mode 100644 index 41faf9a..0000000 --- a/crates/mt-napi/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: crates/mt-napi -resource: crates/mt-napi/ ---- - -| Файл | Тип | -| -------------------- | ----------- | -| [build.rs](build.md) | Rust Module | diff --git a/crates/mt-napi/docs/stryker.config.md b/crates/mt-napi/docs/stryker.config.md deleted file mode 100644 index 7fb4ea2..0000000 --- a/crates/mt-napi/docs/stryker.config.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -type: JS Module -title: stryker.config.mjs -resource: crates/mt-napi/stryker.config.mjs -docgen: - crc: 2c7c9c37 - model: claude-fable-5 - tier: manual - score: 100 ---- - -## Огляд - -Конфігурація Stryker (mutation testing) для workspace `@7n/mt-napi`: vitest-runner з per-test coverage і інкрементальним кешем результатів. - -## Поведінка - -- `testRunner: 'vitest'` з `configFile: 'vitest.config.mjs'` — мутанти ізолюються в пам'яті через AST-patching, без копіювання `node_modules` у sandbox (стара проблема command runner у Bun monorepo; тому `inPlace` не потрібен). -- `coverageAnalysis: 'perTest'` — на кожен мутант запускаються лише тести, що покривають мутовану лінію; головний приріст швидкості проти command runner з повним suite. -- `tempDirName: 'reports/stryker/.tmp'` — sandbox-и під `reports/`, щоб не засмічувати корінь (vitest їх виключає). -- Репортери `json` (`reports/stryker/mutation.json`) + `clear-text`. -- `incremental: true` з `incrementalFile: 'reports/stryker/incremental.json'` — зберігає результати між запусками і відновлюється після краш/kill; ~262× прискорення на noop-прогонах (див. benchmarks/runner-comparison/SPIKE.md). -- Concurrency не задано — Stryker бере `os.cpus().length - 1`. diff --git a/crates/mt-napi/docs/vitest.config.md b/crates/mt-napi/docs/vitest.config.md deleted file mode 100644 index fc0aa44..0000000 --- a/crates/mt-napi/docs/vitest.config.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -type: JS Module -title: vitest.config.mjs -resource: crates/mt-napi/vitest.config.mjs -docgen: - crc: d32a8e2d - model: claude-fable-5 - tier: manual - score: 100 ---- - -## Огляд - -Конфігурація vitest для workspace `@7n/mt-napi` (build-обгортка napi-аддона): визначає, які тести підхоплюються, і гарантує процесну ізоляцію між test-файлами. - -## Поведінка - -- `include` — дві розкладки тестів: поряд із кодом (`**/*.test.{js,mjs}`, конвенція `test`-правила — піддиректорії `tests/`) і top-level integration suites у `<root>/tests/`. -- `exclude` — крім стандартних `node_modules`/`dist`, виключає `reports/stryker/**`: там лежать sandbox-копії тестів від Stryker (incremental або aborted-runs), які поза реальним repo root фейляться. -- `environment: 'node'` — без DOM. -- `pool: 'forks'` — defense-in-depth ізоляція: у дефолтному `threads` усі workers ділять один процес, і паралельний `process.chdir(dir)` у тестовій фікстурі перехоплює cwd сусіда посеред FS/`git`-операції (реальний інцидент: `git init`+`git commit` із tmp-фікстури потрапив у робочий репозиторій). Канон тестів — `withTmpDir(async dir => ...)` (test.mdc). -- `coverage` — провайдер `v8`, репортери `lcov` + `text-summary`. diff --git a/crates/mt-napi/jsconfig.json b/crates/mt-napi/jsconfig.json deleted file mode 100644 index ca1e3d0..0000000 --- a/crates/mt-napi/jsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "lib": ["esnext"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "target": "esnext", - "checkJs": false - }, - "include": ["src/**/*"] -} diff --git a/crates/mt-napi/package.json b/crates/mt-napi/package.json deleted file mode 100644 index ef012ec..0000000 --- a/crates/mt-napi/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@7n/mt-napi", - "version": "0.3.1", - "private": true, - "type": "module", - "description": "Build-обгортка napi-аддона mt (артефакти йдуть у платформні підпакети @7n/mt-*)", - "license": "ISC", - "scripts": { - "build": "napi build --platform --release", - "build:debug": "napi build --platform" - }, - "devDependencies": { - "@napi-rs/cli": "^3.7.4", - "ajv": "^8.20.0" - }, - "engines": { - "bun": ">=1.3", - "node": ">=24" - }, - "napi": { - "binaryName": "mt", - "targets": [ - "aarch64-apple-darwin", - "x86_64-unknown-linux-gnu" - ] - } -} diff --git a/crates/mt-napi/src/docs/index.md b/crates/mt-napi/src/docs/index.md deleted file mode 100644 index b2da77c..0000000 --- a/crates/mt-napi/src/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: crates/mt-napi/src -resource: crates/mt-napi/src/ ---- - -| Файл | Тип | -| ---------------- | ----------- | -| [lib.rs](lib.md) | Rust Module | diff --git a/crates/mt-napi/src/docs/lib.md b/crates/mt-napi/src/docs/lib.md deleted file mode 100644 index 4a6d2c6..0000000 --- a/crates/mt-napi/src/docs/lib.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -type: Rust Module -title: lib.rs -resource: crates/mt-napi/src/lib.rs -docgen: - crc: b07b821c - model: omlx/gemma-4-e2b-it-4bit - score: 95 ---- - -## Огляд - -Файл є біндінгом до `mt-core` для використання з `@7n/mt`. Він забезпечує конвертацію типів між JavaScript та Rust та мапінг помилок у `napi::Error`. Уся доменна логіка знаходиться в `mt-core`, а JS-обгортки знаходяться в `npm/lib/core/native.mjs`. - -## Поведінка - -scan_tasks Сканує директорію tasks і повертає дерево вузлів. -create_task Створює вузол задачі з ім'ям та опціями. -find_workspaces Виявляє workspace-и з заданих директорій. -discover_worktrees Виявляє workspace-и з початкової директорії. -pad_nnn Форматує число у формат NNN-рядок. -next_run_nnn Розраховує наступне NNN для run_файлів. -next_plan_nnn Розраховує наступне NNN для plan_файлів. -latest_fact_nnn Повертає найвищий NNN з fact-файлів. -latest_pending_audit_nnn Повертає найвищий NNN з pending-audit-файлів. -latest_audit_result_nnn Повертає найвищий NNN з audit-result-файлів. -latest_build_markdown Повертає згенерований markdown-файл. -parse_front_matter Парсить YAML front-matter з markdown-тексту. -get_body Отримує тіло документа без frontmatter. -serialize_yaml Серіалізує об'єкт у формат YAML. -build_markdown Будує markdown-файл із frontmatter та тілом. -sanitize_task_name Санітизує ім'я задачі для worktree. -validate_task_name Валідує ім'я задачі відповідно до специфікації. -sanitize_branch Нормалізує ім'я гілки до безпечного імені директорії. -config_defaults Повертає дефолтну конфігурацію. -merge_config Зливає сирий текст `.mt.json` з дефолтними значеннями. -effective_config Створює ефективну конфігурацію з різних джерел. -make_worktree_name Генерує ім'я worktree з шляху та epoch. -find_worktree_match Знаходить відповідність worktree з даним шляхом. - -## Публічний API - -Я готовий. Надайте мені код, який потрібно переписати у вигляді лаконічної поведінкової документації, дотримуючись усіх ваших інструкцій. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/crates/mt-napi/src/lib.rs b/crates/mt-napi/src/lib.rs deleted file mode 100644 index fb847bc..0000000 --- a/crates/mt-napi/src/lib.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! napi-біндінги до `mt-core` для `@7n/mt`. -//! -//! Тонкий шар: конвертація типів JS ⇄ Rust і мапінг помилок у `napi::Error`. -//! Уся доменна логіка живе в `mt-core`. JS-обгортки — `npm/lib/core/native.mjs`. - -use std::path::PathBuf; - -use napi::bindgen_prelude::*; -use napi_derive::napi; - -fn to_napi_err(e: String) -> Error { - Error::from_reason(e) -} - -/// Сканує tasks-директорію і повертає дерево вузлів (JSON-контракт як у CLI `scan`). -/// `worktrees: None` → discovery через `git worktree list` від `tasks_dir`. -#[napi] -pub fn scan_tasks(tasks_dir: String, worktrees: Option<Vec<String>>) -> Result<serde_json::Value> { - let wt = worktrees.unwrap_or_else(|| mt_core::discover_worktrees(&PathBuf::from(&tasks_dir))); - let nodes = mt_core::scan_tasks(tasks_dir, wt).map_err(to_napi_err)?; - serde_json::to_value(nodes).map_err(|e| to_napi_err(e.to_string())) -} - -/// Створює вузол задачі (JSON-контракт як у CLI `create`: поле `created: bool`). -#[napi] -pub fn create_task( - tasks_dir: String, - name: String, - opts: Option<serde_json::Value>, -) -> Result<serde_json::Value> { - let opts: mt_core::CreateOpts = match opts { - Some(v) => serde_json::from_value(v).map_err(|e| to_napi_err(e.to_string()))?, - None => mt_core::CreateOpts::default(), - }; - let outcome = mt_core::create_task(tasks_dir, name, opts).map_err(to_napi_err)?; - Ok(outcome.to_cli_json()) -} - -/// Виявляє workspace-и (mt/-директорії) від заданих коренів або від cwd. -#[napi] -pub fn find_workspaces(dirs: Option<Vec<String>>) -> Result<serde_json::Value> { - let workspaces = match dirs { - Some(ds) if !ds.is_empty() => ds - .iter() - .flat_map(|d| mt_core::find_all_tasks_dirs_from(&PathBuf::from(d))) - .collect(), - _ => mt_core::find_all_tasks_dirs().map_err(to_napi_err)?, - }; - serde_json::to_value(workspaces).map_err(|e| to_napi_err(e.to_string())) -} - -/// Імена активних git-worktree (останній компонент шляху) від `start_dir`. -#[napi] -pub fn discover_worktrees(start_dir: String) -> Vec<String> { - mt_core::discover_worktrees(&PathBuf::from(&start_dir)) -} - -// ── nnn (npm/lib/core/nnn.mjs) ──────────────────────────────────────────────── - -/// Форматує число як NNN-рядок ('001', '002', …). -#[napi] -pub fn pad_nnn(n: u32) -> String { - mt_core::nnn::pad_nnn(u64::from(n)) -} - -/// Наступний NNN для `run_NNN.md`: count(run_*.md) + 1. -#[napi] -pub fn next_run_nnn(files: Vec<String>) -> String { - mt_core::nnn::next_run_nnn(&files) -} - -/// Наступний NNN для `plan_NNN.md`: max(plan_*.md) + 1. -#[napi] -pub fn next_plan_nnn(files: Vec<String>) -> String { - mt_core::nnn::next_plan_nnn(&files) -} - -/// Найвищий NNN серед `fact_NNN.md`, або null. -#[napi] -pub fn latest_fact_nnn(files: Vec<String>) -> Option<String> { - mt_core::nnn::latest_fact_nnn(&files) -} - -/// Найвищий NNN серед `pending-audit_NNN.md`, або null. -#[napi] -pub fn latest_pending_audit_nnn(files: Vec<String>) -> Option<String> { - mt_core::nnn::latest_pending_audit_nnn(&files) -} - -/// Найвищий NNN серед `audit-result_NNN.md`, або null. -#[napi] -pub fn latest_audit_result_nnn(files: Vec<String>) -> Option<String> { - mt_core::nnn::latest_audit_result_nnn(&files) -} - -// ── frontmatter (npm/lib/core/frontmatter.mjs) ──────────────────────────────── - -/// Парсить YAML front-matter з markdown-тексту (без fm → `{}`). -#[napi] -pub fn parse_front_matter(text: String) -> serde_json::Value { - mt_core::frontmatter::parse_front_matter(&text) -} - -/// Тіло документа без front-matter. -#[napi] -pub fn get_body(text: String) -> String { - mt_core::frontmatter::get_body(&text) -} - -/// Серіалізує об'єкт у YAML-рядок (байт-у-байт як JS `serializeYaml`). -#[napi] -pub fn serialize_yaml(obj: serde_json::Value, indent_level: Option<u32>) -> String { - mt_core::frontmatter::serialize_yaml(&obj, indent_level.unwrap_or(0) as usize) -} - -/// Будує markdown-файл із front-matter і тілом. -#[napi] -pub fn build_markdown(fm: serde_json::Value, body: Option<String>) -> String { - mt_core::frontmatter::build_markdown(&fm, body.as_deref().unwrap_or("")) -} - -// ── state (npm/lib/core/state.mjs) ──────────────────────────────────────────── - -/// Санітизує ім'я задачі для worktree: `[^A-Za-z0-9_-]` → '-'. -#[napi] -pub fn sanitize_task_name(name: String) -> String { - mt_core::sanitize(&name) -} - -/// Валідує id вузла (§8 spec). Повертає текст помилки або null якщо валідне. -#[napi] -pub fn validate_task_name(name: String) -> Option<String> { - mt_core::validate_name(&name).err() -} - -/// Нормалізує ім'я гілки до безпечного імені директорії у `.worktrees/`. -#[napi] -pub fn sanitize_branch(branch: String) -> String { - mt_core::sanitize_branch(&branch) -} - -// ── config (npm/lib/core/config.mjs) ────────────────────────────────────────── - -/// Дефолтна конфігурація (JS `CONFIG_DEFAULTS`, порядок ключів збережено). -#[napi] -pub fn config_defaults() -> serde_json::Value { - mt_core::config::config_defaults() -} - -/// Зливає сирий текст `.mt.json` (або null) з дефолтами -#[napi] -pub fn merge_config(raw: Option<String>) -> serde_json::Value { - mt_core::config::merge_config(raw.as_deref()) -} - -/// Ефективний конфіг вузла: plan_NNN > .mt-override.json > task.md > .mt.json. -#[napi] -pub fn effective_config( - mt_json: Option<String>, - task_md: Option<String>, - mt_override_json: Option<String>, - plan_md: Option<String>, -) -> serde_json::Value { - mt_core::config::effective_config( - mt_json.as_deref(), - task_md.as_deref(), - mt_override_json.as_deref(), - plan_md.as_deref(), - ) -} - -// ── runner (npm/lib/commands/run.mjs — тонкий клієнт) ───────────────────────── - -/// Preflight вузла (бюджети, NNN/attempt, тир/драбина, agent_cli) — план -/// запуску або помилка-відмова. Конфіг виконавців — ENV процесу. -#[napi] -pub fn run_preflight(tasks_dir: String, node_path: String) -> Result<serde_json::Value> { - let plan = mt_core::runner::preflight(&tasks_dir, &node_path).map_err(to_napi_err)?; - serde_json::to_value(plan).map_err(|e| to_napi_err(e.to_string())) -} - -/// Запускає вузол: CAS claim → worktree → виконавець (підписочний CLI з -/// каскадом або node_executor) → `## Check` → fenced publish. **Блокуючий.** -#[napi] -pub fn run_node(tasks_dir: String, node_path: String) -> Result<serde_json::Value> { - let outcome = mt_core::runner::run_node(&tasks_dir, &node_path).map_err(to_napi_err)?; - serde_json::to_value(outcome).map_err(|e| to_napi_err(e.to_string())) -} - -/// Оркестраторний прохід `run --auto`: waiting-агентські вузли чергами по -/// `concurrency` через run_node. **Блокуючий.** -#[napi] -pub fn run_auto(tasks_dir: String, concurrency: u32) -> Result<serde_json::Value> { - let results = - mt_core::orchestrate::run_auto(&tasks_dir, concurrency as usize).map_err(to_napi_err)?; - serde_json::to_value(results).map_err(|e| to_napi_err(e.to_string())) -} - -/// `mt kill` (файловий рівень): піддерево без run-артефактів видаляється -/// назавжди; інакше — архів у `.history/<ts>-kill-<path>/`. Повертає -/// `deleted:<path>` або `.history/<archive>`. -#[napi] -pub fn kill_node(tasks_dir: String, node_path: String) -> Result<String> { - mt_core::lifecycle::kill(&tasks_dir, &node_path).map_err(to_napi_err) -} - -// ── worktree (npm/lib/core/worktree.mjs) ────────────────────────────────────── - -/// Ім'я worktree для задачі: `<sanitized-path>-<epoch-сек>`. -#[napi] -pub fn make_worktree_name(task_path: String, epoch_sec: i64) -> String { - mt_core::worktree::make_worktree_name(&task_path, epoch_sec.max(0) as u64) -} - -/// Перший запис зі списку, що належить задачі (точний або `<prefix>-...`), або null. -#[napi] -pub fn find_worktree_match(entries: Vec<String>, task_path: String) -> Option<String> { - mt_core::worktree::find_worktree_match(&entries, &task_path) -} diff --git a/crates/mt-napi/vitest.config.mjs b/crates/mt-napi/vitest.config.mjs deleted file mode 100644 index 97529a5..0000000 --- a/crates/mt-napi/vitest.config.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - // Підхоплюються обидві основні розкладки: тести поряд із кодом (rule `test`-конвенція — - // у піддиректоріях `tests/`) і top-level integration suites у `<root>/tests/`. - include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}'], - // reports/stryker/.tmp/ містить sandbox-копії тестів від Stryker (incremental - // або aborted-runs); без exclude vitest run --coverage їх підхоплює і вони - // фейляться, бо запускаються поза реальним repo root. - exclude: ['**/node_modules/**', '**/dist/**', '**/reports/stryker/**'], - environment: 'node', - // `pool: 'forks'` — defense-in-depth ізоляція процесів між test-файлами. - // У default `pool: 'threads'` усі workers ділять один процес → паралельний - // `process.chdir(dir)` у тестовій фікстурі перехоплює cwd сусіда посеред - // FS- або `git`-операції. Реальний інцидент: `git init`+`git commit` із - // tmp-фікстури потрапив у реальний робочий репозиторій. Forks гарантують - // ізоляцію. Канон тестів — `withTmpDir(async dir => ...)` (test.mdc). - pool: 'forks', - coverage: { provider: 'v8', reporter: ['lcov', 'text-summary'] } - } -}) diff --git a/deny.toml b/deny.toml deleted file mode 100644 index 689111b..0000000 --- a/deny.toml +++ /dev/null @@ -1,31 +0,0 @@ -version = 2 - -[advisories] -vulnerability = "deny" -unmaintained = "warn" -yanked = "warn" -notice = "warn" -ignore = [] - -[bans] -multiple-versions = "allow" -wildcards = "deny" -highlight = "all" -skip = [] - -[licenses] -unlicensed = "deny" -allow = [ - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", - "BSD-3-Clause", - "ISC", - "MIT", - "Unicode-3.0", -] -confidence-threshold = 0.8 - -[sources] -unknown-registry = "deny" -unknown-git = "deny" diff --git a/docs/adr/.gitkeep b/docs/adr/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/adr/20260531-134056-n-cursor-flow-sovereign-orchestrator.md b/docs/adr/20260531-134056-n-cursor-flow-sovereign-orchestrator.md deleted file mode 100644 index 639ba64..0000000 --- a/docs/adr/20260531-134056-n-cursor-flow-sovereign-orchestrator.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -captured: 2026-05-31T16:40:56+03:00 ---- - -## ADR mt — Суверенний Stateful AI-Оркестратор - -## Context and Problem Statement - -`@nitra/cursor` надає CLI-набір (`worktree`, `coverage`, `change`, `verify`) без єдиного lifecycle-двигуна. Попередня версія spec (v1.1) рекомендувала `compose-and-extend` — `n-cursor` доповнює `superpowers`-скіли тонким Contract Gate. Однак вимога повної суверенності, fault-tolerant відновлення після збоїв та автономного запуску без зовнішніх плагінів (CI, pi.dev) спонукала переглянути цей підхід. Потрібно вирішити: чи `n-cursor` лишається тонким gate-провайдером, чи стає самодостатнім двигуном lifecycle. - -## Considered Options - -* **Compose-and-extend (Contract Gate)** — `n-cursor` дає Contract (`worktree` + `coverage` + `.changes`), `superpowers` reference-скіл у середині lifecycle; pi.dev стартує агента, `n-cursor` лише `verify`. -* **Capability Router з `capability-matrix.json`** — два шляхи виконання на основі авто-детекції моделі (`native_workflows` vs скриптовий loop). -* **Sovereign Stateful AI Orchestrator** — `mt` є повним lifecycle-двигуном: explicit model declaration → polyfill/native router, 5-фазний engine, fault-tolerant `.flow-state.json`, `resume`/`cancel`. - -## Decision Outcome - -Chosen option: **"Sovereign Stateful AI Orchestrator"**, because compose-and-extend не дає fault-tolerance (немає `.flow-state.json`/`resume`), superpowers-кеш ефемерний і може зникнути mid-session (підтверджено в сесії), а автономний runner на сервері вимагає самодостатнього двигуна без зовнішніх плагінів. Capability Router реалізується через **явне** оголошення моделі (`--model` › env › config › default `polyfill`), а не авто-детекцію (механізм детекції в кодовій базі відсутній). - -### Consequences - -* Good, because `mt resume` відновлює виконання з `.flow-state.json` після будь-якого збою (мережа, таймаут, перезавантаження). -* Good, because самодостатній baseline без `superpowers` вкрай потрібний для CI та pi.dev (автономних серверів). -* Good, because `n-cursor trace` будує наскрізний граф `ADR ↔ spec ↔ .flow-state.json ↔ .changes ↔ git commit` — трасованість для всього 9-фазного lifecycle. -* Good, because capability router з `capability-matrix.json` + явна декларація моделі — будівний контракт (на відміну від авто-детекції). -* Bad, because власний 5-фазний engine (`planner.mjs`, `executor.mjs`, `reviewer.mjs`) потребує підтримки prompt-шаблонів при кожному релізі нових моделей (ризик дрейфу стосовно апстрім superpowers). -* Bad, because два шари оркестрації (`mt` + харнес) потенційно знижують прозорість у чаті — мітигація: `flow` виводить кожен крок в stdout із structured log. - -## More Information - -- Spec v2.0: `docs/specs/2026-05-31-n-cursor-lifecycle-composition-design.md` -- Файлова структура двигуна: `npm/scripts/dispatcher/{index,planner,executor,reviewer,native}.mjs`, `lib/{prompts,state-store}.mjs` -- Конфіг: `npm/config/capability-matrix.json` -- State: `.worktrees/<branch>/.flow-state.json` (gitignored) -- Прецедент headless subagent у репо: `npm/scripts/coverage-fix.mjs` (`@anthropic-ai/claude-agent-sdk`) -- Міграція шляхів: `docs/superpowers/specs` → `docs/specs`, `docs/superpowers/plans` → `docs/plans` (legacy не підтримується) -- Supersedes: compose-and-extend spec v1.1 (intermediate decisions captured in `20260531-141658-*` та `20260531-155531-*`) diff --git a/docs/adr/20260531-141658-n-cursor-superpowers-lifecycle-composition.md b/docs/adr/20260531-141658-n-cursor-superpowers-lifecycle-composition.md deleted file mode 100644 index f425309..0000000 --- a/docs/adr/20260531-141658-n-cursor-superpowers-lifecycle-composition.md +++ /dev/null @@ -1,47 +0,0 @@ -# n-cursor × superpowers: Lifecycle Composition - -**Status:** Accepted -**Date:** 2026-05-31 - -## Context and Problem Statement - -`@nitra/cursor` надає CLI-команди для worktree, coverage, change і lint, але не має єдиного «done»-контракту. Одночасно superpowers плагін надає lifecycle-скіли для агентів, проте на серверах (pi.dev CI runners) він не встановлений. Виникло питання: чи будувати власний оркестратор із `capability-matrix.json` та детекцією моделі, чи інтегруватися з superpowers мінімально. - -## Considered Options - -- `capability-matrix.json` + Capability Router (детекція моделі → Path A/B) -- In-house Orchestrator (замінити superpowers власними скриптами) -- Compose-and-extend (n-cursor дає Contract, superpowers лишається процесним шаром) - -## Decision Outcome - -Chosen option: "Compose-and-extend", because детекція активної моделі в рантаймі неможлива (жодного механізму в кодобазі немає, `native_workflows` — це фіча харнеса, не бітфлаг моделі), а superpowers вже спроєктований делегувати native tools через `AGENTS.md` (SKILL.md рядки 55, 203) — конфлікту немає. Контракт (`worktree + coverage + .changes`) стабільний незалежно від версії моделі. - -### Consequences - -- Good, because `n-cursor verify` дає єдину read-only перевірку Контракту для CI, autonomous runner і ручного dev-флоу. -- Good, because baseline lifecycle skill матеріалізується при `npx @nitra/cursor` sync — агент на сервері без superpowers отримує самодостатні інструкції. -- Good, because superpowers апстрім-покращення автоматично стають доступними без форку. -- Bad, because `mt --autonomous` — окремий scope із вимогою budget guard (`.n-cursor.json#autonomous.maxCostUsd`); не реалізується до підтвердження конкретного use-case і бюджету. - -## More Information - -- `npm/bin/n-cursor.js:1435–1546` — command dispatch (немає `flow`, `verify` — майбутні точки розширення) -- `npm/scripts/coverage-fix.mjs` — прецедент headless `claude-agent-sdk` виклику з репо -- superpowers `using-git-worktrees/SKILL.md:55,203` — native tool delegation design (підтверджує відсутність конфлікту) -- `docs/specs/2026-05-31-n-cursor-lifecycle-composition-design.md` — повний spec з міграційним планом v1/v2 -- `@nitra/cursor` v1.39.0 (`npm/package.json`) -- Додаткової інформації про Capability Router в transcript не зафіксовано понад те, що він явно відкладений. - -## Update 2026-05-31 - -Spec закомічений у воркдереві `keen-swanson-f7dff6` (commit `c8dfe28`): `docs/specs/2026-05-31-n-cursor-superpowers-composition-design.md`. - -Spec фіксує повне рішення: -- 8-фазний ланцюжок `задача → ADR → spec → план → код → тести → документація → changelog → notify` з front-matter-лінками по спільному `id` -- **Contract Gate** (`n-cursor verify`) — єдиний блокуючий gate для interactive та pi.dev -- **Baseline без superpowers** — `n-cursor` матеріалізує мінімальний lifecycle при `npx @nitra/cursor` -- **Capability Router** — явно відкладено як named-тригер (умова перегляду: стабільний програмний handoff в Anthropic API / `claude-agent-sdk`) -- 4 Open Questions для наступного рев'ю (OQ-1..4), включно з питанням autonomous launcher vs pi.dev-host - -ADR: `docs/adr/20260531-141658-n-cursor-×-superpowers-lifecycle-composition.md` (commit `ad98ac8`). Прецедент headless-агентів: `npm/scripts/coverage-fix.mjs` (@anthropic-ai/claude-agent-sdk). Прецедент pi.dev-розширення: `.pi/extensions/n-cursor-adr`. diff --git "a/docs/adr/20260601-104128-n-flow-\321\202\321\200\321\226\320\260\320\266-\320\272\320\276\320\266\320\275\320\276\320\263\320\276-\320\267\320\260\320\277\320\270\321\202\321\203.md" "b/docs/adr/20260601-104128-n-flow-\321\202\321\200\321\226\320\260\320\266-\320\272\320\276\320\266\320\275\320\276\320\263\320\276-\320\267\320\260\320\277\320\270\321\202\321\203.md" deleted file mode 100644 index 429ccb5..0000000 --- "a/docs/adr/20260601-104128-n-flow-\321\202\321\200\321\226\320\260\320\266-\320\272\320\276\320\266\320\275\320\276\320\263\320\276-\320\267\320\260\320\277\320\270\321\202\321\203.md" +++ /dev/null @@ -1,31 +0,0 @@ -# n-flow: тріаж кожного запиту у репо - -**Status:** Accepted -**Date:** 2026-06-01 - -## Context and Problem Statement - -Правило `.cursor/rules/n-flow.mdc` описувало «попередній MT workflow», що мав активуватися на coding-task. На практиці агент не класифікував звичайні питання як coding-задачу, тому `mt init` не викликався — контракт ігнорувався для будь-яких не-coding запитів. - -## Considered Options - -- Розширити текстовий контракт — додати крок тріажу, що спрацьовує на кожен запит у репо (гілки R / C / A). -- Hook-примус у `settings.json`, що технічно блокує `Edit`/`Write` поза `.worktrees/…` без попереднього `mt init`. - -## Decision Outcome - -Chosen option: "Розширити текстовий контракт (без hook-примусу)", because користувач вирішив обкатати контракт на довірі та повернутись до hook-варіанту пізніше за необхідності. - -### Consequences - -- Good, because турнікет тепер є явним входом для кожного запиту: крок 0 — тріаж із класифікацією у гілку R (read-only), C (зміна коду) або A (неоднозначно); агент називає гілку вголос перед діями. -- Bad, because без hook-примусу дотримання контракту залишається на дисципліні агента — технічного блокування `Edit`/`Write` немає. - -## More Information - -- Змінений файл: `.cursor/rules/n-flow.mdc` (два послідовних Edit у сесії). -- Виняток: мета-зміни самого контракту (правки `.mdc`, доків про flow) не проходять турнікет — уникнення рекурсії «щоб полагодити правило, треба вже діяти за полагодженим правилом». -- Гілка R зафіксована як штатна гілка контракту (пряма відповідь без `mt init`), не «обхід турнікета». -- Жорстке правило: жоден Edit/Write по файлах репо до визнання запиту гілкою C і проходження кроку «Старт» (`mt init`). -- Hook-варіант (`settings.json`) відкладений як можливий наступний крок. -- Додаткової інформації щодо конкретних файлів змін у transcript не зафіксовано. diff --git "a/docs/adr/20260601-213006-worktrees-gitignore-sync-\320\272\321\200\320\276\320\272.md" "b/docs/adr/20260601-213006-worktrees-gitignore-sync-\320\272\321\200\320\276\320\272.md" deleted file mode 100644 index 2323e4e..0000000 --- "a/docs/adr/20260601-213006-worktrees-gitignore-sync-\320\272\321\200\320\276\320\272.md" +++ /dev/null @@ -1,84 +0,0 @@ -# `.worktrees/` гарантовано gitignored через окремий sync-крок - -**Status:** Accepted -**Date:** 2026-06-01 - -## Context and Problem Statement - -`n-cursor worktree add` створює каталог `.worktrees/<name>/` та супутні локальні файли (інвентарний `.md`, MT file-presence state, `.events.jsonl`), але не гарантувала наявність рядка `.worktrees/` у `.gitignore`. У репо без цього рядка worktree-артефакти вилізали в `git status` як untracked, а інвентарний `.md` можна було випадково закомітити. - -## Considered Options - -* A. Lazy via `worktree add` — дописувати `.worktrees/` у `.gitignore` безпосередньо в команді `worktree add` (`worktree-cli.mjs`) -* B1. Eager sync-крок безумовно — окремий top-level `runSyncStep` у `npm/bin/n-cursor.js` при кожному `npx @nitra/cursor` sync -* B2. Eager sync-крок з гейтом за worktree-rule у `.n-cursor.json` (за симетрією з adr-фрагментом) - -## Decision Outcome - -Chosen option: "B1 — окремий sync-крок, безумовно", because гейт за worktree-rule (B2) розриває звʼязок між продюсером (`mt`/CLI, `alwaysApply: true`) і гарантією ignore: можна вимкнути worktree-rule, але `mt init` далі створює `.worktrees/` без ignore-рядка. Варіант A вводить паралельну gitignore-механіку поза наявною конвенцією sync і спрацьовував би лише через CLI, а не через `npx @nitra/cursor`. Sync-крок лягає в існуючий протестований патерн (`ensureGitignoreEntries`, append-only, idempotent) і не змішує концерни `syncClaudeConfig` (Claude-конфіг-бандл) із ортогональним worktree-концерном. - -### Consequences - -* Good, because `.worktrees/` гарантовано gitignored з першого `npx @nitra/cursor`, незалежно від тумблерів правил і без ручного рядка в `.gitignore`. -* Good, because `ensureGitignoreEntries` — idempotent: якщо рядок уже є, виконується no-op без побічних ефектів. -* Bad, because у репо, де worktree ніколи не використовується, sync дописує один зайвий ignore-рядок — нешкідливий no-op, але не нульовий side-effect. - -## More Information - -- Новий модуль: `npm/scripts/lib/sync-gitignore-worktree.mjs` + тести `npm/scripts/lib/tests/sync-gitignore-worktree.test.mjs` -- Точка вмонтування: `npm/bin/n-cursor.js`, `runSync()`, окремий `runSyncStep` після блоку Claude-конфіг (~рядок 1435) -- Базова утиліта: `npm/scripts/utils/ensure-gitignore-entries.mjs` (`ensureGitignoreEntries(cwd, entries, sectionLabel)` → `{ added: string[] }`) -- Зразок повернення: прапор `gitignoreWorktree: boolean` у звіт (за зразком `gitignoreAdr`) -- Коміт: `e0f5e52` у гілці `feat-worktree-gitignore`; реліз: `@nitra/cursor@3.9.0` -- Рядки `.gitignore` у корені репо: рядок 9 — `.claude/worktrees/`, рядок 10 — `.worktrees/` -- Правило `n-flow.mdc`: `alwaysApply: true` — продюсер `.worktrees/`-артефактів активний завжди незалежно від конфігурації правил - -## Update 2026-06-01 - -Деталі щодо розміщення sync-кроку і умов гейтингу: - -**Чому не всередині `syncClaudeConfig`**: функція `syncClaudeConfig` (`npm/scripts/sync-claude-config.mjs`) має ранній `return` при `claude-config: false`. Вкладення `.worktrees/`-кроку всередину призвело б до дірки — репо з вимкненим claude-config не отримувало б ignore-рядка, хоча `flow` від claude-config не залежить. Кожен `runSyncStep` — один концерн; нема прихованого зчеплення через опт-аут. - -**Чому гейт за worktree-rule (B2) відхилено**: продюсер артефактів `.worktrees/` — `flow` (`alwaysApply: true`) і `worktree-cli`, активні незалежно від worktree-rule. ADR-фрагмент коректно гейтується, бо продюсер (adr Stop-hook) і гейт (adr-rule) — та сама сутність; для worktree ця симетрія не виконується. - -Ключові файли: `npm/bin/n-cursor.js` (`runSync`, `runSyncStep`), `npm/scripts/sync-claude-config.mjs` (ранній return при `claude-config: false`), `npm/scripts/utils/ensure-gitignore-entries.mjs`. - -## Update 2026-06-01 - -### Відхилені варіанти та обґрунтування вибору b1 - -Додатково розглядалися: -- **A (lazy)** — `ensureGitignoreEntries()` у `worktree add` CLI в момент створення каталогу; відхилено як неповне (не покриває `mt init` та інші продюсери). -- **B2 (gated)** — sync-крок, гейтований за наявністю worktree-правила в `.n-cursor.json`; відхилено: вимкнене правило + активний `flow` залишає дірку. -- **Вмонтування всередині `syncClaudeConfig()`** — відхилено: функція має ранній `return` при `claude-config: false`, що ховало б запис; неправильне змішування концернів. - -Обраний **b1** (окремий безумовний sync-крок): продюсер `.worktrees/` (`n-flow.mdc: alwaysApply: true`) завжди активний; гейт за тумблером розсинхронив би виробника і `.gitignore`. Утиліта `ensureGitignoreEntries()` вже існувала і є idempotent — інтеграція коштувала один виклик. - -### Деталі реалізації - -- Новий модуль: `npm/scripts/lib/sync-gitignore-worktree.mjs` (обгортка над `ensureGitignoreEntries`) -- Тести: `npm/scripts/lib/tests/sync-gitignore-worktree.test.mjs` (4 тести: fresh-repo, idempotency, append-only, existing gitignore) -- Spec: `docs/specs/2026-06-01-worktree-add-gitignore.md` -- Plan: `docs/plans/2026-06-01-worktree-add-gitignore.md` -- Коміт: `e0f5e52 feat(sync): гарантувати .worktrees/ у .gitignore під час sync` -- Базова утиліта: `npm/scripts/utils/ensure-gitignore-entries.mjs` (idempotent append-only з header-коментарем; також використовується для Stryker temp-каталогів) - -## Update 2026-06-01 - -### Реалізація - -Коміт реалізації: `e0f5e52 feat(sync): гарантувати .worktrees/ у .gitignore під час sync`. Spec: `docs/specs/2026-06-01-worktree-add-gitignore.md`; Plan: `docs/plans/2026-06-01-worktree-add-gitignore.md`. Тести: `npm/scripts/lib/tests/sync-gitignore-worktree.test.mjs` — 4 кейси (fresh repo → `written: true`; idempotency; append-only зі збереженням кастомного вмісту; `written` boolean у return). - -### Відмова від гейтингу за worktree-правилом - -Під час дизайну розглядалося умовне дописування `.worktrees/` у `.gitignore` лише коли worktree-правило увімкнено у `.n-cursor.json` — аналогія з `gitignoreAdr` у `sync-claude-config.mjs` (`const includeAdrHook = ... rules.includes('adr')`). Відхилено на користь безумовного кроку (b1). - -**Причина:** для `adr` гейт коректний — продюсер (ADR Stop-hook) і тумблер — одна сутність; якщо правило вимкнено, артефактів нема. Для worktree продюсер (`mt init` / `worktree-cli`) є `alwaysApply: true` і незалежний від worktree-rule — гейт за правилом розсинхронізував би ігнорування з реальним продюсером. - -Наслідки: репо, де worktree-rule вимкнено але `mt init` використовується, не отримує брудний `git status`. Репо без worktree — несе один зайвий ignore-рядок (idempotent noop). - -## Update 2026-06-01 - -Деталі реалізації sync-кроку: функція `syncGitignoreWorktree(projectRoot)` — тонка обгортка над `ensureGitignoreEntries` з єдиним патерном `.worktrees/`. Підключена у `runSync()` як окремий `runSyncStep` поза `syncClaudeConfig`, щоб уникнути блокування раннім `return` при `claude-config: false`. Нові файли: `npm/scripts/lib/sync-gitignore-worktree.mjs` (модуль), `npm/scripts/lib/tests/sync-gitignore-worktree.test.mjs` (4 тести). Усі 16 тестів зелені (коміт `e0f5e52`). Зміни також у: `npm/bin/n-cursor.js` (import + `runSyncStep`), `docs/specs/2026-06-01-worktree-add-gitignore.md`, `docs/plans/2026-06-01-worktree-add-gitignore.md`. - -Паралельне рішення тієї ж сесії: coverage gate повністю прибрано з `DEFAULT_GATES` у `reviewer.mjs` (Stryker, 215 файлів / 28 552 мутантів, блокував turnstile для тривіальних L1-змін); турнікет лишав лише `lint` (коміт `84bf217`). Це рішення невдовзі переглянуто: coverage повернено у scoped-режимі через `--changed` — див. `20260601-220027-coverage-gate-scoped-changed-від-base-commit.md`. diff --git "a/docs/adr/20260601-220027-coverage-gate-scoped-changed-\320\262\321\226\320\264-base-commit.md" "b/docs/adr/20260601-220027-coverage-gate-scoped-changed-\320\262\321\226\320\264-base-commit.md" deleted file mode 100644 index 4322c9a..0000000 --- "a/docs/adr/20260601-220027-coverage-gate-scoped-changed-\320\262\321\226\320\264-base-commit.md" +++ /dev/null @@ -1,58 +0,0 @@ -# Скоуп coverage-гейту турнікета через `--changed` від `base_commit` - -**Status:** Accepted -**Date:** 2026-06-01 - -## Context and Problem Statement - -Турнікет `mt verify` проганяє `DEFAULT_GATES = [lint, coverage]`, де `coverage` запускає vitest і Stryker по **всьому** проєкту (всі workspace-и, всі файли `src`), незалежно від того, які файли фактично змінено в задачі. Це призводить до надмірних прогонів Stryker — навіть після дрібних правок і навіть кілька разів за TDD-цикл. Додатково: `stryker.config.baseline.mjs` містить `incremental: true`, але `reports/stryker/` є в `.gitignore` (правило `n-test.mdc:221`), тому у свіжому worktree `incremental.json` завжди відсутній — перший прогін завжди повний (cold-start примусовий). - -## Considered Options - -- Повне видалення `coverage` з `DEFAULT_GATES` (лишається тільки на `release`/ручний виклик) -- `coverage --changed`: coverage-гейт лишається у турнікеті, але аналізує лише файли, змінені від `base_commit` задачі; передає scope у vitest (`--changed <base>`) та Stryker (`--mutate <список js-файлів>`) -- Конфіг-кероване увімкнення через `.n-cursor.json#flow.gates` (дефолт `['lint']`, opt-in `coverage`) -- Перенесення `coverage`-гейту на `release`-only (не в per-step `mt verify`) - -## Decision Outcome - -Chosen option: "`coverage --changed`", because користувач явно підтвердив: весь турнікет переходить на `--changed` (повний coverage лишається лише на `release`/ручний виклик), і при цьому coverage-гейт зберігається у `DEFAULT_GATES` — але завжди через `coverage --changed`. - -### Consequences - -- Good, because турнікет більше не ганяє весь Stryker і весь vitest-suite після кожної дрібної правки: scope обмежено `git diff <base_commit>` проти робочого дерева (uncommitted + committed рівноцінно). -- Good, because усувається примусовий cold-start Stryker у свіжому worktree (де `incremental.json` завжди відсутній через `.gitignore`). -- Bad, because порожній scope (наприклад, лише non-JS зміни) потрібно явно обробляти як `pass (0)`, а не поточний `exit 1` «Жодного провайдера»; без цієї обробки турнікет падатиме на правках документації. -- Bad, because coverage більше не форситься автоматично для всього проєкту на кожному `verify`; мутаційне покриття поза зміненими файлами перевіряється лише явно (`/n-coverage-fix`, `bun run coverage`) або на `release`. - -## More Information - -- `DEFAULT_GATES` визначено у `npm/scripts/dispatcher/lib/reviewer.mjs:14` -- Coverage-гейт оркеструється через `npm/rules/test/coverage/coverage.mjs` (orchestrator) → `npm/rules/js-lint/coverage/coverage.mjs` (js-lint provider) -- `base_commit` для `git diff` береться зі стану flow (MT file-presence state#metadata.base_commit`) -- `collectChangedFilesSince(base, cwd)` — новий helper у `npm/scripts/lib/changed-files.mjs` (поруч із наявним `collectChangedFiles`); об'єднує committed `git diff ${base}..HEAD`, uncommitted `git diff HEAD`, untracked `git ls-files --others` -- vitest 4.1.7 підтримує `--changed [since]`; Stryker 9 приймає `--mutate <файли>` (comma-separated) -- Fallback: якщо стан flow відсутній (ручний виклик поза flow), `coverage --changed` відступає до `collectChangedFiles` (working-tree від HEAD) -- `npm/scripts/dispatcher/lib/active.mjs:43` — `defaultVerify` → `runReview` (consumer без override) -- `npm/scripts/dispatcher/lib/commands.mjs:141` — `mt verify` (consumer) -- Тести `tests/reviewer.test.mjs` хардкодять `['lint','coverage']` — потребують оновлення при зміні `DEFAULT_GATES` - -## Update 2026-06-01 - -Передісторія рішення: початковий аналіз розглядав повне видалення `coverage` з `DEFAULT_GATES`, перенесення на `release`-only та конфіг-кероване увімкнення через `.n-cursor.json#flow.gates` (дефолт `['lint']`, opt-in `coverage`). Аргумент проти видалення: `lint`-гейт вже задовольняє «лише змінені файли» (quick-режим + `changedFiles` з `changed-files.mjs`), тоді як `coverage` не має аналогічного режиму — вилучення без scoping лишало б coverage лише на `release`/ручний виклик без автоматичної гарантії. - -Додатковий контекст cold-start: `stryker.config.baseline.mjs:16` містить `incremental: true`, але `reports/stryker/` є в `.gitignore` (n-test.mdc:221), тому у свіжому worktree `incremental.json` завжди відсутній незалежно від incremental-налаштування. - -## Update 2026-06-01 - -Уточнення джерела scope: `--changed` базується на `git diff <base_commit>` (без `..`) — єдиний виклик, що покриває committed і uncommitted зміни від `base_commit` однаково, на відміну від `git diff HEAD` (не бачить закомічених змін від base у feature-гілці). `base_commit` читається з MT file-presence state (так само, як у `review.mjs:29`). Новий helper: `collectChangedFilesSince(base, cwd)` у `npm/scripts/lib/changed-files.mjs`; fallback на `collectChangedFiles` (HEAD-diff) при відсутньому стані flow для ручних викликів поза flow. - -Окреме рішення: порожній `--changed`-scope → pass (exit 0) у `runCoverageSteps` при `rows.length === 0`. Root без змінених JS-файлів (документація, Rust, конфіги) — валідний стан, не помилка; `COVERAGE.md` не перезаписується. Тест: `coverage.test.mjs` (changed-scope, немає JS-файлів → exit 0, `COVERAGE.md` не створюється). - -## Update 2026-06-02 - -Деталі реалізації (продовження сесії 37e16d83): `DEFAULT_GATES` у `reviewer.mjs` → `['npx','@nitra/cursor','coverage','--changed']`. Провайдери: `npm/rules/js-lint/coverage/coverage.mjs` (+111 рядків, `scopeToRoot`: vitest `--changed <base>`, Stryker `--mutate <changed-js>`; root без змінених JS — skip); `npm/rules/rust/coverage/coverage.mjs` (skip crate при відсутності змінених `.rs`); `npm/rules/test/coverage/coverage.mjs` (`--changed` резолвить base зі `MT file-presence state#metadata.base_commit`). 148 тестів зелені, lint exit 0. Зафіксований баг (`mt audit` L1): у `npm/rules/js-lint/coverage/coverage.mjs` (~рядок 335) exit code `runStryker` ігнорується (`await runner.runStryker(...)` без перевірки) — підриває контракт «змінений src без тестів має дати NoCoverage-мутанти й впасти»; на момент сесії не виправлено. - -## Update 2026-06-02 - -Fail-closed поведінка `collectChangedFilesSince`: при недосяжному `base_commit` (відсутній у git-graph) — throw з повідомленням `недосяжний` замість мовчазного порожнього scope. Тести: `npm/scripts/lib/tests/changed-files.test.mjs` (committed changes видимі, uncommitted changes видимі, поза flow → fallback на HEAD-diff). `coverage --changed` як gate — exit-код без перезапису `COVERAGE.md`: часткові дані по підмножині файлів не замінюють повний звіт; `runCoverageSteps` при `opts.changed === true` і успішному прогоні повертає `0` без запису файлу (рядки 262–266). Тест: `'changed + провайдер з даними → exit 0, але COVERAGE.md НЕ перезаписується'` у `npm/rules/test/coverage/tests/coverage.test.mjs`. diff --git "a/docs/adr/20260602-063622-flow-\321\201\321\202\320\260\320\275-sibling-\321\204\320\260\320\271\320\273.md" "b/docs/adr/20260602-063622-flow-\321\201\321\202\320\260\320\275-sibling-\321\204\320\260\320\271\320\273.md" deleted file mode 100644 index d3ca9cd..0000000 --- "a/docs/adr/20260602-063622-flow-\321\201\321\202\320\260\320\275-sibling-\321\204\320\260\320\271\320\273.md" +++ /dev/null @@ -1,29 +0,0 @@ -# Flow-стан як sibling-файл поряд із worktree-директорією - -**Status:** Accepted -**Date:** 2026-06-02 - -## Context and Problem Statement - -`mt audit` потребує `base_commit` і метаданих задачі (`level`, `risk`, `plan`, `status`), щоб побудувати правильний `git diff`. Постало питання, де зберігати цей стан відносно git-worktree: всередині директорії worktree або поруч із нею як sibling-файл. - -## Considered Options - -* Зберігати стан у файлі всередині worktree-директорії -* Зберігати стан як sibling-файл `.worktrees/<sanitized-branch>.mt-state.json` поряд із worktree-директорією - -## Decision Outcome - -Chosen option: "sibling-файл `.worktrees/<sanitized-branch>.mt-state.json`", because `lib/state-store.mjs` явно описує цю конвенцію: директорія `.worktrees/feat-x` → стан у `.worktrees/feat-x.mt-state.json`. Стан кладеться поруч із директорією, а не всередині неї — `mt audit` читає `base_commit` через `statePath`, обчислений у `state-store.mjs` за шляхом worktree. - -### Consequences - -* Good, because стан залишається доступним навіть якщо worktree-директорію видалено або не змонтовано — `mt audit` може перечитати `base_commit` незалежно від git-checkout стану. -* Bad, because worktree, створений через `n-cursor worktree add` або `git worktree add` без `mt init`, не матиме MT file-presence state-файлу, і `mt audit` не зможе знайти необхідний стан (exit 1: `review: стану нема — спершу mt init`). Для відновлення: `cd .worktrees/<branch> && npx @nitra/cursor mt init <branch> "<опис>"` (ідемпотентно — не вкладає новий worktree, лише записує стан). - -## More Information - -- `npm/scripts/dispatcher/lib/state-store.mjs:4–7` — документація конвенції sibling-файлу -- `npm/scripts/dispatcher/lib/commands.mjs:99–117` — `mt init`: `ensureWorktree` + `writeState(statePath, {...})` двома кроками -- `npm/scripts/dispatcher/lib/review.mjs:116–123` — `readState(statePath)` → якщо `null`, exit 1 -- Ідемпотентна ініціалізація стану у вже існуючому worktree: `[[mt-init-ідемпотентна-ініціалізація]]` diff --git "a/docs/adr/20260602-092725-mt-init-\321\226\320\264\320\265\320\274\320\277\320\276\321\202\320\265\320\275\321\202\320\275\320\260-\321\226\320\275\321\226\321\206\321\226\320\260\320\273\321\226\320\267\320\260\321\206\321\226\321\217.md" "b/docs/adr/20260602-092725-mt-init-\321\226\320\264\320\265\320\274\320\277\320\276\321\202\320\265\320\275\321\202\320\275\320\260-\321\226\320\275\321\226\321\206\321\226\320\260\320\273\321\226\320\267\320\260\321\206\321\226\321\217.md" deleted file mode 100644 index 41fbfe1..0000000 --- "a/docs/adr/20260602-092725-mt-init-\321\226\320\264\320\265\320\274\320\277\320\276\321\202\320\265\320\275\321\202\320\275\320\260-\321\226\320\275\321\226\321\206\321\226\320\260\320\273\321\226\320\267\320\260\321\206\321\226\321\217.md" +++ /dev/null @@ -1,43 +0,0 @@ -# `mt init` — ідемпотентна ініціалізація стану у вже існуючому worktree - -**Status:** Accepted -**Date:** 2026-06-02 - -## Context and Problem Statement - -Worktree, створений через `n-cursor worktree add` або `git worktree add` без `mt init`, має git-ізоляцію, але не має flow-стану (MT file-presence state). Команди `mt audit`, `mt verify` та `mt done` читають стан першим кроком і повертають `exit 1` без нього. Потрібен спосіб добрати стан до вже існуючого worktree без видалення і повторного створення. - -## Considered Options - -* Вимагати видалення worktree і повторного запуску `mt init` з нуля -* Зробити `mt init` ідемпотентним: виявляти CWD-linked-worktree через `isLinkedWorktree(cwd)`, пропускати `worktree add`, записувати лише MT file-presence state - -## Decision Outcome - -Chosen option: "ідемпотентний `mt init`", because `commands.mjs` реалізує guard `isLinkedWorktree(cwd)` (рядки 76–77): якщо вже всередині worktree — `worktree add` пропускається, виконується лише `writeState` — без втрати незакомічених змін і без подвійного вкладання ізоляції. - -### Consequences - -* Good, because transcript фіксує очікувану користь: `mt init` всередині worktree виводить `flow: уже в worktree — не вкладаю новий`, після чого `mt audit` знаходить стан і відпрацьовує без помилок. -* Good, because будь-який worktree без стану може бути відновлений однією командою без знесення незакомічених змін. -* Bad, because worktree, створений без `mt init`, залишається сліпою плямою для flow-турнікета до явного виклику `mt init` або `--branch`-override. - -## More Information - -- `npm/scripts/dispatcher/lib/commands.mjs:76–77` — guard `if (isLinkedWorktree(cwd))` -- `npm/scripts/dispatcher/lib/commands.mjs:79` — `worktree add` викликається лише коли guard не спрацьовує -- `npm/scripts/dispatcher/lib/commands.mjs:99–117` — два кроки `init`: `ensureWorktree` + `writeState` -- Команда recovery: `cd .worktrees/<branch> && npx @nitra/cursor mt init <branch> "<опис>"` → вивід: `flow: уже в worktree — не вкладаю новий; init: … → <branch>.mt-state.json` -- Конвенція sibling-файлу стану: `[[flow-стан-sibling-файл]]` - -## Update 2026-06-02 - -Додаткові деталі реалізації з тієї ж сесії (6fe23dd0): `commands.mjs:99–117` першим кроком викликає `ensureWorktree` (який викликає `npx @nitra/cursor worktree add`), другим — `writeState(statePath, {...})`. Голий `worktree add` є підмножиною без файлу стану. Зафіксований баг coverage-gate у тій самій сесії: у `npm/rules/js-lint/coverage/coverage.mjs` (~рядок 335) exit code `runStryker` ігнорується — виявлено `mt audit` (L1); підриває заявлений контракт scoped coverage gate; окремий fix на момент сесії не виконано. - -## Update 2026-06-05 - -`mt init` зсередини вже наявного worktree (`isLinkedWorktree(cwd) === true`) не вкладає новий worktree — `ensureWorktree` (`commands.mjs:76-77`) детектує прив'язаний worktree і лише записує MT file-presence state поряд (`.worktrees/<branch>.mt-state.json`), зберігаючи незакомічену роботу. Це дозволяє відновити flow-стан у worktree, що був створений без `mt init` (наприклад, через голий `worktree add`). - -Підтверджено на кейсі `feat/coverage-changed-gate`: `cd .worktrees/feat-coverage-changed-gate && npx @nitra/cursor mt init feat/coverage-changed-gate "<опис>"` — незакомічені зміни збережено, стан записано (`level 1, risk low`), `mt audit` підхопив MT file-presence state і відпрацював із 11 findings. - -Релевантні локації: `commands.mjs:76-90` (`ensureWorktree`), `state-store.mjs:4-7` (sibling-файл), `review.mjs:116-121` (`readState` при старті `mt audit`, exit 1 при відсутньому стані). diff --git "a/docs/adr/20260602-093821-trace-\320\262\321\226\320\264\320\275\320\276\321\201\320\275\321\226-\320\273\321\226\320\275\320\272\320\270-flow-info.md" "b/docs/adr/20260602-093821-trace-\320\262\321\226\320\264\320\275\320\276\321\201\320\275\321\226-\320\273\321\226\320\275\320\272\320\270-flow-info.md" deleted file mode 100644 index 4f9cd9d..0000000 --- "a/docs/adr/20260602-093821-trace-\320\262\321\226\320\264\320\275\320\276\321\201\320\275\321\226-\320\273\321\226\320\275\320\272\320\270-flow-info.md" +++ /dev/null @@ -1,34 +0,0 @@ -# Відносна резолюція лінків та info-поле `flow` у `trace.mjs` - -**Status:** Accepted -**Date:** 2026-06-02 - -## Context and Problem Statement - -У `npm/scripts/dispatcher/trace.mjs` усі LINK_FIELDS (`adr`, `spec`, `plan`, `flow`, `change`, `task`) резолвилися через `exists(join(root, target))`. Це не враховувало конвенцію file-relative шляхів у front-matter (`../plans/a.md`), яку рендерять GitHub/Obsidian та інші MD-переглядачі — тому кожен коректний лінк між `docs/specs/` і `docs/plans/` давав хибне «✗ РОЗРИВ». Окремо: поле `flow:` вказує на runtime-артефакт `.worktrees/<branch>.mt-state.json`, gitignored і відсутній у clean checkout/CI — хибний розрив горів завжди і став ігнорованим шумом. - -## Considered Options - -* File-relative резолюція з fallback на root-relative для всіх лінків + маркування `flow:` як не-breaking через `INFO_LINK_FIELDS` -* Прибрати `flow:` з `LINK_FIELDS` зовсім — не показувати й не перевіряти -* Перевіряти всі поля однаково (relative + root fallback): `flow:` також рахується breaking - -## Decision Outcome - -Chosen option: "File-relative резолюція з fallback на root-relative + `INFO_LINK_FIELDS` для `flow:`", because file-relative — конвенція наявних закомічених доків; поле `flow:` є корисним людським вказівником на стан задачі, але MT file-presence state gitignored і відсутній у чистому checkout/CI — його відсутність не означає «розрив ланцюга». - -### Consequences - -* Good, because `../plans/a.md` у `docs/specs/x.md` тепер резолвиться коректно без зміни конвенції написання лінків у front-matter. -* Good, because відсутній `flow:` у CI більше не дає exit 1 і не забруднює звіт хибними розривами; рендер `~ … (runtime-стан, не перевіряється)` замість `✗ РОЗРИВ`. -* Bad, because подвійний резолв (file-relative, потім root-relative fallback) маскує помилки конвенції у front-matter — семантично некоректний шлях може зарезолвитися через fallback без попередження. Прийнято як компроміс між сигналом і шумом (позначено 🟡 рецензентом). - -## More Information - -- Змінені файли: `npm/scripts/dispatcher/trace.mjs`, `npm/scripts/dispatcher/tests/trace.test.mjs` (+7 нових кейсів) -- Нова функція: `resolveLink(root, artifactFile, target, exists)` — `node:path` `dirname` + `join`; спершу file-relative (`join(root, dirname(artifactFile), target)`), потім root-relative fallback -- Константа `LINK_FIELDS` розділена на `CHAIN_FIELDS` (breaking: `adr`/`spec`/`plan`/`change`/`task`) та `const INFO_LINK_FIELDS = new Set(['flow'])` (не breaking) -- `analyze` перейменовано параметр `exists` → `resolve`; сигнатура: `(target, artifactFile) => boolean` -- `runTraceCli` передає `(target, file) => resolveLink(root, file, target, exists)` замість `target => exists(join(root, target))` -- Exit-code умова: `l.breaking && !l.ok` замість `!l.ok` -- 16 тестів trace + 181 dispatcher зелені; eslint exit 0; коміт `1bd829a`; гілка `flow-trace-relative-links` diff --git "a/docs/adr/20260602-094451-flow-cwd-\320\261\320\260\320\263\320\260\321\202\320\276\321\200\321\226\320\262\320\275\320\265\320\262\320\270\320\271-\321\200\320\265\320\267\320\276\320\273\320\262\320\270\320\275\320\263-\321\201\321\202\320\260\320\275\321\203.md" "b/docs/adr/20260602-094451-flow-cwd-\320\261\320\260\320\263\320\260\321\202\320\276\321\200\321\226\320\262\320\275\320\265\320\262\320\270\320\271-\321\200\320\265\320\267\320\276\320\273\320\262\320\270\320\275\320\263-\321\201\321\202\320\260\320\275\321\203.md" deleted file mode 100644 index 475e88f..0000000 --- "a/docs/adr/20260602-094451-flow-cwd-\320\261\320\260\320\263\320\260\321\202\320\276\321\200\321\226\320\262\320\275\320\265\320\262\320\270\320\271-\321\200\320\265\320\267\320\276\320\273\320\262\320\270\320\275\320\263-\321\201\321\202\320\260\320\275\321\203.md" +++ /dev/null @@ -1,32 +0,0 @@ -# Багаторівневий cwd-незалежний резолвинг активного стану flow - -**Status:** Accepted -**Date:** 2026-06-02 - -## Context and Problem Statement - -Команди `mt init/plan/verify/review/gate/release` викликали `flowStatePath(cwd)`, де `cwd` — поточний каталог shell-виклику. Кожен новий Bash-блок скидає cwd у головне дерево репозиторію, а не у worktree задачі, тому команди повертали «стану нема — спершу `mt init`» навіть за активного flow. За сесію це траплялось 3 рази. - -## Considered Options - -* A — багаторівневий резолвинг: sibling-перевірка (cwd) → scan активних `.worktrees/*.mt-state.json` (один активний — взяти з info-логом; кілька — fail зі списком) → явний `--branch` завжди перемагає -* B — лише toplevel-резолвинг: `git rev-parse --show-toplevel`; поза worktree вимагати `--branch` -* C — завжди вимагати явний `--branch` поза кореневою текою worktree - -## Decision Outcome - -Chosen option: "A — багаторівневий резолвинг", because лише варіант A авторезолвить один активний flow незалежно від cwd; sibling-шлях (B) не рятує при запуску з головного дерева; завжди-явний `--branch` (C) усуває зручність авторезолву. - -### Consequences - -* Good, because новий модуль `flow-resolve.mjs` + `extractBranchFlag` у `dispatcher/index.mjs` авторезолвить один активний flow; 193 тести dispatcher зелені; eslint exit 0. -* Good, because code review (2 рецензенти, L2) виявив і було виправлено: `--branch` без значення тихо ковтав сусідній аргумент → валідація додана; `--branch` неіснуючого worktree → чітке повідомлення замість ENOENT; авторезолв тягнув чужий flow із worktree-без-стану → обмежено запуском поза worktree. -* Bad, because worktree без стану і єдиний активний flow поруч — transcript фіксує, що цей edge-case свідомо не авторезолвиться (лише за `--branch`). - -## More Information - -- Новий файл: `npm/scripts/dispatcher/lib/flow-resolve.mjs` -- Змінені файли: `npm/scripts/dispatcher/index.mjs`, `npm/scripts/dispatcher/lib/commands.mjs`, `npm/scripts/dispatcher/lib/spec.mjs`, `npm/scripts/dispatcher/lib/plan.mjs`, `npm/scripts/dispatcher/lib/gate.mjs`, `npm/scripts/dispatcher/lib/review.mjs` -- Change-файл: `npm/.changes/` (bump: minor, section: Added); коміт `ccaad96`; гілка `flow-cwd-state-resolution` -- Паралельне рішення тієї ж сесії (c893caa2): видалення `checkDirtyNpmRequiresVersionBump` і `checkChangelogTopMatchesPackageVersion` з `npm/rules/npm-module/js/package_structure.mjs` — перевірки інвертовано суперечили `n-changelog.mdc` (вимагали ручного bump, якого правило забороняє); відповідальність делегована у `changelog/js/consistency.mjs`; `npm-module.mdc` (v1.13→1.14), `changelog.mdc` (v3.1→3.2); коміти `fe08579`, `38828ad`; гілка `changelog-npm-module-align` -- Резолюція file-relative лінків у trace.mjs: `[[trace-відносні-лінки-flow-info]]` diff --git "a/docs/adr/20260602-100744-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-version-changelog-\320\267-package-structure.md" "b/docs/adr/20260602-100744-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-version-changelog-\320\267-package-structure.md" deleted file mode 100644 index 9102881..0000000 --- "a/docs/adr/20260602-100744-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-version-changelog-\320\267-package-structure.md" +++ /dev/null @@ -1,21 +0,0 @@ -# Видалення перевірок version/CHANGELOG з package_structure.mjs - -**Status:** Accepted -**Date:** 2026-06-02 - -## Context and Problem Statement -`package_structure.mjs` (npm/rules/npm-module) містив перевірки, що вимагали відповідності `version` у `package.json` та наявності свіжого запису у `CHANGELOG`. Ці перевірки конфліктували з правилом `n-changelog.mdc`, яке забороняє ручний bump — єдиний дозволений артефакт зміни є change-файл (`npx @nitra/cursor change …`). Результат: `npx @nitra/cursor fix changelog npm-module` давав ❌ навіть за коректно складеної гілки. - -## Considered Options -- Видалити перевірки `version`/`CHANGELOG` з `package_structure.mjs`, лишивши `changelog/consistency.mjs` єдиним валідатором узгодженості. -- Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Видалити суперечливі перевірки з `package_structure.mjs`", because єдиний легальний артефакт змін — change-файл; узгодженість version/CHANGELOG вже валідує `changelog/consistency.mjs`, тому дублювання лише примушувало до ручного bump, що `n-npm-module.mdc` явно забороняє. - -### Consequences -- Good, because `npx @nitra/cursor fix changelog npm-module` проходить без ❌ по version/CHANGELOG; ручний bump більше не вимагається. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Змінені файли: `npm/rules/npm-module/js/package_structure.mjs`, `npm/tests/integration-repo-checks.test.mjs`. Коміт `fe08579`. Change-файл — `npm/.changes/` (bump: patch, section: Fixed). Перша спроба помістила change-файл у кореневий `.changes/` через відсутній `--ws npm` — виправлено повторним `mt done --ws npm`. У цій же сесії розв'язано конфлікт merge гілки `feat/coverage-changed-gate` у `main` (коміт `c091708`): для `reviewer.mjs`/`flow.mdc` обрано бік feat (`DEFAULT_GATES = [lint, coverage --changed]`), для `rust/coverage.mjs` — бік HEAD (новіший, містить `diffPath`/`baseline:skip`). Валідація: `node --check` по 3 файлах, `grep -rl '<<<'` — маркерів нема, 330/330 тестів зелені. diff --git a/docs/adr/20260609-070002-scanner-gitignore-rust-target.md b/docs/adr/20260609-070002-scanner-gitignore-rust-target.md deleted file mode 100644 index 7c03967..0000000 --- a/docs/adr/20260609-070002-scanner-gitignore-rust-target.md +++ /dev/null @@ -1,32 +0,0 @@ -# Ігнорування артефактів Rust-сканера в `.gitignore` - -**Status:** Accepted -**Date:** 2026-06-09 - -## Context and Problem Statement -До репозиторію `mt` додано Rust-проєкт `scanner/` (бінарний крейт `mt-scanner`). Директорія `scanner/target/` містить артефакти компіляції і не повинна потрапляти до git, але до цього моменту `.gitignore` не мав жодного правила для Rust. - -## Considered Options -- Додати `scanner/target/` до кореневого `.gitignore`. -- Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Додати `scanner/target/` до кореневого `.gitignore`", because `target/` — стандартний каталог білд-артефактів Rust і його виключення є загальноприйнятою практикою; `Cargo.lock` навмисно залишається незаігнорованим, оскільки для бінарного крейту він фіксує точні версії залежностей і забезпечує відтворюваність білдів. - -### Consequences -- Good, because `scanner/target/` більше не відстежується git і не забруднює `git status` / `git diff`. -- Good, because `Cargo.lock` закомічений — відтворювані білди бінарника гарантовані. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -- Змінений файл: `.gitignore` -- Додано правило: `scanner/target/` -- `scanner/Cargo.toml`: пакет `mt-scanner`, edition 2021, `[[bin]]` → бінарний крейт. -- Рішення щодо `Cargo.lock` базується на офіційній рекомендації Rust/Cargo для бінарних крейтів. - -## Update 2026-06-11 - -Замінено `scanner/target/` → `target/` у `.gitignore`. Кореневий `Cargo.toml` визначає Cargo workspace (`members = ["scanner"]`), тому Rust складає артефакти в кореневий `target/`, а не в `scanner/target/`. Стара директива `scanner/target/` не ігнорувала реальну директорію збірки — виправлено. - -- Файл: `.gitignore`, рядок 5 -- Workspace config: `Cargo.toml` (root) — `[workspace] members = ["scanner"]` diff --git "a/docs/adr/20260611-192621-\320\262\320\265\321\200\321\201\321\226\321\216\320\262\320\260\320\275\320\275\321\217-\320\264\320\276\320\272\321\203\320\274\320\265\320\275\321\202\320\260-npm-docs-mt.md" "b/docs/adr/20260611-192621-\320\262\320\265\321\200\321\201\321\226\321\216\320\262\320\260\320\275\320\275\321\217-\320\264\320\276\320\272\321\203\320\274\320\265\320\275\321\202\320\260-npm-docs-mt.md" deleted file mode 100644 index f6a2d55..0000000 --- "a/docs/adr/20260611-192621-\320\262\320\265\321\200\321\201\321\226\321\216\320\262\320\260\320\275\320\275\321\217-\320\264\320\276\320\272\321\203\320\274\320\265\320\275\321\202\320\260-npm-docs-mt.md" +++ /dev/null @@ -1,30 +0,0 @@ -# Версіювання документа `npm/docs/mt.md` через inline-мітку та секцію Changelog - -**Status:** Accepted -**Date:** 2026-06-11 - -## Context and Problem Statement - -Документ `npm/docs/mt.md` (специфікація `@7n/mt`, ~1839 рядків) змінювався разом із пакетом, але не мав власної версійної мітки і не фігурував у `CHANGELOG.md` пакету. Правило `n-changelog.mdc` явно виключає зміни в `docs/` зі звичайного change-file-потоку, тому потрібен окремий механізм відстеження змін саме для цього файлу. - -## Considered Options - -* Inline-мітка версії у заголовку файлу + секція `## Changelog` в кінці `mt.md` -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "Inline-мітка + секція `## Changelog` у `mt.md`", because це дозволяє вести версіювання документа незалежно від change-file-процесу пакету (який виключає `docs/` згідно з `n-changelog.mdc`), зберігаючи всю інформацію в одному файлі. - -Початкова версія мітки — `0.2.0`, вирівняна з поточною версією пакету `@7n/mt@0.2.0` як baseline. - -### Consequences - -* Good, because зміни в документі тепер відстежуються явно, а мітка одразу показує, якій версії пакету відповідає документ. -* Bad, because версія документа і версія пакету ведуться вручну й можуть розійтися, якщо оновлення пакету не супроводжується оновленням мітки в `mt.md`. - -## More Information - -* Змінений файл: `npm/docs/mt.md` — додано рядок з версійною міткою після першого заголовку `#` (формат: `> Версія документа: **0.2.0** — відповідає @7n/mt@0.2.0`) і секцію `## Changelog` в кінці файлу. -* Правило `n-changelog.mdc` (`.cursor/rules/n-changelog.mdc`) виключає `docs/` із обов'язкового change-file-потоку — саме це зробило inline-підхід необхідним. -* Поточна версія пакету: `0.2.0` (`npm/package.json`). diff --git "a/docs/adr/20260611-193434-\320\262\320\270\321\200\321\226\320\262\320\275\321\216\320\262\320\260\320\275\320\275\321\217-scanner-state-\320\267-\321\201\320\277\320\265\321\206\320\270\321\204\321\226\320\272\320\260\321\206\321\226\321\224\321\216-mt.md" "b/docs/adr/20260611-193434-\320\262\320\270\321\200\321\226\320\262\320\275\321\216\320\262\320\260\320\275\320\275\321\217-scanner-state-\320\267-\321\201\320\277\320\265\321\206\320\270\321\204\321\226\320\272\320\260\321\206\321\226\321\224\321\216-mt.md" deleted file mode 100644 index d5c72a2..0000000 --- "a/docs/adr/20260611-193434-\320\262\320\270\321\200\321\226\320\262\320\275\321\216\320\262\320\260\320\275\320\275\321\217-scanner-state-\320\267-\321\201\320\277\320\265\321\206\320\270\321\204\321\226\320\272\320\260\321\206\321\226\321\224\321\216-mt.md" +++ /dev/null @@ -1,38 +0,0 @@ -# Вирівнювання scanner/state з оновленою специфікацією mt - -**Status:** Accepted -**Date:** 2026-06-11 - -## Context and Problem Statement - -Модулі `lib/core/scanner.mjs` та `lib/core/state.mjs` реалізовували стару архітектуру: залежності читались із `fm.deps` у frontmatter `task.md`, набір станів містив 7 записів зі старими іменами (`needs-plan`, `invalidated`), а composite-стан обчислювався через рекурсивну агрегацію дочірніх вузлів. Оновлена специфікація `npm/docs/mt.md` змінила всі ці контракти, зробивши реалізацію несумісною з нею. - -## Considered Options - -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "Повний рефакторинг `state.mjs` і `scanner.mjs` відповідно до оновленої специфікації", because специфікація однозначно описувала нові контракти — будь-яка інша стратегія (тонкий shim або поступова міграція) не розглядалась. - -Конкретні зміни: - -| Аспект | Було | Стало | -|---|---|---| -| Залежності | `fm.deps` із `task.md` frontmatter | `deps/` директорія; `ls -R deps/` → strip `.md` | -| Набір станів | 7: `needs-plan`, `waiting`, `running`, `pending-audit`, `resolved`, `failed`, `invalidated` | 12: `unassigned`, `pending`, `waiting`, `blocked`, `plan-review`, `spawned`, `running`, `stalled`, `pending-audit`, `resolved`, `failed`, `unresolvable` | -| Sentinel `invalidated` | `fileSet.has('invalidated')` | Видалено; стан не існує | -| Детекція executor | Читання `mode:` із `task.md` | Наявність файлів `a.md` / `h.md` | -| `failed_streak` | Читання вмісту run-файлів (`result: failed`) | `max(run_NNN) - max(fact_NNN)` — виключно з імен файлів | -| Composite-стан | `deriveCompositeState()` — агрегація станів дітей | Видалено; composite-вузол має той самий O(1)-derived стан, що й atomic | -| Стан `blocked` | Не існував | Виставляється в другому проході `scanTasks` після побудови повної мапи вузлів | - -### Consequences - -* Good, because 78/78 тестів (`state.test.mjs` та решта suite, крім pre-existing `docs.test.mjs`) пройшли зелено після рефакторингу. -* Good, because `deriveNodeState` стала чистою функцією без читання вмісту файлів (окрім frontmatter plan-файлу для `plan-review`/`spawned`), що узгоджується з вимогою spec щодо O(1)-деривації. -* Bad, because `blocked` не тестується в unit-тестах `state.test.mjs` — його виставляє лише другий прохід у `scanTasks`, тому покриття стану неповне на рівні unit-тестів. - -## More Information - -Змінені файли: `lib/core/state.mjs`, `lib/core/scanner.mjs`, `lib/tests/state.test.mjs`, `lib/commands/scan.mjs`, `lib/commands/watch.mjs`, `lib/commands/status.mjs`. Специфікація: `npm/docs/mt.md`. Тести запускались командою `bun test`. diff --git a/docs/adr/20260611-214219-ua-docs-benchmark-design.md b/docs/adr/20260611-214219-ua-docs-benchmark-design.md deleted file mode 100644 index 9bca621..0000000 --- a/docs/adr/20260611-214219-ua-docs-benchmark-design.md +++ /dev/null @@ -1,38 +0,0 @@ -## ADR UA docs benchmark — підхід до оцінки якості та продуктивності LLM - -## Context and Problem Statement - -Потрібно порівняти кілька MLX-варіантів Gemma 4 E2B (uniform PTQ, QAT, OptiQ) за якістю генерації технічної документації українською мовою та за швидкістю інференсу на локальному oMLX-сервері (Apple M2 8 GB RAM). - -## Considered Options - -* 5 промптів × 4 перевірки якості + tok/s + RAM diff на першому запиті, з послідовним вивантаженням між моделями -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "5 промптів × 4 перевірки якості + tok/s + RAM diff, sequential unload", because це покриває репрезентативні категорії технічної документації (REST endpoint, JSDoc, architecture, error catalog, config reference) і дозволяє запустити всі три моделі послідовно на машині з 8 GB RAM. - -**Деталі реалізації:** - -- **Якість** — 4 `VERDICT_CHECKS` на кожну відповідь: (1) частка українських слів ≥ 30%, (2) наявність Markdown (`#` або ` ``` `), (3) відповідь не є суто англійською (< 20% англ. слів), (4) наявність технічного контенту (`api|endpoint|config|error` тощо). -- **Швидкість** — `completion_tokens / elapsed_sec` → `tok/s`; вимірюється для кожного промпту. -- **RAM** — `vm_stat` до першого запиту кожної моделі; різниця між замірами = приблизний footprint завантаженої моделі. -- **Кеш-bust** — унікальний `RUN_SEED = random.randint(0, 2**31 - 1)` передається у `"seed"` кожного `chat()`-запиту, щоб oMLX не повертав закешовану відповідь. -- **Retry** — при `IncompleteRead` або мережевій помилці `chat()` повторює до 3 разів з паузою 3 с. -- **Вивантаження між моделями** — `POST /v1/models/{id}/unload` + 5 с очікування; наступна модель завантажується oMLX автоматично на першому запиті. -- **Memory guard** — `memory_guard_tier: balanced`; моделі що не вміщаються отримують ПОМИЛКА і не враховуються в таблиці (не aborting run). - -### Consequences - -* Good, because transcript фіксує очікувану користь: перша модель (4bit) дала 20/20 score і реальний ~226 t/s; qat дала 4/20 (перший промпт пройшов, решта — IncompleteRead від memory pressure), що підтвердило необхідність retry. -* Bad, because RAM-замір через `vm_stat` дає шум від інших процесів; різниця між замірами показує приблизний footprint моделі, а не точний. - -## More Information - -- `docs/omlx-ua-docs-bench.py` — основний benchmark-скрипт -- `docs/run-bench.sh` — оболонка: перевіряє `memory_guard_tier`, рестартує oMLX якщо потрібно, запускає benchmark -- `~/.omlx/model_settings.json` — `ttl_seconds: 300`, `enable_thinking: false` для всіх трьох моделей (persistent, не перезаписується при рестарті) -- Unload API: `POST http://127.0.0.1:8000/v1/models/{model_id}/unload` -- Admin login: `POST http://127.0.0.1:8000/admin/api/login` з cookie (`-c /tmp/omlx-bench-cookies.txt`) -- `enable_thinking: false` критично — без цього модель генерує chain-of-thought англійською і не доходить до українського контенту в межах `max_tokens` diff --git "a/docs/adr/20260613-071723-\320\267\320\260\320\274\321\226\320\275\320\260-js-\321\201\320\272\320\260\320\275\320\265\321\200\320\260-\320\275\320\260-rust-shim.md" "b/docs/adr/20260613-071723-\320\267\320\260\320\274\321\226\320\275\320\260-js-\321\201\320\272\320\260\320\275\320\265\321\200\320\260-\320\275\320\260-rust-shim.md" deleted file mode 100644 index 5bdb0c8..0000000 --- "a/docs/adr/20260613-071723-\320\267\320\260\320\274\321\226\320\275\320\260-js-\321\201\320\272\320\260\320\275\320\265\321\200\320\260-\320\275\320\260-rust-shim.md" +++ /dev/null @@ -1,33 +0,0 @@ -# Заміна JS-сканера на тонкий шим, що викликає Rust-бінарник `mt-scanner` - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -У проєкті `@7n/mt` існували дві паралельні реалізації логіки сканування DAG-задач: `scanner/src/lib.rs` (Rust-бінарник `mt-scanner`) та `npm/lib/core/scanner.mjs` (самостійна Node.js-реалізація на `node:fs`). JS-реалізація ніколи не викликала Rust-бінарник — обидві кодові бази розвивалися незалежно. Користувач поставив вимогу усунути JS-реалізацію і зробити Rust єдиним джерелом правди. - -## Considered Options - -* Залишити JS-реалізацію як основну (поточний стан) -* Видалити JS-реалізацію; `scanner.mjs` стає тонким шимом, що викликає `mt-scanner scan <mtDir>` через `spawnSync` і адаптує JSON-вихід до контракту споживачів -* Інтеграція через NAPI `.node`-аддон або WASM (згадано як технічна альтернатива, але не обиралась) - -## Decision Outcome - -Chosen option: "Тонкий JS-шим поверх Rust-бінарника через `spawnSync`", because користувач явно сформулював вимогу: «потрібно щоб js реалізації не існувало, а вона викликала rust варіант»; `spawnSync` — найпростіший міст без зміни публічної сигнатури `scanTasks`. - -### Consequences - -* Good, because transcript фіксує очікувану користь: єдина реалізація логіки сканування (в Rust), усунення дублювання `deriveNodeState`/`isComposite` між `state.mjs` і `lib.rs`. -* Bad, because контракти JS і Rust розходяться — потрібен адаптер у шимі: `snake_case`-стани → kebab, `is_composite` → `composite`, відсутнє поле `dir` (відновлюється як `join(mtDir, path)`), вкладене дерево → плаский список. -* Bad, because `spawnSync` додає латентність процес-форку на кожен скан; для команди `mt watch` (часті скани) це може бути відчутно — зафіксовано в специфікації як майбутній ризик поза поточним scope. - -## More Information - -- Rust-сканер: `scanner/src/main.rs`, `scanner/src/lib.rs`, `scanner/Cargo.toml`; CLI: `mt-scanner scan <tasks_dir>` → JSON array топологічно відсортованих вузлів. -- Файл що замінюється шимом: `npm/lib/core/scanner.mjs`; адаптер виконує: `flatten(tree, mtDir)`, `state.replace('_', '-')`, відновлення `dir = join(mtDir, path)`, `is_composite → composite`, `children[]` — масив шляхів. -- Споживачі шима: `scan.mjs`, `status.mjs`, `run.mjs`, `invalidate.mjs`, `watch.mjs`, `kill.mjs`; публічний re-export у `npm/index.js` (`findTasks`, `getActiveWorktrees`, `parseWorktreeList`). -- `topoSort`, `areDepsResolved`, `getActiveWorktrees`, `parseWorktreeList` — лишаються в JS без змін. -- `deriveNodeState`/`isComposite` з `state.mjs` використовувалися виключно `scanner.mjs` і після заміни видаляються. -- Специфікація рефакторингу: `docs/spec-scanner-rust-integration.md`. diff --git "a/docs/adr/20260613-071723-\320\267\320\260\320\274\321\226\320\275\320\260-js-\321\201\320\272\320\260\320\275\320\265\321\200\320\260-\320\275\320\260-rust-\321\210\320\270\320\274.md" "b/docs/adr/20260613-071723-\320\267\320\260\320\274\321\226\320\275\320\260-js-\321\201\320\272\320\260\320\275\320\265\321\200\320\260-\320\275\320\260-rust-\321\210\320\270\320\274.md" deleted file mode 100644 index dfeb35c..0000000 --- "a/docs/adr/20260613-071723-\320\267\320\260\320\274\321\226\320\275\320\260-js-\321\201\320\272\320\260\320\275\320\265\321\200\320\260-\320\275\320\260-rust-\321\210\320\270\320\274.md" +++ /dev/null @@ -1,45 +0,0 @@ -# Заміна JS-сканера на тонкий шим, що викликає Rust-бінарник `mt-scanner` - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -У проєкті `@7n/mt` існували дві паралельні реалізації однієї логіки сканування DAG-задач: `scanner/src/lib.rs` (Rust-бінарник `mt-scanner`) та `npm/lib/core/scanner.mjs` (самостійна Node.js-реалізація на `node:fs`). JS-версія не викликала Rust-бінарник і виконувала весь FS-обхід самостійно; Rust-бінарник фактично не використовувався в рантаймі npm-пакета. Необхідно усунути дублювання і залишити єдину канонічну реалізацію логіки сканування. - -## Considered Options - -* Зберегти дві паралельні реалізації (поточний стан) -* Видалити JS-реалізацію; `scanner.mjs` стає тонким шимом, що викликає `mt-scanner scan <mtDir>` через `spawnSync` і адаптує JSON-вихід до контракту споживачів -* Інтеграція через NAPI `.node`-аддон або WASM (згадано асистентом як можливі альтернативи, не обирались) - -## Decision Outcome - -Chosen option: "Тонкий JS-шим поверх `mt-scanner` через `spawnSync`", because користувач явно сформулював вимогу: «потрібно щоб js реалізації не існувало, а вона викликала rust варіант»; `spawnSync` — найпростіший міст без зміни публічної сигнатури функцій-споживачів. - -### Consequences - -* Good, because єдина реалізація логіки сканування (в Rust) усуває ризик розходження JS/Rust поведінки і дублювання `deriveNodeState`/`isComposite` між `state.mjs` і `lib.rs`. -* Bad, because контракти JS і Rust розходяться: шим виконує адаптацію `pending_audit` → `pending-audit`; `is_composite` → `composite`; відновлення поля `dir = join(mtDir, path)`; flatten вкладеного дерева у плоский список. -* Bad, because `spawnSync` на кожен скан додає латентність форку процесу; для команди `mt watch` (часті скани) це може бути відчутним — зафіксовано в специфікації як майбутній ризик поза поточним scope. -* Bad, because тести з ін'єкцією `fs`-моку через `scanner.mjs` потребують переписування (`vi.mock('../core/scanner.mjs', ...)`); стратегія доставки бінарника на момент рішення залишалась відкритим питанням. - -## More Information - -- Rust-сканер: `scanner/src/main.rs`, `scanner/src/lib.rs`, `scanner/Cargo.toml`; CLI: `mt-scanner scan <tasks_dir>` → JSON array (топологічний порядок). -- JS-шим (`npm/lib/core/scanner.mjs`) виконує лише: `spawnSync` → JSON.parse → flatten → state mapping → field restoration; нуль FS-операцій. -- Зафіксований механізм виклику з transcript: -```js -const bin = process.env.MT_SCANNER_BIN ?? 'mt-scanner' -const r = spawnSync(bin, ['scan', tasksDir], { encoding: 'utf8' }) -if (r.status !== 0) throw new Error(`mt-scanner failed: ${r.stderr}`) -return JSON.parse(r.stdout) -``` -- Функції `topoSort`, `areDepsResolved`, `getActiveWorktrees`, `parseWorktreeList` — залишаються в JS без змін. -- Споживачі шима: `scan.mjs`, `status.mjs`, `run.mjs`, `invalidate.mjs`, `watch.mjs`, `kill.mjs`; публічний re-export у `npm/index.js`. -- `target/` виключено з git (`20260609-070002-scanner-gitignore-rust-target.md`); стратегія доставки бінарника — `20260613-084500-prebuilt-scanner-binary-delivery.md`. -- Специфікація рефакторингу: `docs/spec-scanner-rust-integration.md`. - -## Update 2026-06-13 - -`deriveNodeState` та `isComposite` з `npm/lib/core/state.mjs` використовувались виключно `scanner.mjs` і після заміни шимом видаляються з `state.mjs`. `scanTasks` більше не приймає `fs` та `config` аргументи — Dependency Injection знята. Доставка бінарника (optionalDependencies + матриця платформ) зафіксована в `20260613-084500-prebuilt-scanner-binary-delivery.md`. diff --git "a/docs/adr/20260613-073949-fs-\321\200\320\276\320\261\320\276\321\202\320\260-\320\262-rust-worktree-running.md" "b/docs/adr/20260613-073949-fs-\321\200\320\276\320\261\320\276\321\202\320\260-\320\262-rust-worktree-running.md" deleted file mode 100644 index 69ecc69..0000000 --- "a/docs/adr/20260613-073949-fs-\321\200\320\276\320\261\320\276\321\202\320\260-\320\262-rust-worktree-running.md" +++ /dev/null @@ -1,37 +0,0 @@ -# Виявлення активних worktree та весь FS-доступ переноситься в Rust - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Після рішення викликати Rust-бінарник через JS-шим постало питання: де виконувати логіку визначення стану `running` — виявлення активного git-worktree для задачі. Оригінальний JS-код робив це post-process кроком у `scanTasks`, викликаючи `getActiveWorktrees` (JS `execSync git worktree list`). Потрібно було вирішити: FS-логіка залишається в JS-шимі як post-process чи переноситься в Rust-бінарник. - -## Considered Options - -* JS post-process у шимі: `mt-scanner scan` повертає JSON без `running`-стану; JS отримує список worktree через `getActiveWorktrees` і підвищує стан самостійно -* Перенести виявлення активних worktree в Rust: `mt-scanner scan` сам виконує `git worktree list --porcelain`, застосовує `sanitize_task_name` для матчингу і повертає `running`-стан у JSON - -## Decision Outcome - -Chosen option: "Перенести виявлення worktree в Rust", because користувач встановив директиву: «усе, що стосується роботи з файловою системою, повинно бути Rust»; виклик `git worktree list` і читання FS для матчингу — файлова операція, тому JS не повинен цього торкатися. - -### Consequences - -* Good, because JS-шим стає суто тонким адаптером без FS-операцій — лише `spawnSync`, JSON-парсинг і адаптація контракту. -* Good, because єдина точка відповідальності за стан задачі; принцип «єдина точка FS-логіки» витримано. -* Bad, because `sanitizeTaskName` (конвенція іменування worktree) тепер існує в двох місцях: `scanner/src/lib.rs` (для матчингу при скануванні) і `npm/lib/core/state.mjs` (для створення worktree в `mt run`); без спільних тест-векторів розходження логіки дасть false negative у `running`-детекції. - -## More Information - -- `scanner/src/lib.rs` — `discover_worktrees(tasks_root)`: виконує `git worktree list --porcelain`, парсить рядки `worktree <path>`, повертає `Vec<String>` (назви — останній компонент шляху). -- `scanner/src/lib.rs` — `sanitize_task_name(s)`: портована логіка з `npm/lib/core/state.mjs`; використовується для матчингу worktree-імені проти task-path при визначенні `running`. -- `scanner/src/main.rs` — прапор `--worktrees w1,w2,...`: дозволяє детерміновані тести без реального `git`; у продакшені пропускається, Rust виконує discovery самостійно. -- `npm/lib/core/state.mjs` — `sanitizeTaskName` збережено; `deriveNodeState`/`isComposite` видалено. -- `getActiveWorktrees`/`parseWorktreeList` залишаються в JS — потрібні `mt run` для *створення* worktree (поза скануванням). -- 30 Rust unit-тестів (`#[cfg(test)]` у `lib.rs`) покривають усі кейси `state.test.mjs`, включно з `worktree→running` і `sanitize`-векторами; `cargo test` зелений. -- Специфікація: `docs/spec-scanner-rust-integration.md §5`. - -## Update 2026-06-13 - -`sanitizeTaskName` в `npm/lib/core/state.mjs` використовується не лише `mt run` (створення worktree), а й `npm/lib/commands/worktree.mjs` — обидва не пов'язані зі скануванням і залишаються в JS. Уточнення щодо прапора `--worktrees`: приймає список через кому і використовується виключно в `#[cfg(test)]`-тестах для детермінізму без реального git. diff --git "a/docs/adr/20260613-083923-fs-\320\273\320\276\320\263\321\226\320\272\320\260-\320\262-rust-worktree-running.md" "b/docs/adr/20260613-083923-fs-\320\273\320\276\320\263\321\226\320\272\320\260-\320\262-rust-worktree-running.md" deleted file mode 100644 index 56f5227..0000000 --- "a/docs/adr/20260613-083923-fs-\320\273\320\276\320\263\321\226\320\272\320\260-\320\262-rust-worktree-running.md" +++ /dev/null @@ -1,32 +0,0 @@ -# ФС-логіка — в Rust; виявлення `worktree→running` переноситься в бінарник - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Після рішення викликати `mt-scanner` через JS-шим постало питання розподілу відповідальності: де виконувати логіку визначення стану `running` для задачі, що виконується в активному git-worktree. JS-реалізація визначала цей стан двома шляхами: `running_*`-sentinel файл на диску та наявність активного git-worktree через JS `execSync`. Перехід на шим вимагав явного рішення — залишити цю логіку у JS чи перенести в Rust. - -## Considered Options - -* JS post-process у шимі: Rust повертає дерево без `running`-стану; JS отримує список worktree через `getActiveWorktrees` і підвищує стан поверх JSON -* Перенести виявлення активних worktree в Rust: `mt-scanner scan` сам виконує `git worktree list --porcelain` і повертає `running`-стан у JSON - -## Decision Outcome - -Chosen option: "Перенести виявлення worktree в Rust", because користувач сформулював принцип: «все що стосується роботи з файловою системою повинно бути Rust»; виклик `git worktree list` і читання ФС для матчингу є файловою операцією — JS не повинен цього торкатися. - -### Consequences - -* Good, because JS-шим стає суто тонким адаптером (запуск бінарника + JSON-парсинг + flatten + мапінг станів); жодних FS-операцій у JS. -* Good, because transcript фіксує очікувану користь: єдина точка відповідальності за стан задачі; принцип «єдина точка ФС-логіки» витримано. -* Bad, because `sanitizeTaskName` (конвенція іменування worktree) потрібно продублювати в Rust для матчингу — JS-копія лишається лише для `mt run` (створення worktree); синхронізація через спільні тест-вектори є необхідною умовою коректності. - -## More Information - -- `scanner/src/lib.rs` — `discover_worktrees(tasks_root)`: викликає `git worktree list --porcelain`, парсить `worktree <path>` рядки, повертає `Vec<String>` імен (останній компонент шляху). -- `scanner/src/lib.rs` — `sanitize_task_name(s)`: портована логіка з JS `state.mjs`; використовується для матчингу worktree-імені проти task-path при визначенні стану `running`. -- `scanner/src/main.rs` — опція `--worktrees <list>` дозволяє детерміновані тести без реального `git`; у продакшені пропускається — бінарник сам виявляє worktrees. -- `npm/lib/core/state.mjs` — `sanitizeTaskName` збережено для `mt run` (створення worktree); `deriveNodeState`/`isComposite` видаляються. -- 30 Rust unit-тестів (`#[cfg(test)]` у `lib.rs`) відтворюють кейси з `state.test.mjs`, включно з `worktree→running` і `sanitize`-векторами; `cargo test` зелений. -- Специфікація: `docs/spec-scanner-rust-integration.md §5`. diff --git a/docs/adr/20260613-084500-prebuilt-scanner-binary-delivery.md b/docs/adr/20260613-084500-prebuilt-scanner-binary-delivery.md deleted file mode 100644 index 580e4c4..0000000 --- a/docs/adr/20260613-084500-prebuilt-scanner-binary-delivery.md +++ /dev/null @@ -1,91 +0,0 @@ -## ADR Доставка Rust-сканера `mt-scanner` через 2 prebuilt-підпакети + видалення JS-реалізації - -## Context and Problem Statement - -У проєкті існували дві паралельні реалізації сканування DAG задач: Rust-бінарник `mt-scanner` -(`scanner/src/`) і чистий JS `npm/lib/core/scanner.mjs` + `deriveNodeState` у `state.mjs`. Вони -дивергували (різний порядок пріоритетів станів) і JS жодного разу не викликав Rust. Рішення: -JS-реалізації не повинно існувати — npm має делегувати сканування Rust-бінарнику. Це впиралося в -невирішений блокер: **як доставляти бінарник у рантаймі** (`target/` у `.gitignore`, у `files` -його немає, build-кроку в npm немає → опублікований `@7n/mt` не мав звідки взяти бінарник). - -Уточнено також принцип: **усе, що стосується роботи з файловою системою, має бути в Rust** — -включно з деривацією `running` від активного git-worktree. - -## Considered Options - -Доставка бінарника: -* (A) postinstall `cargo build` — вимагає Rust-тулчейн у кожного користувача CLI. -* (B) Prebuilt-бінарники по платформах через `optionalDependencies` (модель esbuild/swc). -* (C) Зібрати локально й покласти бінарник у `files` — крихко для чужих платформ. - -Набір платформ (для B): від мінімального (mac+linux) до повного (9 матриць як esbuild). - -## Decision Outcome - -Chosen: **(B) prebuilt через `optionalDependencies`**, на старті — **рівно 2 підпакети**: - -| Підпакет | os/cpu/libc | Rust target | покриття | -|---|---|---|---| -| `@7n/mt-darwin-arm64` | darwin/arm64/— | `aarch64-apple-darwin` | усі Apple Silicon | -| `@7n/mt-linux-x64` | linux/x64/**без libc** | `x86_64-unknown-linux-musl` (static) | весь Linux x64 (Alpine, Ubuntu, Docker, CI) | - -Обґрунтування «лише 2»: статичний musl-бінарник без поля `libc` покриває весь Linux x64 одним -пакетом (і glibc, і musl); решта платформ (Intel Mac, linux-arm64, Windows) додаються пізніше -**add-only** — новий підпакет + рядок в `optionalDependencies` + CI-job, без зміни коду. - -Супутні рішення: -* **JS-сканер видалено**; `scanner.mjs` — тонкий шим: `spawnSync` бінарника → парсинг JSON → - flatten дерева + мапінг полів (`dir=join(mtDir,path)`, snake_case→kebab стани, `is_composite`, - `children`-шляхи). `topoSort`/`areDepsResolved`/`getActiveWorktrees`/`parseWorktreeList` - лишились у JS (граф/git, не ФС-скан). `deriveNodeState`/`isComposite` з `state.mjs` видалено; - лишились `sanitizeTaskName` (створення worktree) і `NODE_STATES`. -* **worktree→running перенесено в Rust**: `mt-scanner scan` сам виконує `git worktree list` - (або приймає `--worktrees a,b,c` для тестів/прокидування) і підвищує стан. `sanitize` - портовано в Rust (синхронність із JS — спільні тест-вектори). -* **Порядок пріоритетів станів у Rust вирівняно з авторитетним JS-тестом**: pending-audit > - resolved > unresolvable > running > plan-review > spawned > waiting/failed > pending > - unassigned. Виправлено: `unresolvable` більше не передує fact-станам; `pending-audit` - рахується лише для останнього fact NNN. -* **Резолвер** `npm/lib/core/scanner-bin.mjs`: `MT_SCANNER_BIN` → `require.resolve(@7n/mt-<key>/<bin>)` - → dev-fallback `target/release|debug` → зрозуміла помилка. Ім'я з `.exe` на win32 закладено - наперед (Windows = add-only). -* **CI tooling — `cargo-zigbuild`**: один Linux-раннер крос-збирає musl-таргети; дешеве додавання - linux-arm64 потім. macOS arm64 — native `macos-14`. -* **Покриття**: деривація станів тепер під `cargo test` (раніше нуль Rust-тестів) — додано - відтворення кейсів зі `state.test.mjs` + worktree + sanitize. JS-`state.test.mjs` зрізано до - `NODE_STATES`/`sanitizeTaskName`. - -### Consequences - -* Good: одна канонічна реалізація сканування (Rust), без дивергенції; весь ФС-доступ у Rust; - доставка по платформах як у зрілих CLI; масштабування платформ add-only. -* Good: `cargo test` зелений (30 тестів), `vitest run` — 46/47 (єдиний фейл `docs.test.mjs` про - кількість ADR — прееснуючий, поза цією зміною). -* Bad: `spawnSync` бінарника + внутрішній `git worktree list` на кожен скан (для CLI прийнятно; - для частого `watch` — потенційно кеш/довгоживучий процес, поза scope). -* Bad: `sanitize`-конвенція дублюється (JS + Rust) — мусить лишатися синхронною. -* Bad: на непокритій платформі CLI впаде без `MT_SCANNER_BIN` (резолвер дає зрозумілу підказку). - -## More Information - -- Специфікація рефакторингу: `docs/spec-scanner-rust-integration.md` -- Попередні ADR сесії: `20260613-071723-заміна-js-сканера-на-виклик-rust-бінарника-mt-scanner.md`, - `20260611-193434-вирівнювання-scanner-state-з-специфікацією-mt.md` -- Бінарник: `cargo build --release --manifest-path scanner/Cargo.toml`; CLI: - `mt-scanner scan <tasks_dir> [--worktrees a,b,c]` -- Відкладено за рішенням користувача: інтеграція підкоманди `mt-scanner workspaces` у JS. - -## Update 2026-06-13 - -### Вибір `cargo-zigbuild` для збірки Linux musl у CI - -Для збірки `x86_64-unknown-linux-musl` обрано `cargo-zigbuild` (zig як лінкер) замість `musl-tools + rustup target add`. Причина (з transcript): дозволяє крос-збирати `aarch64-unknown-linux-musl` з того самого `ubuntu`-раннера без нового тулчейну — додавання `linux-arm64` = зміна лише `--target` у матриці. CI-job: `ubuntu-latest`, `cargo install cargo-zigbuild`, `cargo zigbuild --release --target x86_64-unknown-linux-musl`. Обмеження: Windows-таргет через zigbuild не збирається незалежно від вибору — потребує окремого `windows-latest` раннера. - -## Update 2026-06-13 - -`.gitignore` містить записи `packages/*/mt-scanner` і `packages/*/mt-scanner.exe` — бінарники платформних підпакетів виключено з git. Резолвер `npm/lib/core/scanner-bin.mjs` додає суфікс `.exe` на `win32` як forward-compat для майбутнього Windows-підпакету. Повний порядок резолвингу: `MT_SCANNER_BIN` env → `require.resolve('@7n/mt-<key>/mt-scanner[.exe]')` → dev-fallback `target/release/mt-scanner` → зрозуміла помилка (не мовчки). CI-тригер у `npm-publish.yml` розширено: `scanner/**` і `packages/**` поряд із `npm/**`. - -## Update 2026-06-13 - -Перша публікація нових scope-пакетів (`@7n/mt-darwin-arm64`, `@7n/mt-linux-x64`) блокується помилкою `ENEEDAUTH` — потребує `npm login` або налаштування trusted-publishing (OIDC) для нових пакетів у `@7n` scope. Процедура ручної першої публікації: скопіювати macOS-бінарник локально (`cp target/release/mt-scanner packages/mt-darwin-arm64/`), завантажити Linux-артефакт з CI (`gh run download <run-id> --name mt-linux-x64 --dir packages/mt-linux-x64/`), потім `npm publish` з кожної директорії підпакету, після — `npm publish` головного `npm/`. diff --git a/docs/adr/20260613-101143-cargo-zigbuild-musl-ci.md b/docs/adr/20260613-101143-cargo-zigbuild-musl-ci.md deleted file mode 100644 index 3ffaa88..0000000 --- a/docs/adr/20260613-101143-cargo-zigbuild-musl-ci.md +++ /dev/null @@ -1,27 +0,0 @@ -# `cargo-zigbuild` як CI-інструмент для musl-статичних Linux-збірок - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`npm-publish.yml` потребував способу зібрати статичний musl-бінарник `mt-scanner` для `x86_64-unknown-linux-musl` на GitHub Actions. Вибір підходу впливав на вартість додавання `linux-arm64` у майбутньому. Rust-код вже є path-портабельним (рядок `lib.rs:273` нормалізує бекслеші; `lines()` коректно обробляє CRLF), тому Windows-підтримка вимагатиме лише `.exe`-суфіксу у резолвері й окремого CI-job без змін логіки. - -## Considered Options - -* `cargo-zigbuild` — один Linux-раннер крос-компілює будь-який musl-таргет через `zig cc`; arm64 пізніше додається як `--target aarch64-unknown-linux-musl` без нових тулчейнів -* `musl-tools` + `rustup target add x86_64-unknown-linux-musl` — стандартний apt-підхід; arm64 потребує окремого aarch64-тулчейна або окремого раннера - -## Decision Outcome - -Chosen option: "`cargo-zigbuild`", because zigbuild дає дешеве додавання `linux-arm64` пізніше без нових тулчейнів; Windows є ортогональним і потребує нативного `windows-latest` незалежно від вибору tooling. - -### Consequences - -* Good, because майбутній `linux-arm64` = `+1` пакет + `+1` CI-job без зміни тулчейна; матриця підтвердила роботу в CI (`ubuntu-latest`, job `build-binaries`, ~2m10s). -* Good, because Windows-підтримка закладена наперед у резолвері: `binName(platform)` у `npm/lib/core/scanner-bin.mjs` повертає `'mt-scanner.exe'` для `win32`; додавання `@7n/mt-win32-x64` і CI-job `windows-latest` є add-only. -* Bad, because `cargo-zigbuild` додає залежність від `zig` і є менш зрілим інструментом; для локального тестування musl-збірок необхідно `brew install zig` та `cargo install --locked cargo-zigbuild`. - -## More Information - -Файл: `.github/workflows/npm-publish.yml`, job `build-binaries`, matrix `os: ubuntu-latest`, команда `cargo zigbuild --release --target x86_64-unknown-linux-musl`. Локальна верифікація: бінарник типу `ELF x86-64 statically linked`; `npm pack --dry-run` у `packages/mt-linux-x64/` підтвердив коректний вміст. Spec §6.5: «Додавання Windows пізніше = add-only»; `lib.rs:273` нормалізує бекслеші, що підтверджує наявну Windows-портабельність. diff --git a/docs/adr/20260613-120000-single-publish-owner-guarantee.md b/docs/adr/20260613-120000-single-publish-owner-guarantee.md deleted file mode 100644 index 537aa4a..0000000 --- a/docs/adr/20260613-120000-single-publish-owner-guarantee.md +++ /dev/null @@ -1,40 +0,0 @@ -## ADR Single Publish Owner як гарантія замість Mutual Exclusion - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`npm/docs/mt.md` використовував термін "mutual exclusion" для claim-гарантій fencing-протоколу. Після рев'ю виявлено: fencing через Git refs зупиняє лише push у `main`, але не зупиняє виконання процесу. Zombie-runner після lease takeover може продовжувати роботу і генерувати зовнішні side effects: повторна оплата, повторний API-запит, зміна database, deployment, відправлення повідомлення. - -## Considered Options - -* Залишити термін "mutual exclusion" без змін -* Перейменувати гарантію на "single publish owner" і додати документацію щодо side effects та вимог до задач з non-idempotent операціями - -## Decision Outcome - -Chosen option: "single publish owner", because термін точно описує реальну гарантію протоколу: лише один runner може публікувати Git-результат у `main` у даний момент. Mutual exclusion виконання не забезпечується fencing-механізмом, тому вживання цього терміну вводило в оману авторів задач. - -### Consequences - -* Good, because документ точно описує межі гарантії; автори задач розуміють що потрібен idempotency key або передача fencing `generation` у зовнішню систему для non-idempotent side effects; задачі без idempotent side effects явно виключаються з auto-takeover. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` — секція Fencing (доданий абзац "Межа fencing — лише Git publish"), рядок "Protocol гарантує..." (mutual exclusion → single Git publisher), таблиця summary (рядок перейменовано з "Mutual exclusion" на "Single publish owner"). - -## Update 2026-06-13 - -### Heartbeat loop для виявлення zombie-runner - -Для довготривалих задач рекомендовано periodic heartbeat loop: runner re-reads і верифікує claim ref кожні N секунд. Реакція на `claim-lost`: негайно скасувати задачу і зупинити зовнішні side effects. Абзац додано між кроком renewal і секцією Межа fencing у Direct publish protocol. - -### Зовнішній fencing key: {node-hash, generation} - -Рекомендується передавати комбінований ключ `{node-hash, generation}` або `claim_id = node-hash + token` у зовнішні системи замість лише `generation`. `generation` є монотонним лічильником лише в межах одного вузла: два різних вузли можуть мати однаковий `generation`. `token` — uuid4 унікальний per-claim; `node-hash` унікально ідентифікує вузол — комбінація є глобально унікальним ключем. Оновлено рядок 190 у `npm/docs/mt.md`. - -### Scope ізоляції claim: node-level, не runner-level - -Claim isolation діє на рівні вузла, а не runner-рівні. Multi-tasking runner (тримає кілька claim-ів одночасно) не порушує протокол; кожен вузол ізольований незалежно. Для runner-level обмежень потрібен окремий registry поза MT. Оновлено рядок таблиці summary: `Claim isolation` → `Claim isolation (node-level)`. diff --git a/docs/adr/20260613-120001-integration-bot-fenced-push.md b/docs/adr/20260613-120001-integration-bot-fenced-push.md deleted file mode 100644 index 2c2e208..0000000 --- a/docs/adr/20260613-120001-integration-bot-fenced-push.md +++ /dev/null @@ -1,26 +0,0 @@ -## ADR Integration bot виконує fenced git push замість GitHub Merge API - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Protected-main fallback використовував GitHub Merge API: bot перевіряв claim, викликав GitHub merge, потім CAS-видаляв claim. Між перевіркою claim (крок 1) та GitHub merge (крок 2) виникав TOCTOU race: claim міг бути renewed або takeover-нутий. У разі renewal — bot не міг CAS-видалити claim (SHA вже інший), залишаючи "висячий" claim. У разі takeover — PR старого runner потрапляв у `main` під ownership нового runner. - -## Considered Options - -* Залишити GitHub Merge API з додатковою синхронізацією між перевіркою і merge -* Bot виконує той самий `git push --atomic` з трьома `--force-with-lease` що й direct publisher; PR залишається approval interface - -## Decision Outcome - -Chosen option: "bot виконує fenced git push", because `git push --atomic` з `--force-with-lease` на `main`, claim ref і run ref усуває TOCTOU повністю: перевірка claim і запис у `main` відбуваються в одній атомарній операції. Якщо claim змінився між approval і push — операція відхиляється цілком. - -### Consequences - -* Good, because TOCTOU race усунено; protected-main шлях отримує ті самі атомарні гарантії що й direct publish; GitHub Merge API як залежність прибрано; PR залишається для review/CI/human sign-off без участі в механізмі commit. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` — секція про protected main fallback (~рядки 1165–1174), рядок про "integration branch + PR + bot", рядок 874, таблиця "Lifecycle у main". Bot потребує "bypass branch protection" permission в GitHub branch protection rules. Аналогія: GitHub Merge Queue або Dependabot-style direct push. diff --git a/docs/adr/20260613-120002-deferred-differential-cascade.md b/docs/adr/20260613-120002-deferred-differential-cascade.md deleted file mode 100644 index 9d09805..0000000 --- a/docs/adr/20260613-120002-deferred-differential-cascade.md +++ /dev/null @@ -1,26 +0,0 @@ -## ADR Deferred differential cascade як поведінка mt invalidate за замовчуванням - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`mt invalidate` рекурсивно архівував version chain всіх descendants одразу (eager cascade). Але специфікація одночасно стверджувала що після re-run descendants можна залишити `resolved` якщо content-addressed hash не змінився — що логічно неможливо: їх `fact_*.md` вже заархівовані eager cascade і видалені з робочого дерева. Суперечність унеможливлювала реальну диференційну оптимізацію. - -## Considered Options - -* Eager cascade: архівувати всіх descendants одразу (поточна поведінка) -* Deferred cascade: архівувати лише target вузол; нащадки природно стають `blocked` через відсутність resolved upstream; cascade запускається тільки після re-run і hash-порівняння - -## Decision Outcome - -Chosen option: "deferred cascade як default", because eager cascade завжди >= deferred за обсягом роботи: якщо hash однаковий — нащадки не чіпаються; якщо різний — виконується той самий cascade. Eager ніколи не краще. Окремий стан `blocked-stale` не потрібен: стандартний `blocked` достатній через природну відсутність resolved upstream. - -### Consequences - -* Good, because усунено суперечність у специфікації; зекономлено re-execution нащадків при незмінному upstream-результаті; `mt kill` зберігає eager cascade (там hash-порівняння безглузде). -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` — секція `mt invalidate` (~883–890, прибрано рядок про recursive cascade нащадків), секція "Каскад інвалідації" (схема з двома гілками: однаковий hash → розблокування, різний hash → cascade), таблиця summary (рядок "Інвалідація"). diff --git a/docs/adr/20260613-120003-mt-invalidate-handles-stop.md b/docs/adr/20260613-120003-mt-invalidate-handles-stop.md deleted file mode 100644 index d0c3032..0000000 --- a/docs/adr/20260613-120003-mt-invalidate-handles-stop.md +++ /dev/null @@ -1,26 +0,0 @@ -## ADR mt invalidate зупиняє running процес внутрішньо; mt stop не є окремою CLI-командою - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Patch protocol описував `mt kill` для зупинки successor-вузлів перед патчем цільового вузла. Але `mt kill` виконує `git rm -r` і знищує topology: після kill restart каскаду неможливий без повторної матеріалізації через `mt spawn --approve`. Reviewer запропонував окрему команду `mt stop` (зупинка процесу без знищення topology), яку patch protocol використовував би перед `mt invalidate`. - -## Considered Options - -* Окрема команда `mt stop` + `mt invalidate` в patch protocol -* `mt invalidate` сам виконує SIGTERM + CAS-delete claim для running-вузла перед архівацією; `mt stop` — не окрема CLI-команда - -## Decision Outcome - -Chosen option: "`mt invalidate` обробляє stop внутрішньо", because `mt stop` як standalone команда не має самостійного use case: пауза без подальшого `mt invalidate` залишає вузол у невизначеному стані (run без `result:`). Вбудування stop-логіки спрощує протокол і усуває race між `mt stop` і `mt invalidate` (retake claim у вікні між командами). - -### Consequences - -* Good, because patch protocol використовує одну команду замість двох; неможливий race між зупинкою і архівацією; `mt kill` явно зарезервований тільки для остаточного видалення topology. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` — секція `mt invalidate` (додано: "Якщо вузол має активний claim: локальний runner → SIGTERM + CAS-delete claim; remote runner → CAS-delete claim"), engineer protocol (~рядок 1476: `mt kill <dep-node>` → `mt stop + mt invalidate`), engineer permissions (~1483). `mt stop` залишається як CLI-команда для explicit human use (звільнити claim без архівації), але з patch protocol прибрано. diff --git a/docs/adr/20260613-120004-absolute-dep-id-addressing.md b/docs/adr/20260613-120004-absolute-dep-id-addressing.md deleted file mode 100644 index c464084..0000000 --- a/docs/adr/20260613-120004-absolute-dep-id-addressing.md +++ /dev/null @@ -1,26 +0,0 @@ -## ADR dep-id завжди абсолютний від tasks-root - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`npm/docs/mt.md` описував "сусідній dep" як `deps/collect-data.md` з dep-id = `collect-data`. У вузлі `quarterly-anomalies/analyze` це резолвилося б у `mt/collect-data` (кореневий рівень), а не `mt/quarterly-anomalies/collect-data` (фактичний сусід). Адресація була неоднозначною для вкладених вузлів і ламалась при посиланнях вгору по ієрархії. - -## Considered Options - -* Відносна адресація: шлях у `deps/` відносно поточного вузла (`collect-data.md` → сусід, `../research.md` → батько) -* Абсолютна адресація: шлях файлу відносно `deps/` директорії (без `.md`) = dep-id = абсолютний шлях від `tasks-root` - -## Decision Outcome - -Chosen option: "абсолютна адресація від tasks-root", because відносна адресація ламається для посилань вгору через `../..` нотацію яка нагадує path traversal і вимагає знати глибину вузла. Абсолютна: `ls -R deps/` дає повний перелік — parser не потребує знати де знаходиться поточний вузол; `deps/` дзеркалює структуру `mt/`. - -### Consequences - -* Good, because однозначна адресація для будь-якого вузла незалежно від рівня (кореневий, сусід, батько, дочірній, будь-який); parser простий; cross-level deps вже так і працювали. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` — рядок ~148 (опис dep-id addressing: "завжди абсолютний від tasks-root"), секція `deps/` (~371–385, приклад для `quarterly-anomalies/analyze` з абсолютними шляхами), scenario summary (~1733, виправлено `deps/collect-data.md` → `deps/quarterly-anomalies/collect-data.md`). diff --git a/docs/adr/20260613-120005-mt-done-integrity-guards.md b/docs/adr/20260613-120005-mt-done-integrity-guards.md deleted file mode 100644 index 0b179d4..0000000 --- a/docs/adr/20260613-120005-mt-done-integrity-guards.md +++ /dev/null @@ -1,26 +0,0 @@ -## ADR mt done перевіряє immutability task.md та відсутність run-draft.md перед publish - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`mt done`/`mt audit` перевіряли лише наявність `fact_NNN.md` і `## Check` gate. Runner-агент міг непомітно змінити `task.md`, `a.md` або `h.md` у worktree і ці зміни потрапили б у `main` при fenced publish. Аналогічно, `run-draft.md` позначений як git-ignored, але якщо runner явно додав його до staged файлів або `.gitignore` не покривав worktree-директорію — файл потрапив би у `main`. - -## Considered Options - -* Залишити перевірки як є: `fact_NNN.md` existence + `## Check` gate -* Додати integrity check (`task.md`/`a.md`/`h.md` vs `origin/main`) і ephemeral file guard (`run-draft.md` у staged/tracked файлах worktree) - -## Decision Outcome - -Chosen option: "додати integrity check і ephemeral file guard", because `task.md` immutable after spawned — це інваріант протоколу, який має примусово перевірятися wrapper-ом, а не довірятися поведінці агента. Defense-in-depth вимагає явного gate на рівні publish для ephemeral файлів. - -### Consequences - -* Good, because неможливо опублікувати мутований `task.md` через `mt done`/`mt audit`; `run-draft.md` не може потрапити у `main` навіть якщо агент явно його застейджив; порушення виявляються до publish з чітким повідомленням про diff. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` — рядок ~988 (агент prompt секція): додано два кроки після перевірки `fact_NNN.md`: (1) integrity check через `git diff origin/main -- task.md a.md h.md`; (2) ephemeral file guard через `git diff --cached --name-only` (наявність `run-draft.md` → відмова). Таблиця summary рядок "Межа immutability" оновлено. diff --git a/docs/adr/20260613-120006-schema-version-backward-compat.md b/docs/adr/20260613-120006-schema-version-backward-compat.md deleted file mode 100644 index a8a5a0f..0000000 --- a/docs/adr/20260613-120006-schema-version-backward-compat.md +++ /dev/null @@ -1,26 +0,0 @@ -## ADR schema_version: backward compatibility для всіх відомих версій - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Оркестратор відмовляв читати файли з "невідомою" `schema_version`. При частковій міграції `v1`→`v2` repository з сумішшю файлів обробка зупинялась би повністю. Reviewer запропонував `migration_state: migrating | migrated` у `.mt.json` для graceful degradation під час міграції. - -## Considered Options - -* `migration_state` у `.mt.json`: читати обидві версії за `migrating`, логувати конфлікти, не зупинятись -* Backward compatibility: orchestrator читає всі версії які він знає (включаючи попередні); відмовляє лише версії вищі за власну максимальну - -## Decision Outcome - -Chosen option: "backward compatibility", because orchestrator завжди знає всі попередні версії схеми, які він обробляв. Відмова потрібна тільки для майбутніх версій (невідомих поточному бінарнику). `migration_state` додає зайву складність і ризик застрягти у стані `migrating` невизначено довго. - -### Consequences - -* Good, because часткова міграція не блокує обробку; не потрібен `migration_state`; major release MT постачає migration script і новий orchestrator читає і стару, і нову версію без додаткової конфігурації. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Змінено: `npm/docs/mt.md` рядок ~144: "Оркестратор підтримує backward compatibility: читає всі версії, які він знає (включаючи попередні). Відмовляє лише файли з версією вищою за власну максимальну (майбутні релізи). Breaking schema changes постачаються як окремий major release MT з явним описом переходу і migration script." diff --git a/docs/adr/20260613-120007-orchestrator-runner-roles.md b/docs/adr/20260613-120007-orchestrator-runner-roles.md deleted file mode 100644 index f48137a..0000000 --- a/docs/adr/20260613-120007-orchestrator-runner-roles.md +++ /dev/null @@ -1,42 +0,0 @@ -## ADR Явне розмежування ролей Orchestrator і Runner в MT - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`npm/docs/mt.md` вживав терміни "orchestrator" і "runner" без чіткого визначення меж між ними. Системи типу Apache Airflow явно розділяють scheduling (orchestrator) і execution (runner/worker). Документ не фіксував: це одна роль чи дві, один binary чи різні, чи підтримується distributed deployment з runner-ами на окремих машинах. - -## Considered Options - -* Залишити неявне розмежування (поточний стан) -* Явно описати дві ролі, поточну реалізацію (один `mt` binary, два subcommands) і підтримку distributed deployment через fencing-протокол - -## Decision Outcome - -Chosen option: "явне документування двох ролей", because fencing-протокол (remote claim validation, lease renewal, CAS через Git refs) побудований так що runner може виконуватись на окремій машині від watch. Дизайн підтримує горизонтальне масштабування runner-ів, але це ніде не було зафіксовано, що залишало архітектурне рішення прихованим. - -### Consequences - -* Good, because чіткі межі між scheduling і execution; документально підтверджено що кілька runner-ів можуть паралельно виконувати різні вузли під одним watch-процесом; deployment topology зрозуміла для нових розробників. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Додано: `npm/docs/mt.md` — новий розділ "Ролі: Orchestrator і Runner" (~рядок 812, перед CLI контрактом). Секція описує: `mt watch` як scheduling-процес, `mt run` як execution-процес, поточну реалізацію (один binary), distributed deployment (runner на окремій машині через fencing), горизонтальне масштабування (кілька runner-ів, один watch, CAS claim гарантує single runner per node). - -## Update 2026-06-13 - -### Оркестратор як єдиний caller `mt done` для `spawned`-вузла - -Після `mt spawn` батьківський вузол переходить у стан `spawned` і claim на нього не утримується. Оркестратор (centralized daemon/loop) — єдиний caller для `mt done` на `spawned`-вузлі: моніторить дочірні вузли і клеймить батька після завершення всіх дітей. - -Wrapper pattern: orchestrator → `mt claim <parent-path>` → aggregate → `mt done <parent-path>`. - -Claim preconditions для `spawned`: `accepted` лише коли всі діти `resolved` і caller = orchestrator; `rejected-children-not-resolved` в усіх інших випадках. - -### `failed-dependency` як окремий стан - -Введено `failed-dependency` як стан, відмінний від `failed`: вузол переходить у `failed-dependency` коли блокуюча залежність переходить у `failed` (не через власне виконання). Перехід `blocked` → `failed-dependency` тригерить оркестратор. - -Дозволяє розрізняти першопричини відмов: `failed` — власне виконання, `failed-dependency` — upstream. Усі перевірки `status == failed` потребують врахування `failed-dependency`. Додано до `npm/docs/mt.md`: `failed-dependency` у enum станів; опис стану; рядок у orchestration state-machine table. diff --git "a/docs/adr/20260613-120739-mt-cleanup-\320\272\320\276\320\274\320\260\320\275\320\264\320\260-\320\264\320\273\321\217-orphan-worktrees.md" "b/docs/adr/20260613-120739-mt-cleanup-\320\272\320\276\320\274\320\260\320\275\320\264\320\260-\320\264\320\273\321\217-orphan-worktrees.md" deleted file mode 100644 index 548b2c7..0000000 --- "a/docs/adr/20260613-120739-mt-cleanup-\320\272\320\276\320\274\320\260\320\275\320\264\320\260-\320\264\320\273\321\217-orphan-worktrees.md" +++ /dev/null @@ -1,26 +0,0 @@ -# mt cleanup — окрема CLI-команда для очищення orphan worktrees - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Після failed runs worktrees залишались у `.worktrees/` для debug. Без явного cleanup-механізму при частих падіннях директорія накопичувала сотні worktrees. Єдиним GC-механізмом був `mt watch`, але він може не запускатись у CI/CD або single-run environments. - -## Considered Options - -* Тільки автоматичне очищення всередині `mt watch` -* Окрема команда `mt cleanup [--older-than N]` плюс виклик з `mt watch` - -## Decision Outcome - -Chosen option: "окрема команда `mt cleanup` плюс виклик з `mt watch`", because `mt watch` може не запускатись у CI/CD або single-run environments; оператор повинен мати явний інструмент без залежності від watch. - -### Consequences - -* Good, because orphan worktrees не накопичуються в середовищах де watch не запущений. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Файл `npm/docs/mt.md`; секція "mt cleanup"; CLI-список команд. Default `--older-than 7` (днів). diff --git a/docs/adr/20260613-120859-mt-kill-atomic-cas-delete-via-force-with-lease.md b/docs/adr/20260613-120859-mt-kill-atomic-cas-delete-via-force-with-lease.md deleted file mode 100644 index 443ed7e..0000000 --- a/docs/adr/20260613-120859-mt-kill-atomic-cas-delete-via-force-with-lease.md +++ /dev/null @@ -1,28 +0,0 @@ -# mt kill: атомарне CAS-видалення claim через force-with-lease - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -`mt kill` виконував перевірку claim (крок 1) і CAS-delete (крок 3) як окремі, не атомарні операції. Між ними інший runner міг захопити claim — тоді CAS-delete знищував чужий claim, залишаючи нового власника без lease. - -## Considered Options - -* Зберегти двокроковий check + delete з retry-логікою -* Зробити кроки 1 і 3 атомарними через `git push --force-with-lease` -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "force-with-lease для CAS-delete claim", because `git push --force-with-lease=refs/mt/claims/<hash>:<expected-sha> origin :refs/mt/claims/<hash>` атомарно перевіряє expected SHA і видаляє ref в одній операції — ідентично механізму direct publish. Якщо claim змінився між check і push — rejected non-fast-forward, kill безпечно завершується з помилкою. - -### Consequences - -* Good, because неможливо випадково видалити чужий claim у distributed середовищі. -* Bad, because transcript не містить підтверджених негативних наслідків. -* Neutral, because зауваження надійшло як пункт 6 code review; зміни у документ `npm/docs/mt.md` на момент завершення transcript не були внесені — рішення лише проаналізовано. - -## More Information - -Файл: `npm/docs/mt.md`, секція `mt kill`. Команда для атомарного CAS-delete: `git push --force-with-lease=refs/mt/claims/<hash>:<expected-sha> origin :refs/mt/claims/<hash>`. Рекомендацію надав колега-рецензент. diff --git "a/docs/adr/20260613-121700-branch-protection-\320\276\320\261\320\276\320\262\321\217\320\267\320\272\320\276\320\262\320\260-\320\277\320\265\321\200\320\265\320\264\321\203\320\274\320\276\320\262\320\260-mt.md" "b/docs/adr/20260613-121700-branch-protection-\320\276\320\261\320\276\320\262\321\217\320\267\320\272\320\276\320\262\320\260-\320\277\320\265\321\200\320\265\320\264\321\203\320\274\320\276\320\262\320\260-mt.md" deleted file mode 100644 index f84ccec..0000000 --- "a/docs/adr/20260613-121700-branch-protection-\320\276\320\261\320\276\320\262\321\217\320\267\320\272\320\276\320\262\320\260-\320\277\320\265\321\200\320\265\320\264\321\203\320\274\320\276\320\262\320\260-mt.md" +++ /dev/null @@ -1,26 +0,0 @@ -# Branch protection — обов'язкова передумова деплойменту MT - -**Status:** Accepted -**Date:** 2026-06-13 - -## Context and Problem Statement - -Fencing через Git CAS гарантується лише для compliant MT runners. Будь-який актор з прямим доступом до репозиторію може push-нути в `main` і обійти механізм. Документ `npm/docs/mt.md` формулював branch protection як умовну рекомендацію ("щоб fencing було security boundary"), а не hard prerequisite. - -## Considered Options - -* Branch protection як best-practice з поясненням наслідків відсутності -* Branch protection як mandatory prerequisite; `mt setup` fail closed без неї - -## Decision Outcome - -Chosen option: "mandatory prerequisite з fail-closed у `mt setup`", because без branch protection fencing не є security boundary за визначенням — один non-compliant writer руйнує гарантію для всіх runners. Fail-closed підхід робить порушення явним замість мовчазної деградації. - -### Consequences - -* Good, because fencing стає реальним security boundary, а не рекомендацією; документ явно вказує що MT не надає гарантій без branch protection. -* Bad, because вимагає налаштування "bypass required pull requests" для MT runner і integration bot identities — додаткові операційні вимоги при першому розгортанні. - -## More Information - -Файл `npm/docs/mt.md`, секція Bootstrap (крок 0), рядок ~1275. GitHub: Settings → Branches → "Allow specified actors to bypass required pull requests". MT runner identity та integration bot identity мають бути явно додані до bypass list. diff --git "a/docs/adr/20260613-mt-kill-only-\320\262\321\226\320\264\321\205\320\270\320\273\320\265\320\275\320\276-\321\202\321\200\320\270-\320\272\320\276\320\274\320\260\320\275\320\264\320\270-kill-stop-invalidate.md" "b/docs/adr/20260613-mt-kill-only-\320\262\321\226\320\264\321\205\320\270\320\273\320\265\320\275\320\276-\321\202\321\200\320\270-\320\272\320\276\320\274\320\260\320\275\320\264\320\270-kill-stop-invalidate.md" deleted file mode 100644 index 2172b88..0000000 --- "a/docs/adr/20260613-mt-kill-only-\320\262\321\226\320\264\321\205\320\270\320\273\320\265\320\275\320\276-\321\202\321\200\320\270-\320\272\320\276\320\274\320\260\320\275\320\264\320\270-kill-stop-invalidate.md" +++ /dev/null @@ -1,18 +0,0 @@ -## ADR `mt kill`-only підхід відхилено: три окремі команди kill / stop / invalidate - -## Context and Problem Statement -Під час обговорення patch protocol було запропоновано спростити CLI, прибравши `mt stop` і `mt invalidate` на користь єдиної команди `mt kill`. У цьому варіанті patch protocol виглядав би: `mt kill analyze && mt kill synthesize` → патч → `mt init analyze && mt spawn --approve analyze` і т.д. Потрібно було оцінити, чи ця спрощуюча трейд-офф прийнятна. - -## Considered Options -* `mt kill`-only: одна команда, проста CLI; topology відновлюється через `mt init` + `mt spawn --approve` -* Три окремі команди: `mt kill` (topology deletion) / `mt stop` (process halt без topology removal) / `mt invalidate` (execution reset, зберігає topology) - -## Decision Outcome -Chosen option: "Три окремі команди", because `mt kill`-only руйнує три властивості: (1) differential cascade — після `mt init` descendants перестворюються з нуля й завжди виконуються заново незалежно від того, чи змінився hash upstream; (2) planning overhead — вузол заново проходить `mt plan`, і якщо план LLM-генерований або потребував human review, людина змушена апрувати вже схвалений план; (3) pause-семантика — engineer не може тимчасово зупинити вузол без руйнування topology (наприклад, для збору контексту або передачі іншому актору). - -### Consequences -* Good, because transcript фіксує очікувану користь: differential cascade залишається ефективним; повторний human review при незмінній задачі не потрібен; topology зберігається між runs. -* Bad, because три команди з різними семантиками потребують чіткого документування розмежування між ними; `mt kill`-only залишається обґрунтованою альтернативою якщо differential cascade не потрібен і весь деferred cascade виключається зі специфікації. - -## More Information -Змінений файл: `npm/docs/mt.md`. Рядок 1476: `mt kill <dep-node>` у Engineer protocol замінено на `mt stop <dep-node>` + `mt invalidate <dep-node>`. Рядок 1483: уточнено що `mt kill` — виключно остаточне видалення topology; основні інструменти engineer — `mt stop` і `mt invalidate`. `mt kill`-only як підхід явно позначений як свідома спрощуюча трейд-офф що обнуляє deferred cascade. diff --git a/docs/adr/20260614-070000-task-create-rust-write-side.md b/docs/adr/20260614-070000-task-create-rust-write-side.md deleted file mode 100644 index b5399dc..0000000 --- a/docs/adr/20260614-070000-task-create-rust-write-side.md +++ /dev/null @@ -1,89 +0,0 @@ -## ADR Перенесення створення задач (write-side) у Rust-крейт `mt-scanner` - -**Status:** Accepted -**Date:** 2026-06-14 - -## Context and Problem Statement - -Принцип проєкту: *усе, що стосується роботи з файловою системою, має бути в Rust* — read-side -(скан) уже делеговано бінарнику `mt-scanner` (`scanner/src/lib.rs`). Створення задачі лишалося в -JS (`npm/lib/commands/init.mjs` + `buildTaskFrontMatter`), що дублювало файловий контракт і давало -дрейф. До того ж стара `mt init` писала `mode: human` у frontmatter, але **не** створювала -прапор `h.md` → свіжа задача сканувалася як `unassigned` замість `pending`. Спека: -`docs/spec-task-create-rust-integration.md`. - -## Considered Options - -* Лишити авторинг у JS — зберігає дрейф контракту й баг із прапором. -* (виконавець у frontmatter) тримати `executor.model_tier` у `task.md` — конфліктує з рішенням - «істина = прапор `a.md`/`h.md`». -* (виконавець у прапорі) `a.md` як машинний YAML — vs markdown із секціями. - -## Decision Outcome - -Chosen: **створення задачі — у крейті `mt-scanner`**, симетрично до скану. Одна реалізація, три -споживачі (npm CLI shim, бінарник `mt-scanner create`, Tauri-команда в репо `task`). - -* `pub fn create_task(tasks_dir, name, opts) -> Result<CreateOutcome, String>` + типи - `Mode`/`CreateOpts`/`CreateOutcome` (serde) у `scanner/src/lib.rs`; підкоманда `create` у - `main.rs` з JSON-виходом (`created: true/false`). -* `task.md`: `schema_version: 1` першим полем, `created_at` (через `chrono`), `budget_sec`, `hint`. - **Без** `mode`/`executor`/`interactive`/`deps` у frontmatter. -* **Виконавець = прапор-файл**: `--mode agent` → `a.md` (markdown із секціями `## Model tier`, - `## Skills`); `--mode human` → `h.md` (`## Qualification`, вільна форма). Ніколи обидва. Це - виправляє баг із `unassigned`. -* **Залежності** — лише порожні `deps/<id>.md` (топологічне ребро); поле `deps:` прибрано. -* **Валідація імен** (`validate_name`, §8) **відхиляє** (не санітизує): сегменти `[a-z0-9-]+`, - без великих літер/`_`/пробілів/`..`/traversal. Спільні тест-вектори Rust↔JS — - `npm/lib/tests/fixtures/name-vectors.json` (`validateTaskName` у `state.mjs` — дзеркало). -* **Дефолти** з `.mt.json`: додано `default_mode: 'human'`, `default_model_tier: 'AVG'` у - `CONFIG_DEFAULTS`; budget — наявний `default_budget_sec` (1800). -* **Атомарність**: запис tmp-файл + `rename`; при частковій відмові — відкат щойно створеної - гілки директорій. -* `init.mjs` → тонкий шим: `spawnSync(bin, ['create', mtDir, name, ...flags])` + parse JSON; - `buildTaskFrontMatter`/`mkdir`/`writeFile` видалено. -* `run.mjs` — `resolveExecutor` читає `model_tier` із `a.md` (секція `## Model tier`), fallback на - старий frontmatter→`.mt.json` (інакше `--model-tier` губився б при `run`). - -### Consequences - -* Авторинг `task.md` має одне джерело істини (Rust); coverage переноситься в `cargo test` - (38 тестів зелені). -* Свіжа задача одразу має коректний стан (`pending`/`waiting`) завдяки прапору. -* Контракт `a.md` тепер змістовний (раніше читалась лише наявність файла) — `run.mjs` його споживає. -* Tauri-команда `create_task` у репо `task` — окремий крок (крейт уже лінкується через - `[patch]` на локальний `mt/scanner`). - -## More Information - -`docs/spec-task-create-rust-integration.md` (write-side), `docs/spec-scanner-rust-integration.md` -(read-side counterpart), `docs/mt.md` (файловий контракт вузла). - -## Update 2026-06-14 - -### validate_name відхиляє некоректні імена замість sanitize - -Специфікація §8 вимагає суворої відмови (exit 2) замість мовчазного виправлення символів. Нова функція `validate_name` (Rust) / `validateTaskName` (JS) відхиляє імена з uppercase, пробілами, `_`, `..`, traversal, порожніми сегментами, загальною довжиною > 100 символів. Існуючий `sanitize` у `scanner/src/lib.rs:183` залишається незмінним (використовується у worktree-matching з іншою семантикою). - -### Спільні тест-вектори Rust↔JS у `name-vectors.json` - -Для гарантування синхронності правил між реалізаціями використовується єдиний файл `npm/lib/tests/fixtures/name-vectors.json`. Rust-тести споживають його через `include_str!`, JS-тести — через `import ... with { type: "json" }` у `init.test.mjs`. Структура: `{ "valid": [...], "invalid": { "uppercase": [...], "spaces": [...], "underscore": [...], "double_dot": [...], "traversal": [...], "empty_segment": [...], "too_long": [...] } }`. - -### Атомарний запис задачі через tmp-dir + rename - -Запис відбувається у тимчасову директорію `<name>.<uuid>.tmp` всередині `tasks_dir`, після чого `fs::rename` переміщує її у фінальний шлях. При помилці tmp-директорія видаляється через `fs::remove_dir_all`. `fs::rename` атомарна в межах одного filesystem; розміщення tmp-dir у тому ж `tasks_dir` мінімізує ризик cross-filesystem операції. Залежність: `uuid = { version = "1", features = ["v4"] }` у `scanner/Cargo.toml`. - -### chrono для ISO-8601 у created_at - -Поле `created_at` у frontmatter `task.md` генерується через `chrono::Utc::now().to_rfc3339()`. Залежність: `chrono = { version = "0.4", features = ["serde"] }` у `scanner/Cargo.toml`. - -## Update 2026-06-14 - -Драфт уточнює вже прийняте рішення про write-side створення task з JS CLI, Rust API і Tauri bridge. - -- CLI контракт: `mt init <name> [--mode agent|human] [--model-tier AVG|MAX] [--budget-sec 3600] [--hint "..."] [--dep upstream]`. -- Exit codes CLI: `0` = created/exists, `1` = usage-помилка, `2` = validate/FS-помилка. -- Rust crate `mt_scanner` відкриває API `create_task(tasks_dir, name, CreateOpts)` і повертає `CreateOutcome::Created` або `CreateOutcome::Exists`. -- Defaults у Rust API: `mode` з `.mt.json`, `model_tier` з `.mt.json`, `budget_sec` default `1800`, `hint` default `"atomic"`, `deps` default `[]`, `skills` default `["bash", "write-files"]`. -- Tauri command `create_task` повертає `{ created, name, task_path, flag?, deps? }`, де `flag` дорівнює `"a.md"` або `"h.md"`. -- `validate_name` дозволяє тільки сегменти `[a-z0-9-]` через `/`; заборонені `..`, uppercase, пробіли та `_`. diff --git "a/docs/adr/20260614-072747-model-tier-\321\200\320\265\320\267\320\276\320\273\321\216\321\206\321\226\321\217-\320\267-a-md.md" "b/docs/adr/20260614-072747-model-tier-\321\200\320\265\320\267\320\276\320\273\321\216\321\206\321\226\321\217-\320\267-a-md.md" deleted file mode 100644 index a17c43c..0000000 --- "a/docs/adr/20260614-072747-model-tier-\321\200\320\265\320\267\320\276\320\273\321\216\321\206\321\226\321\217-\320\267-a-md.md" +++ /dev/null @@ -1,27 +0,0 @@ -# model_tier при запуску задачі: a.md як джерело істини - -**Status:** Accepted -**Date:** 2026-06-14 - -## Context and Problem Statement - -До введення write-side `run.mjs` брав `model_tier` виключно з `executor.model_tier` у frontmatter `task.md`. Нова специфікація §2.6 прибирає `executor` з frontmatter і переносить tier у прапор `a.md` (markdown-файл із named-секціями). Без змін у `run.mjs` задачі, створені через `mt-scanner create --model-tier MAX`, мовчки відкочувались би до дефолтного tier при запуску. - -## Considered Options - -* Варіант A: `run.mjs` читає `model_tier` із секції `## Model tier` в `a.md`; fallback на `executor` у frontmatter (сумісність зі старими вузлами) → `default_model_tier` з `.mt.json` -* Варіант B: залишити за межами scope, прийняти тихе падіння tier - -## Decision Outcome - -Chosen option: "Варіант A — читати model_tier з a.md з трирівневим fallback", because варіант B призводить до мовчазної втрати MAX — задачі з явно заданим tier запускаються на нижчому tier без жодного попередження. - -### Consequences - -* Good, because `--model-tier MAX` при `create` зберігається і застосовується при `run`; зворотна сумісність зі старими вузлами через fallback-ланцюг `a.md → fm.executor.model_tier → config.default_model_tier`. -* Bad, because `run.mjs` ускладнився: додано функцію `resolveExecutor` із трьома рівнями fallback. -* Neutral, because `a.md` і `h.md` — markdown-файли із named-секціями (`## Model tier`, `## Skills`, `## Qualification`); парсинг рядковий без YAML-схеми — нові секції додаються без schema-міграції. - -## More Information - -Реалізовано в `npm/lib/commands/run.mjs`: функція `resolveExecutor(taskDir, fm, config, deps)` — читає `a.md` через `deps.readFile`, парсить секцію `## Model tier`; при відсутності `a.md` або секції — fallback на `fm.executor.model_tier ?? config.default_model_tier`. Формат `a.md`: секції `## Model tier` (значення tier) і `## Skills` (список). Формат `h.md`: секція `## Qualification` (placeholder при створенні). diff --git "a/docs/adr/20260615-000001-integration-bot-git-push-atomic-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-github-merge-api.md" "b/docs/adr/20260615-000001-integration-bot-git-push-atomic-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-github-merge-api.md" deleted file mode 100644 index 50873f5..0000000 --- "a/docs/adr/20260615-000001-integration-bot-git-push-atomic-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-github-merge-api.md" +++ /dev/null @@ -1,29 +0,0 @@ -# Integration Bot: git push --atomic замість GitHub Merge API - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -MT Integration Bot виконував merge через GitHub Merge API з подальшим видаленням claim як окремі нетомарні кроки. Це створювало TOCTOU-гонку: між перевіркою claim, викликом Merge API і видаленням claim стан міг змінитися іншим учасником. Потрібно було усунути цю гонку й привести протокол бота у відповідність до прямого publish-протоколу. - -## Considered Options - -* GitHub Merge API + окреме видалення claim (попередній підхід) -* `git push --atomic --force-with-lease` по трьох рефах: `main`, `refs/mt/claims/<hash>`, `refs/mt/runs/<hash>/<token>` - -## Decision Outcome - -Chosen option: "`git push --atomic --force-with-lease` по трьох рефах", because атомарний push виключає TOCTOU-гонку — всі три рефи оновлюються в одній транзакції або не оновлюються взагалі; протокол бота стає ідентичним прямому publish-протоколу. PR перетворюється виключно на approval-інтерфейс. - -### Consequences - -* Good, because TOCTOU-гонка між check → merge → delete усунута: операція атомарна на рівні Git. -* Good, because уніфікація з прямим publish-протоколом — одна кодова гілка для обох сценаріїв. -* Bad, because бот-ідентичність потребує дозволу bypass branch protection для запису в `main` напряму без PR-merge. - -## More Information - -- `npm/docs/mt.md` — змінено протокол Integration Bot: видалено виклик GitHub Merge API, додано `git push --atomic --force-with-lease` на `main`, `refs/mt/claims/<hash>`, `refs/mt/runs/<hash>/<token>`. -- Три рефи: `main` — цільова гілка; `refs/mt/claims/<hash>` — claim видаляється; `refs/mt/runs/<hash>/<token>` — run-запис публікується. -- Дозвіл `bypass branch protection` — необхідна умова для bot-ідентичності. diff --git "a/docs/adr/20260615-000002-mt-invalidate-\320\262\321\226\320\264\320\272\320\273\320\260\320\264\320\265\320\275\320\270\320\271-\320\272\320\260\321\201\320\272\320\260\320\264-\320\267\320\260-\320\267\320\260\320\274\320\276\320\262\321\207\321\203\320\262\320\260\320\275\320\275\321\217\320\274.md" "b/docs/adr/20260615-000002-mt-invalidate-\320\262\321\226\320\264\320\272\320\273\320\260\320\264\320\265\320\275\320\270\320\271-\320\272\320\260\321\201\320\272\320\260\320\264-\320\267\320\260-\320\267\320\260\320\274\320\276\320\262\321\207\321\203\320\262\320\260\320\275\320\275\321\217\320\274.md" deleted file mode 100644 index 905f63d..0000000 --- "a/docs/adr/20260615-000002-mt-invalidate-\320\262\321\226\320\264\320\272\320\273\320\260\320\264\320\265\320\275\320\270\320\271-\320\272\320\260\321\201\320\272\320\260\320\264-\320\267\320\260-\320\267\320\260\320\274\320\276\320\262\321\207\321\203\320\262\320\260\320\275\320\275\321\217\320\274.md" +++ /dev/null @@ -1,29 +0,0 @@ -# mt invalidate: відкладений каскад за замовчуванням - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -Попередній дизайн `mt invalidate` поєднував eager-каскад нащадків, порівняння хешів і збереження resolved-вузлів — комбінація, що є логічно суперечливою. Потрібно було визначити семантику команди щодо нащадків інвалідованого вузла та розмежувати її з `mt kill`. - -## Considered Options - -* Eager cascade: `mt invalidate` негайно знищує нащадків разом із цільовим вузлом -* Deferred cascade: `mt invalidate` архівує лише цільовий вузол; нащадки природно блокуються через невирішений upstream - -## Decision Outcome - -Chosen option: "Deferred cascade", because eager cascade + hash compare + keep-resolved є логічно неможливою комбінацією; відкладений підхід усуває суперечність без нового стану `blocked-stale`. Нащадки самостійно розблоковуються після повторного запуску: той самий хеш → розблокування; інший хеш → каскад тепер. `mt kill` залишається eager (завжди знищує). - -### Consequences - -* Good, because усунуто логічну суперечність: не потрібен новий стан `blocked-stale`. -* Good, because нащадки автоматично реагують на результат повторного запуску через існуючий механізм хешів. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -- `npm/docs/mt.md` — змінено семантику `mt invalidate`: тепер архівує лише цільовий вузол; нащадки блокуються природно через `upstream not resolved`. -- `mt kill` — поведінка не змінилась: eager-знищення цільового вузла і всіх нащадків. -- Re-run logic: однаковий хеш → нащадки розблоковуються; інший хеш → каскад виконується на цьому етапі. diff --git "a/docs/adr/20260615-000003-mt-invalidate-\320\277\320\276\320\263\320\273\320\270\320\275\320\260\321\224-\320\267\321\203\320\277\320\270\320\275\320\272\321\203-running-\320\262\321\203\320\267\320\273\321\226\320\262.md" "b/docs/adr/20260615-000003-mt-invalidate-\320\277\320\276\320\263\320\273\320\270\320\275\320\260\321\224-\320\267\321\203\320\277\320\270\320\275\320\272\321\203-running-\320\262\321\203\320\267\320\273\321\226\320\262.md" deleted file mode 100644 index ada4dbc..0000000 --- "a/docs/adr/20260615-000003-mt-invalidate-\320\277\320\276\320\263\320\273\320\270\320\275\320\260\321\224-\320\267\321\203\320\277\320\270\320\275\320\272\321\203-running-\320\262\321\203\320\267\320\273\321\226\320\262.md" +++ /dev/null @@ -1,29 +0,0 @@ -# mt invalidate поглинає зупинку running-вузлів - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -Якщо вузол перебуває у стані `running` під час виклику `mt invalidate`, необхідно було вирішити: зупиняти процес явно перед архівуванням чи вимагати окремого кроку `mt stop`. Також потрібно було переглянути протокол engineer-агента щодо роботи з вузлами, що виконуються. - -## Considered Options - -* Окремий `mt stop` перед `mt invalidate` (два-кроковий протокол) -* `mt invalidate` автоматично виконує SIGTERM + CAS-delete claim перед архівуванням (поглинання) - -## Decision Outcome - -Chosen option: "`mt invalidate` автоматично виконує SIGTERM + CAS-delete claim", because не виявлено самостійного use case для `mt stop` у людському сценарії — lifecycle вузла обмежений `budget_total_sec`; поглинання спрощує протокол і виключає розрив між зупинкою та архівуванням. - -### Consequences - -* Good, because протокол engineer-агента спрощується: `mt stop` + `mt invalidate` замінюється одним `mt invalidate` для dependency patches. -* Good, because виключається стан, коли claim існує після архівування вузла. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -- `npm/docs/mt.md` ~line 913 — додано логіку SIGTERM + CAS-delete claim у `mt invalidate` для running-вузлів. -- `npm/docs/mt.md` ~line 1476 — оновлено протокол engineer-агента: для dependency patches використовувати `mt stop` + `mt invalidate` замість `mt kill`. -- CAS-delete claim: compare-and-swap гарантує, що лише власник claim виконує видалення. diff --git "a/docs/adr/20260615-000004-deps-filename-\321\217\320\272-\320\260\320\261\321\201\320\276\320\273\321\216\321\202\320\275\320\270\320\271-\321\226\320\264\320\265\320\275\321\202\320\270\321\204\321\226\320\272\320\260\321\202\320\276\321\200-\320\267\320\260\320\273\320\265\320\266\320\275\320\276\321\201\321\202\321\226.md" "b/docs/adr/20260615-000004-deps-filename-\321\217\320\272-\320\260\320\261\321\201\320\276\320\273\321\216\321\202\320\275\320\270\320\271-\321\226\320\264\320\265\320\275\321\202\320\270\321\204\321\226\320\272\320\260\321\202\320\276\321\200-\320\267\320\260\320\273\320\265\320\266\320\275\320\276\321\201\321\202\321\226.md" deleted file mode 100644 index 8149247..0000000 --- "a/docs/adr/20260615-000004-deps-filename-\321\217\320\272-\320\260\320\261\321\201\320\276\320\273\321\216\321\202\320\275\320\270\320\271-\321\226\320\264\320\265\320\275\321\202\320\270\321\204\321\226\320\272\320\260\321\202\320\276\321\200-\320\267\320\260\320\273\320\265\320\266\320\275\320\276\321\201\321\202\321\226.md" +++ /dev/null @@ -1,32 +0,0 @@ -# deps/: filename як абсолютний ідентифікатор залежності - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -Вузли mt-графу можуть мати залежності від інших вузлів (siblings або вузлів на рівні батька). Потрібно було вирішити, як кодувати ідентифікатори залежностей у директорії `deps/`, щоб усунути неоднозначність при resolve — зокрема ситуацію, коли `deps/collect-data.md` з вузла `quarterly-anomalies/analyze` міг хибно резолвитися до кореневого `collect-data` замість `quarterly-anomalies/collect-data`. - -## Considered Options - -* Відносні імена у `deps/` (sibling = bare name, parent-relative = `../name`) -* Абсолютні шляхи безпосередньо у `deps/` -* Option 3 (обраний): авторинг у `## Children` через відносні імена; `mt spawn` резолвить до абсолютних шляхів і записує `deps/<absolute-path>.md` - -## Decision Outcome - -Chosen option: "Option 3: filename як абсолютний ідентифікатор (авторинг відносний, зберігання абсолютне)", because усуває неоднозначність resolve без ускладнення авторського синтаксису; оркестратор читає `ls -R deps/` + strip `.md` без читання вмісту файлів; `deps/` дзеркалює структуру `mt/`. - -### Consequences - -* Good, because оркестратор не читає вміст `deps/`-файлів — лише список імен через `ls -R`. -* Good, because `deps/` структурно дзеркалює `mt/`, що спрощує навігацію та дебаг. -* Good, because неоднозначність sibling vs. parent-relative усунута на рівні `mt spawn`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -- `npm/docs/mt.md` — змінено декілька секцій: авторинг `## Children`, логіка `mt spawn` (resolve відносних → абсолютних), формат `deps/`. -- Авторинг: sibling = bare name (наприклад `collect-data`); parent-relative = `../name`. -- `mt spawn` resolve: `quarterly-anomalies/analyze` + `collect-data` → `deps/quarterly-anomalies/collect-data.md`. -- Оркестратор: `ls -R deps/` → strip `.md` → список абсолютних dep-ідентифікаторів. diff --git "a/docs/adr/20260615-000005-failed-streak-\320\273\320\270\321\210\320\265-execution-failures.md" "b/docs/adr/20260615-000005-failed-streak-\320\273\320\270\321\210\320\265-execution-failures.md" deleted file mode 100644 index bfa695d..0000000 --- "a/docs/adr/20260615-000005-failed-streak-\320\273\320\270\321\210\320\265-execution-failures.md" +++ /dev/null @@ -1,30 +0,0 @@ -# failed_streak рахує лише execution failures - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -Лічильник `failed_streak` відстежує послідовні невдачі вузла для прийняття рішень про ескалацію. Попередня реалізація включала до підрахунку події `decomposed` (lifecycle-перехід) та `claim-lost` (ownership-подія), що могло спричинити хибне обмеження нормальних операцій. Формула обчислення також мала помилку: використовувала арифметичний `max(run NNN) - max(fact NNN)` замість читання поля `result:` з frontmatter. - -## Considered Options - -* Рахувати всі типи невдач (failed, decomposed, claim-lost, progress-timeout, budget-exceeded, merge-conflict) -* Рахувати лише execution failures: failed, progress-timeout, budget-exceeded, merge-conflict - -## Decision Outcome - -Chosen option: "Лише execution failures", because `decomposed` — це нормальний lifecycle-перехід, а `claim-lost` — ownership-подія; включення їх у streak призводить до помилкового обмеження. Нескінченні decompose-цикли обмежуються `budget_total_sec`, а не streak. Формула виправлена: читати `result:` з frontmatter `run_NNN.md`. - -### Consequences - -* Good, because усунуто хибні спрацювання streak-ескалації через lifecycle та ownership-події. -* Good, because формула стає детермінованою та простою: одне поле з frontmatter замість арифметичної різниці. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -- `npm/docs/mt.md` ~lines 515-535 — змінено визначення `failed_streak`: виключено `decomposed` та `claim-lost`. -- `npm/docs/mt.md` ~lines 740-755 — виправлено формулу: `result:` field з `run_NNN.md` frontmatter замість `max(run NNN) - max(fact NNN)`. -- Events що рахуються: `failed`, `progress-timeout`, `budget-exceeded`, `merge-conflict`. -- Events що не рахуються: `decomposed` (lifecycle), `claim-lost` (ownership). diff --git "a/docs/adr/20260615-000006-schema-evolution-explicit-fail-closed-\320\267-\320\273\320\276\320\263\321\203\320\262\320\260\320\275\320\275\321\217\320\274.md" "b/docs/adr/20260615-000006-schema-evolution-explicit-fail-closed-\320\267-\320\273\320\276\320\263\321\203\320\262\320\260\320\275\320\275\321\217\320\274.md" deleted file mode 100644 index da28e3d..0000000 --- "a/docs/adr/20260615-000006-schema-evolution-explicit-fail-closed-\320\267-\320\273\320\276\320\263\321\203\320\262\320\260\320\275\320\275\321\217\320\274.md" +++ /dev/null @@ -1,30 +0,0 @@ -# Schema evolution: explicit fail-closed з логуванням - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -MT-схема версіонується. Потрібно було визначити поведінку системи при зустрічі документа з версією, вищою за підтриману (`version > max_known`), та при наявності невідомих полів. Тиха ігнорація несумісних версій могла б призводити до некоректної обробки даних без сигналу про проблему. - -## Considered Options - -* Silent ignore: невідома версія та невідомі поля — просто ігноруються -* Explicit fail-closed: `version > max_known` → FATAL; невідомі поля → WARN у `--verbose` - -## Decision Outcome - -Chosen option: "Explicit fail-closed з логуванням", because тиха ігнорація несумісної версії схеми є потенційно небезпечною — система може обробляти дані некоректно без будь-якого сигналу. Явний FATAL гарантує, що несумісна версія не буде оброблена мовчки. - -### Consequences - -* Good, because несумісна версія одразу видима: FATAL до stderr з path, actual version, max supported. -* Good, because чіткий інваріант: `--verbose` ЗАВЖДИ логує WARN для невідомих полів; ніколи не ігнорує мовчки при `--verbose`. -* Bad, because вузол переходить у стан `stalled` при зустрічі несумісної версії — потребує ручного втручання або оновлення інструменту. - -## More Information - -- `npm/docs/mt.md` ~line 145 — додано специфікацію schema evolution: FATAL для `version > max_known`, WARN для невідомих полів у `--verbose`. -- FATAL-повідомлення містить: path документа, actual version, max supported version — виводиться до stderr. -- Невідомі поля в normal mode: ignored; у `--verbose` mode: WARN (не допускається тиха ігнорація). -- Стан вузла при FATAL: `stalled`. diff --git a/docs/adr/20260615-100000-bot-fenced-push-instead-of-github-merge-api.md b/docs/adr/20260615-100000-bot-fenced-push-instead-of-github-merge-api.md deleted file mode 100644 index a423d95..0000000 --- a/docs/adr/20260615-100000-bot-fenced-push-instead-of-github-merge-api.md +++ /dev/null @@ -1,18 +0,0 @@ -## ADR Bot переходить з GitHub Merge API на fenced atomic push - -## Context and Problem Statement -Bot для protected main використовував GitHub Merge API для публікації результатів, що створювало TOCTOU-гонку: перевірка claim і merge не є атомарними, тому між кроками claim міг змінитись. - -## Considered Options -* Залишити GitHub Merge API (поточний підхід) -* Bot виконує той самий `git push --atomic` з `--force-with-lease` що й direct publisher - -## Decision Outcome -Chosen option: "Bot виконує `git push --atomic` з `--force-with-lease`", because race condition між перевіркою claim і GitHub Merge API усувається через той самий fenced push що й direct publish — перевірка і запис відбуваються в одній атомарній операції. - -### Consequences -* Good, because TOCTOU race між check і merge повністю усунуто — `git push --atomic` є атомарним по трьом refs (main, claim, run). -* Bad, because bot потребує bypass-дозволу в branch protection rules щоб пушити напряму в protected main. - -## More Information -PR залишається виключно як approval interface (review + CI). Після approval bot формує commit і виконує `git push --atomic --force-with-lease` на refs: `refs/heads/main`, `refs/mt/claims/<hash>`, `refs/mt/runs/<hash>/<token>`. Файл: `npm/docs/mt.md`. diff --git a/docs/adr/20260615-100001-deferred-cascade-default-for-mt-invalidate.md b/docs/adr/20260615-100001-deferred-cascade-default-for-mt-invalidate.md deleted file mode 100644 index 2d2f1ca..0000000 --- a/docs/adr/20260615-100001-deferred-cascade-default-for-mt-invalidate.md +++ /dev/null @@ -1,20 +0,0 @@ -## ADR Deferred cascade як поведінка за замовчуванням для `mt invalidate` - -## Context and Problem Statement -`mt invalidate` рекурсивно архівував version chain усіх нащадків одразу (eager cascade), але пізніше документ стверджував що нащадки можуть залишитись `resolved` якщо hash не змінився — суперечність, бо їхні facts вже заархівовані. - -## Considered Options -* Eager cascade (поточний підхід): архівувати нащадків одразу при `mt invalidate` -* Deferred cascade як default: архівувати тільки target-вузол, нащадки природно стають `blocked` -* `--defer-cascade` як окремий флаг (рекомендація рев'юера) - -## Decision Outcome -Chosen option: "Deferred cascade як default", because eager cascade ніколи не краща за deferred — вона або рівна (hash змінився), або зайво знищує роботу (hash не змінився); нащадки природно стають `blocked` коли upstream не `resolved`, їх facts залишаються нетронутими. - -### Consequences -* Good, because differential cascade тепер можливий — однаковий hash після re-run означає що нащадки не потребують повторного виконання. -* Good, because `mt kill` явно зберігає eager cascade для випадків постійного видалення topology. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`mt kill` — завжди eager cascade (знищення topology). `mt invalidate` — тільки target-вузол; cascade відкладається до hash-порівняння після re-run. Файл: `npm/docs/mt.md` рядки ~883, ~1397. diff --git a/docs/adr/20260615-100002-mt-stop-not-added-as-standalone-cli-command.md b/docs/adr/20260615-100002-mt-stop-not-added-as-standalone-cli-command.md deleted file mode 100644 index 4cf0a51..0000000 --- a/docs/adr/20260615-100002-mt-stop-not-added-as-standalone-cli-command.md +++ /dev/null @@ -1,19 +0,0 @@ -## ADR `mt stop` не додається як окрема CLI-команда - -## Context and Problem Statement -Потрібно було вирішити чи виносити логіку зупинки процесу і звільнення claim в окрему команду `mt stop`, чи інтегрувати її в `mt invalidate`. - -## Considered Options -* `mt stop` як окрема CLI-команда (рекомендація рев'юера) -* Інтегрувати SIGTERM + CAS-delete claim як перший крок `mt invalidate` - -## Decision Outcome -Chosen option: "Інтегрувати stop-логіку в `mt invalidate`", because конкретного сценарію де людині потрібен `mt stop` без подальшого `mt invalidate` або `mt kill` — не знайдено; інтеграція усуває клас помилок між окремими викликами. - -### Consequences -* Good, because patch protocol потребує одного кроку (`mt invalidate`) замість двох (`mt stop` + `mt invalidate`). -* Good, because між stop і invalidate немає вікна для retake claim. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`mt invalidate` на running вузлі: локальний runner → SIGTERM + CAS-delete claim перед архівацією; remote runner → CAS-delete claim (remote детектує втрату при наступному renewal). Файл: `npm/docs/mt.md`. diff --git a/docs/adr/20260615-100003-patch-protocol-uses-mt-invalidate-not-mt-kill.md b/docs/adr/20260615-100003-patch-protocol-uses-mt-invalidate-not-mt-kill.md deleted file mode 100644 index dd9d284..0000000 --- a/docs/adr/20260615-100003-patch-protocol-uses-mt-invalidate-not-mt-kill.md +++ /dev/null @@ -1,20 +0,0 @@ -## ADR Patch protocol використовує `mt invalidate` замість `mt kill` - -## Context and Problem Statement -Patch protocol для вузлів з нащадками використовував `mt kill` для successors, але `mt kill` виконує `git rm -r` і видаляє topology — після цього restart каскаду неможливий без повторної матеріалізації вузлів. - -## Considered Options -* `mt kill` для successors (поточний підхід) -* `mt stop` + `mt invalidate` для successors -* `mt invalidate` для successors (інтегрований stop) - -## Decision Outcome -Chosen option: "`mt invalidate` для successors", because `mt kill` видаляє topology (`git rm -r`) що унеможливлює restart каскаду; `mt invalidate` скидає execution state зберігаючи `task.md`, `a.md/h.md`, `deps/`, `plan_*`. - -### Consequences -* Good, because topology зберігається — restart каскаду відбувається автоматично без повторного `mt spawn --approve`. -* Good, because `mt kill` тепер семантично чіткий — виключно для постійного видалення topology. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`mt kill` — тільки для остаточного видалення вузла та піддерева з topology. Engineer protocol також виправлено: `mt stop + mt invalidate` замість `mt kill` при патчуванні залежного вузла. Файл: `npm/docs/mt.md` рядки ~1476, ~1483. diff --git a/docs/adr/20260615-100004-dep-addressing-relative-authoring-absolute-storage.md b/docs/adr/20260615-100004-dep-addressing-relative-authoring-absolute-storage.md deleted file mode 100644 index 227ddaf..0000000 --- a/docs/adr/20260615-100004-dep-addressing-relative-authoring-absolute-storage.md +++ /dev/null @@ -1,21 +0,0 @@ -## ADR Адресація залежностей: відносний авторинг, абсолютне зберігання (Option 3) - -## Context and Problem Statement -`deps/` файли з короткими іменами (`collect-data.md`) були неоднозначними: для вузла `quarterly-anomalies/analyze` ім'я `collect-data` могло означати і кореневий вузол `mt/collect-data/`, і сусіда `mt/quarterly-anomalies/collect-data/`. - -## Considered Options -* Абсолютні dep-id від `mt/` (рекомендація рев'юера): `deps/quarterly-anomalies/collect-data.md` -* Відносні з `../` кодуванням у файловій системі (Option 2а/2б) -* Відносний авторинг у `## Children`, абсолютне зберігання в `deps/` після резолюції `mt spawn` (Option 3) -* YAML dep-descriptor з полем `node:` у вмісті файлу - -## Decision Outcome -Chosen option: "Відносний авторинг, абсолютне зберігання (Option 3)", because оркестратор отримує однозначні абсолютні dep-id через `ls -R deps/` + strip `.md` без читання вмісту; авторинг залишається зручним через відносні імена у `## Children`; `mt spawn --approve` резолвить перед записом. - -### Consequences -* Good, because оркестратор не читає вміст `deps/` файлів — сканування через `ls -R` достатнє. -* Good, because сусіди, нащадки і крос-рівневі залежності виражаються природно при авторингу. -* Bad, because `mt spawn --approve` і `mt init --deps` мають виконувати резолюцію відносних шляхів до абсолютних перед записом. - -## More Information -Формула резолюції: `resolved = normalize(parent_path + "/" + dep_ref)`. `deps/` дзеркалює структуру `mt/` — filename є абсолютним dep-id від root. Вміст dep-файлу опційний (ref-нотатки для агента). Файл: `npm/docs/mt.md` рядки ~75, ~146, ~387, ~1362, ~1837. diff --git a/docs/adr/20260615-100005-decomposed-claim-lost-excluded-from-failed-streak.md b/docs/adr/20260615-100005-decomposed-claim-lost-excluded-from-failed-streak.md deleted file mode 100644 index fd20b75..0000000 --- a/docs/adr/20260615-100005-decomposed-claim-lost-excluded-from-failed-streak.md +++ /dev/null @@ -1,20 +0,0 @@ -## ADR `decomposed` і `claim-lost` виключено з `failed_streak`; додано `plan_reject_max` - -## Context and Problem Statement -Всі результати крім `success` рахувались у `failed_streak`, включаючи `decomposed` (штатна планова декомпозиція) і `claim-lost` (ownership event). Кілька rejected composite plans могли вичерпати retry budget без жодної execution failure. Також агент-ревʼюер і агент-виконавець могли зациклитись на відхиленнях планів без автоматичної ескалації. - -## Considered Options -* Усі не-`success` результати в `failed_streak` (поточний підхід) -* Розділення на категорії: execution failures, lifecycle transitions, ownership events - -## Decision Outcome -Chosen option: "Розділення на категорії", because `decomposed` є штатним lifecycle переходом, а `claim-lost` — ownership event; лише execution failures (`failed`, `progress-timeout`, `budget-exceeded`, `merge-conflict`) мають збільшувати `failed_streak`. - -### Consequences -* Good, because агент може декілька разів пропонувати composite план без штучного вичерпання `agent_retry_max`. -* Good, because втрата claim (наприклад, lease expiry на повільній машині) не карає вузол ескалацією. -* Good, because план-відхилення між агентами отримують окремий `plan_reject_max` поріг з ескалацією до людини (не EngineerAgent). -* Bad, because формула `failed_streak = max(run NNN) - max(fact NNN)` стає складнішою: оркестратор тепер читає `result:` з frontmatter `run_*.md` при скані. - -## More Information -Нова формула: `failed_streak = count(run_*.md де result ∈ {failed, progress-timeout, budget-exceeded, merge-conflict} і NNN > last_fact_NNN)`. Два окремих ескалаційних шляхи: `failed_streak ≥ agent_retry_max` → EngineerAgent; `count(plan-rejected_*.md) ≥ plan_reject_max` → `unresolvable` + алерт людині. Файл: `npm/docs/mt.md` рядки ~475, ~519, ~744. diff --git "a/docs/adr/20260615-202339-plan-reject-max-\320\265\321\201\320\272\320\260\320\273\320\260\321\206\321\226\321\217-\320\275\320\260-\320\273\321\216\320\264\320\270\320\275\321\203.md" "b/docs/adr/20260615-202339-plan-reject-max-\320\265\321\201\320\272\320\260\320\273\320\260\321\206\321\226\321\217-\320\275\320\260-\320\273\321\216\320\264\320\270\320\275\321\203.md" deleted file mode 100644 index 9ec1b67..0000000 --- "a/docs/adr/20260615-202339-plan-reject-max-\320\265\321\201\320\272\320\260\320\273\320\260\321\206\321\226\321\217-\320\275\320\260-\320\273\321\216\320\264\320\270\320\275\321\203.md" +++ /dev/null @@ -1,32 +0,0 @@ ---- -type: ADR -title: plan_reject_max ескалює повторні відхилення плану на людину -description: Повторні plan-rejected результати відокремлюються від execution failures і після порогу створюють unresolvable.md з алертом людині. ---- - -**Status:** Accepted -**Date:** 2026-06-15 - -## Context and Problem Statement - -Специфікація зараховувала всі результати крім `success` у failure-сімейство та `failed_streak`. Але `decomposed` є штатним lifecycle transition, а `claim-lost` є ownership event. Окремо зафіксовано ризик циклу, коли два агенти не можуть домовитися по плану: repeated `plan-rejected_*.md` могли некоректно вичерпувати retry-ліміт execution failures або зациклити процес без людського рішення. - -## Considered Options - -* Усі результати крім `success` рахувати як failures і включати в `failed_streak`. -* Розділити execution failures, lifecycle transitions, ownership events і додати окремий поріг `plan_reject_max` для repeated plan disagreements. - -## Decision Outcome - -Chosen option: "Розділити execution failures, lifecycle transitions, ownership events і додати окремий поріг `plan_reject_max`", because transcript фіксує, що `decomposed` і `claim-lost` не є execution failures, а repeated plan disagreements мають ескалюватися до людини через `unresolvable.md`, не до EngineerAgent. - -### Consequences - -* Good, because `failed_streak` відображає лише execution failures: `failed`, `progress-timeout`, `budget-exceeded`, `merge-conflict`. -* Good, because repeated `plan-rejected_*.md` обробляються окремим шляхом: `count(plan-rejected_*.md) >= plan_reject_max` створює `unresolvable.md` і алерт людині. -* Bad, because `failed_streak` більше не рахується лише арифметикою за назвами файлів; оркестратор читає frontmatter `run_*.md`. -* Neutral, because transcript не містить підтвердження інших наслідків для retry-політики. - -## More Information - -Файл специфікації: `npm/docs/mt.md`. Новий конфіг: `plan_reject_max`, default `3`. `mt scan` відстежує `count(plan-rejected_*.md)` окремо від `failed_streak`. У тій самій сесії також згадано вже зафіксовані рішення про `git push --atomic`, deferred cascade для `mt invalidate`, patch protocol через `mt stop + mt invalidate` і absolute storage для dep-id. diff --git "a/docs/adr/260606-1200-append-only-\321\204\320\260\320\271\320\273\320\276\320\262\320\260-\321\201\320\270\321\201\321\202\320\265\320\274\320\260-\320\264\320\273\321\217-\321\201\321\202\320\260\320\275\321\203-\320\263\321\200\320\260\321\204\321\203.md" "b/docs/adr/260606-1200-append-only-\321\204\320\260\320\271\320\273\320\276\320\262\320\260-\321\201\320\270\321\201\321\202\320\265\320\274\320\260-\320\264\320\273\321\217-\321\201\321\202\320\260\320\275\321\203-\320\263\321\200\320\260\321\204\321\203.md" deleted file mode 100644 index 07db01c..0000000 --- "a/docs/adr/260606-1200-append-only-\321\204\320\260\320\271\320\273\320\276\320\262\320\260-\321\201\320\270\321\201\321\202\320\265\320\274\320\260-\320\264\320\273\321\217-\321\201\321\202\320\260\320\275\321\203-\320\263\321\200\320\260\321\204\321\203.md" +++ /dev/null @@ -1,58 +0,0 @@ -## ADR Append-only файлова система для стану графу задач - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Для зберігання стану вузлів у Рекурсивному складеному ОАГ розглядалась мутабельна JSON-схема де файл `meta.json` перезаписувався при кожній зміні стану. Це створювало дві проблеми: (1) відсутність атомарності — агент міг записати `outputs.json` але впасти до виклику CLI-хука, залишаючи граф у невалідному стані; (2) конфлікти при злитті git-ворктрі — два агенти могли змінити один файл. - -## Considered Options - -* Мутабельні файли — стан зберігається в одному файлі, перезаписується -* Append-only підхід — файли лише створюються або видаляються, ніколи не модифікуються - -## Decision Outcome - -Chosen option: "Append-only підхід", because `create file` є атомарною операцією на POSIX-системах, а naming convention ворктрі `<ідВузла>-виконання` стає природним мютексом через `git worktree add`, який атомарно провалюється якщо директорія вже існує. - -### Consequences - -* Good, because стан вузла визначається наявністю файлів-сентинелів: поява `outputs.md` = сигнал "вирішено" без окремого CLI-хука для зміни стану. -* Good, because конфлікти при злитті ворктрі неможливі за визначенням: кожен агент лише створює нові файли з унікальними іменами. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Файлова структура стану вузла: -``` -tasks/<ідВузла>/ - meta.md ← створюється при spawn, незмінний - inputs.md ← створюється при spawn, незмінний - виконується ← порожній sentinel; наявність = агент активний - outputs.md ← наявність = вирішено - error.md ← наявність = помилка - знедійснений ← наявність = invalidated - patches/ - 001-<timestamp>.md ← новий файл на кожен патч інженера -``` - -Переходи стану: -- `spawn` → створити `meta.md + inputs.md` -- `start` → створити `виконується` -- `done` → видалити `виконується` + створити `outputs.md` (через `write tmp → fsync → rename`) -- `failed` → видалити `виконується` + створити `error.md` - -Агент перевіряє наявність `знедійснений` перед записом `outputs.md` і зупиняється якщо файл знайдений. - -TOCTOU race condition вирішується через `git worktree add .worktrees/<ідВузла>-виконання/` — ця команда атомарно провалюється якщо ворктрі вже існує; другий планувальник отримує помилку і пропускає вузол. - -## Update 2026-06-06 - -### Динамічне розширення підграфу та контракт моніторингу - -Динамічне розширення підграфу виконується через нові файли `граф-розш-001.md`, `граф-розш-002.md` тощо — не через модифікацію `граф.md`. Це зберігає append-only інваріант при динамічному spawn. - -`вхідні.md`/`вихідні.md` містять лише посилання (`ref:`) на файли або конкретні рядки — не копії даних. - -Контракт для скрипту моніторингу: реконструювати граф зі сканування `місія.md` усіх дочірніх вузлів; відновлювати незавершені операції за наявністю `*-план.md` без відповідного `*-факт.md`. Агент може читати файли будь-яких вузлів без обмежень. diff --git "a/docs/adr/260606-1202-git-\320\262\320\276\321\200\320\272\321\202\321\200\321\226-\321\202\320\260-\320\260\320\263\320\265\320\275\321\202-\320\274\320\265\320\264\321\226\320\260\321\202\320\276\321\200-\320\264\320\273\321\217-\320\277\320\260\321\200\320\260\320\273\320\265\320\273\321\214\320\275\320\276\320\263\320\276-\320\262\320\270\320\272\320\276\320\275\320\260\320\275\320\275\321\217.md" "b/docs/adr/260606-1202-git-\320\262\320\276\321\200\320\272\321\202\321\200\321\226-\321\202\320\260-\320\260\320\263\320\265\320\275\321\202-\320\274\320\265\320\264\321\226\320\260\321\202\320\276\321\200-\320\264\320\273\321\217-\320\277\320\260\321\200\320\260\320\273\320\265\320\273\321\214\320\275\320\276\320\263\320\276-\320\262\320\270\320\272\320\276\320\275\320\260\320\275\320\275\321\217.md" deleted file mode 100644 index 7382a96..0000000 --- "a/docs/adr/260606-1202-git-\320\262\320\276\321\200\320\272\321\202\321\200\321\226-\321\202\320\260-\320\260\320\263\320\265\320\275\321\202-\320\274\320\265\320\264\321\226\320\260\321\202\320\276\321\200-\320\264\320\273\321\217-\320\277\320\260\321\200\320\260\320\273\320\265\320\273\321\214\320\275\320\276\320\263\320\276-\320\262\320\270\320\272\320\276\320\275\320\260\320\275\320\275\321\217.md" +++ /dev/null @@ -1,54 +0,0 @@ -## ADR Git-ворктрі та агент-медіатор для паралельного виконання вузлів - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Незалежні вузли Рекурсивного складеного ОАГ можуть виконуватись паралельно. Потрібен механізм ізоляції агентів один від одного і стратегія злиття результатів без конфліктів. - -## Considered Options - -* Послідовне виконання — простіше, але повільніше -* Спільна файлова система без ізоляції — race condition між агентами -* Git-ворктрі з ізольованими робочими деревами — кожен агент у своєму ворктрі - -## Decision Outcome - -Chosen option: "Git-ворктрі з ізольованими робочими деревами", because naming convention `<ідВузла>-виконання` слугує мютексом через атомарний `git worktree add`, а незалежні вузли завжди пишуть у різні директорії `tasks/<ідВузла>/` — конфлікти при злитті неможливі. - -### Consequences - -* Good, because конфлікт злиття при патчі спільного батьківського файлу агентом-інженером вирішується тим самим патерном що й помилка вузла — симетрія системи. -* Good, because git history дає безкоштовний time-travel debugging всього графу. -* Bad, because масштаб ворктрі — 100+ паралельних вузлів = 100+ ворктрі на диску. - -## More Information - -Щасливий шлях (без конфліктів): -``` -агент A: tasks/вузол_5/outputs.md ← своя директорія -агент B: tasks/вузол_6/outputs.md ← своя директорія -git merge → чисте, без конфліктів -``` - -Складний шлях — коли інженер з ворктрі A патчить `tasks/батько/meta.md` поки агент у ворктрі B також пише туди. При злитті — конфлікт на цьому файлі. Вирішується через агента-медіатора: - -``` -АгентМедіатор( - версія_A: meta.md з ворктрі A, - версія_B: meta.md з ворктрі B, - контекст: чому кожен вніс свої зміни -) → злитий meta.md + пояснення рішення -``` - -Якщо медіатор падає двічі — конфлікт ескалується напряму до старшого інженера без нового бюджету. Медіатор має суворо обмежену місію: лише злиття двох файлів, без змін у структурі графу. - -Правило злиття: -``` -при завершенні ворктрі: - git merge → - ├── чисте → застосувати, продовжити - └── конфлікт → spawn АгентМедіатор - результат = новий коміт, продовжити граф -``` diff --git "a/docs/adr/260606-1210-\321\201\320\272\320\260\321\201\321\203\320\262\320\260\320\275\320\275\321\217-\321\202\320\260-\321\226\320\275\320\262\320\260\320\273\321\226\320\264\320\260\321\206\321\226\321\217-\320\262\321\203\320\267\320\273\321\226\320\262-append-only-\320\263\321\200\320\260\321\204.md" "b/docs/adr/260606-1210-\321\201\320\272\320\260\321\201\321\203\320\262\320\260\320\275\320\275\321\217-\321\202\320\260-\321\226\320\275\320\262\320\260\320\273\321\226\320\264\320\260\321\206\321\226\321\217-\320\262\321\203\320\267\320\273\321\226\320\262-append-only-\320\263\321\200\320\260\321\204.md" deleted file mode 100644 index 19f6f8e..0000000 --- "a/docs/adr/260606-1210-\321\201\320\272\320\260\321\201\321\203\320\262\320\260\320\275\320\275\321\217-\321\202\320\260-\321\226\320\275\320\262\320\260\320\273\321\226\320\264\320\260\321\206\321\226\321\217-\320\262\321\203\320\267\320\273\321\226\320\262-append-only-\320\263\321\200\320\260\321\204.md" +++ /dev/null @@ -1,50 +0,0 @@ -**Status:** Accepted -**Date:** 2026-06-06 - -## ADR Скасування та інвалідація вузлів в append-only графі - -## Context and Problem Statement -В системі з append-only файловим сховищем стану вузлів і паралельним виконанням через git worktrees виникають граничні випадки: агент може записати `вихідні.md` одночасно з тим як інженер вирішує скасувати вузол; наступний вузол може вже запуститись у ворктрі; вирішений вузол може потребувати ретроактивного виправлення. Потрібна однозначна семантика sentinel-файлів і правила каскаду. - -## Considered Options -* Пріоритет sentinel `скасовано` над `вихідні.md` + каскадне скасування наступників -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Пріоритет sentinel `скасовано` + каскадне скасування", because при append-only моделі файли лише створюються, тому однозначний пріоритет є єдиним способом вирішити гонку між записом і скасуванням без транзакцій. - -Чотири зафіксовані випадки: - -**Випадок 1 — звичайне скасування вузла що виконується:** -``` -створити задачі/вузол_N/скасовано -→ каскадне скасування всіх запущених наступників -``` - -**Випадок 2 — гонка між записом і скасуванням:** -``` -агент: перевіряє скасовано? → ні → пише вихідні.md -інженер: створює скасовано ← між перевіркою і записом -``` -Правило: якщо існують одночасно `вихідні.md` + `скасовано` → **`скасовано` має пріоритет**, оркестратор ігнорує вихідні. - -**Випадок 3 — наступник вже запустився до скасування:** -Pre-flight check пройшов до моменту скасування — наступний вузол вже виконується у ворктрі з `вхідні.md` від тепер-скасованого попередника. Правило: каскад `скасовано` на всі запущені наступники незалежно від того коли вони стартували. - -**Випадок 4 — ретроактивна зміна вирішеного вузла:** -Інженер визнає що вузол (вже `вирішено`) дав невірні вихідні. Оскільки `вихідні.md` незмінний (append-only): -``` -створити задачі/вузол_N/знедійснений -створити задачі/вузол_N/вихідні-v2.md ← новий файл, не зміна старого -→ каскад знедійснений/скасовано на всіх наступниках -``` - -Інваріант перед стартом наступника: **завжди перевіряємо попередника** — якщо він не в фінальному дозволеному стані (`вирішено` без `скасовано`/`знедійснений`) → не стартуємо. - -### Consequences -* Good, because семантика пріоритетів sentinel-файлів однозначна і не потребує транзакцій — атомарність файлової системи достатня. -* Good, because append-only інваріант не порушується: ретроактивна зміна виражається через нові файли (`вихідні-v2.md`, `знедійснений`), а не мутацію існуючих. -* Bad, because каскадне скасування при ретроактивній зміні може зупинити великий підграф вузлів що вже виконуються. - -## More Information -Файли: `npm/docs/mt.md` (розділ "Скасування та інвалідація"). Sentinel-файли: `виконується`, `вихідні.md`, `помилка.md`, `скасовано`, `знедійснений`. Ретроактивні вихідні: `вихідні-v2.md`. Пріоритет: `скасовано` > `вихідні.md`. diff --git "a/docs/adr/260606-1323-task-md-\321\217\320\272-\321\224\320\264\320\270\320\275\320\270\320\271-\321\204\320\260\320\271\320\273-\320\262\321\203\320\267\320\273\320\260.md" "b/docs/adr/260606-1323-task-md-\321\217\320\272-\321\224\320\264\320\270\320\275\320\270\320\271-\321\204\320\260\320\271\320\273-\320\262\321\203\320\267\320\273\320\260.md" deleted file mode 100644 index 6ca4806..0000000 --- "a/docs/adr/260606-1323-task-md-\321\217\320\272-\321\224\320\264\320\270\320\275\320\270\320\271-\321\204\320\260\320\271\320\273-\320\262\321\203\320\267\320\273\320\260.md" +++ /dev/null @@ -1,74 +0,0 @@ ---- -type: ADR -title: "task.md як єдиний файл вузла" -description: Вузол зберігає місію і вхідні дані в одному `task.md`, а зміна inputs після старту проходить через patch-протокол. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Під час проєктування файлового контракту вузла потрібно було вирішити, чи розділяти місію вузла і вхідні дані між `task.md` та окремим `inputs.md`, чи тримати їх в одному документі. Агент при старті завжди потребує і опис задачі, і посилання на вхідні дані, а зміни inputs після старту мають бути контрольованими. - -## Considered Options - -- Два файли: `task.md` для місії та `inputs.md` для вхідних даних. -- Один файл `task.md` із секцією `## Inputs`. - -## Decision Outcome - -Chosen option: "Один файл `task.md` із секцією `## Inputs`", because transcript фіксує згоду: "Фіксуємо один файл `task.md` і якщо інженер хоче змінити inputs це patch"; агент читає один файл, а зміна inputs проходить через наявний `patches/patch-plan-<ts>.md` / `patches/patch-fact-<ts>.md` протокол. - -### Consequences - -- Good, because місія вузла і вхідні дані завжди знаходяться в одному контексті для агента. -- Good, because зміна inputs після старту не є неявним редагуванням стану, а оформлюється як patch. -- Bad, because transcript не містить підтвердження негативних наслідків цього рішення. -- Neutral, because окремий `inputs.md` не використовується, а всі вхідні дані мають бути описані в `task.md`. - -## More Information - -`task.md` містить YAML frontmatter з `created_at`, опційними `parent` і `deps`, а також обов'язкові секції: - -- `## Task` -- `## Done when` -- `## Inputs` - -Підсекції `## Inputs` можуть містити `ref:` на інші файли або inline-текст. Приклад transcript фіксує refs на `outputs.md` попередників і контекст батьківського вузла. Для операційних файлів використовується timestamp у назві формату `YYYYMMDD-HHMMSS`, наприклад `patches/patch-plan-20260606-100600.md`. - -## Update 2026-06-06 - -Драфт додає аргумент до рішення про один `task.md`: `## Task`, `## Done when`, `## Inputs` мають бути обов'язковими англійськими заголовками, які парсить скрипт. Підсекції `## Inputs` можуть мати довільні назви, рекомендовано англійські. - -Good, because агент читає один файл замість двох, а місія і вхідні дані залишаються в одному цілісному контексті. -Neutral, because transcript не містить підтвердження негативних наслідків цього об'єднання. - -## Update 2026-06-06 - -- `budget_sec` зберігається у frontmatter `task.md`, а не в окремому `repair_context.md`, бо часовий бюджет є атрибутом вузла і застосовується до агента, інженера та інших акторів. -- `## Inputs` містить підсекції `### name` з `ref:` або inline-текстом; `ref:` обовʼязковий, якщо дані вже існують у файлі, inline використовується лише коли даних немає деінде. -- Інваріант редагування лишається таким: немає worktree → `task.md` можна редагувати вільно; є worktree → `mt kill` → редагування → restart. - -## Update 2026-06-07 - -Уточнено практичне застосування `task.md` як єдиного файлу вузла: - -- стан задачі зберігається у файловій системі `tasks/<name>/` без централізованої бази даних; -- базовий вузол має `task.md` з YAML frontmatter і markdown-секціями; -- frontmatter містить машинозчитувані поля на кшталт `created_at`, `budget_sec`, `parent`, `deps`; -- markdown-секції `## Task`, `## Done when`, `## Inputs` містять опис задачі, критерії завершення і вхідні дані; -- стан `waiting` може визначатися наявністю `task.md` без `run_*.md` і `outputs_*.md`. - -Transcript наводить приклади створених вузлів: `tasks/ui-task-view/task.md`, `tasks/coverage-skill-test/task.md`, `tasks/skills-orchestrator-migration/task.md`. - -## Update 2026-06-07 - -У transcript зафіксовано першу матеріалізацію схеми `tasks/<node-name>/task.md` у репозиторії: створені вузли `tasks/ui-task-view/task.md`, `tasks/coverage-skill-test/task.md`, `tasks/skills-orchestrator-migration/task.md`. - -Додані transcript facts: -- `task.md` містить YAML-frontmatter з `created_at`, `budget_sec`, опціонально `parent`, `deps`. -- Обовʼязкові секції: `## Task`, `## Done when`, `## Inputs`. -- Стан вузла читається з наявності файлів: лише `task.md` → `waiting`; `run_*.md` → `running`; `outputs_*.md` → `resolved`. -- Запуск вузла: `mt run tasks/<name>`. -- Назва UI-проєкту в цій сесії не була підтверджена як фінальне рішення. diff --git "a/docs/adr/260606-1323-\320\276\320\264\320\270\320\275-\321\204\320\260\320\271\320\273-task-md-\320\264\320\273\321\217-\320\274\321\226\321\201\321\226\321\227-\321\202\320\260-inputs.md" "b/docs/adr/260606-1323-\320\276\320\264\320\270\320\275-\321\204\320\260\320\271\320\273-task-md-\320\264\320\273\321\217-\320\274\321\226\321\201\321\226\321\227-\321\202\320\260-inputs.md" deleted file mode 100644 index 7a556d8..0000000 --- "a/docs/adr/260606-1323-\320\276\320\264\320\270\320\275-\321\204\320\260\320\271\320\273-task-md-\320\264\320\273\321\217-\320\274\321\226\321\201\321\226\321\227-\321\202\320\260-inputs.md" +++ /dev/null @@ -1,42 +0,0 @@ ---- -type: ADR -title: Один файл task.md для місії та inputs -description: Місія вузла і вхідні дані зберігаються в одному task.md, а зміна inputs після старту виконується через patch-протокол. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Агенту при старті потрібні і формулювання задачі, і вхідні дані або посилання на них. У transcript обговорювалось, чи зберігати місію та inputs у двох файлах (`task.md` і `inputs.md`) або об'єднати їх в одному файлі. Також потрібно було визначити, що робити, якщо EngineerAgent хоче змінити inputs після старту вузла. - -## Considered Options - -* Два файли: `task.md` для місії та `inputs.md` для вхідних даних. -* Один файл `task.md` із секцією `## Inputs`. - -## Decision Outcome - -Chosen option: "Один файл `task.md` із секцією `## Inputs`", because transcript фіксує згоду: `task.md` містить і місію, і вхідні дані; якщо інженер хоче змінити inputs, це виконується як patch. - -### Consequences - -* Good, because агент читає один файл замість двох, а місія та inputs завжди перебувають в одному контексті. -* Good, because spawn створює менше артефактів: `inputs.md` як окремий файл не використовується. -* Neutral, because зміна inputs після старту не є прямим редагуванням окремого файлу, а проходить через `patches/patch-plan-<ts>.md` і `patches/patch-fact-<ts>.md`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Файл специфікації: `npm/docs/mt.md`. Фінальна схема `task.md`: YAML frontmatter з `created_at`, опційними `parent` і `deps`, далі обов'язкові секції `## Task`, `## Done when`, `## Inputs`. У `## Inputs` підсекції мають довільні назви; значення можуть бути `ref: tasks/.../outputs.md#section` або inline-текстом. Формат `<ts>` для operation/patch-файлів: `YYYYMMDD-HHMMSS`. Пов'язаний CLI-контракт у transcript: `mt init`, `mt start`, `mt spawn`, `mt done`, `mt fail`, `mt kill`, `mt repair`, `mt status`. - -## Update 2026-06-06 - -Драфт уточнює контракт об'єднаного `task.md` і пов'язаних форматів: - -- `task.md` містить обов'язкові англійські секції `## Task`, `## Done when`, `## Inputs`. -- `## Inputs` має підсекції з довільними назвами; значення можуть бути `ref:` або inline-текстом. -- Зміна inputs після старту трактується як patch, а не як окреме редагування `inputs.md`. -- Формат файлів лишається Markdown + YAML frontmatter: frontmatter для машинозчитуваних полів, тіло Markdown для LLM-контексту. -- Додатково драфт фіксує, що секції, які парсить скрипт/оркестратор, мають англійські заголовки; довільні секції можуть бути будь-якою мовою. diff --git "a/docs/adr/260606-1358-\321\201\321\205\320\265\320\274\320\260-outputs-md.md" "b/docs/adr/260606-1358-\321\201\321\205\320\265\320\274\320\260-outputs-md.md" deleted file mode 100644 index d5e8f37..0000000 --- "a/docs/adr/260606-1358-\321\201\321\205\320\265\320\274\320\260-outputs-md.md" +++ /dev/null @@ -1,58 +0,0 @@ ---- -type: ADR -title: "Фінальна схема outputs.md" -description: Результат вузла записується в outputs.md з обов'язковими секціями Summary і Results та правилом ref-first. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Потрібно визначити формат файлу `outputs.md`, який є результатом виконання вузла. Transcript фіксує питання: мати одну секцію чи дві (`## Summary` і `## Results`), а також коли використовувати inline-дані замість посилань. - -## Considered Options - -- Одна секція `## Results` — агент пише результати у довільному форматі. -- Дві секції `## Summary` + `## Results` — `Summary` для observability, `Results` для даних. - -## Decision Outcome - -Chosen option: "Дві секції `## Summary` + `## Results` з правилом ref-first", because `## Summary` потрібен observability-скрипту без парсингу специфічних даних вузла, `## Results` призначений для агентів-наступників, а посилання замість копіювання вже зафіксовані як інваріант системи. - -### Consequences - -- Good, because observability-скрипт може читати `## Summary` з будь-якого `outputs.md` без розуміння доменної структури результатів. -- Good, because `## Results` відокремлює машинно корисні результати від людського резюме. -- Neutral, because якщо дані вже існують у файлі, transcript вимагає `ref:`, а inline допускається лише коли окремого артефакту немає. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Файл: `tasks/<node-id>/outputs.md`. - -Фінальна схема з transcript: - -```markdown ---- -created_at: 2026-06-06T10:05:00Z ---- -## Summary -Коротке резюме результату — для observability і людини. - -## Results - -### report -ref: tasks/research/subgraph/analyze/artifacts/report.md - -### findings -Три закономірності виявлено безпосередньо в тексті, бо окремого файлу немає. -``` - -Правила: - -- `created_at` — перше поле frontmatter. -- `## Summary` — обов'язкова секція для observability. -- `## Results` — обов'язкова секція для результатів, які читатимуть наступники. -- Якщо дані живуть у файлі — використовувати `ref:`. -- Якщо агент сформував текст без окремого артефакту — писати inline у відповідній підсекції. diff --git "a/docs/adr/260606-1358-\321\204\321\226\320\275\320\260\320\273\321\214\320\275\320\260-\321\201\321\205\320\265\320\274\320\260-outputs-md-2.md" "b/docs/adr/260606-1358-\321\204\321\226\320\275\320\260\320\273\321\214\320\275\320\260-\321\201\321\205\320\265\320\274\320\260-outputs-md-2.md" deleted file mode 100644 index 441e190..0000000 --- "a/docs/adr/260606-1358-\321\204\321\226\320\275\320\260\320\273\321\214\320\275\320\260-\321\201\321\205\320\265\320\274\320\260-outputs-md-2.md" +++ /dev/null @@ -1,58 +0,0 @@ ---- -type: ADR -title: Фінальна схема outputs.md -description: Файл outputs.md має містити обовʼязкові секції Summary і Results та використовувати ref-first правило для артефактів. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Потрібно визначити формат файлу `outputs.md`, який є результатом виконання вузла. Transcript фіксує питання: мати одну секцію чи дві (`## Summary` і `## Results`), а також коли використовувати inline-дані замість посилань. - -## Considered Options - -- Одна секція `## Results` — агент пише результати у довільному форматі. -- Дві секції `## Summary` і `## Results` — `Summary` для observability, `Results` для даних наступників. - -## Decision Outcome - -Chosen option: "Дві секції `## Summary` і `## Results` з ref-first правилом", because `## Summary` потрібен observability-скрипту без парсингу даних, а `## Results` потрібен агентам-наступникам; transcript також фіксує правило посилатися на файл через `ref:` якщо дані вже існують як артефакт. - -### Consequences - -- Good, because observability-скрипт може читати коротке резюме з будь-якого `outputs.md` без розуміння специфіки вузла. -- Good, because `## Results` відокремлює дані для наступників від людиночитного summary. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because inline-дані дозволені лише коли окремого артефакту немає. - -## More Information - -Файл: `tasks/<node-id>/outputs.md`. - -Фінальна схема з transcript: - -```markdown ---- -created_at: 2026-06-06T10:05:00Z ---- -## Summary -Коротке резюме результату — для observability і людини. - -## Results - -### report -ref: tasks/research/subgraph/analyze/artifacts/report.md - -### findings -Три закономірності виявлено безпосередньо в тексті, якщо немає окремого файлу. -``` - -Правило: якщо дані живуть у файлі — використовувати `ref:`; inline дозволений лише якщо агент сформував текст без окремого артефакту. `created_at` — перше поле frontmatter. - -## Update 2026-06-06 - -- Для великих файлів вузла зафіксовано директорію `tasks/<node>/artifacts/`. -- Посилання `ref:` з `outputs_NNN.md` на великі артефакти мають вказувати всередину `artifacts/` цього вузла. -- Transcript також фіксує, що `running.lock` містить PID, а NNN для `run_*.md` рахує wrapper через кількість наявних `run_*.md`; ці деталі не змінюють основного рішення про numbered immutable файли. diff --git "a/docs/adr/260606-1406-llm-first-markdown-yaml-\321\204\320\260\320\271\320\273\320\270-\320\262\321\203\320\267\320\273\321\226\320\262.md" "b/docs/adr/260606-1406-llm-first-markdown-yaml-\321\204\320\260\320\271\320\273\320\270-\320\262\321\203\320\267\320\273\321\226\320\262.md" deleted file mode 100644 index 7f950ff..0000000 --- "a/docs/adr/260606-1406-llm-first-markdown-yaml-\321\204\320\260\320\271\320\273\320\270-\320\262\321\203\320\267\320\273\321\226\320\262.md" +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: ADR -title: LLM-first Markdown з YAML-frontmatter для файлів вузлів -description: Файли вузлів графу зберігаються як Markdown з YAML-frontmatter, щоб бути одночасно зручними для LLM-агентів і скриптів. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Кожен вузол графу задач зберігає стан у файловій системі. Потрібно обрати формат файлів, який зручний для LLM-агентів, що читають і дописують контекст, і водночас придатний для машинного парсингу оркестратором. - -## Considered Options - -- JSON для всіх файлів стану. -- Markdown з YAML-frontmatter як LLM-first формат. - -## Decision Outcome - -Chosen option: "Markdown з YAML-frontmatter", because LLM-агент читає Markdown природно і може продовжувати текст без реконструкції JSON-структури, а YAML-frontmatter надає машинозчитувані поля для оркестратора. - -### Consequences - -- Good, because `repair_history.md` або інші журнальні файли можна читати й дописувати як природний Markdown-контекст. -- Good, because frontmatter відокремлює machine-readable metadata від довільного людського або LLM-контенту. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because секції, які парсить скрипт, мають бути стандартизовані англійськими заголовками. - -## More Information - -Файли вузла з transcript: `task.md`, `outputs.md`, `error.md`, `repair_context.md`, `repair_history.md`, `ops/*`, `patches/*`. - -Правила контракту: - -- `created_at` — перше поле frontmatter у всіх файлах. -- Імена файлів і директорій — англійською. -- Атрибути frontmatter — англійською у `snake_case`. -- Секції, які парсить скрипт або оркестратор, мають англійські заголовки. -- Секції з довільними даними можуть бути будь-якою мовою. -- Якщо дані вже існують у файлі, потрібно використовувати `ref:` замість копіювання. diff --git "a/docs/adr/260606-1406-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\204\320\276\321\200\320\274\320\260\321\202-\320\262\321\203\320\267\320\273\321\226\320\262-llm-first-markdown-\320\267-yaml-\321\204\321\200\320\276\320\275\321\202\320\274\320\260\321\202\320\265\321\200\320\276\320\274.md" "b/docs/adr/260606-1406-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\204\320\276\321\200\320\274\320\260\321\202-\320\262\321\203\320\267\320\273\321\226\320\262-llm-first-markdown-\320\267-yaml-\321\204\321\200\320\276\320\275\321\202\320\274\320\260\321\202\320\265\321\200\320\276\320\274.md" deleted file mode 100644 index daa0f62..0000000 --- "a/docs/adr/260606-1406-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\204\320\276\321\200\320\274\320\260\321\202-\320\262\321\203\320\267\320\273\321\226\320\262-llm-first-markdown-\320\267-yaml-\321\204\321\200\320\276\320\275\321\202\320\274\320\260\321\202\320\265\321\200\320\276\320\274.md" +++ /dev/null @@ -1,49 +0,0 @@ ---- -type: ADR -title: Файловий формат вузлів — LLM-first Markdown з YAML-фронтматером -description: Файли вузлів графу задач зберігаються як Markdown з YAML-frontmatter, щоб бути зручними і для LLM, і для оркестратора. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Кожен вузол графу задач зберігає свій стан у файловій системі. Потрібно обрати формат файлів, який одночасно зручний для скриптів-оркестраторів і для LLM-агентів, що читають і записують ці файли. - -## Considered Options - -- JSON для всіх файлів стану. -- Markdown з YAML-фронтматером як LLM-first формат. - -## Decision Outcome - -Chosen option: "Markdown з YAML-фронтматером", because LLM-агент читає Markdown природно і може продовжувати запис без реконструкції контексту з JSON, а frontmatter надає машинозчитувані поля для скрипту-оркестратора. - -### Consequences - -- Good, because `repair_history.md` або інші журнальні файли можна читати й дописувати як природний текст без парсингу JSON-масиву. -- Good, because frontmatter відокремлює машинні атрибути від довільного LLM-контексту в тілі Markdown. -- Neutral, because transcript фіксує мовний контракт: YAML-атрибути та заголовки секцій, які парсить скрипт, мають бути англійськими; довільні дані можуть бути будь-якою мовою. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Файли вузла з transcript: `task.md`, `outputs.md`, `error.md`, `repair_history.md`; директорії: `subgraph/`, `ops/`, `patches/`. - -Контракт: - -- Всі імена файлів і директорій — англійська. -- Атрибути YAML-frontmatter — англійська, `snake_case`. -- Секції, які парсить скрипт або оркестратор, мають англійські заголовки. -- Секції з довільними даними можуть бути будь-якою мовою. -- `created_at` в ISO 8601 — перше поле frontmatter у всіх файлах. -- Якщо дані вже є в окремому файлі, використовується `ref:`; inline-текст допускається, коли окремого файлу немає. -- Контракт зафіксовано в `npm/docs/mt.md`. - -## Update 2026-06-06 - -- Уточнено правило мови: імена файлів і директорій — англійська; YAML-атрибути — англійська `snake_case`; секції, які парсить оркестратор, мають англійські заголовки; довільні дані можуть бути будь-якою мовою. -- `created_at` має бути першим полем frontmatter у всіх файлах. -- `ref:` використовується, коли дані вже існують у файлі; inline-текст використовується лише коли окремого артефакту немає. -- Transcript також фіксує, що `repair/summary.md` відхилено як порушення append-only інваріанту; контекст попередніх repair-сесій має компілювати wrapper або зберігатися в immutable файлах спроб. diff --git "a/docs/adr/260606-1443-\321\203\320\275\321\226\321\204\321\226\320\272\320\276\320\262\320\260\320\275\320\260-\321\201\320\277\321\200\320\276\320\261\320\260-\320\262\321\203\320\267\320\273\320\260-attempt-nnn.md" "b/docs/adr/260606-1443-\321\203\320\275\321\226\321\204\321\226\320\272\320\276\320\262\320\260\320\275\320\260-\321\201\320\277\321\200\320\276\320\261\320\260-\320\262\321\203\320\267\320\273\320\260-attempt-nnn.md" deleted file mode 100644 index 824687d..0000000 --- "a/docs/adr/260606-1443-\321\203\320\275\321\226\321\204\321\226\320\272\320\276\320\262\320\260\320\275\320\260-\321\201\320\277\321\200\320\276\320\261\320\260-\320\262\321\203\320\267\320\273\320\260-attempt-nnn.md" +++ /dev/null @@ -1,67 +0,0 @@ ---- -type: ADR -title: Уніфікована спроба вузла в attempt_NNN.md -description: Звичайний збій і repair-спроба фіксуються як однакова immutable спроба вузла в attempts/attempt_NNN.md. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -У попередній схемі збій вузла і спроби відновлення могли зберігатися окремими файлами: `error.md`, `repair_history.md` або варіантами `error_NNN.md`. Transcript уточнює, що збій — це теж спроба виконання вузла, тому окремий файл помилки не потрібен для кожного запуску. - -## Considered Options - -- Окремі файли для помилок і repair-журналу. -- Уніфікований `attempt_NNN.md` для кожної спроби, незалежно від того, це звичайний агент чи інженер. - -## Decision Outcome - -Chosen option: "Уніфікований `attempt_NNN.md`", because звичайний агент і інженер однаково виконують спробу вирішити вузол; result, script output, notes і patch reference можна зберігати в одному immutable файлі спроби. - -### Consequences - -- Good, because структура вузла спрощується: немає окремих `error_NNN.md` і `repair_history_NNN.md`. -- Good, because кожна спроба immutable і повністю описує план, технічний результат wrapper-а, notes агента та опціональний patch. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because `repair_context.md` залишається окремим файлом через інший lifecycle: він створюється до repair-спроб і читається інженером перед роботою. - -## More Information - -Схема з transcript: - -```markdown ---- -created_at: 2026-06-06T10:01:00Z -result: failed | success ---- -## Plan -Що планувалось зробити. - -## Script -exit_code: 1 -stderr: Error: context length exceeded - -## Notes -Не вклався в контекст. Наступного разу розбити на батчі. - -## Patch -ref: patches/001-plan.md -``` - -Правила з transcript: - -- `## Plan` пише агент і секція є обовʼязковою. -- `## Script` пише wrapper, якщо є технічна помилка. -- `## Notes` пише агент, якщо встиг записати пояснення. -- `## Patch` пише інженер і лише для інженерних спроб. -- Структура вузла включає `attempts/attempt_001.md`, `attempts/attempt_002.md`, `patches/`, `ops/`, `task.md`, `outputs.md` і опціональний `repair_context.md`. - -## Update 2026-06-06 - -- Пізніша назва для уніфікованої спроби може бути `run_NNN.md`, але суть рішення та сама: звичайний агент, інженер, human або auditor фіксують immutable спробу в одному форматі. -- Frontmatter для такої спроби може містити `actor: agent|engineer|human|auditor` і `result: success|failed`. -- Для результатів виконання може використовуватись окремий immutable файл `outputs_NNN.md` з обовʼязковим `## Summary`. -- Це доповнює рішення про `attempt_NNN.md`; transcript не містить достатнього підтвердження, що потрібно створювати окремий clean ADR лише через перейменування `attempt` у `run`. -- Додатково зафіксовано, що всі імена файлів і директорій мають бути англійською, а `budget_sec` є властивістю задачі у `task.md`. diff --git "a/docs/adr/260606-1522-\321\203\320\275\321\226\321\204\321\226\320\272\320\276\320\262\320\260\320\275\320\270\320\271-run-nnn-\320\264\320\273\321\217-\320\262\321\201\321\226\321\205-\321\202\320\270\320\277\321\226\320\262-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\226\320\262.md" "b/docs/adr/260606-1522-\321\203\320\275\321\226\321\204\321\226\320\272\320\276\320\262\320\260\320\275\320\270\320\271-run-nnn-\320\264\320\273\321\217-\320\262\321\201\321\226\321\205-\321\202\320\270\320\277\321\226\320\262-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\226\320\262.md" deleted file mode 100644 index cf9d870..0000000 --- "a/docs/adr/260606-1522-\321\203\320\275\321\226\321\204\321\226\320\272\320\276\320\262\320\260\320\275\320\270\320\271-run-nnn-\320\264\320\273\321\217-\320\262\321\201\321\226\321\205-\321\202\320\270\320\277\321\226\320\262-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\226\320\262.md" +++ /dev/null @@ -1,83 +0,0 @@ ---- -type: ADR -title: Уніфікований run_NNN.md для всіх типів виконавців -description: Звичайний агент, engineer, human і auditor записують спроби виконання вузла в єдиному immutable форматі run_NNN.md. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Система мала окремі файли для різних сценаріїв: `outputs.md`, `error.md`, `repair_history.md`, `repair_context.md` та окремі repair-записи. У transcript постало питання уніфікації, оскільки звичайний агент і engineer-agent мають однакову базову природу: кожен робить спробу вирішити вузол і фіксує результат цієї спроби. - -## Considered Options - -- Окремі файли: `outputs.md`, `error.md`, `repair_history.md`, `repair_context.md`. -- Уніфікований `run_NNN.md` для спроб виконання разом з окремим `outputs_NNN.md` для результатів. - -## Decision Outcome - -Chosen option: "Уніфікований `run_NNN.md`", because і звичайний агент, і engineer, і human, і auditor є виконавцями спроби; спільний формат з `actor`, `result`, reasoning/script/ref секціями усуває спеціальні append-only журнали та робить observability однаковою для всіх типів виконавців. - -### Consequences - -- Good, because transcript фіксує очікувану користь: один формат для всіх типів виконавців замість окремих `error.md` і `repair_history.md` сценаріїв. -- Good, because кожен `run_NNN.md` immutable, тому не потрібен append-only журнал, який дописується всередину одного файлу. -- Good, because observability може сканувати `run_NNN.md` незалежно від того, чи це звичайний agent run, engineer repair, human action або auditor action. -- Neutral, because результати виконання винесені в окремий `outputs_NNN.md`, щоб спроба і її output мали чітку структуру. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Фінальна схема з transcript: - -- `run_NNN.md` frontmatter: `created_at`, `actor: agent|engineer|human|auditor`, `result: success|failed`. -- Секції `run_NNN.md`: `## Reasoning` обов'язкова; `## Script` опціональна і заповнюється wrapper-ом; `## Ref` опціональна. -- `outputs_NNN.md` — окремий immutable файл результату з обов'язковою секцією `## Summary` і довільними секціями-портами. -- `task.md` містить `budget_sec`, бо часовий бюджет є властивістю задачі, а не окремого repair-контексту. -- Transcript також фіксує суміжні правила: всі імена файлів і директорій — англійська; YAML-атрибути — англійська `snake_case`; секції, які парсить скрипт, мають англійські заголовки; довільні дані можуть бути будь-якою мовою. -- Контракт описано в `npm/docs/mt.md`. - -## Update 2026-06-06 - -Transcript уточнює мотивацію уніфікації: збій також є спробою виконання, тому окремі `error_NNN.md` і `repair_history_NNN.md` не потрібні. У ранній формі це описувалося як `attempt_NNN.md` із секціями: - -- `## Plan` — що планувалось зробити. -- `## Script` — технічний результат wrapper-а, зокрема `exit_code` і `stderr`, якщо була технічна помилка. -- `## Notes` — нотатки агента, якщо він встиг їх записати. -- `## Patch` — посилання на patch plan, тільки для інженерних спроб. - -Це уточнення підтримує фінальне рішення про уніфікований immutable файл спроби `run_NNN.md`: звичайний агент, engineer і технічний failed run мають один життєвий цикл — спроба вирішити вузол. - -## Update 2026-06-06 - -- `run_NNN.md` замінює `repair_history.md`, `error.md`, `outputs.md` як append-only журнали спроб. -- Frontmatter `run_NNN.md`: `created_at`, `actor: agent | engineer | human`, `result: success | failed`, опційно `worktree` для failed-спроби. -- Секції: `## Reasoning` обовʼязкова, `## Script` опційна для wrapper-збою, `## Ref` опційна для посилання на результат. -- `budget_sec` переноситься у frontmatter `task.md`, бо бюджет є частиною специфікації задачі, а не окремого runtime-стану. -- `ops/` і `patches/` прибираються на цьому етапі; деталі змін інженера записуються у `## Reasoning` відповідного `run_NNN.md`. -- NNN генерується wrapper-ом через підрахунок існуючих `run_*.md` і zero-padding до 3 цифр; transcript визнає теоретичну race condition прийнятним компромісом. -- Повторне рішення про post-merge hook не створює окремого ADR у цьому batch. - -## Update 2026-06-06 - -- Уточнено, що `run_NNN.md` покриває спроби `agent`, `engineer`, `human` і `auditor` через поле `actor`. -- Успішний запуск додатково породжує immutable `outputs_NNN.md` з тим самим номером спроби. -- Стан вузла читається скануванням `run_*.md` і `outputs_*.md`, без окремих форматів для `error.md` чи `repair_history.md`. -- Директорії `ops/` і `patches/` видаляються; crash recovery для часткового spawn/kill у transcript відкладено як неактуальний для поточного етапу. -- Для аудиту в цьому transcript зафіксовано альтернативний варіант: агент може ініціювати перевірку через `mt audit <path>`, а після 3 поспіль audit failures система зупиняється й чекає людину. - -## Update 2026-06-06 - -- Поле `actor` у `run_NNN.md` явно відрізняє `agent`, `engineer`, `human` і `auditor` без окремих файлів `error.md`, `repair_history_NNN.md` чи `audit_NNN.md`. -- `worktree` у frontmatter використовується лише коли `result: failed` і потрібно зберегти шлях до залишеного worktree. -- NNN рахується wrapper-ом як `ls run_*.md | wc -l + 1` і форматується zero-padded до 3 цифр. -- Аудитор у цьому transcript запускається в тому самому worktree в read-only режимі, пише `run_(NNN+1).md`, а після 3 поспіль failed audit runs wrapper зупиняється. - -## Update 2026-06-06 - -- Підтверджено мінімальну структуру `run_NNN.md`: frontmatter `created_at`, `actor`, `result: success | failed`; секції `## Reasoning`, опційні `## Script` і `## Ref`. -- `outputs_NNN.md` і `run_NNN.md` є numbered immutable файлами замість append-only `outputs.md` і `repair_history.md`. -- `ops/` і `patches/` видаляються; інформація про зміни інженера зберігається у `## Reasoning`. -- Додано повʼязане рішення про `mt watch` як pull-модель: команда сканує граф і репортить 3 поспіль audit failures, root-level engineer failure та stale worktree за `stale_worktree_min`. diff --git "a/docs/adr/260606-1600-\320\260\320\275\320\263\320\273\321\226\320\271\321\201\321\214\320\272\321\226-\321\226\320\274\320\265\320\275\320\260-\321\204\320\260\320\271\320\273\321\226\320\262-\320\264\320\270\321\200\320\265\320\272\321\202\320\276\321\200\321\226\320\271-yaml-\320\260\321\202\321\200\320\270\320\261\321\203\321\202\321\226\320\262.md" "b/docs/adr/260606-1600-\320\260\320\275\320\263\320\273\321\226\320\271\321\201\321\214\320\272\321\226-\321\226\320\274\320\265\320\275\320\260-\321\204\320\260\320\271\320\273\321\226\320\262-\320\264\320\270\321\200\320\265\320\272\321\202\320\276\321\200\321\226\320\271-yaml-\320\260\321\202\321\200\320\270\320\261\321\203\321\202\321\226\320\262.md" deleted file mode 100644 index 66af83e..0000000 --- "a/docs/adr/260606-1600-\320\260\320\275\320\263\320\273\321\226\320\271\321\201\321\214\320\272\321\226-\321\226\320\274\320\265\320\275\320\260-\321\204\320\260\320\271\320\273\321\226\320\262-\320\264\320\270\321\200\320\265\320\272\321\202\320\276\321\200\321\226\320\271-yaml-\320\260\321\202\321\200\320\270\320\261\321\203\321\202\321\226\320\262.md" +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: ADR -title: Англійські імена файлів, директорій і YAML-атрибутів -description: Машинозчитувані файли, директорії та frontmatter-поля MT-графу мають англійські імена, а id вузла читається зі шляху. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Файлова система є state store системи, а файли й директорії обробляються скриптами-оркестраторами. Початкові схеми містили українські імена файлів, директорій і frontmatter-полів, а також дублювали `id` вузла у frontmatter `task.md`, хоча той самий id уже визначався назвою директорії. - -## Considered Options - -- Залишити українські імена файлів, директорій і YAML-атрибутів. -- Перейти на англійські імена файлів і директорій та англійські YAML-атрибути у `snake_case`. -- Зберігати `id:` у frontmatter `task.md`. -- Прибрати `id:` з frontmatter і читати id вузла зі шляху директорії. - -## Decision Outcome - -Chosen option: "Перейти на англійські імена файлів і директорій, англійські YAML-атрибути у snake_case, а id вузла читати зі шляху", because ці назви є машинозчитуваним контрактом для скриптів і агентів, а дублювання id у frontmatter створює ризик розбіжності з назвою директорії. - -### Consequences - -- Good, because скрипти не мають проблем з Unicode у шляхах, glob-патернах і shell-командах. -- Good, because YAML-поля уніфіковані з програмними конвенціями через англійську мову та `snake_case`. -- Good, because відсутність `id:` у frontmatter прибирає ризик розбіжності між id у файлі та назвою директорії. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because секції з довільними даними можуть лишатися будь-якою мовою, але секції, які парсить скрипт або оркестратор, мають англійські заголовки. - -## More Information - -Зафіксовані перейменування: `вхідні.md` → `inputs.md` і далі секція `## Inputs` у `task.md`, `підграф/` → `subgraph/`, `операції/` → `ops/`, `патчі/` → `patches/`, `вихідні.md` → `outputs_NNN.md`, `місія.md` → `task.md`. - -Відображення YAML-атрибутів: `батько` → `parent`, `залежності` → `deps`, `створено_о` → `created_at`, `час` → `occurred_at`, `вузли-створено` → `nodes_created`. - -Правило id: `id` вузла дорівнює назві директорії вузла; `task.md` не містить поля `id:`. - -Transcript також фіксує `budget_sec` як поле frontmatter `task.md`: часовий бюджет задається на вузол і стосується будь-якого actor-типу. diff --git "a/docs/adr/260606-1606-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-ops-\321\202\320\260-patches-\320\267\321\226-\321\201\321\202\321\200\321\203\320\272\321\202\321\203\321\200\320\270-\320\262\321\203\320\267\320\273\320\260.md" "b/docs/adr/260606-1606-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-ops-\321\202\320\260-patches-\320\267\321\226-\321\201\321\202\321\200\321\203\320\272\321\202\321\203\321\200\320\270-\320\262\321\203\320\267\320\273\320\260.md" deleted file mode 100644 index 4eb13cd..0000000 --- "a/docs/adr/260606-1606-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-ops-\321\202\320\260-patches-\320\267\321\226-\321\201\321\202\321\200\321\203\320\272\321\202\321\203\321\200\320\270-\320\262\321\203\320\267\320\273\320\260.md" +++ /dev/null @@ -1,56 +0,0 @@ ---- -type: ADR -title: Видалення ops та patches зі структури вузла -description: Директорії ops і patches прибираються, бо recovery через plan/fact визнано передчасним ускладненням для поточного MT-графу. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Початкова структура вузла містила `ops/spawn-plan`, `ops/spawn-fact`, `ops/kill-plan`, `ops/kill-fact` і `patches/NNN-plan`, `patches/NNN-fact` для WAL-патерну plan→fact та відновлення після перерваних операцій spawn, kill або patch. Під час ітеративного проєктування постало питання, чи ці recovery-сценарії потрібні на поточному етапі. - -## Considered Options - -- Зберегти `ops/` і `patches/` з повним WAL-патерном plan→fact. -- Видалити `ops/` і `patches/`, ігноруючи crash-recovery для spawn, kill і patch на поточному етапі. - -## Decision Outcome - -Chosen option: "Видалити `ops/` і `patches/`", because якщо spawn відбувається у worktree, то при обриві worktree просто не мержиться і граф залишається чистим; recovery для цих операцій визнано передчасним ускладненням. - -### Consequences - -- Good, because файлова структура вузла стає мінімальною: `task.md`, `invalidated`, `run_NNN.md`, `outputs_NNN.md` і дочірні директорії. -- Good, because інформація про reasoning та зміни інженера зберігається у `run_NNN.md`, без дублювання в `patches/`. -- Bad, because transcript не містить підтверджених негативних наслідків для поточного етапу. -- Neutral, because якщо spawn або patch колись виконуватиметься напряму в main, transcript не підтверджує наявність механізму recovery для такого сценарію. - -## More Information - -Зміни зафіксовані в `npm/docs/mt.md`. Після видалення `ops/` і `patches/` стан вузла визначається наявністю файлів: якщо `run_*.md` існує без `outputs_*.md`, вузол перебуває у стані `failed`; `invalidated` є sentinel-файлом інвалідації. - -`run_NNN.md` лишається єдиним файлом спроби для `actor: agent | engineer | human | auditor`. Успішний запуск створює окремий `outputs_NNN.md`. - -## Update 2026-06-06 - -- Transcript додатково фіксує мотивацію спростити файлову структуру для поточного етапу: сценарій обриву spawn ігнорується як нерелевантний для MacBook/людина-оркестратор. -- `run_NNN.md` лишається місцем для `actor: agent|engineer|human|auditor`, `result: success|failed`, `## Reasoning`, опціонального `## Script` і `## Ref`. -- `outputs_NNN.md` лишається окремим immutable-файлом на успішний запуск, а `budget_sec` переноситься у `task.md` замість `repair_context.md`. - -## Update 2026-06-06 - -- Transcript підтверджує, що `ops/` видаляється повністю, а не лише для `spawn`: `spawn-plan/fact` і `kill-plan/fact` прибираються зі схем `npm/docs/mt.md`. -- Зафіксовано нейтральний ризик: якщо spawn колись виконуватиметься напряму в main, механізму plan/fact recovery не буде; transcript не містить підтвердження, що цей сценарій потрібен зараз. - -## Update 2026-06-06 - -- Transcript фіксує фінальну мінімальну структуру вузла після видалення `patches/`: `task.md`, `invalidated`, `run_001.md`, `run_002.md`, `outputs_001.md` і дочірні директорії з власними `task.md`. -- Усі дані про інженерні зміни після цього рішення мають бути тільки в `run_NNN.md`, без окремих `patches/NNN-plan.md` або `patches/NNN-fact.md`. - -## Update 2026-06-06 - -- Transcript уточнює, що `patches/` видаляється не лише зі структури файлів, а й з recovery-логіки: `mt scan` більше не шукає незавершені `patch-plan` без `patch-fact`. -- Інформація про намір і зміни інженера переноситься у `## Reasoning` відповідного `run_NNN.md`. -- Негативний наслідок, зафіксований у transcript: детальний структурований аудит-трейл конкретних patch-змін не зберігається окремо, лише як вільний текст у `## Reasoning`. diff --git "a/docs/adr/260606-1615-mt-run-\320\277\320\260\321\200\320\260\320\273\320\265\320\273\321\214\320\275\320\270\320\271-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200-\321\206\320\270\320\272\320\273.md" "b/docs/adr/260606-1615-mt-run-\320\277\320\260\321\200\320\260\320\273\320\265\320\273\321\214\320\275\320\270\320\271-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200-\321\206\320\270\320\272\320\273.md" deleted file mode 100644 index d72297c..0000000 --- "a/docs/adr/260606-1615-mt-run-\320\277\320\260\321\200\320\260\320\273\320\265\320\273\321\214\320\275\320\270\320\271-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200-\321\206\320\270\320\272\320\273.md" +++ /dev/null @@ -1,43 +0,0 @@ ---- -type: ADR -title: mt run як паралельний оркестратор-цикл -description: Команда mt run без аргументів запускає готові вузли графу паралельно та повторює сканування після merge, доки черга не спорожніє. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Після ручного merge worktree в main потрібно визначити, хто і як запускає наступні готові вузли графу. Розглядалося, чи людина має запускати кожен вузол окремо, чи система повинна мати автоматичний оркестратор-цикл. - -## Considered Options - -- Людина вручну запускає кожен вузол через `mt run <path>`. -- `mt run` без аргументів працює як автоматичний оркестратор-цикл. - -## Decision Outcome - -Chosen option: "`mt run` без аргументів як оркестратор-цикл", because команда знаходить усі вузли з resolved-залежностями, запускає їх паралельно в топологічному порядку, після кожного merge повторює сканування і продовжує, доки черга не порожня. - -### Consequences - -- Good, because людина запускає одну команду, а система сама доводить граф до кінця. -- Good, because незалежні вузли одного рівня запускаються паралельно і зменшують загальний час виконання. -- Bad, because паралельність обмежена локальними лімітами `warn_worktrees_above: 4` і `max_worktrees: 8`, зафіксованими для MacBook-контексту. -- Neutral, because transcript не містить підтвердження щодо max-глибини графу і max-розміру файлу; ці питання лишалися відкритими. - -## More Information - -Повʼязані команди з transcript: `mt run [<path>]`, `mt kill <path>`, `mt invalidate <path> [--cascade]`, `mt done`, `mt failed`, `mt spawn`. - -Конфіг: `.n-cursor.json` з полями `warn_worktrees_above: 4` і `max_worktrees: 8`. - -Файл специфікації: `npm/docs/mt.md`. - -## Update 2026-06-06 - -- Bootstrap кореневого вузла використовує той самий формат `task.md`, але без поля `parent`; людина вручну створює `tasks/<project>/task.md`, після чого `mt run tasks/<project>` стартує корінь. -- Ліміт паралелізму задається як `max_worktrees` у `.n-cursor.json`; `mt run --auto` тримає чергу й запускає наступників лише якщо кількість активних worktree менша за `max_worktrees`. -- Observability лишається мінімальним: `mt scan` для pull-моделі, а при `result: failed` wrapper виводить terminal bell (`\a`) без зовнішніх залежностей. -- Transcript також фіксує `artifacts/` як фіксовану директорію вузла для великих файлів; `ref:` в `outputs_NNN.md` має вказувати всередину неї. diff --git "a/docs/adr/260606-2108-\320\276\320\272\321\200\320\265\320\274\320\270\320\271-\320\274\320\276\320\264\321\203\320\273\321\214-task-orchestration-dag.md" "b/docs/adr/260606-2108-\320\276\320\272\321\200\320\265\320\274\320\270\320\271-\320\274\320\276\320\264\321\203\320\273\321\214-task-orchestration-dag.md" deleted file mode 100644 index e5b6777..0000000 --- "a/docs/adr/260606-2108-\320\276\320\272\321\200\320\265\320\274\320\270\320\271-\320\274\320\276\320\264\321\203\320\273\321\214-task-orchestration-dag.md" +++ /dev/null @@ -1,44 +0,0 @@ ---- -type: ADR -title: "Окремий модуль task orchestration DAG у npm/scripts/graph" -description: Нову систему task orchestration DAG реалізуємо в окремому модулі, не переписуючи legacy dispatcher graph. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Існуючий `npm/scripts/dispatcher/graph.mjs` є read-only прототипом для `docs/graphs/<g>/nodes/*.md` і деривує статус з artifact-файлів `plan`, `claim`, `fact`, `ask`, `ans`. Документ `npm/docs/mt.md` описує іншу архітектуру: автономний DAG задач на основі `tasks/<node>/task.md`, worktree-ізоляції та lifecycle через сигнальні файли. Потрібно було вирішити, чи розширювати legacy dispatcher, чи створювати окрему реалізацію для нового формату. - -## Considered Options - -- Розширити існуючий `dispatcher/graph.mjs` з backward-compat шаром для нового формату. -- Створити окремий модуль `npm/scripts/graph/` і залишити старий `dispatcher/graph.mjs` незайманим. - -## Decision Outcome - -Chosen option: "Створити окремий модуль `npm/scripts/graph/`", because архітектури несумісні на рівні file layout і state machine: старий формат читає `docs/graphs/` з artifact-типами, новий читає `tasks/` і стан з присутності sentinel-файлів; пряма заміна зламала б існуючі тести `dispatcher/tests/graph.test.mjs` без практичної користі. - -### Consequences - -- Good, because старі тести `dispatcher/tests/` залишаються зеленими для legacy-реалізації. -- Good, because нова система отримує ізольований простір для власних тестів, зокрема `graph/tests/state.test.mjs`. -- Bad, because transcript фіксує потенційну плутанину: `mt` тимчасово має дві реалізації під різними route-ами до видалення legacy `dispatcher/graph.mjs`. -- Neutral, because transcript містить факт окремого top-level route для `watch`, але не містить підтвердження додаткових наслідків цього рішення. - -## More Information - -- Нові файли, згадані в transcript: `npm/scripts/graph/config.mjs`, `state.mjs`, `scan.mjs`, `setup.mjs`, `init.mjs`, `invalidate.mjs`, `signals.mjs`, `run.mjs`, `kill.mjs`, `watch.mjs`, `index.mjs`, `tests/state.test.mjs`. -- CLI routing: `npm/bin/n-cursor.js`, `case 'graph'` імпортує `../scripts/graph/index.mjs`. -- Доданий route: `case 'watch'` → `../scripts/graph/watch.mjs`. -- Стан вузла в новій системі деривується з файлів у `tasks/<node>/`: `task.md`, `run_NNN.md`, `outputs_NNN.md`, `invalidated` та активного worktree. -- Сигнальні команди агента згадані в transcript: `mt done <path>`, `mt audit <path>`, `mt failed <path>`, `mt spawn <path>`; вони пишуть `.signal` у директорію вузла, wrapper читає сигнал після завершення процесу. - -## Update 2026-06-06 - -- Уточнено sentinel-based state machine для `tasks/<node>/`: `waiting` — лише `task.md`; `running` — активний worktree; `resolved` — існує `outputs_NNN.md`; `failed` — є `run_NNN.md` без відповідного `outputs_NNN.md` і без worktree; `invalidated` — існує sentinel `invalidated`. -- Реалізаційні функції, згадані в transcript: `deriveNodeState`, `latestNumbered`, `nextNumbered`, `sanitizePathToWorktreePrefix` у `npm/scripts/graph/state.mjs`. -- CLI routing уточнено як `case 'graph'` → `scripts/graph/index.mjs`, `case 'graph-dag'` → legacy `scripts/dispatcher/graph.mjs`, `case 'watch'` → `scripts/graph/watch.mjs`. -- Для комунікації агент→wrapper використовується sentinel `.ncursor-signal` з `type: done|audit|failed|spawn`; wrapper читає файл після завершення процесу, видаляє sentinel і виконує відповідну дію. -- Після повної реалізації transcript фіксує відкат: `git checkout -- npm/bin/n-cursor.js`, `rm -rf npm/scripts/graph/`, `rm -f .changes/260606-2107.md`, після чого команда переходить до ітеративного проектування. diff --git "a/docs/adr/260606-2125-\320\262\321\226\320\264\320\272\320\260\321\202-\321\200\320\265\320\260\320\273\321\226\320\267\320\260\321\206\321\226\321\227-graph-\320\274\320\276\320\264\321\203\320\273\321\217-\321\202\320\260-\321\226\321\202\320\265\321\200\320\260\321\202\320\270\320\262\320\275\320\265-\320\277\321\200\320\276\320\265\320\272\321\202\321\203\320\262\320\260\320\275\320\275\321\217.md" "b/docs/adr/260606-2125-\320\262\321\226\320\264\320\272\320\260\321\202-\321\200\320\265\320\260\320\273\321\226\320\267\320\260\321\206\321\226\321\227-graph-\320\274\320\276\320\264\321\203\320\273\321\217-\321\202\320\260-\321\226\321\202\320\265\321\200\320\260\321\202\320\270\320\262\320\275\320\265-\320\277\321\200\320\276\320\265\320\272\321\202\321\203\320\262\320\260\320\275\320\275\321\217.md" deleted file mode 100644 index 2d82396..0000000 --- "a/docs/adr/260606-2125-\320\262\321\226\320\264\320\272\320\260\321\202-\321\200\320\265\320\260\320\273\321\226\320\267\320\260\321\206\321\226\321\227-graph-\320\274\320\276\320\264\321\203\320\273\321\217-\321\202\320\260-\321\226\321\202\320\265\321\200\320\260\321\202\320\270\320\262\320\275\320\265-\320\277\321\200\320\276\320\265\320\272\321\202\321\203\320\262\320\260\320\275\320\275\321\217.md" +++ /dev/null @@ -1,35 +0,0 @@ ---- -type: ADR -title: Відкат реалізації graph-модуля та перехід до ітеративного проектування -description: Після повної спроби реалізації нового `mt graph` зміни відкочено, щоб спочатку узгодити архітектуру і лише потім імплементувати її поетапно. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -У сесії була реалізована нова система autonomous DAG для `mt`: файловий стан вузлів у `tasks/<node>/task.md`, sentinel-файли, окремий модуль `npm/scripts/graph/`, CLI routing і тести. Після цього користувач вирішив не залишати повну реалізацію, а перейти до ітеративної дискусії перед наступною імплементацією. - -## Considered Options - -* Ітеративне проектування: спочатку обговорити архітектурні рішення, потім реалізовувати поетапно. -* Пряма реалізація за наявною spec, яка була виконана спочатку. - -## Decision Outcome - -Chosen option: "Ітеративне проектування", because transcript містить явний відкат через `git checkout -- npm/bin/n-cursor.js`, `rm -rf npm/scripts/graph/`, `rm -f .changes/260606-2107.md` і завершується запитом перейти до ітеративної дискусії перед побудовою архітектури. - -### Consequences - -* Good, because менша ймовірність зафіксувати архітектуру, яка не відповідає потребам після глибшого обговорення. -* Bad, because уже написана реалізація `npm/scripts/graph/` та повʼязані тести не залишаються в робочому дереві. -* Neutral, because transcript фіксує, що зміни до сесії залишилися недоторканими. - -## More Information - -Відкочені зміни: `npm/bin/n-cursor.js`, `npm/scripts/graph/`, `.changes/260606-2107.md`. У відкоченій реалізації згадувалися `state.mjs`, `signals.mjs`, `watch.mjs`, `run.mjs`, `kill.mjs`, `index.mjs`, `tests/state.test.mjs`. Сигнал агент→wrapper пропонувався як `.ncursor-signal` з типами `done`, `audit`, `failed`, `spawn`, але після відкату це не стало прийнятою реалізацією. - -## Update 2026-06-06 - -Перед відкатом була спроба створити окремий модуль `npm/scripts/graph/` замість розширення legacy `dispatcher/graph.mjs`. Transcript фіксує файли `config.mjs`, `state.mjs`, `scan.mjs`, `setup.mjs`, `init.mjs`, `invalidate.mjs`, `signals.mjs`, `run.mjs`, `kill.mjs`, `watch.mjs`, `index.mjs`, `tests/state.test.mjs`, а також CLI routing у `npm/bin/n-cursor.js`. Старі dispatcher-тести залишалися зеленими, але реалізацію потім відкочено. diff --git "a/docs/adr/260606-2141-flow-\321\217\320\272-\320\277\321\200\320\276\321\202\320\276\320\272\320\276\320\273-\320\262\321\203\320\267\320\273\320\260-graph-\321\217\320\272-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200.md" "b/docs/adr/260606-2141-flow-\321\217\320\272-\320\277\321\200\320\276\321\202\320\276\320\272\320\276\320\273-\320\262\321\203\320\267\320\273\320\260-graph-\321\217\320\272-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200.md" deleted file mode 100644 index a5620ba..0000000 --- "a/docs/adr/260606-2141-flow-\321\217\320\272-\320\277\321\200\320\276\321\202\320\276\320\272\320\276\320\273-\320\262\321\203\320\267\320\273\320\260-graph-\321\217\320\272-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200.md" +++ /dev/null @@ -1,85 +0,0 @@ ---- -type: ADR -title: "Flow як внутрішній протокол вузла, graph як оркестратор" -description: Розділяємо відповідальність між graph-оркестратором DAG і flow-протоколом виконання одного вузла. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -У репозиторії існують дві паралельні системи. Ранній `flow`/`mt` workflow змішує init, spec, plan, verify і release, зберігає власний file-presence state та артефакти в `docs/`. Нова архітектура з `npm/docs/mt.md` описує автономний DAG задач: `tasks/<node>/task.md`, file-based state, git worktree, merge і post-merge orchestration. Ці системи перекриваються у worktree lifecycle та кроках виконання, але мають різні формати і різні рівні відповідальності. Потрібно поєднати їх без дублювання стану й команд. - -## Considered Options - -- `graph` як зовнішній оркестратор, `flow` як протокол всередині вузла. -- Повне злиття, де команди старого `flow` напряму стають командами нового `mt` lifecycle. -- Зберегти обидві системи паралельно без злиття. -- Два окремих кроки виконання вузла: Stage 1 `mt plan` і Stage 2 execution. -- Один монолітний крок, де агент сам вирішує коли планувати, а коли виконувати. -- Обʼєднати `mt init` і `mt plan` в один крок `mt plan` з режимом через атрибут `mode:` у `task.md`. -- Залишити окремі кроки `mt init` для design і `mt plan` для decompose. - -## Decision Outcome - -Chosen option: "`graph` як зовнішній оркестратор, `flow` як протокол всередині вузла; Stage 1 `mt plan` і Stage 2 execution", because `graph` має керувати worktree lifecycle, залежностями, merge і каскадом, а `flow` має обслуговувати логіку одного запуску зсередини worktree. Явний Stage 1 дає місце для design/decompose і людського перегляду перед execution, а обʼєднання `mt init` і `mt plan` прибирає дублювання команд. - -### Consequences - -- Good, because `flow` стає легшим: зникають MT file-presence state, `docs/specs/` і `docs/plans/` як окремий стан поза DAG. -- Good, because `graph` отримує повний контроль над станом вузлів, паралелізмом, worktree lifecycle, merge і каскадом. -- Good, because Stage 1 має два чіткі виходи: composite-вузол створює дочірні `task.md` і сигналізує spawn, atomic-вузол створює `plan_001.md` і переходить до виконання. -- Good, because retry можна робити для Stage 2 без повторення planning, якщо transcript не вимагає нового plan. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because transcript не містить остаточного рішення щодо окремих полів review для plan/output і спадкування review-параметрів дочірніми задачами. - -## More Information - -- Старі артефакти, які зникають у цьому рішенні: MT file-presence state, `docs/specs/`, `docs/plans/`. -- Нові артефакти: `task.md`, `plan_001.md`, `outputs_NNN.md`. -- `mt plan` запускається всередині worktree після `mt run`. -- `mt plan` читає `task.md` поточного вузла і `.n-cursor/system-prompt.md`. -- Атрибут `mode:` у frontmatter `task.md`: `human` як default для інтерактивного діалогу, `agent` для автономного режиму. -- Preflight-перевірка, що команда виконується в `.worktrees/`, переноситься з `mt init` у `mt plan`. -- Stage 2: агент виконує роботу, перевіряє критерії з `## Done when`, пише `outputs_NNN.md` і сигналізує `mt done`, `mt audit` або `mt failed`. -- `mt init` як окрема команда більше не потрібна в цьому дизайні; transcript фіксує обʼєднання design і decompose у спільний `mt plan`. - -## Update 2026-06-06 - -- Перед фінальним формулюванням рішення обговорювалась межа між двома режимами: Stage 1 Planning для побудови графу і Stage 2 Resolution для виконання атомарного вузла. -- Запропонований Stage 1 приймає high-level опис або `task.md` і створює ієрархію `tasks/<node>/task.md` з deps, budget і done-when. -- Запропонований Stage 2 запускає `mt run <node>`: worktree → agent → `outputs_NNN.md`. -- `verify` у цій дискусії розглядався як частина критерію `Done when`, а сигнали `done/failed/spawn/audit` — як заміна окремих команд завершення. - -## Update 2026-06-06 - -- Деталізовано Stage 1 `mt plan`: агент уже перебуває всередині worktree після `mt run` і читає `task.md` поточного вузла та `.n-cursor/system-prompt.md`. -- `mt plan` має вирішити, чи вузол atomic, чи composite. -- Для composite-вузла агент створює дочірні `tasks/<current>/<child>/task.md` і сигналізує spawn. -- Для atomic-вузла агент пише `plan.md` або numbered plan artifact і переходить до виконання. -- У transcript обговорювались, але не були остаточно вирішені, окремі поля review для plan/output і спадкування review-параметрів дочірніми задачами. - -## Update 2026-06-07 - -Підтверджено межу відповідальності: - -- `graph` оркеструє worktrees, deps, merge і cascade ззовні. -- `flow` лишається execution protocol всередині одного вузла. -- `npm/docs/mt.md` є living spec і джерелом правди; prompts і rules мають деривуватися з нього. -- `.n-cursor/system-prompt.md`, `.n-cursor/engineer-prompt.md` і `.n-cursor/actors.md` плануються як runtime-промпти та capability manifest. -- `task.md` може мати `actors: [human, agent]`; `mt run --actor X` має перевіряти дозволених акторів перед стартом. -- Асинхронний аудит може ставати файловою чергою через `pending-audit_NNN.md`, яку підхоплює `mt watch`. - -Transcript не містить підтверджених негативних наслідків для такого розподілу знань; ризики дублювання зменшуються через правило, що `npm/docs/mt.md` лишається living spec. - -## Update 2026-06-07 - -Уточнено двоетапний протокол вузла: - -- Stage 1: `mt plan` читає `task.md`, визначає atomic/composite шлях і створює `plan_001.md` або дочірні `task.md`. -- Для `mode: human` рекомендовано тонкий helper: IDE-агент є плануючим, а CLI робить preflight/контекст/валідацію. -- Для `mode: agent` transcript фіксує subagent spawning через наявний runner. -- Composite spawn не виконується автоматично: агент після створення дочірніх `task.md` має явно викликати spawn-команду. - -Transcript також згадує файли реалізації: `npm/scripts/dispatcher/index.mjs`, `npm/scripts/dispatcher/lib/commands.mjs`, `npm/scripts/dispatcher/lib/plan.mjs`, `npm/scripts/dispatcher/lib/spec.mjs`, `npm/scripts/dispatcher/lib/subagent-runner/`, `npm/docs/mt.md`. diff --git "a/docs/adr/260606-2149-\320\277\320\276\320\262\320\275\320\265-\320\277\320\265\321\200\320\265\321\204\320\276\321\200\320\274\320\260\321\202\321\203\320\262\320\260\320\275\320\275\321\217-flow-\320\277\321\226\320\264-\320\260\321\200\321\205\321\226\321\202\320\265\320\272\321\202\321\203\321\200\321\203-mt.md" "b/docs/adr/260606-2149-\320\277\320\276\320\262\320\275\320\265-\320\277\320\265\321\200\320\265\321\204\320\276\321\200\320\274\320\260\321\202\321\203\320\262\320\260\320\275\320\275\321\217-flow-\320\277\321\226\320\264-\320\260\321\200\321\205\321\226\321\202\320\265\320\272\321\202\321\203\321\200\321\203-mt.md" deleted file mode 100644 index 3370aae..0000000 --- "a/docs/adr/260606-2149-\320\277\320\276\320\262\320\275\320\265-\320\277\320\265\321\200\320\265\321\204\320\276\321\200\320\274\320\260\321\202\321\203\320\262\320\260\320\275\320\275\321\217-flow-\320\277\321\226\320\264-\320\260\321\200\321\205\321\226\321\202\320\265\320\272\321\202\321\203\321\200\321\203-mt.md" +++ /dev/null @@ -1,124 +0,0 @@ ---- -type: ADR -title: Повне переформатування flow під архітектуру mt -description: `graph` стає зовнішнім оркестратором DAG, а `flow` перетворюється на внутрішній двостадійний протокол виконання одного вузла. ---- - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Існуючий `flow` змішував планування, виконання, verify/release і власний file-presence state. Архітектура `npm/docs/mt.md` описує інший контракт: автономний DAG у `tasks/<node>/task.md`, виконання агентів у git worktree, файловий стан вузлів і оркестрація графу ззовні. Потрібно поєднати ці системи без дублювання відповідальності. - -## Considered Options - -* Повне переформатування `flow` під контракт `npm/docs/mt.md`, де `graph` є зовнішнім оркестратором, а `flow` — внутрішнім протоколом вузла. -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "Повне переформатування `flow` під контракт `npm/docs/mt.md`", because transcript фіксує природний поділ: `graph` керує DAG, worktree lifecycle, merge, cascade і запуском наступників, а `flow` обслуговує один вузол зсередини через planning та execution. - -### Consequences - -* Good, because уніфікована файлова модель `task.md`, `plan_001.md`, `run_001.md`, `outputs_001.md`, `invalidated` замінює розрізнені `docs/specs/`, `docs/plans/` і MT file-presence state. -* Good, because Stage 1 може завершити composite-вузол через spawn без зайвого execution, а atomic-вузол переходить до виконання з `plan_001.md`. -* Bad, because `mt init`, попередній `mt done`, MT file-presence state, `docs/specs/` і `docs/plans/` зникають та потребують міграції існуючих flows. -* Neutral, because transcript не містить підтвердження додаткових наслідків для сумісності поза переліченою міграцією. - -## More Information - -Нова межа відповідальності: `graph` оркеструє весь DAG ззовні; `flow` працює всередині одного worktree. Двостадійний протокол вузла: Stage 1 — `mt plan`, що поєднує design і decompose; Stage 2 — виконання, `mt verify`, запис `outputs_NNN.md`, сигнал `mt done | mt audit | mt failed`. Для Stage 1 transcript фіксує `mode: human|agent` у frontmatter `task.md`, default `human`. Atomic-вихід Stage 1 — immutable `plan_001.md`; composite-вихід — дочірні `task.md` і явний `mt spawn`. Оркестрація черги: `mt run --auto` одноразово сканує ready-вузли і запускає worktrees з урахуванням `max_worktrees`; post-merge hook викликає `mt run --auto`; `mt watch` працює як демон для файлових подій у `tasks/` і watchdog для stale worktrees. - -## Update 2026-06-06 - -Додатково зафіксовано pull-модель людської сигналізації через `mt watch`: команда сканує граф і репортить вузли, що потребують втручання, зокрема ≥ 3 поспіль `actor: auditor, result: failed`, failed root-level engineer run та stale worktree без змін довше `stale_worktree_min`. Transcript також повʼязує `mt watch` з post-merge hook поруч із `mt run --auto`. - -## Update 2026-06-06 - -Transcript додатково формулює межу відповідальності: `graph` керує worktree-lifecycle, залежностями, merge і каскадом ззовні, а `flow` лишається внутрішнім протоколом одного вузла. Для Stage 1 зафіксовано `mt plan` як поєднання design і decompose; інтерактивність задається `mode: human|agent` у frontmatter `task.md`, де `human` є default. Preflight перевірка виконання в `.worktrees/` переноситься з колишнього `mt init` у `mt plan`. - -## Update 2026-06-06 - -Уточнено інтеграцію `flow` і `graph`: - -- `graph` керує DAG і worktree lifecycle ззовні. -- `flow` стає протоколом одного вузла зсередини. -- Stage 1 виконується через `mt plan` і поєднує design/decompose. -- `mode: human | agent` зберігається у frontmatter `task.md`; default — `human`. -- Для composite-шляху `mt plan` лише створює дочірні `task.md`; агент явно викликає `mt spawn`. -- Оркестрація наступних вузлів покривається і post-merge hook, і `mt watch` daemon. - -Transcript також фіксує breaking change: старий MT file-presence state, `docs/specs/`, `docs/plans/` і команди старого flow-протоколу мають бути прибрані або переписані під файловий контракт `tasks/<node>/`. - -## Update 2026-06-06 - -Додано реалізаційні наслідки реформи `flow`: - -- `mt init` зникає; його роль поглинає `mt plan`. -- `mt plan` читає `task.md`, враховує `mode: human | agent`, пише `plan_001.md` для atomic-шляху або дочірні `task.md` для composite-шляху. -- Для composite-шляху агент після перевірки дочірніх файлів явно викликає `mt spawn <path>`. -- Stage 2 виконує атомарну роботу, `mt verify` перевіряє `## Done when`, після чого агент сигналізує `mt done | mt audit | mt failed`. -- До видалення або переписування потрапляють старі модулі `spec`, `plan`, `plan-panel`, `flow-lock`, `state-store`, `snapshot`, `artifact`, а також старі CLI-команди `init`, `spec`, `release`. - -Додатково підтверджено потребу в окремому оркестраторі черги: `mt run --auto` для one-shot запуску після hook і `mt watch` як daemon. - -## Update 2026-06-07 - -Уточнено дворівневий протокол вузла: - -- Stage 1 — `mt plan`: `mode: human` створює `plan-pending`, `mode: agent` дозволяє агенту перейти далі без паузи. -- Atomic path: `plan_001.md` як numbered immutable артефакт. -- Composite path: дочірні `task.md` і `plan_001.md` з `## Sub-tasks`; агент явно викликає `mt spawn`. -- Stage 2 — `mt verify`: LLM-аудитор читає `task.md ## Done when`, `outputs_NNN.md` і `plan_001.md`, пише `verify_001.md`, exit `0=PASS` / `1=FAIL`. -- Додається спеціаліст-інструмент `flow audit` у формі `mt audit --criterion "..." --file path`, який повертає JSON `{verdict: PASS|FAIL, reason}`. -- Новий стан вузла: `plan-pending` — є `plan_*.md`, немає `outputs_*.md`, немає активного worktree, `mode: human`. - -Видалення старих команд (`mt init`, `spec`, `run`, `cancel`, `resume`, `repair`, `review`, `gate`, `release`) є breaking change і має бути реалізоване разом з оновленням `n-flow.mdc`. - -## Update 2026-06-07 - -Додано деталізацію рішень щодо `flow`: - -- `mt init` поглинається в `mt plan`; `docs/specs/` зникає. -- `mt plan` стає Stage 1 і створює `plan_001.md` або дочірні `task.md`. -- Stage 2 охоплює виконання atomic-вузла і подальший `mt verify` за `## Done when`. -- `mode: human | agent` у `task.md` керує human-in-the-loop без зміни коду. -- `mt spawn` не bundled у `mt plan`; агент викликає його явно після створення дочірніх задач. -- Фасад B (`mt run/resume/cancel/repair`) видаляється, бо файловий стан у `tasks/<node>/` замінює MT file-presence state. - -Окремо зафіксовано схему `pending-audit_NNN.md`, де NNN дзеркалить NNN відповідного `outputs_NNN.md`; детальне рішення про аудит нормалізується окремим ADR у цьому батчі. - -## Update 2026-06-07 - -Transcript уточнив переформатування протоколу навколо єдиного namespace для оркестрації вузлів: окремий `flow` namespace вважався надлишковим, якщо після видалення старих lifecycle-команд лишається лише planning-команда. - -Додаткові зафіксовані уточнення: -- Stage 1: `mt plan` читає `task.md` і створює або `plan_001.md`, або дочірні `task.md`; -- Stage 2: агент виконує роботу, пише `outputs_NNN.md` і сигналізує `done`, `audit` або `failed`; -- `mode: human` є дефолтом для planning-етапу, `mode: agent` дозволяє автономне планування; -- аудит-черга через `pending-audit_NNN.md` замінює дублювання між self-check і зовнішнім аудитом; -- `pending-audit_NNN.md` дзеркалить номер відповідного `outputs_NNN.md`. - -## Update 2026-06-07 - -- Transcript уточнює, що `flow` має зникнути повністю, а не залишатися bridge-namespace: `mt plan` переноситься у `mt plan`, а `mt init/spec/verify/release/run/resume/cancel/repair` видаляються або поглинаються новим `mt` lifecycle. -- Двофазний протокол вузла фіксується як Stage 1 `mt plan` і Stage 2 виконання: атомарний вузол пише `plan_001.md`, composite вузол створює дочірні `task.md` через `mt spawn`. -- Async аудит замінює синхронні `mt verify`/`mt audit`: агент може створити `pending-audit_NNN.md`, а окремий auditor обробляє чергу. -- Composite вузол не пише `outputs_NNN.md`; його стан деривується bottom-up зі станів дочірніх вузлів. -- Для `pending-audit_NNN.md` зафіксовано вимогу однозначного звʼязку з відповідним результатом аудиту, щоб оркестратор не dispatch-ив аудит повторно. - -## Update 2026-06-07 - -- Уточнено, що окремий `flow` namespace ліквідується: єдина точка входу лишається `mt`, а команди `plan`, `done`, `audit`, `failed`, `spawn`, `run`, `kill`, `invalidate`, `scan`, `setup`, `init`, `watch` належать новій graph-архітектурі. -- Файловий контракт аудиту: `pending-audit_NNN.md` сигналізує очікування аудиту для відповідного `fact_NNN.md`/попередньо `outputs_NNN.md`, а `audit-result_NNN.md` з тим самим `NNN` означає, що audit-request consumed. -- Composite-вузол не пише власний result-файл: його стан `resolved` деривується implicit aggregation, коли всі діти resolved. -- Координація `mt run --auto` і `mt watch` виконується через atomic `mkdir` worktree-директорії: перший runner створює worktree, другий отримує `EEXIST` і пропускає вузол. - -## Update 2026-06-07 - -- Підтверджено окремий audit-result контракт: аудитор не пише `run_NNN.md`, а створює `audit-result_NNN.md`; `pending-audit_002.md` consumed тоді й лише тоді, коли існує `audit-result_002.md`. -- `audit-result_NNN.md` має містити reasoning для actionable feedback, особливо при `result: failed`. -- `mt run --actor auditor` працює як wrapper: запускає auditor subprocess, читає `audit-result_NNN.md`, при `result: success` виконує merge і видаляє worktree. -- Для `mode: human` без `plan_001.md` автоматичний оркестратор не блокується: вузол пропускається як `human-pending`, а людина запускає `mt plan <path>` вручну. diff --git a/docs/adr/260607-0527-260607-0527-bce336cc.md b/docs/adr/260607-0527-260607-0527-bce336cc.md deleted file mode 100644 index 3efb0fc..0000000 --- a/docs/adr/260607-0527-260607-0527-bce336cc.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: ADR -title: "260607-0527-bce336cc" ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement -Архітектура в `npm/docs/mt.md` визначає дві ролі людини щодо UI: 1. Моніторинг — бачити де і в якому стані граф прямо зараз; 2. Розслідування інцидентів — коли вузол `failed`, зрозуміти **чому** і вирішити що робити. UI є суто read-only переглядачем, оскільки всі дії (`mt kill`, `mt run`, патчі) виконуються лише через CLI. - -## Considered Options -* Flow 1 — Щоденний моніторинг: UI бачить дерево вузлів із кольоровими станами (waiting, running, resolved, failed, invalidated); оновлення відбувається через `polling mt scan --json`. -* Flow 2 — Розслідування інциденту: Клік на вузол відкриває панель деталей з файлами (`task.md`, `run_001.md` тощо), що дозволяє зрозуміти причину та іти в CLI. -* Flow 3 — Прогрес виконання задачі: Розгортання кореневого вузла показує дочірні вузли та їхні стани (наприклад, `collect-data` $ o$ resolved ✓, `analyze` $ o$ running $ ext{⟳}$). - -## Decision Outcome -Chosen option: "UI буде реалізовувати три основні flow (моніторинг, розслідування інцидентів, прогрес виконання задачі), які зводяться до циклу: `scan файлів` $ o$ `відобразити стани` $ o$ `людина читає` $ o$ `людина діє через CLI`.", because Це пряме відображення контракту моніторингу з `npm/docs/mt.md` (розділ "Контракт для моніторингу"): скрипт сканує, показує дерево зі станами, failed-вузли, активні worktree. UI — це той самий контракт, але у браузері замість термінала. - -### Consequences -* Good, because Суто read-only переглядач, що відповідає принципам CLI-first архітектури. -* Good, because Використання polling (`mt scan --json`) як найпростішого та найпрямолінійнішого варіанту для відображення стану, оскільки стан вузла залежить від наявності файлів, а не від подій. -* Good, because Структура навігації заснована на дерево файлової системи, а не на складний DAG-граф з стрілками. -* Good, because Відсутність неконтрольованих функцій, як-от кнопки `kill`/`run` у самому UI. -* Good, because Виключення відображення повного вмісту великих файлів (`outputs_NNN.md`), замінюючи це посиланнями. -* Good, because Немає реального потоку логів агента, оскільки він пише фінальні файли (`run_NNN.md`) після завершення. - -## More Information -polling mt scan --json,task.md,run_001.md,CLI (mt kill / mt run / патч) diff --git "a/docs/adr/260607-0527-read-only-ui-\320\274\320\276\320\275\321\226\321\202\320\276\321\200\320\270\320\275\320\263-\320\263\321\200\320\260\321\204\321\203-mt.md" "b/docs/adr/260607-0527-read-only-ui-\320\274\320\276\320\275\321\226\321\202\320\276\321\200\320\270\320\275\320\263-\320\263\321\200\320\260\321\204\321\203-mt.md" deleted file mode 100644 index c1682c2..0000000 --- "a/docs/adr/260607-0527-read-only-ui-\320\274\320\276\320\275\321\226\321\202\320\276\321\200\320\270\320\275\320\263-\320\263\321\200\320\260\321\204\321\203-mt.md" +++ /dev/null @@ -1,50 +0,0 @@ ---- -type: ADR -title: Read-only UI для моніторингу графу mt -description: UI має бути read-only переглядачем станів task-графу, а всі керуючі дії залишаються в CLI. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Архітектура `npm/docs/mt.md` описує файловий task-граф, де стан вузлів визначається файлами в `tasks/<node>/`. Людині потрібен інтерфейс для щоденного моніторингу, розслідування `failed` або `invalidated` вузлів і перегляду прогресу складених задач. Потрібно визначити роль UI відносно CLI. - -## Considered Options - -- Read-only UI: сканує файловий стан, показує дерево вузлів, деталі `task.md` і `run_NNN.md`, а керуючі дії залишає CLI. -- UI як control plane з кнопками `kill`, `run`, редагуванням `task.md` та іншими діями. -- DAG-граф зі стрілками замість tree-view. -- SSE або real-time stream замість polling. - -## Decision Outcome - -Chosen option: "Read-only UI", because transcript визначає UI як observability tool: стан читається з файлів через `mt scan --json`, а всі дії (`mt kill`, `mt run`, патчі) виконуються через CLI. - -### Consequences - -- Good, because UI прямо відображає контракт моніторингу з `npm/docs/mt.md`: scan файлів → показ станів → людина читає → людина діє через CLI. -- Good, because tree-view відповідає фізичній структурі вкладених директорій `tasks/` і не додає складності DAG-рендерингу без підтвердженої користі для read-only переглядача. -- Neutral, because polling `mt scan --json` обрано замість SSE через файлову природу стану: сервер теж дізнається про зміни тільки після сканування. -- Bad, because transcript не містить підтвердження негативних наслідків read-only підходу. - -## More Information - -UI flow з transcript: - -- щоденний моніторинг: відкрити UI → побачити дерево вузлів зі станами `waiting`, `running`, `resolved`, `failed`, `invalidated` → auto-refresh через polling; -- розслідування інциденту: клік на `failed` або `invalidated` вузол → панель деталей з `task.md`, `run_001.md`, `run_002.md` і `## Reasoning` → дія через CLI; -- прогрес складеної задачі: розгорнути кореневий вузол → побачити дочірні вузли і їхні стани. - -У UI не додаються кнопки `kill` / `run`, редагування `task.md`, повний inline-вміст великих `outputs_NNN.md` або real-time stream логів агента. - -## Update 2026-06-07 - -Transcript уточнив спосіб доступу UI до стану task-графа: UI має читати стан через API-шар на базі `mt scan --json`, REST або SSE, а не через пряме монтування `tasks/` у UI. - -Додаткові факти: -- прямий доступ до `tasks/` через dev pod/Zed remote розглядався як developer-debug сценарій, а не як основний UI transport; -- для Kubernetes-середовища обговорено dev pod, який монтує той самий PVC, що й worker pods; -- запропонована тимчасова команда доступу: `kubectl port-forward pod/n-graph-dev 2222:22`, але остаточне рішення щодо доступу тут не зафіксовано; -- назви `n-graph`, `graphwatch`, `taskflow` обговорювалися, але фінальний вибір у transcript не підтверджено. diff --git a/docs/adr/260607-0536-flow-agent-type.md b/docs/adr/260607-0536-flow-agent-type.md deleted file mode 100644 index 7b18fc7..0000000 --- a/docs/adr/260607-0536-flow-agent-type.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -type: ADR -title: Введення типу агента flow у @nitra/cursor -description: До реєстру агентів додається новий тип flow, який використовує CLI API mt plan, mt verify і mt run. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Пакет `@nitra/cursor` має фіксований набір типів агентів: `adr`, `coverage`, `docgen`, `fix`, `lint`, `taze`. Потрібно додати новий тип агента `flow`, який ходить по агентам через API і працює з командами `mt plan`, `mt verify` та `mt run <name> <input>`. Задача має бути описана у TypeScript-типах і сутностях агентів. - -## Considered Options - -- Додати `flow` як повноцінний агент: розширити `AgentId`, створити `FlowAgent`, додати export і запис у `AGENTS`. -- Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "Додати `flow` як повноцінний агент", because користувач прямо описав новий тип агента `flow`, який використовує API `mt plan`, `mt verify` і `mt run <name> <input>`, а існуюча кодова база вже має патерн агентів через `AgentId`, `Agent` і `runCli()`. - -### Consequences - -- Good, because `flow` стає першокласним значенням у типах і реєстрі `AGENTS` поруч з іншими агентами. -- Good, because реалізація може повторити наявний патерн `AdrAgent`, `CoverageAgent`, `DocgenAgent`, `FixAgent`, `LintAgent`, `TazeAgent`. -- Neutral, because transcript фіксує, що `npm/src/cli/flow/plan.ts`, `verify.ts` і `run.ts` на момент аналізу містять TODO-заглушки. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Файли, зафіксовані в transcript як релевантні: - -- `npm/src/types.ts` — розширити `AgentId` значенням `'flow'`; за потреби додати `StructuredOutput`, `FlowPlan`, `FlowVerify`, `FlowStep`. -- `npm/src/agents/flow.ts` — новий файл з `FlowAgent implements Agent`. -- `npm/src/agents.ts` — додати `export { FlowAgent }` і запис `flow` у `AGENTS`. -- `npm/src/common.ts` — містить helper `runCli(command, input)` через `spawnSync('n-cursor', ...)`. -- `npm/src/cli/flow/plan.ts` — API `mt plan`, повертає `StructuredOutput` з plan. -- `npm/src/cli/flow/verify.ts` — API `mt verify`, повертає `StructuredOutput` з verify. -- `npm/src/cli/flow/run.ts` — API `mt run <name> <input>`, де `<input>` є JSON-рядком. - -Для major bump створено changeset `.changesets/1749296099946-npm.md` з `bump: major` для workspace `npm`; версію вручну не змінювати. - -## Update 2026-06-07 - -Transcript уточнив роль `flow`/внутрішнього протоколу вузла як двоетапної взаємодії агента з файловими артефактами вузла. - -Додаткові факти: -- для `mode: human` розглянуто варіант, де IDE-агент є planning-мозком, а CLI лише робить preflight, показує контекст і валідує `plan_001.md` через finalize-крок; -- для `mode: agent` очікувався subprocess агента з timeout, похідним від `budget_sec`; -- `mt plan --finalize` у transcript описано як перевірку того, що IDE-агент уже створив коректний `plan_001.md`; -- семантичний verify розглядався як гібрид: скрипт перевіряє наявність файлів, LLM/агент оцінює `## Done when`. - -## Update 2026-06-07 - -Після рефакторингу transcript зафіксував реалізаційні деталі двоетапного протоколу вузла. - -Додаткові факти: -- `mt plan` читає `task.md`, враховує `mode` і опціональний `hint`, створює numbered `plan_NNN.md` template; -- для planning розглянуто гібридний підхід `hint: atomic|composite`, де людина підказує напрям, але агент не заблокований цим полем; -- `mt verify` у цій ітерації описано як структурний check плюс stdout-контекст без запису `verify_*.md`; -- `flow done`, `flow audit`, `flow failed`, `flow spawn` описано як сигнали, що знаходять node path через `MT_NODE_PATH` або `.n-cursor/current-node` і делегують у graph-level команди; -- async audit queue використовує numbered `pending-audit_NNN.md`, де NNN відповідає `outputs_NNN.md`. diff --git "a/docs/adr/260607-0536-\320\262\320\262\320\265\320\264\320\265\320\275\320\275\321\217-\321\202\320\270\320\277\321\203-\320\260\320\263\320\265\320\275\321\202\320\260-flow-\321\203-nitra-cursor-2.md" "b/docs/adr/260607-0536-\320\262\320\262\320\265\320\264\320\265\320\275\320\275\321\217-\321\202\320\270\320\277\321\203-\320\260\320\263\320\265\320\275\321\202\320\260-flow-\321\203-nitra-cursor-2.md" deleted file mode 100644 index b575077..0000000 --- "a/docs/adr/260607-0536-\320\262\320\262\320\265\320\264\320\265\320\275\320\275\321\217-\321\202\320\270\320\277\321\203-\320\260\320\263\320\265\320\275\321\202\320\260-flow-\321\203-nitra-cursor-2.md" +++ /dev/null @@ -1,37 +0,0 @@ ---- -type: ADR -title: "Введення типу агента `flow` у `@nitra/cursor`" ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement -Поточна архітектура агентів не передбачає вбудованого типу `flow`, який би керував виконанням інших агентів через API. Потребується розширення існуючої системи агентів для підтримки такого оркеструючий логіки. - -## Considered Options -* Створення нового агента-оркестратора типу `flow` з використанням внутрішніх API. -Реалізація `flow` як спеціального режиму для існуючих агентів. -Використання зовнішнього оркестратора для управління `flow`. - -## Decision Outcome -Chosen option: "Створення нового агента-оркестратора типу `flow` з використанням внутрішніх API.", because Новий тип `flow` чітко окреслює новий паттерн поведінки — керування іншими агентами через API. Це забезпечить модульність та легкість розширення, дозволяючи ізолювати логіку оркестрації від базових агентів. API `mt plan`, `mt verify`, `mt run` ідеально підходять для реалізації цього механізму. - -### Consequences -* Good, because Чітке визначення нової парадигми поведінки агента (`flow`). -Можливість повторно використовувати існуючі компоненти (агенти, API). -Абстрагування складної логіки оркестрації в окремий тип. -* Bad, because Потребує модифікації ядра системи агентів (типи та сутності). -Необхідність докладної роботи з JSON-серіалізацією для вхідних даних `mt run`. - -## More Information -Типи (interfaces/types) сутностей агентів для визначення `FlowAgent`. -Реалізація логіки `flow` у відповідній сутності (entity) або сервісі. -Оновлення логіки обробки викликів API, що відповідає схемою `StructuredOutput`. -Інтерфейс для взаємодії з `mt run <name> <input>`. - -## Update 2026-06-07 - -Уточнено роль `mt plan` у `mode: human`: скрипт має бути тонким helper-ом, а IDE-агент лишається планувальним інтелектом. Helper виконує preflight, читає `task.md`, форматує контекст і на фінальному кроці валідує наявність коректного `plan_001.md` або дочірніх `task.md`. - -Для `mode: agent` зафіксовано автономний шлях через subprocess/subagent runner із бюджетом на planning-фазу. Для composite-вузла `mt plan` не запускає дітей автоматично: агент явно викликає spawn після створення дочірніх `task.md`. diff --git "a/docs/adr/260607-0607-pending-audit-\321\207\320\265\321\200\320\263\320\260.md" "b/docs/adr/260607-0607-pending-audit-\321\207\320\265\321\200\320\263\320\260.md" deleted file mode 100644 index e62fe43..0000000 --- "a/docs/adr/260607-0607-pending-audit-\321\207\320\265\321\200\320\263\320\260.md" +++ /dev/null @@ -1,53 +0,0 @@ ---- -type: ADR -title: Аудит-черга через pending-audit_NNN.md -description: Запит на аудит зберігається як numbered immutable файл, який mt watch підхоплює асинхронно. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Після того як агент завершує роботу над вузлом DAG, потрібен механізм аудиту якості за критеріями з `task.md`. Старий підхід запускав аудитора синхронно через wrapper або команду, але нова архітектура `npm/docs/mt.md` базується на файловому стані `tasks/<node>/` і черзі, яку сканує `mt watch`. Потрібно визначити, як позначати запит на аудит і як пов'язувати його з конкретною версією `outputs_NNN.md`. - -## Considered Options - -- Синхронний запуск аудитора wrapper-скриптом. -- Асинхронна черга через файл `pending-audit_NNN.md`. -- Порожній sentinel `.pending-audit` без прив'язки до версії. -- Overwrite-файл `.pending-audit` з `ref:` полем. - -## Decision Outcome - -Chosen option: "Асинхронна черга через `pending-audit_NNN.md`", because transcript фіксує принцип «стан = файли», а NNN в імені `pending-audit_NNN.md` однозначно посилається на відповідний `outputs_NNN.md` без окремого `ref:` поля. - -### Consequences - -- Good, because `mt watch` отримує єдину точку відповідальності за dispatch аудиту без синхронного блокування wrapper-процесу. -- Good, because `pending-audit_003.md` однозначно відповідає `outputs_003.md`; нумерація не губиться між output, запитом аудиту і обробкою аудитором. -- Good, because запит на аудит стає immutable файловим фактом поруч з `run_NNN.md` і `outputs_NNN.md`. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Факти з transcript: - -- файл запиту: `tasks/<node>/pending-audit_NNN.md`; -- NNN у `pending-audit_NNN.md` дорівнює NNN відповідного `outputs_NNN.md`; -- при повторній доробці агент пише новий `outputs_002.md` і створює `pending-audit_002.md`; -- `mt watch` сканує вузли зі станом `pending-audit` і запускає auditor-агента; -- auditor пише `run_NNN.md` з `actor: auditor` і результатом `success|failed`; -- `run_NNN.md` має незалежний лічильник для всіх акторів, а `outputs_NNN.md` і `pending-audit_NNN.md` мають спільний ключ NNN; -- стан `pending-audit` додається до таблиці станів вузла поруч з `waiting`, `running`, `resolved`, `failed`, `invalidated`. - -## Update 2026-06-07 - -Додано уточнення з паралельного драфта: - -- `pending-audit_NNN.md` обрано як numbered immutable варіант замість порожнього sentinel або overwrite-файлу з `ref:`. -- `pending-audit_003.md` є посиланням на `outputs_003.md` самим ім'ям файлу. -- Auditor-агент обробляє запит асинхронно і пише окремий `run_NNN.md` з `actor: auditor, result: success|failed`. -- Таблиця станів вузла включає `pending-audit` поруч із `waiting`, `running`, `resolved`, `failed`, `invalidated`. - -Цей драфт також повторює рішення про дворівневий `flow`, `mt plan`, явний `mt spawn` і видалення Фасаду B; вони вже покриті існуючим ADR про переформатування `flow` під архітектуру mt. diff --git "a/docs/adr/260607-0607-\320\260\321\203\320\264\320\270\321\202-\321\207\320\265\321\200\320\263\320\260-\321\207\320\265\321\200\320\265\320\267-pending-audit-nnn-md.md" "b/docs/adr/260607-0607-\320\260\321\203\320\264\320\270\321\202-\321\207\320\265\321\200\320\263\320\260-\321\207\320\265\321\200\320\265\320\267-pending-audit-nnn-md.md" deleted file mode 100644 index ae0303e..0000000 --- "a/docs/adr/260607-0607-\320\260\321\203\320\264\320\270\321\202-\321\207\320\265\321\200\320\263\320\260-\321\207\320\265\321\200\320\265\320\267-pending-audit-nnn-md.md" +++ /dev/null @@ -1,24 +0,0 @@ ---- -type: ADR -title: "Аудит-черга через `pending-audit_NNN.md`" ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement -Потрібно визначити механізм запуску аудитора після того, як агент завершує роботу над вузлом DAG. Існуючий дизайн передбачав синхронний запуск аудитора безпосередньо через wrapper-скрипт, але система мала перейти до нового контракту на основі файлів (tasks/). - -## Considered Options -* Синхронний запуск аудитора wrapper-скриптом (старий підхід — `mt audit` → auditor у тому ж worktree одразу) -* Асинхронна черга через файл `pending-audit_NNN.md` (новий підхід — `mt audit` записує файл, `mt watch` підхоплює) - -## Decision Outcome -Chosen option: "Асинхронна черга через `pending-audit_NNN.md`", because аудит має бути обробленим чергою (як скан файлів), а не синхронно в wrapper-скрипті — це відповідає загальному принципу «стан = файли» і дає `mt watch` єдину точку відповідальності за dispatch. - -### Consequences -* Good, because `mt watch` отримує єдину точку управління чергою аудиту та виконання вузлів — без синхронних блокувань у wrapper. -* Good, because NNN у `pending-audit_NNN.md` дорівнює NNN відповідного `outputs_NNN.md` — ім'я файлу саме по собі є посиланням, без потреби у явному полі `ref:`. - -## More Information -Додаткової інформації не зафіксовано. diff --git "a/docs/adr/260607-0607-\320\277\320\265\321\200\320\265\321\204\320\276\321\200\320\274\320\260\321\202\321\203\320\262\320\260\320\275\320\275\321\217-flow-\320\277\321\226\320\264-\320\264\320\262\320\276\321\200\321\226\320\262\320\275\320\265\320\262\320\270\320\271-\320\277\321\200\320\276\321\202\320\276\320\272\320\276\320\273-\320\262\321\203\320\267\320\273\320\260-\320\263\321\200\320\260\321\204\321\203-2.md" "b/docs/adr/260607-0607-\320\277\320\265\321\200\320\265\321\204\320\276\321\200\320\274\320\260\321\202\321\203\320\262\320\260\320\275\320\275\321\217-flow-\320\277\321\226\320\264-\320\264\320\262\320\276\321\200\321\226\320\262\320\275\320\265\320\262\320\270\320\271-\320\277\321\200\320\276\321\202\320\276\320\272\320\276\320\273-\320\262\321\203\320\267\320\273\320\260-\320\263\321\200\320\260\321\204\321\203-2.md" deleted file mode 100644 index 391bbdf..0000000 --- "a/docs/adr/260607-0607-\320\277\320\265\321\200\320\265\321\204\320\276\321\200\320\274\320\260\321\202\321\203\320\262\320\260\320\275\320\275\321\217-flow-\320\277\321\226\320\264-\320\264\320\262\320\276\321\200\321\226\320\262\320\275\320\265\320\262\320\270\320\271-\320\277\321\200\320\276\321\202\320\276\320\272\320\276\320\273-\320\262\321\203\320\267\320\273\320\260-\320\263\321\200\320\260\321\204\321\203-2.md" +++ /dev/null @@ -1,52 +0,0 @@ ---- -type: ADR -title: "Переформатування `flow` під дворівневий протокол вузла графу" ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement -Існуюча система `mt` ("попередній MT workflow") виконувала всі ролі всередині одного процесу: ізоляція worktree, планування, виконання коду, перевірка, реліз. Нова архітектура (`npm/docs/mt.md`) вводить зовнішній оркестратор `mt`, який бере на себе lifecycle worktree. Це зробило `flow` надлишково товстим і суперечливим із зовнішнім DAG-шаром. - -## Considered Options -* Залишити `flow` як є, дублювати lifecycle логіку в `graph` -* Повністю замінити `flow` командами `graph` -* Переформатувати `flow` як внутрішній протокол вузла (два чіткі Stage), делегувавши worktree lifecycle до `graph` - -## Decision Outcome -Chosen option: "Переформатувати `flow` як внутрішній протокол вузла", because `graph` керує зовнішнім lifecycle (worktree, deps, merge, cascade), а `flow` залишається протоколом всередині одного вузла і ділиться на Stage 1 (planning) та Stage 2 (execution). - -### Consequences -* Good, because чітке розмежування "зовнішній DAG" vs "внутрішній протокол вузла" без дублювання lifecycle-логіки. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Нові ролі команд після рефакторингу: `mt plan` (Stage 1), `mt verify` (Stage 2 quality gate), решта видаляється. Зафіксовано в `npm/docs/mt.md` секція "Інтеграція з `mt`". - -## Update 2026-06-06 - -* Визначення ролей між Stage 1 (Planning) та Stage 2 (Execution) буде жорстко прив'язано до вихідного артефакту планування (`mt plan`), який тепер є єдиною точкою входу в процес виконання вузла. -* Якщо вузол є атомарним, Stage 1 (Planning) завершується генерацією плану, який полягає в простому маркері, що вказує на пряме виконання, і Stage 2 (Execution) виконує роботу відповідно до цього маркера. -* Якщо вузол складений, Stage 1 генерує декомпозицію в дочірній підграф, і Stage 2 ініціює рекурсивний виклик цього підграфа, використовуючи новий двораівневий протокол. -* Мета цього двораівневого протоколу — забезпечити, що *що* робити (планування) чітко відокремлено від *як* робити (виконання), навіть якщо обидва етапи відбуваються в межах одного виклику `mt plan`. - -## Update 2026-06-06 - -* Stage 1 (`mt plan`) відповідає за визначення, чи повинен вузол виконуватися як атомарна задача (самостійне вирішення), чи як надвузлів (складний вузол), що вимагає створення дочірнього графа. -* Stage 2 (`solve`/`verify`/`signal`) виконує фактичну логіку: якщо вузол атомарний, він виконується; якщо складений, він ініціює виконання дочірнього графа. -* Це розділення гарантує, що рішення про архітектурну структуру (графова декомпозиція) відбувається на найпершій стадії, до початку дорогих операцій виконання, що відповідає вимогам багатоетапного DAG. - -## Update 2026-06-07 - -Продовження реалізації B вимагає чіткого визначення, як саме буде виглядати "повсилення" команди `mt verify`. Це має включати специфікацію, які саме інструменти (`tools`) буде використовувати LLM-агент для верифікації (наприклад, залежності від `flow audit`), які саме критерії він оцінюватиме (наприклад, відповідність згенерованого результату опису в `task.md`), та механізм повернення результату верифікації до `flow` для переходу до Stage 2. Крім того, необхідно формалізувати роль `flow audit` — він повинен бути механізмом самодіагностики вузла, який, на відміну від `mt verify` (що є зовнішньою перевіркою), оцінює внутрішню консистентність логіки вузла. Важливо визначити, чи цей аудит є обов'язковим для входу в Stage 2, і як його відхилення впливає на загальний стан графа. - -## Update 2026-06-07 - -У контексті архітектурної інтеграції, необхідно уточнити, що перехід до дворівневого протоколу передбачає, що `flow` відповідатиме лише за послідовність дій (workflow orchestration) в межах *одного* конкретного вузла графа (`graph node`), тоді як `graph` відповідатиме за координацію *між* вузлами (cross-node dependencies та overall DAG execution). Це підтверджує відмову від варіанту B. - -Також важливо зазначити, що зникнення команд `mt init`, `mt done` тощо означає повне перенесення стану керування з екзотичних команд CLI в stateless-файлс (наприклад, `task.md`), що вимагає від всіх інтеграційних скриптів (включаючи нові в `npm/scripts/dispatcher`) повного переходу на читання/запис стану з файлової системи замість використання внутрішнього стану `mt`. - -## Update 2026-06-07 - -У Stage 1 (planning) фокусується на генерації плану на основі вхідних вимог та стану, визначеного файлами у `tasks/<node>/task.md`, і кінцевий артефакт цього етапу — це деталізований, але ще не виконаний план, який визначається у структурі планування. Stage 2 (execution) відповідає за фактичне виконання кроків, описаних у цьому плані, з використанням інструментів, доступних у worktree, і фінальний результат — це оновлений стан файлів, який підтверджується гейтом `mt verify`. diff --git "a/docs/adr/260607-0609-\321\204\320\260\320\271\320\273\320\276\320\262\320\260-\321\201\320\270\321\201\321\202\320\265\320\274\320\260-\321\217\320\272-\321\201\321\205\320\276\320\262\320\270\321\211\320\265-\321\201\321\202\320\260\320\275\321\203-\320\267\320\260\320\264\320\260\321\207-tasks-2.md" "b/docs/adr/260607-0609-\321\204\320\260\320\271\320\273\320\276\320\262\320\260-\321\201\320\270\321\201\321\202\320\265\320\274\320\260-\321\217\320\272-\321\201\321\205\320\276\320\262\320\270\321\211\320\265-\321\201\321\202\320\260\320\275\321\203-\320\267\320\260\320\264\320\260\321\207-tasks-2.md" deleted file mode 100644 index d1e807d..0000000 --- "a/docs/adr/260607-0609-\321\204\320\260\320\271\320\273\320\276\320\262\320\260-\321\201\320\270\321\201\321\202\320\265\320\274\320\260-\321\217\320\272-\321\201\321\205\320\276\320\262\320\270\321\211\320\265-\321\201\321\202\320\260\320\275\321\203-\320\267\320\260\320\264\320\260\321\207-tasks-2.md" +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: ADR -title: "Файлова система як сховище стану задач (tasks/)" ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement -У проєкті з'явилась потреба відстежувати кілька пов'язаних робочих одиниць (UI-перегляд, тестування скілу, міграція оркестратора). Потрібна конкретна структура для зберігання і читання стану кожної задачі без централізованої бази даних. - -## Considered Options -* Файлова система: один каталог на вузол (`tasks/<name>/`) - -## Decision Outcome -Chosen option: "Файлова система: один каталог на вузол", because структура вже описана в `npm/docs/mt.md` як «Рекурсивний складений ОАГ» і прийнята як базова архітектура системи задач. - -### Consequences -* Good, because стан `waiting` визначається лише наявністю `task.md` (без `run_*.md` і `outputs_*.md`), що не потребує зовнішнього сховища. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Створені вузли: `tasks/ui-task-view/task.md`, `tasks/coverage-skill-test/task.md`, `tasks/skills-orchestrator-migration/task.md`. Запуск задачі: `mt run tasks/<name>`. Контекст структури: `npm/docs/mt.md`. - -## Update 2026-06-07 - -Уточнено практичну матеріалізацію файлового сховища задач: у репозиторії створюються вузли `tasks/<node-name>/task.md` з YAML-frontmatter (`created_at`, `budget_sec`, опційно `parent`, `deps`) і секціями `## Task`, `## Done when`, `## Inputs`. - -Зафіксовані приклади вузлів: -- `tasks/ui-task-view/task.md` — UI-перегляд графу `mt`, budget 3600 сек; -- `tasks/coverage-skill-test/task.md` — перевірка `n-coverage-fix` після міграції `claude-agent-sdk` → `pi`, budget 1800 сек; -- `tasks/skills-orchestrator-migration/task.md` — міграція `npm/skills/` на JS-оркестратор, budget 7200 сек. - -Команда запуску: `mt run tasks/<name>`. Назва окремого UI-проєкту в transcript не зафіксована як фінальне рішення. diff --git "a/docs/adr/260607-0619-ui-\320\267\320\260\320\264\320\260\321\207-\321\207\320\265\321\200\320\265\320\267-rest-sse.md" "b/docs/adr/260607-0619-ui-\320\267\320\260\320\264\320\260\321\207-\321\207\320\265\321\200\320\265\320\267-rest-sse.md" deleted file mode 100644 index e5b5a5f..0000000 --- "a/docs/adr/260607-0619-ui-\320\267\320\260\320\264\320\260\321\207-\321\207\320\265\321\200\320\265\320\267-rest-sse.md" +++ /dev/null @@ -1,40 +0,0 @@ ---- -type: ADR -title: UI задач через REST або SSE API -description: UI для перегляду графа задач читає стан через API-шар, а не через пряме монтування файлового сховища. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Для перегляду стану задач і циклу виконання графа потрібен окремий веб-інтерфейс. Потрібно визначити, як UI отримуватиме дані про `tasks/`: напряму через змонтовану файлову систему чи через API, який читає стан задач у Kubernetes. - -## Considered Options - -- UI читає стан через `mt scan --json` через REST або SSE. -- Пряме монтування `tasks/` у Zed remote dev pod. - -## Decision Outcome - -Chosen option: "UI читає стан через `mt scan --json` через REST або SSE", because для UI немає потреби монтувати `tasks/` локально: UI може спілкуватися з API-сервером у Kubernetes і не залежати від Zed remote доступу. - -### Consequences - -- Good, because UI не залежить від Zed remote й безпосереднього доступу до файлів; достатньо HTTP/SSE до API-сервера у Kubernetes. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because остаточну назву UI-проєкту transcript не підтверджує. - -## More Information - -У transcript згадано `mt scan --json`, REST або SSE як інтерфейс читання стану. Запропоновані назви проєкту: `n-graph`, `graphwatch`, `taskflow`; фінальний вибір назви не зафіксований. Для dev-сценарію окремо розглядався dev pod, що монтує той самий PVC, що й worker pods, але це не є обовʼязковим шляхом доступу для UI. - -## Update 2026-06-07 - -Transcript фіксує, що UI-проєкт для task-графу названо `nitra/task` і використовує файлову структуру задач із `tasks/<name>/task.md`: - -- Приклади task-файлів у transcript: `tasks/ui-task-view/task.md`, `tasks/coverage-skill-test/task.md`, `tasks/skills-orchestrator-migration/task.md`, `tasks/open-in-editor/task.md`. -- Frontmatter task-файлів містить `created_at` і `budget_sec`. -- Поведінкові секції task-файлу: `## Task`, `## Done when`, `## Inputs`. -- Для UI "Open in Editor" підтверджено підтримку VS Code і Cursor через URI deep link, а Zed — через copy hostname, бо transcript не містить підтвердження URI-протоколу Zed. diff --git "a/docs/adr/260607-0627-teleport-ssh-gateway-\320\264\320\273\321\217-kubernetes-task-nodes.md" "b/docs/adr/260607-0627-teleport-ssh-gateway-\320\264\320\273\321\217-kubernetes-task-nodes.md" deleted file mode 100644 index 9bd2242..0000000 --- "a/docs/adr/260607-0627-teleport-ssh-gateway-\320\264\320\273\321\217-kubernetes-task-nodes.md" +++ /dev/null @@ -1,72 +0,0 @@ ---- -type: ADR -title: Teleport SSH gateway для Kubernetes task nodes -description: Доступ розробників до dev pods у Kubernetes проходить через Teleport, а не через прямий kubectl-доступ. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Система `mt` запускатиме task-вузли у Kubernetes. Розробникам потрібен SSH-доступ до dev pods, зокрема для Zed remote, інспекції та патчингу конкретних task-нод. Водночас розробники не мають і не повинні мати прямого `kubectl`-доступу до кластера. Авторизацію має контролювати backend-застосунок, а не k8s RBAC напряму для кожного розробника. - -## Considered Options - -- Teleport як identity-aware SSH gateway з label-based RBAC. -- `kubectl port-forward` для прямого SSH-доступу до pod. - -## Decision Outcome - -Chosen option: "Teleport як identity-aware SSH gateway з label-based RBAC", because `kubectl port-forward` вимагає наявного `kubectl`-доступу у розробника, а це явно відхилено в transcript. Teleport дозволяє backend-у контролювати доступ через labels без видачі kubectl-прав розробникам. - -### Consequences - -- Good, because backend при створенні dev pod може ставити labels на кшталт `owner: <email>`, а Teleport надає доступ лише відповідному користувачу. -- Good, because Teleport використовує short-lived SSH-сертифікати замість статичних ключів. -- Good, because Zed може підключатися як до звичайного SSH через `~/.ssh/config` і `ProxyCommand tsh proxy ssh`. -- Bad, because потрібно задеплоїти Teleport Auth Server і Proxy як додаткову операційну залежність у кластері. - -## More Information - -У transcript згадано UI-застосунок для задач `nitra/task` і task-вузли `tasks/ui-task-view/task.md`, `tasks/coverage-skill-test/task.md`, `tasks/skills-orchestrator-migration/task.md`. Dev pod монтує `tasks-pvc`; worker pods і dev pod бачать той самий файловий стан. Label-схема: `task: <node-name>`, `owner: <email>`, `project: nitra-cursor`. Для SSH-конфігурації згадано `ProxyCommand tsh proxy ssh --cluster=nitra %h:%p`. Як SSO достатні GitHub OAuth або Google. - -## Update 2026-06-07 - -Додатково transcript зафіксував модель on-demand dev pod-ів для доступу до task-node: - -- Dev pod створюється бекендом `nitra/task` після UI-запиту розробника. -- Бекенд перевіряє права у власній RBAC/БД, виконує `kubectl apply dev-pod.yaml`, проставляє labels `task=<name>` і `owner=<email>`, чекає `Pod Ready`, після чого Teleport node-agent реєструє pod автоматично. -- Pod монтує `tasks-pvc`, тому розробник бачить актуальні `task.md`, `run_NNN.md`, `outputs_NNN.md`. -- Lifecycle: spawn on request, grace period після закриття SSH, auto-delete по timeout або при переході task-node у `resolved`. -- Transcript згадує очікуваний cold-start приблизно `5–15с`, але не містить підтвердження негативного наслідку цього часу. - -## Update 2026-06-07 - -Transcript уточнює UX доступу через `nitra/task`: - -- Проєкт UI для task-графу названо `nitra/task` і розташовано у `/Users/vitaliytv/www/nitra/task`. -- Кнопка "Open in Zed" ініціює backend-controlled flow: перевірка прав → створення dev pod з labels `task=X`, `owner=email` → очікування Ready → Teleport registration → повернення connection string. -- Dev pod монтує `tasks-pvc`; це відрізняє підхід від загального dev-environment, бо pod є точкою доступу до DAG-стану агентів. -- Для Zed transcript фіксує стандартне SSH-підключення через `~/.ssh/config` і `ProxyCommand tsh proxy ssh --cluster=nitra %h:%p`. - -## Update 2026-06-07 - -Transcript додає multi-editor аспект для доступу до dev pod-ів через Teleport: - -- VS Code і Cursor підтримуються через URI deep link: - - `vscode://vscode-remote/ssh-remote+<hostname>.teleport.nitra.com/tasks` - - `cursor://vscode-remote/ssh-remote+<hostname>.teleport.nitra.com/tasks` -- Zed у transcript не має підтвердженого URI-протоколу, тому для нього лишається fallback: скопіювати hostname і відкрити SSH вручну. -- Усі редактори використовують той самий `~/.ssh/config` з Teleport `ProxyCommand`. -- Transcript фіксує намір перейменувати scope з `open-in-zed` на `open-in-editor`, але не містить підтвердження виконаного перейменування в цьому драфті. - -## Update 2026-06-07 - -Transcript уточнює Kubernetes-реалізацію Teleport/dev-pod доступу: - -- У `/Users/vitaliytv/www/nitra/task/` підготовлено k8s manifests: `k8s/teleport/configmap.yaml`, `deployment.yaml`, `service.yaml`, `ingress.yaml`, `pvc.yaml`, `rbac.yaml`, `roles.yaml`, `k8s/dev-pod/template.yaml`, `k8s/dev-pod/rbac.yaml`, `k8s/README.md`. -- Dev pod монтує той самий `tasks-pvc`, що й worker pods `mt`. -- Join method описано як k8s ServiceAccount JWT без статичних токенів. -- RBAC роль `developer` обмежує доступ через `node_labels: {owner: "{{internal.logins}}"}`. -- Transcript фіксує `replicas: 1` для Teleport Auth+Proxy з коментарем про SQLite як обмеження для HA. diff --git "a/docs/adr/260607-0627-teleport-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-kubernetes-task-node.md" "b/docs/adr/260607-0627-teleport-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-kubernetes-task-node.md" deleted file mode 100644 index c5495eb..0000000 --- "a/docs/adr/260607-0627-teleport-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-kubernetes-task-node.md" +++ /dev/null @@ -1,49 +0,0 @@ ---- -type: ADR -title: Teleport як SSH gateway для доступу розробників до Kubernetes task-node -description: Розробники підключаються до dev pods через Teleport, без прямого kubectl-доступу до кластера. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Система `mt` запускатиме task-вузли у Kubernetes. Розробникам потрібен SSH-доступ до dev pods, зокрема для Zed Remote, інспекції та патчингу конкретних task-node. Водночас transcript фіксує вимогу: розробники не мають і не повинні мати прямого `kubectl`-доступу до кластера. Авторизацію того, хто до якого вузла може підключитись, має контролювати backend-застосунок або identity-aware gateway, а не ручна видача Kubernetes credentials. - -## Considered Options - -- Teleport як identity-aware SSH gateway з label-based RBAC. -- `kubectl port-forward` для прямого SSH-доступу до pod. - -## Decision Outcome - -Chosen option: "Teleport як identity-aware SSH gateway з label-based RBAC", because `kubectl port-forward` вимагає `kubectl`-доступу в розробника, а transcript явно відхиляє таку модель доступу. Teleport дозволяє підключатися через стандартний SSH flow і контролювати доступ через identity, short-lived certificates та labels pod/node. - -### Consequences - -- Good, because backend може створювати dev pod з labels на кшталт `owner: <email>`, `task: <node-name>`, `project: nitra-cursor`, а gateway надає доступ лише дозволеному користувачу. -- Good, because Zed Remote не потребує спеціальної інтеграції: transcript описує підключення через `~/.ssh/config` і `ProxyCommand tsh proxy ssh`. -- Good, because Teleport використовує short-lived SSH-сертифікати замість довгоживучих статичних ключів. -- Bad, because потрібно задеплоїти й підтримувати Teleport Auth Server і Proxy як додаткову операційну залежність у кластері. -- Neutral, because transcript не містить підтвердження, що конкретна Helm/Operator-конфігурація вже реалізована. - -## More Information - -Transcript facts: -- UI/backend застосунок для задач згадано як `nitra/task` у `/Users/vitaliytv/www/nitra/task`. -- Dev pod label-схема: `task: <node-name>`, `owner: <email>`, `project: nitra-cursor`. -- Teleport Role може використовувати динамічний шаблон `{{internal.logins}}` для привʼязки owner/email. -- Teleport Operator через Kubernetes CRD розглядався як спосіб декларативної реєстрації dev pods. -- SSO варіанти в transcript: GitHub OAuth або Google. -- Приклад SSH proxy з transcript: `ProxyCommand tsh proxy ssh --cluster=nitra %h:%p`. - -## Update 2026-06-07 - -Перед вибором Teleport transcript зафіксував архітектурний принцип: розробники не повинні отримувати прямий `kubectl`-доступ до кластера для роботи із dev pods. - -Додаткові уточнення: -- `kubectl port-forward` відхилено як базовий механізм, бо він вимагає Kubernetes credentials у розробника; -- доступ має контролювати backend/gateway, який перевіряє права перед SSH-зʼєднанням; -- рівень доступу до task-node може бути read або rw залежно від ролі; -- вибір між Teleport і власним gateway у цьому ранньому драфті ще не був зафіксований. diff --git "a/docs/adr/260607-0627-teleport-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-\320\264\320\276\321\201\321\202\321\203\320\277\321\203-\321\200\320\276\320\267\321\200\320\276\320\261\320\275\320\270\320\272\321\226\320\262-\320\264\320\276-task-\320\275\320\276\320\264-\321\203-kubernetes.md" "b/docs/adr/260607-0627-teleport-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-\320\264\320\276\321\201\321\202\321\203\320\277\321\203-\321\200\320\276\320\267\321\200\320\276\320\261\320\275\320\270\320\272\321\226\320\262-\320\264\320\276-task-\320\275\320\276\320\264-\321\203-kubernetes.md" deleted file mode 100644 index aa0a876..0000000 --- "a/docs/adr/260607-0627-teleport-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-\320\264\320\276\321\201\321\202\321\203\320\277\321\203-\321\200\320\276\320\267\321\200\320\276\320\261\320\275\320\270\320\272\321\226\320\262-\320\264\320\276-task-\320\275\320\276\320\264-\321\203-kubernetes.md" +++ /dev/null @@ -1,61 +0,0 @@ ---- -type: ADR -title: Teleport як SSH gateway для доступу розробників до task-нод у Kubernetes -description: Розробники підключаються до dev pods через Teleport без прямого kubectl-доступу до Kubernetes-кластера. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Система `mt` запускатиме task-вузли у Kubernetes. Розробникам потрібен доступ до dev pods, наприклад через Zed Remote SSH, для інспекції та патчингу конкретних task-нод. Водночас transcript фіксує вимогу: розробники не мають і не повинні мати прямого `kubectl`-доступу до кластера. Авторизацію на доступ до конкретного вузла має контролювати backend-застосунок, а не прямий k8s RBAC для розробників. - -## Considered Options - -- Teleport як identity-aware SSH gateway з label-based RBAC. -- `kubectl port-forward` для прямого SSH-доступу до pod. - -## Decision Outcome - -Chosen option: "Teleport як identity-aware SSH gateway з label-based RBAC", because `kubectl port-forward` вимагає наявного `kubectl`-доступу у розробника, що transcript явно відхиляє, а Teleport дозволяє контролювати SSH-доступ через identity, labels і short-lived certificates без видачі kubectl-прав. - -### Consequences - -- Good, because backend може створювати dev pod з labels на кшталт `owner: <email>`, а Teleport надає доступ лише відповідному користувачу. -- Good, because Teleport використовує short-lived SSH-сертифікати замість статичних ключів. -- Good, because Zed Remote може підключатися через стандартний SSH із `ProxyCommand tsh proxy ssh` без спеціальних патчів UI/IDE. -- Bad, because потрібно задеплоїти Teleport Auth Server і Proxy як додаткову операційну залежність у Kubernetes. -- Neutral, because transcript не містить підтвердження фінальної реалізації Teleport Operator або конкретного identity provider. - -## More Information - -Transcript facts: - -- UI-застосунок для задач згадано як `nitra/task` (`/Users/vitaliytv/www/nitra/task`). -- Структура task-вузлів у сесії: `tasks/ui-task-view/task.md`, `tasks/coverage-skill-test/task.md`, `tasks/skills-orchestrator-migration/task.md`. -- Запропонована label-схема dev pod: `task: <node-name>`, `owner: <email>`, `project: nitra-cursor`. -- Teleport Role може використовувати динамічний шаблон `{{internal.logins}}` для привʼязки `owner` до email користувача. -- Teleport Operator згадано як спосіб декларативно реєструвати dev pods через Kubernetes CRD. -- SSO через GitHub OAuth або Google згадано як достатній identity provider. -- Zed Remote підключається через `~/.ssh/config` і `ProxyCommand tsh proxy ssh --cluster=nitra %h:%p`. - -## Update 2026-06-07 - -Уточнено Kubernetes-контекст для доступу до task-вузлів: - -- task-вузли мають виконуватись у Kubernetes. -- Рекомендований dev-доступ: dev pod монтує той самий PVC, що й worker-поди, щоб Zed Remote бачив живий стан `tasks/`. -- Прямий `kubectl port-forward pod/n-graph-dev 2222:22` обговорювався як технічний варіант доступу, але пізніше відхилений для розробників без `kubectl`-прав. -- Для UI окремо зафіксовано, що веб-інтерфейсу не потрібно монтувати `tasks/` напряму: він може читати стан через API-сервер на базі `mt scan --json`, REST або SSE. -- Варіанти назв UI-проєкту (`n-graph`, `graphwatch`, `taskflow`) згадані в transcript, але остаточне підтвердження назви не зафіксовано. - -## Update 2026-06-07 - -Перед вибором Teleport зафіксовано архітектурний принцип: розробники не повинні отримувати прямі `kubectl` credentials, а доступ до dev-середовища має контролювати backend/gateway. - -Додаткові transcript facts: -- `kubectl port-forward` відхилено як основний шлях, бо він вимагає `kubectl`-доступу у розробника. -- Gateway має перевіряти права доступу перед відкриттям SSH-зʼєднання до dev pod. -- Рівень доступу до dev pod може бути read або rw залежно від ролі. -- Вибір між Teleport і власним gateway у цьому ранньому фрагменті ще не був завершений; наступний transcript зафіксував Teleport як обраний варіант. diff --git "a/docs/adr/260607-0627-\321\202\320\265\320\273\320\265\320\277\320\276\321\200\321\202-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-task-\320\275\320\276\320\264-\321\203-kubernetes.md" "b/docs/adr/260607-0627-\321\202\320\265\320\273\320\265\320\277\320\276\321\200\321\202-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-task-\320\275\320\276\320\264-\321\203-kubernetes.md" deleted file mode 100644 index 170cd6e..0000000 --- "a/docs/adr/260607-0627-\321\202\320\265\320\273\320\265\320\277\320\276\321\200\321\202-\321\217\320\272-ssh-gateway-\320\264\320\273\321\217-task-\320\275\320\276\320\264-\321\203-kubernetes.md" +++ /dev/null @@ -1,62 +0,0 @@ ---- -type: ADR -title: Teleport як SSH gateway для task-нод у Kubernetes -description: Розробники підключаються до dev pods через Teleport без прямого kubectl-доступу. ---- - -**Status:** Accepted - -**Date:** 2026-06-07 - -## Context and Problem Statement - -Система `mt` запускає task-вузли у Kubernetes. Розробникам потрібен Zed Remote SSH-доступ до dev pods для інспекції та патчингу task-нод, але transcript фіксує, що вони не мають і не повинні мати прямого `kubectl`-доступу до кластера. Потрібен gateway, де backend контролює, хто до якого task-вузла може підключитися. - -## Considered Options - -- Teleport як identity-aware SSH gateway з label-based RBAC. -- `kubectl port-forward` для прямого SSH-доступу до pod. - -## Decision Outcome - -Chosen option: "Teleport як identity-aware SSH gateway", because `kubectl port-forward` вимагає `kubectl`-доступу у розробника, а transcript явно відхиляє такий доступ; Teleport дозволяє контролювати доступ через labels і short-lived SSH-сертифікати. - -### Consequences - -- Good, because backend може створювати dev pod з label `owner: email`, а Teleport надає доступ лише відповідному користувачу. -- Good, because Zed підключається через стандартний SSH з `ProxyCommand tsh proxy ssh`, без патчів у Zed. -- Good, because Teleport використовує short-lived SSH certificates замість статичних ключів. -- Bad, because потрібно задеплоїти Teleport Auth Server і Proxy як додаткову операційну залежність. -- Neutral, because transcript не фіксує фінальну реалізацію backend-інтеграції, лише варіанти Teleport Operator і label-based доступу. - -## More Information - -Transcript facts: - -- UI-застосунок для задач: `nitra/task` (`/Users/vitaliytv/www/nitra/task`). -- Dev pod labels: `task: <node-name>`, `owner: <email>`, `project: nitra-cursor`. -- Teleport Role може використовувати `{{internal.logins}}` для привʼязки login до email користувача. -- Teleport Operator через Kubernetes CRD може декларативно реєструвати dev pods. -- SSO варіанти: GitHub OAuth або Google. -- SSH config згадує `ProxyCommand tsh proxy ssh --cluster=nitra %h:%p`. - -## Update 2026-06-07 - -Перед вибором Teleport transcript зафіксував Kubernetes-модель виконання: - -- task-вузли живуть у Kubernetes. -- Worker pods і dev pod монтують спільний `tasks-pvc`. -- Dev pod дає Zed Remote доступ до живого файлового стану задач. -- Запропонована тимчасова команда доступу: `kubectl port-forward pod/n-graph-dev 2222:22`, але подальший transcript відхилив прямий `kubectl`-доступ для розробників. - -Для UI окремо зафіксовано, що веб-інтерфейс має читати стан через API/REST/SSE поверх `mt scan --json`, а не через пряме монтування `tasks/`. - -## Update 2026-06-07 - -Transcript до фінального вибору Teleport зафіксував архітектурний принцип: розробники не повинні отримувати прямий `kubectl`-доступ, а авторизація SSH-доступу до dev pods має проходити через backend/gateway. - -Розглянуті варіанти: -- `kubectl port-forward` + SSH у pod — відхилено, бо потребує `kubectl`-прав у розробника. -- SSH gateway / bastion з backend-авторизацією — прийнято як напрям. - -Також згадано, що dev pod монтує `tasks-pvc`, а рівень доступу read/rw має залежати від ролі користувача. diff --git "a/docs/adr/260607-0632-mt-watch-\321\224\320\264\320\270\320\275\320\270\320\271-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200.md" "b/docs/adr/260607-0632-mt-watch-\321\224\320\264\320\270\320\275\320\270\320\271-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200.md" deleted file mode 100644 index 73249c6..0000000 --- "a/docs/adr/260607-0632-mt-watch-\321\224\320\264\320\270\320\275\320\270\320\271-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\202\320\276\321\200.md" +++ /dev/null @@ -1,46 +0,0 @@ ---- -type: ADR -title: mt watch як єдиний оркестратор -description: Оркестрацію ready-вузлів, audit queue і race-sensitive запусків виконує один процес mt watch. ---- - -**Status:** Accepted - -**Date:** 2026-06-07 - -## Context and Problem Statement - -У дизайні `mt` існували два потенційні тригери оркестрації: one-shot запуск після merge і daemon `mt watch`. Якщо обидва процеси одночасно сканують ready-вузли, transcript фіксує race condition: той самий вузол може бути запущений двічі у різних worktrees. Потрібно визначити єдину відповідальність за запуск вузлів і обробку audit queue. - -## Considered Options - -- `mt run --auto` після merge разом із daemon `mt watch`. -- Єдиний оркестратор `mt watch`; post-merge hook лише будить або сигналізує watch. -- Idempotent check через атомарність `git worktree add`. - -## Decision Outcome - -Chosen option: "Єдиний оркестратор `mt watch`", because це прибирає race condition архітектурно: лише один процес приймає рішення про запуск ready-вузлів, audit jobs і roll-up runs. - -### Consequences - -- Good, because transcript фіксує усунення race condition між one-shot auto-run і daemon scan. -- Good, because `mt watch` централізує execution queue, audit queue, stale worktrees і composite roll-up. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because конкретний механізм сигналу від post-merge hook до `mt watch` у transcript не зафіксований. - -## More Information - -Gap-рішення з transcript: - -- Composite resolved: `children-resolved` є derived state; батьківський вузол завершується через roll-up run відповідно до `mode`. -- Pending audit lifecycle: `pending-audit_NNN.md` вважається обробленим, якщо є auditor run з `created_at` пізніше за pending-audit. -- `mode: human` у headless daemon: `mt watch` пропускає такі вузли; людина запускає їх вручну з IDE. -- Merge після аудиту: `mt watch` є wrapper, читає `.ncursor-signal` і робить merge on success. -- Race condition: `mt run --auto` видалено, єдиним оркестратором стає `mt watch`. - -## Update 2026-06-07 - -- Transcript уточнює розділення відповідальностей: `mt watch` стає єдиним процесом, який spawns ready-вузли, dispatch-ить auditor-а, робить merge після успішного аудиту та виконує Telegram-ескалації. -- Post-merge hook не запускає другий orchestrator; він лише будить daemon через trigger-файл `.n-cursor/wake`. -- Це рішення обране замість `mt run --auto` як другого запускальника, бо одна точка orchestration усуває race condition без додаткового mutex. diff --git "a/docs/adr/260607-0641-nitra-task-\321\217\320\272-ui-\320\277\321\200\320\276\321\224\320\272\321\202-\320\267\320\260\320\264\320\260\321\207.md" "b/docs/adr/260607-0641-nitra-task-\321\217\320\272-ui-\320\277\321\200\320\276\321\224\320\272\321\202-\320\267\320\260\320\264\320\260\321\207.md" deleted file mode 100644 index e9879ea..0000000 --- "a/docs/adr/260607-0641-nitra-task-\321\217\320\272-ui-\320\277\321\200\320\276\321\224\320\272\321\202-\320\267\320\260\320\264\320\260\321\207.md" +++ /dev/null @@ -1,37 +0,0 @@ ---- -type: ADR -title: nitra/task як окремий UI-проєкт для task-графу -description: UI для візуалізації та керування task-графом розміщується в окремому проєкті `nitra/task`. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Потрібен окремий веб-проєкт для візуалізації стану task-графу з `npm/docs/mt.md` та керування доступом розробників до task-node середовищ. У transcript обговорювалися назва і розташування такого проєкту. - -## Considered Options - -- `n-graph` -- `graphwatch` -- `taskflow` -- `nitra/task` - -## Decision Outcome - -Chosen option: "nitra/task", because користувач явно визначив назву і розташування проєкту як `/Users/vitaliytv/www/nitra/task`. - -### Consequences - -- Good, because назва вписується у namespace `nitra/*` і не привʼязує UI до конкретної реалізації CLI. -- Bad, because transcript не містить підтвердження негативних наслідків цього вибору. -- Neutral, because transcript фіксує, що проєкт уже існує з `app`, `package.json`, `bun.lock`, `bunfig.toml` та `eslint.config.js`. - -## More Information - -Transcript facts: - -- Шлях проєкту: `/Users/vitaliytv/www/nitra/task`. -- Перший task-node у проєкті повʼязаний із доступом через editor/Teleport. -- Альтернативи `n-graph`, `graphwatch` і `taskflow` були відхилені на користь явно названого користувачем `nitra/task`. diff --git "a/docs/adr/260607-0646-open-in-editor-uri-\320\264\320\273\321\217-dev-pods.md" "b/docs/adr/260607-0646-open-in-editor-uri-\320\264\320\273\321\217-dev-pods.md" deleted file mode 100644 index 2fddee8..0000000 --- "a/docs/adr/260607-0646-open-in-editor-uri-\320\264\320\273\321\217-dev-pods.md" +++ /dev/null @@ -1,50 +0,0 @@ ---- -type: ADR -title: "Open in Editor: URI deep links для dev pods" -description: UI відкриває task dev pod у VS Code і Cursor через URI deep links, а для Zed використовує fallback із копіюванням hostname. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -UI `nitra/task` має дозволити розробнику відкрити конкретний task-node у редакторі через SSH-доступ до dev pod. Початковий фокус був на Zed, але transcript фіксує потребу підтримати також VS Code і Cursor. - -## Considered Options - -- Підтримка тільки Zed із ручним копіюванням SSH hostname. -- Підтримка VS Code і Cursor через URI deep links, Zed — через copy hostname. - -## Decision Outcome - -Chosen option: "Підтримка VS Code і Cursor через URI deep links, Zed — через copy hostname", because VS Code і Cursor підтримують `vscode-remote` URI-схеми для автоматичного відкриття SSH-сесії, а transcript фіксує, що Zed не має аналогічного URI-протоколу. - -### Consequences - -- Good, because для VS Code і Cursor користувач отримує one-click UX через browser URI. -- Bad, because Zed потребує ручного кроку: скопіювати hostname і відкрити SSH-підключення в редакторі. -- Neutral, because усі редактори використовують однакову SSH-конфігурацію з Teleport `ProxyCommand`. - -## More Information - -URI formats із transcript: - -```text -vscode://vscode-remote/ssh-remote+<hostname>.teleport.nitra.com/tasks -cursor://vscode-remote/ssh-remote+<hostname>.teleport.nitra.com/tasks -``` - -Спільний SSH transport очікує Teleport-конфігурацію в `~/.ssh/config` із `ProxyCommand tsh proxy ssh --cluster=nitra %h:%p`. - -## Update 2026-06-07 - -- Transcript уточнює, що задача перейменована з `open-in-zed` на `open-in-editor`, бо scope охоплює VS Code, Cursor і Zed. -- Для VS Code і Cursor використовуються URI-схеми `vscode://vscode-remote/ssh-remote+<hostname>.teleport.nitra.com/tasks` та `cursor://vscode-remote/ssh-remote+<hostname>.teleport.nitra.com/tasks`. -- Для Zed залишається fallback через копіювання hostname, оскільки transcript не фіксує підтримки URI deep link у Zed. - -## Update 2026-06-07 - -- Transcript додає deployment facts для `nitra/task`: dev pod створюється on-demand через backend API після натискання "Open in Editor". -- Потік: backend перевіряє права, застосовує `k8s/dev-pod/template.yaml`, чекає `Pod Ready`, після чого Teleport node-agent реєструє pod і UI повертає connection string або editor URI. -- Dev pod монтує той самий `tasks-pvc`, тому editor відкриває актуальний файловий стан task-графу, а не окремий клон. diff --git "a/docs/adr/260607-0900-\320\260\321\203\320\264\320\270\321\202\320\276\321\200-worktree-\320\275\320\276\320\262\320\270\320\271-\320\267-main-\320\260\320\263\320\265\320\275\321\202-\320\274\320\265\321\200\320\266\320\270\321\202\321\214-\320\277\321\200\320\270-graph-audit.md" "b/docs/adr/260607-0900-\320\260\321\203\320\264\320\270\321\202\320\276\321\200-worktree-\320\275\320\276\320\262\320\270\320\271-\320\267-main-\320\260\320\263\320\265\320\275\321\202-\320\274\320\265\321\200\320\266\320\270\321\202\321\214-\320\277\321\200\320\270-graph-audit.md" deleted file mode 100644 index 3c959bc..0000000 --- "a/docs/adr/260607-0900-\320\260\321\203\320\264\320\270\321\202\320\276\321\200-worktree-\320\275\320\276\320\262\320\270\320\271-\320\267-main-\320\260\320\263\320\265\320\275\321\202-\320\274\320\265\321\200\320\266\320\270\321\202\321\214-\320\277\321\200\320\270-graph-audit.md" +++ /dev/null @@ -1,45 +0,0 @@ -## ADR Аудитор отримує новий worktree з main — агент мержить при `mt audit` - -## Context and Problem Statement -Після того як агент викликає `mt audit`, аудитор має отримати доступ до артефактів вузла (`outputs_NNN.md`, `pending-audit_NNN.md`). Потрібно було визначити: аудитор працює в існуючому worktree агента, чи у новому worktree з main-гілки? - -## Considered Options -* Аудитор у тому ж worktree агента — агент не мержить при `mt audit`, worktree залишається для аудитора -* Аудитор у новому worktree з main — агент мержить при `mt audit`, видаляє worktree; watch диспатчить аудитора у свіжий worktree з main -* Аудитор без worktree — read-only доступ до main без checkout - -## Decision Outcome -Chosen option: "Аудитор у новому worktree з main", because це усуває конфлікт між atomic mkdir-lock (worktree вже існує = EEXIST → skip) і необхідністю аудитора запуститися; агент мержить свої зміни перед виходом, файли потрапляють у main, аудитор стартує у чистому worktree де artifacts вже присутні. - -### Consequences -* Good, because аудитор запускається через той самий механізм `mt run --actor auditor` з тим самим mkdir-lock що й звичайний вузол — жодного спецкейсу. -* Good, because після `mt audit` стан main актуальний: `outputs_NNN.md` і `pending-audit_NNN.md` доступні для інших вузлів і для git history. -* Bad, because якщо аудитор повертає `result: failed`, агент стартує новий worktree з main (де вже є `audit-result_NNN.md`), читає зауваження і починає заново — один більший цикл ніж у варіанті "той самий worktree". - -## More Information -Потік: -``` -agent writes outputs_NNN.md -agent calls: mt audit <path> - → wrapper: creates pending-audit_NNN.md (NNN = NNN outputs) - → wrapper: git merge + delete worktree (файли тепер у main) - -mt watch: pending-audit_NNN.md без audit-result_NNN.md → mt run --actor auditor - → wrapper: git worktree add .worktrees/<node>-audit-<epoch> main - → auditor reads: task.md + plan_NNN.md + outputs_NNN.md + pending-audit_NNN.md - → auditor writes: audit-result_NNN.md (NNN = NNN pending-audit) - → success → merge + delete audit worktree + touch .n-cursor/wake - → failed → merge audit-result → agent starts new worktree, reads audit-result_NNN.md -``` -- Лічильник failed-циклів: wrapper рахує `audit-result_*.md (result: failed)` у main (файли на диску, без shared state між процесами) -- Після 3 failed → worktree залишається, `mt watch` ескалює через Telegram -- Зафіксовано у `npm/docs/mt.md` (секції «Async Audit Queue» і «Wrapper-скрипт») - -## Update 2026-06-06 - -- `audit: true` у frontmatter `task.md` вмикає перевірку вузла. -- Після `result: success` агента wrapper запускає аудитора без окремого worktree, у read-only режимі. -- Аудитор пише наступний `run_(NNN+1).md` з `actor: auditor` і `result: success | failed`. -- Якщо аудитор повертає `result: failed`, вузол не мержиться, а агент перезапускається з feedback. -- Конфіг може містити `audit_model` для дешевшої моделі аудитора. -- У тому ж transcript повторно зафіксовано вже прийняті рішення про `run_NNN.md`, англійські імена файлів, злиття `inputs.md` у `task.md`, межу immutability по worktree та post-merge hook; окремого нового ADR для них не потрібно. diff --git "a/docs/adr/260607-0905-\320\261\321\216\320\264\320\266\320\265\321\202-\320\262\321\203\320\267\320\273\320\260-soft-hard-progress-env-vars.md" "b/docs/adr/260607-0905-\320\261\321\216\320\264\320\266\320\265\321\202-\320\262\321\203\320\267\320\273\320\260-soft-hard-progress-env-vars.md" deleted file mode 100644 index 926bb09..0000000 --- "a/docs/adr/260607-0905-\320\261\321\216\320\264\320\266\320\265\321\202-\320\262\321\203\320\267\320\273\320\260-soft-hard-progress-env-vars.md" +++ /dev/null @@ -1,59 +0,0 @@ -## ADR Бюджетна система вузла: м'який/жорсткий ліміт, progress watchdog та ENV-змінні - -## Context and Problem Statement -Вузол DAG має `budget_sec` як часовий ліміт виконання. Виникло кілька проблем: (1) деякі задачі мають незмінні зовнішні залежності і завжди перевищуватимуть будь-який бюджет (скрапінг, повільні API); (2) агент у headless процесі не знає скільки часу залишилось і не може підготуватись до зупинки; (3) `budget_sec` задається людиною при створенні задачі, але реалістичну оцінку можна зробити лише після аналізу (Stage 1). - -## Considered Options -* SIGTERM агенту при перевищенні — агент не знає про ліміт до SIGTERM -* ENV-змінні при старті + агент сам перевіряє залишок -* Два поля: `budget_sec` (м'який, агент моніторить) + `budget_hard_sec` (жорсткий kill) -* `budget_hard_sec: 0` = без kill для структурно повільних задач -* `progress_timeout_sec` — kill якщо немає змін у worktree N секунд (watchdog) -* Stage 1 уточнює бюджет у `plan_NNN.md` перед виконанням - -## Decision Outcome -Chosen option: "ENV-змінні + soft/hard + progress watchdog + Stage 1 refinement", because: -- агент знає ліміт з першої секунди і може підготуватись до зупинки (`MT_BUDGET_SEC`, `MT_STARTED_AT`) -- `budget_hard_sec: 0` дає спосіб вимкнути kill для повільних але активних задач -- `progress_timeout_sec` ловить справжні зависання (не зайнятість) — агент що активно працює постійно оновлює файли -- Stage 1 (`mt plan`) аналізує задачу і може виставити реалістичний бюджет у `plan_NNN.md` - -### Consequences -* Good, because агент сам керує своїм часом — може писати checkpoint у `run_NNN.md` при нестачі часу замість несподіваного kill. -* Good, because `budget_hard_sec: 0` + `progress_timeout_sec` дає безпечну комбінацію для повільних задач — без зависань при нескінченному run. -* Good, because Stage 1 уточнення усуває класичну проблему "бюджет виставлений без знання задачі". -* Bad, because агент може ігнорувати ENV-змінні і не готуватись до зупинки — wrapper все одно кілить. - -## More Information -**Пріоритет budget-полів:** `plan_NNN.md` > `.n-cursor-override.json` > `task.md` > `.n-cursor.json` - -**ENV-змінні при старті агента:** -```bash -MT_BUDGET_SEC=7200 # м'який ліміт (сек) -MT_HARD_BUDGET_SEC=14400 # жорсткий kill (0 = вимкнено) -MT_STARTED_AT=1749290400 # Unix timestamp старту -``` -Агент обчислює: `remaining = started_at + budget_sec - now()` - -**Wrapper-логіка:** -- поллінг `mtime` worktree кожні 5 сек -- якщо немає змін > `progress_timeout_sec` → SIGKILL + `result: progress-timeout` -- якщо elapsed > `budget_hard_sec` (> 0) → SIGKILL + `result: budget-exceeded` - -**`plan_NNN.md` front-matter:** -```yaml -budget_sec: 3600 # уточнений бюджет (перекриває task.md) -budget_hard_sec: 10800 # уточнений hard limit (0 = без kill) -progress_timeout_sec: 600 # per-task override -``` - -**`.n-cursor.json` дефолти:** -```json -{ - "default_budget_sec": 1800, - "budget_hard_sec_multiplier": 3, - "progress_timeout_sec": 300 -} -``` - -Зафіксовано у `npm/docs/mt.md` (схеми `task.md`, `plan_NNN.md`, «Wrapper-скрипт», «Конфіг»). diff --git "a/docs/adr/260607-0910-human-pending-\321\201\321\202\320\260\320\275-\321\202\320\260-actor-human-\321\201\320\265\320\274\320\260\320\275\321\202\320\270\320\272\320\260.md" "b/docs/adr/260607-0910-human-pending-\321\201\321\202\320\260\320\275-\321\202\320\260-actor-human-\321\201\320\265\320\274\320\260\320\275\321\202\320\270\320\272\320\260.md" deleted file mode 100644 index 781ffac..0000000 --- "a/docs/adr/260607-0910-human-pending-\321\201\321\202\320\260\320\275-\321\202\320\260-actor-human-\321\201\320\265\320\274\320\260\320\275\321\202\320\270\320\272\320\260.md" +++ /dev/null @@ -1,51 +0,0 @@ -## ADR `human-pending` — правильна дефініція стану та семантика `--actor human` - -## Context and Problem Statement -Два пов'язані питання виявились у ході аналізу цілісності дизайну. (1) Стан `human-pending` у таблиці станів вузла був визначений як "є `plan_NNN.md`, немає `run_NNN.md`, mode: human", що суперечило семантиці оркестратора (вузол з `plan_NNN.md` вважається готовим до Stage 2 і запускається автоматично). (2) Команда `mt run --actor human` була перелічена у CLI але не мала специфікованої поведінки. - -## Considered Options -**Для `human-pending`:** -* Стан = є план + немає run + mode: human (стара дефініція — хибна) -* Стан = mode: human + немає `plan_NNN.md` (виправлена дефініція) -* Прибрати стан, замінити `waiting` з підказкою - -**Для `--actor human`:** -* Не підтримувати `--actor human` — видалити з CLI -* Wrapper створює worktree і виводить шлях — людина працює вручну -* Wrapper відкриває інтерактивний термінал - -## Decision Outcome -`human-pending`: chosen option "mode: human + немає `plan_NNN.md`", because це єдиний стан де вузол семантично чекає на людину: без плану ні `--auto`, ні `mt watch` не може стартувати Stage 2. Вузол з `plan_NNN.md` і mode: human — просто `waiting` і запускається автоматично як будь-який інший. - -`--actor human`: chosen option "wrapper створює worktree і виводить шлях", because це дає людині ізольоване робоче середовище з усіма залежностями (як у агента), і людина самостійно викликає `mt done|audit|failed` після завершення. - -### Consequences -* Good, because `human-pending` тепер точно відображає де система чекає участі людини — watch надсилає Telegram тільки для цих вузлів. -* Good, because `--actor human` дозволяє людині виконати вузол з тим самим ізольованим контекстом що і агент, без ручного `git worktree add`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -**Оновлена таблиця станів атомарного вузла:** - -| Умова | Стан | -|---|---| -| mode: human + немає `plan_NNN.md` | `human-pending` | -| є `plan_NNN.md` (будь-який mode) або mode: agent без plan | `waiting` | -| активний worktree | `running` | -| є `pending-audit_NNN.md` без `audit-result_NNN.md` | `pending-audit` | -| є `fact_NNN.md` без `invalidated` | `resolved` | -| є `run_NNN.md` без `fact_NNN.md` і немає активного worktree | `failed` | -| є `invalidated` | `invalidated` | - -**`--actor human` flow:** -``` -mt run tasks/<node>/ --actor human - → wrapper: git worktree add .worktrees/<node>-<epoch> main - → виводить: "Worktree ready: .worktrees/<node>-<epoch>/" - → людина відкриває директорію у редакторі, виконує роботу - → людина викликає: mt done|audit|failed tasks/<node>/ -``` - -**Moніторинг `human-pending`:** `mt watch` надсилає Telegram якщо вузол у стані `human-pending` > `stale_worktree_min` хвилин без появи `plan_NNN.md`. - -Зафіксовано у `npm/docs/mt.md` (таблиця «Стани вузла», секція «Список команд»). diff --git a/docs/adr/260607-1000-graph-sentinel-cleanup.md b/docs/adr/260607-1000-graph-sentinel-cleanup.md deleted file mode 100644 index 114d47a..0000000 --- a/docs/adr/260607-1000-graph-sentinel-cleanup.md +++ /dev/null @@ -1,46 +0,0 @@ -## ADR Очищення sentinel-файлу після аварійного завершення вузла - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -`mt run` пише `running_<pid>_until_<ts>` у `tasks/<node>/` при старті worktree і видаляє його при нормальному завершенні. При аварійному завершенні процесу (`kill -9`, OOM, crash хоста) wrapper не отримує шанс виконати cleanup — sentinel залишається назавжди, вузол застряє у стані `stalled` і нова спроба запуску через `mkdir lock` не може стартувати. - -## Considered Options - -* Варіант A — cleanup як перший крок нового `mt run`: перевірити `kill -0 <pid>` перед стартом, якщо мертвий — прибрати sentinel і worktree -* Варіант B — PID у назві sentinel файлу (`running_<pid>_until_<ts>`), щоб `mt watch` міг перевіряти живість процесу без читання вмісту -* Гібрид A+B — обидва механізми паралельно - -## Decision Outcome - -Chosen option: "Гібрид A+B", because PID у filename дає `mt watch` та `mt run` спільний інструмент детекції через `kill -0` без читання вмісту; cleanup-on-startup гарантує відновлення навіть якщо watch тік пропустив. - -### Consequences - -* Good, because sentinel `stalled` з мертвим PID автоматично очищається в двох точках: при `mt run` (наступна спроба) і при `mt watch` (кожні 5 хв). -* Bad, because `kill -0 <pid>` коректний лише на тому ж хості — у розподіленому сценарії (NFS worktree + декілька машин) детекція мертвих процесів потребує іншого механізму (наприклад, heartbeat-файл). - -## More Information - -Фінальний формат sentinel: `running_<pid>_until_<ts>` у `tasks/<node>/`. - -Watch-логіка при скані: -``` -якщо running_<pid>_until_<ts> існує: - якщо ts ≤ now() → stalled: - kill -0 <pid>; якщо ESRCH (мертвий) → cleanup → run_NNN.md(result: timeout-or-crash) → стан: failed - якщо ts > now() → running: - kill -0 <pid>; якщо ESRCH → cleanup → run_NNN.md(result: crash) → стан: failed -``` - -Cleanup-on-startup у `mt run <path>`: -``` -якщо є running_<pid>_until_<ts>: - kill -0 <pid>; якщо ESRCH → rm sentinel + worktree → продовжити старт - якщо живий та ts > now() → EBUSY, skip (вузол справді running) - якщо живий та ts ≤ now() → kill <pid> → cleanup → продовжити -``` - -Spec: `npm/docs/mt.md`, секція "Wrapper-скрипт" і "Watch". diff --git a/docs/adr/260607-1001-graph-waiting-states.md b/docs/adr/260607-1001-graph-waiting-states.md deleted file mode 100644 index ec43edd..0000000 --- a/docs/adr/260607-1001-graph-waiting-states.md +++ /dev/null @@ -1,51 +0,0 @@ -## ADR Семантика станів очікування у graph: waiting-plan / waiting-run - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Стан `waiting` у початковому дизайні покривав три різних сценарії: `h.md` без плану (людина має створити план), `a.md` без плану (агент має автоматично згенерувати план), `h.md`/`a.md` + `plan_*.md` + deps resolved (готово до виконання). Водночас runner ніколи не діє на `h.md`-вузли — лише на `a.md`. Зовнішній monitor або CI, бачачи `waiting`, не міг без читання файлів зрозуміти чи щось відбудеться автоматично. Стан `human-pending` частково вирішував проблему, але лише для `h.md` без плану. - -## Considered Options - -* Залишити `waiting`, додати поле `actor` у `--json` виводі -* Окремий стан `ready-human` для `h.md` + `plan_*.md` + deps resolved -* Розділити за фазою: `waiting-plan` (потрібен план) / `waiting-run` (план є, готово до виконання); `a.md`/`h.md` — ортогональний вимір "хто виконує" - -## Decision Outcome - -Chosen option: "`waiting-plan` / `waiting-run` з `a.md`/`h.md` як ортогональним виміром", because стан відповідає на питання "що потрібно далі" (plan або run), а `a.md`/`h.md` — "хто це робить". Усуває дублювання між станом і файлом-прапором. Видалені стани: `waiting`, `human-pending`, `needs-plan`. - -### Consequences - -* Good, because таблиця станів симетрична і повністю детермінована через `ls`: `waiting-plan` = є `a.md` або `h.md`, немає `plan_*.md`; `waiting-run` = є `plan_*.md`, deps resolved, немає `running_*`, немає `fact_*`. -* Good, because runner та watch отримують однозначний контракт: перевірити стан → перевірити `a.md`/`h.md` → вирішити дію. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Повна таблиця нових станів (атомарний вузол): - -| Умова (file presence) | Стан | -|---|---| -| `task.md`, немає `a.md`/`h.md` | `unassigned` | -| `a.md` або `h.md`, немає `plan_*.md` | `waiting-plan` | -| `plan_*.md`, deps resolved, немає `running_*`, немає `fact_*` | `waiting-run` | -| `plan_*.md`, deps НЕ resolved | `blocked` | -| `running_<pid>_until_<ts>`, `ts > now()` | `running` | -| `running_<pid>_until_<ts>`, `ts ≤ now()` | `stalled` | -| `pending-audit_N`, немає `audit-result_N` | `pending-audit` | -| `fact_*.md`, немає `invalidated` | `resolved` | -| `run_*.md`, немає `fact_*`, немає `running_*` | `failed` | -| `invalidated` є | `invalidated` | - -Runner-логіка (перевіряє стан + `a.md`/`h.md`): -``` -waiting-plan + a.md → auto: mt plan --mode agent -waiting-plan + h.md → skip + notify людину -waiting-run + a.md → auto: mt run -waiting-run + h.md → skip + notify людину -``` - -Spec: `npm/docs/mt.md`, секція "Стани вузла". diff --git a/docs/adr/260607-1002-graph-cross-level-deps.md b/docs/adr/260607-1002-graph-cross-level-deps.md deleted file mode 100644 index 9de4995..0000000 --- a/docs/adr/260607-1002-graph-cross-level-deps.md +++ /dev/null @@ -1,52 +0,0 @@ -## ADR Крос-рівневі залежності через вкладену структуру deps/ - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -`deps/` директорія використовує ім'я файлу як ідентифікатор dep-вузла без шляху. Тобто `deps/collect-data.md` посилається на прямого сусіда (sibling) у тій самій батьківській директорії. Вузли на різних гілках ієрархії (`tasks/research/analyze/` vs `tasks/reporting/generate-report/`) не можуть залежати один від одного без зміни логічної структури проєкту заради технічного обмеження. - -## Considered Options - -* Варіант A — `__` як роздільник рівнів у назві файлу (`deps/research__analyze.md`) -* Варіант B — шлях у вмісті файлу `deps/*.md` — порушує інваріант (потребує читання вмісту для deps satisfaction) -* Варіант C — вкладена структура `deps/` дзеркалює `tasks/` ієрархію - -## Decision Outcome - -Chosen option: "Варіант C — вкладена `deps/`", because зберігає інваріант (deps satisfaction через `ls -R deps/` без читання вмісту), сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі — вкладені (`deps/research/analyze.md`). - -### Consequences - -* Good, because deps satisfaction: `ls -R deps/` → шлях відносно `tasks/` → перевірити `tasks/<path>/fact_*.md` — без читання вмісту файлів. -* Good, because структура `deps/` є self-documenting: вкладеність відображає реальне місце dep-вузла у графі. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Приклади: - -``` -# Sibling (поточний рівень): -deps/ - collect-data.md → tasks/<parent>/collect-data/fact_*.md - -# Крос-рівнева залежність: -deps/ - research/ - analyze.md → tasks/research/analyze/fact_*.md -``` - -Deps satisfaction алгоритм: -``` -ls -R deps/ → отримати список шляхів відносно deps/ -для кожного <path>: - strip .md → dep-id = <path> - перевірити: exists(tasks/<dep-id>/fact_*.md) -all resolved → deps resolved -``` - -Файли у `deps/` — immutable після `mt init`. Вміст опціональний: `ref: <path>` для контексту агента. - -Spec: `npm/docs/mt.md`, секція "deps/ директорія". diff --git a/docs/adr/260607-1003-graph-composite-resolved.md b/docs/adr/260607-1003-graph-composite-resolved.md deleted file mode 100644 index ba6848d..0000000 --- a/docs/adr/260607-1003-graph-composite-resolved.md +++ /dev/null @@ -1,49 +0,0 @@ -## ADR Явний fact_NNN.md для composite-вузлів - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Composite вузол не мав власного `fact_NNN.md` — його стан `resolved` визначався рекурсивною агрегацією: якщо всі дочірні вузли resolved, батько resolved. Це означає що для кожного composite вузла `mt scan` рекурсивно обходить усіх нащадків (O(глибина × вузли)), а зовнішній інструмент не може перевірити стан кореня без обходу всього дерева. Також логіка перевірки стану відрізнялась між атомарними і composite вузлами. - -## Considered Options - -* Залишити implicit resolved + додати `.n-cursor/graph-index.json` як кеш (порушує принцип відсутності центрального файлу стану) -* Варіант B — явний `fact_NNN.md` для composite: оркестратор пише `fact_NNN.md` автоматично коли всі діти resolved -* Варіант C — ліниве просування стану через `.child-done/<id>` sentinel у батьківській директорії - -## Decision Outcome - -Chosen option: "Варіант B — явний `fact_NNN.md` для composite", because уніфікує перевірку стану для всіх типів вузлів (O(1) `ls` в обох випадках), усуває рекурсивний scan для визначення resolved, зберігає інваріант "стан з listing". - -### Consequences - -* Good, because `mt scan` стає O(n) замість O(n×depth) — кожен вузол перевіряється ізольовано. -* Good, because атомарні і composite вузли мають однакову семантику resolved: `fact_*.md` є. -* Good, because `fact_NNN.md` composite містить агрегований `## Summary` дітей — корисний контекст для батьківського агента або аудитора. -* Bad, because оркестратор отримує додатковий крок: після merge останнього дочірнього вузла — перевірити чи всі сусіди resolved → якщо так, написати `fact_NNN.md` у батька. - -## More Information - -`fact_NNN.md` composite (пише оркестратор, не агент): -```markdown ---- -created_at: ISO8601 -type: composite-summary -children_resolved: [analyze, collect-data, fetch-sources] ---- -## Summary -<агрегація summary з fact_*.md кожного дочірнього вузла> -``` - -Тригер запису: `mt run --auto` або watch після merge worktree перевіряє: -``` -якщо всі tasks/<node>/*/fact_*.md існують (ls, без читання) - і tasks/<node>/fact_*.md не існує - → пише tasks/<node>/fact_NNN.md (NNN = наступний по порядку) -``` - -NNN для composite — незалежна нумерація від дочірніх. `001` при першому composite-resolved. - -Spec: `npm/docs/mt.md`, секція "Composite вузол". diff --git "a/docs/adr/260607-2109-zero-content-read-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\201\321\202\320\260\320\275-mt.md" "b/docs/adr/260607-2109-zero-content-read-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\201\321\202\320\260\320\275-mt.md" deleted file mode 100644 index 084be21..0000000 --- "a/docs/adr/260607-2109-zero-content-read-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\201\321\202\320\260\320\275-mt.md" +++ /dev/null @@ -1,75 +0,0 @@ ---- -type: ADR -title: Zero-content-read файловий стан MT -description: Стани вузлів MT визначаються через імена та наявність файлів без читання їхнього вмісту. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Оркестратор `mt watch` і `mt run --auto` сканують граф задач. Частина попереднього дизайну вимагала читати frontmatter `task.md`, `run_NNN.md` або `deps:` для визначення стану, залежностей і mode. Це суперечило бажаному інваріанту: стан має визначатися через directory listing, імена файлів та наявність директорій. - -Також виникло питання, чи може `run_NNN.md` із `status: done|failed` замінити окремий `fact_NNN.md`. - -## Considered Options - -- `run_NNN.md` з `status: done|failed` замінює `fact_NNN.md` і fail-артефакт. -- Зберегти `fact_NNN.md` як sentinel успіху, а `run_NNN.md` використовувати для журналу спроб. -- Читати frontmatter `task.md` для `mode` і `deps:`. -- Перекодувати стан-визначальну інформацію в імена файлів, sentinel-файли та директорії. -- Для stalled: implicit detection через mtime worktree і budget-поля. -- Для stalled: sentinel-файл `stalled`. -- Для stalled: timestamp у `run_NNN.md`. -- Для stalled: `running_until_<ts>` з deadline у назві. -- Для mode: `task_h.md`/`task_a.md`. -- Для mode: стабільний `task.md` плюс `a.md`/`h.md` sentinel-прапори. -- Для залежностей: `deps:` у frontmatter `task.md`. -- Для залежностей: директорія `deps/`, де кожен файл відповідає одному dep-вузлу. - -## Decision Outcome - -Chosen option: "Зберегти `fact_NNN.md` як sentinel успіху і перекодувати стан у файли/директорії", because transcript фіксує інваріант zero-content-read: `ls` має бути достатньо для визначення станів вузла, залежностей і mode без читання frontmatter або body файлів. - -### Consequences - -- Good, because `resolved` визначається наявністю `fact_*.md`, без читання `run_*.md` і пошуку `status: done` серед N спроб. -- Good, because `stalled` визначається parse назви `running_until_<ts>`: `ts > now()` означає `running`, `ts <= now()` означає `stalled`. -- Good, because `task.md` лишається стабільним, а mode змінюється через mutable sentinel-прапори `a.md`/`h.md`, не руйнуючи git history основного task-файлу. -- Good, because `ls deps/` дає список залежностей без читання `task.md`. -- Bad, because transcript не містить підтверджених негативних наслідків для zero-content-read інваріанту. -- Neutral, because відсутність `a.md` і `h.md` створює окремий корисний стан `setup`/`unassigned`, але transcript не містить повного опису його lifecycle. - -## More Information - -Файли й контракти: - -- `tasks/<node>/fact_NNN.md` — sentinel успішного виконання. -- `tasks/<node>/run_NNN.md` — журнал спроби; `run_NNN.md` без `fact_NNN.md` і без активного worktree трактувався як failed у transcript. -- `tasks/<node>/running_until_<unix-timestamp>` — git-ignored sentinel deadline; відсутність `running_until_*` при наявному worktree є safe fallback до orphan/stalled. -- `tasks/<node>/task.md` — стабільний основний файл місії. -- `tasks/<node>/a.md` — mutable sentinel для agent-mode; frontmatter може містити `model_tier`, `skills`. -- `tasks/<node>/h.md` — mutable sentinel для human-mode; frontmatter може містити `qualification`. -- `tasks/<node>/deps/<dep-node-id>.md` — один файл на одну залежність; імʼя дає dep-ID, вміст може містити `ref:` і додатковий контекст для агента. - -Документ, у якому це фіксувалося: `npm/docs/mt.md`. - -## Update 2026-06-07 - -- Зафіксовано дилему `run_NNN.md` як єдиного артефакта проти окремого success-sentinel: якщо `run_NNN.md` містить `status: success | failed`, scanner мусить читати frontmatter, а стан більше не визначається лише існуванням файлу. -- Цей аргумент став підставою для збереження окремого `fact_NNN.md` як sentinel успішного виконання. - -## Update 2026-06-07 - -- `outputs_NNN.md` перейменовано на `fact_NNN.md`, щоб утворити семантичну пару `plan_NNN.md` / `fact_NNN.md`: план проти факту виконання. -- У `task.md`/плануванні додано оцінки виконавця: `executor: agent|human`, `model_tier: MIM|AVG|MAX`, `skills`, `qualification`; `plan_NNN.md` може override-ити базові значення. -- Нумерація `plan_NNN.md` продовжується для merged/active worktree, але після `mt kill` дозволено reset до `001`, бо `mt kill` видаляє `plan_*.md` як повний reset вузла. -- `mt watch` на стартовому етапі може бути periodic rescan раз на 5 хвилин, використовуючи той самий scan-код, що й `--auto`; daemon/file-watching і Telegram alerts лишаються TODO. -- Попередній implicit-підхід до stalled було зафіксовано як варіант, але пізніше замінено explicit `running_until_<ts>` sentinel-файлом. - -## Update 2026-06-07 - -- Стан `stalled` визначається через git-ignored sentinel `running_until_<unix_ts>` у директорії вузла. -- `running` = sentinel існує і `ts > now()`, `stalled` = sentinel існує і `ts <= now()`; визначення виконується filename parse без читання вмісту. -- Wrapper пише sentinel після створення worktree і видаляє його під час success/failure cleanup; `mt kill` також видаляє `running_until_*`. diff --git "a/docs/adr/260607-2118-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-n-flow-rule-\321\202\320\260-flow-\320\274\320\276\320\264\321\203\320\273\321\226\320\262-dispatcher.md" "b/docs/adr/260607-2118-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-n-flow-rule-\321\202\320\260-flow-\320\274\320\276\320\264\321\203\320\273\321\226\320\262-dispatcher.md" deleted file mode 100644 index fa765e6..0000000 --- "a/docs/adr/260607-2118-\320\262\320\270\320\264\320\260\320\273\320\265\320\275\320\275\321\217-n-flow-rule-\321\202\320\260-flow-\320\274\320\276\320\264\321\203\320\273\321\226\320\262-dispatcher.md" +++ /dev/null @@ -1,62 +0,0 @@ ---- -type: ADR -title: Видалення n-flow rule та flow-модулів dispatcher -description: Застаріле правило `n-flow.mdc` і dispatcher flow-модулі видалено після переходу на graph-архітектуру MT. ---- - -**Status:** Accepted -**Date:** 2026-06-07 - -## Context and Problem Statement - -Правило `n-flow.mdc` описувало попередній MT workflow і мало `alwaysApply: true`, тому продовжувало потрапляти в агентський контекст після переходу на нову graph-архітектуру, описану в `npm/docs/mt.md`. - -У dispatcher також лишалися `flow-*.mjs` модулі під стару схему, зокрема `outputs_NNN.md`, тоді як graph-реалізація вже використовувала новий контракт із `fact_NNN.md`. - -## Considered Options - -- Залишити `n-flow.mdc` і `flow-*.mjs` паралельно з graph-архітектурою. -- Видалити повністю і перемістити `flow-verify.mjs` у `graph/lib/` як `cmd-verify.mjs`. -- Інші варіанти в transcript не обговорювалися. - -## Decision Outcome - -Chosen option: "Видалити повністю і перемістити `flow-verify.mjs` у `graph/lib/` як `cmd-verify.mjs`", because `cmd-plan.mjs` і `cmd-signals.mjs` уже покривали функціонал `flow-plan.mjs` та `flow-signals.mjs` у новій схемі, `flow-resolve.mjs` був мертвим кодом, а підтримка паралельної старої схеми `outputs_NNN.md` створювала б дублювання. - -### Consequences - -- Good, because `n-flow.mdc` більше не інжектує застарілі інструкції через `alwaysApply: true`. -- Good, because dispatcher більше не має `flow`-файлів після видалення `dispatcher/lib/docs/flow-lock.md` і `dispatcher/lib/docs/flow-resolve.md`. -- Good, because transcript фіксує, що 61 тест проходить після рефакторингу, включно з `cmd-verify.test.mjs`. -- Bad, because transcript не містить підтверджених негативних наслідків. -- Neutral, because `dispatcher/lib/nnn.mjs` і `state-store.mjs` лишилися окремою старою utility/state-store областю, але не як `flow`-іменовані модулі. - -## More Information - -Видалено: - -- `npm/rules/flow/flow.mdc` -- `.cursor/rules/n-flow.mdc` -- `dispatcher/lib/flow-plan.mjs` -- `dispatcher/lib/flow-signals.mjs` -- `dispatcher/lib/flow-resolve.mjs` -- `dispatcher/lib/flow-verify.mjs` -- `dispatcher/lib/tests/flow-plan.test.mjs` -- `dispatcher/lib/tests/flow-signals.test.mjs` -- `dispatcher/lib/tests/flow-resolve.test.mjs` -- `dispatcher/lib/tests/flow-verify.test.mjs` -- `dispatcher/lib/docs/flow-lock.md` -- `dispatcher/lib/docs/flow-resolve.md` - -Створено або оновлено: - -- `dispatcher/graph/lib/cmd-verify.mjs` — verify під схему `fact_NNN.md` і `latestFactNNN` з `graph/lib/nnn.mjs`. -- `dispatcher/graph/lib/tests/cmd-verify.test.mjs`. -- `dispatcher/index.mjs` — імпорти з `graph/lib/cmd-plan.mjs`, `graph/lib/cmd-verify.mjs`, `graph/lib/cmd-signals.mjs`. -- `.n-cursor.json` — `flow` прибрано з активних правил і використано як disabled safeguard під час sync. - -Команди й факти transcript: - -- `npx @nitra/cursor` підтвердив видалення `.cursor/rules/n-flow.mdc` як правила поза списком. -- `find ... dispatcher -name "*flow*"` після cleanup не повернув файлів. -- `npx @nitra/cursor change` створив `npm/.changes/pr-20260607-1946.md`. diff --git "a/docs/adr/260607-2119-sentinel-\321\204\320\260\320\271\320\273\320\270-a-md-h-md-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-\320\272\320\276\320\264\321\203\320\262\320\260\320\275\320\275\321\217-mode-\321\203-\320\275\320\260\320\267\320\262\321\226-task.md" "b/docs/adr/260607-2119-sentinel-\321\204\320\260\320\271\320\273\320\270-a-md-h-md-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-\320\272\320\276\320\264\321\203\320\262\320\260\320\275\320\275\321\217-mode-\321\203-\320\275\320\260\320\267\320\262\321\226-task.md" deleted file mode 100644 index c16f53f..0000000 --- "a/docs/adr/260607-2119-sentinel-\321\204\320\260\320\271\320\273\320\270-a-md-h-md-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-\320\272\320\276\320\264\321\203\320\262\320\260\320\275\320\275\321\217-mode-\321\203-\320\275\320\260\320\267\320\262\321\226-task.md" +++ /dev/null @@ -1,114 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:19:42+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Based on the transcript, I need to output ADR documentation for the design decisions made in this session. - -## ADR Sentinel-файли `a.md`/`h.md` замість кодування mode у назві task-файлу - -## Context and Problem Statement -Система потребувала розрізнення між "виконується агентом" та "виконується людиною" для кожного вузла графу. Перші варіанти кодували mode у назві основного файлу місії (`task_h.md`/`task_a.md`), що призводило до деструктивних rename-операцій при зміні mode і втрати git history. - -## Considered Options -* Код у назві task-файлу: `task_h.md` / `task_a.md` -* Окремий sentinel-файл: `a.md` (agent) / `h.md` (human) / відсутність обох = unassigned/setup - -## Decision Outcome -Chosen option: "Окремий sentinel-файл `a.md`/`h.md`", because зміна mode зводиться до `rm h.md && touch a.md` без торкання основного файлу місії, зберігається git history `task.md`, і з'являється третій стан `unassigned`/`setup` (жоден sentinel відсутній) без додаткової логіки. - -### Consequences -* Good, because transcript фіксує очікувану користь: mode-switch = 2 shell-команди, `task.md` immutable, `unassigned` корисний для UI як "ці вузли потребують конфігурації". -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файли: `npm/docs/mt.md` (рядки 65–101, 189–232). Mutable-флаги: `a.md`, `h.md`, `invalidated`, `running_until_<pid>_<ts>`. Immutable: `task.md`, `plan_NNN.md`, `run_NNN.md`, `fact_NNN.md`, `deps/`. - ---- - -## ADR Стан вузла визначається виключно через listing файлової системи (без читання вмісту) - -## Context and Problem Statement -У попередньому дизайні стан `human-pending` вимагав читання фронтматеру `task.md` (поле `mode:`), а перевірка залежностей — читання `deps:` зі списку. При великих графах і частому watch-циклі це перетворювалося на N file-reads на кожен скан. - -## Considered Options -* Зберігати явний стан у центральному файлі (state.json) -* Derived state з читанням фронтматеру кожного файлу -* Derived state виключно з переліку файлів і директорій (presence + filename parse) - -## Decision Outcome -Chosen option: "Derived state виключно з переліку файлів і директорій", because presence-check = O(1) на вузол; будь-який зовнішній інструмент читає граф без знання протоколу; відновлення після збою тривіальне через `ls`. - -### Consequences -* Good, because transcript фіксує очікувану користь: watch-loop = чистий O(file count), `running_until_<pid>_<ts>` у назві файлу дає deadline без читання, `a.md`/`h.md` дають mode без читання. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файли: `npm/docs/mt.md` рядок 405 (інваріант). Контракт: `invalidated` > `resolved` > `pending-audit` > `stalled` > `running` > `waiting`/`blocked` > `human-pending` > `unassigned` > `failed`. Стан `stalled`: `running_until_<pid>_<ts>` де `ts ≤ now()`. - ---- - -## ADR Директорія `deps/` замість поля `deps:` у фронтматері - -## Context and Problem Statement -Залежності між вузлами зберігались у полі `deps:` фронтматеру `task.md`. Це порушувало інваріант "стан з listing": перевірка залежностей вимагала читання і парсингу YAML. - -## Considered Options -* `deps:` список у фронтматері `task.md` -* Директорія `deps/` де ім'я кожного файлу = ідентифікатор залежного вузла - -## Decision Outcome -Chosen option: "Директорія `deps/` з файлами-залежностями", because `ls deps/` дає список залежностей без читання вмісту; файл в `deps/` може опціонально містити `ref:` та контекст, доступний агенту лише коли потрібен; наявність/відсутність `deps/` = відсутність залежностей. - -### Consequences -* Good, because transcript фіксує очікувану користь: deps satisfaction = `ls deps/` + перевірка `fact_*.md` у кожному dep-вузлі; `task.md` спрощується (немає `mode:`, `executor:`, `deps:`). -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файли: `npm/docs/mt.md` рядки 236–259. Формат: `deps/<dep-node-id>.md`. Приклад: `deps/collect-data.md` з `ref: ../collect-data/fact_001.md` + текст контексту. - ---- - -## ADR Стан `stalled` через `running_until_<pid>_<ts>` у назві файлу - -## Context and Problem Statement -У початковому дизайні не було явного стану "вузол завис" — тільки `running`. Watch не міг відрізнити живий процес від процесу що застряв або загинув без cleanup, без читання файлів або PID-перевірок. - -## Considered Options -* Implicit: watch вбиває `running` вузол за таймаутом без окремого стану -* Sentinel файл `stalled` (watch пише при виявленні) -* Deadline у назві worktree директорії -* Deadline + PID у назві sentinel-файлу `running_until_<ts>` - -## Decision Outcome -Chosen option: "Hybrid: `running_<pid>_until_<ts>` як sentinel + cleanup-on-start (wrapper перевіряє `kill -0 <pid>` при новому запуску)", because deadline кодується у назві файлу (детектується з `ls`), PID дозволяє перевіряти чи процес живий без читання вмісту, а cleanup-on-start гарантує відновлення після краша при наступному `mt run`. - -### Consequences -* Good, because transcript фіксує очікувану користь: `stalled` = presence + filename parse, watch + wrapper обидва роблять cleanup, `budget_hard_sec: 0` потребує спеціальної обробки (deadline = минуле). -* Bad, because transcript фіксує відкрите питання: clock skew на distributed FS може зробити `ts ≤ now()` некоректним. - -## More Information -Файли: `npm/docs/mt.md` рядки 407–460 (таблиця станів), рядки 682–750 (wrapper). Формат sentinel: `tasks/<node>/running_<pid>_until_<unix-ts>`. Перевірка: `kill -0 <pid>` (нульовий сигнал = тільки перевірка існування, без вбивства). - ---- - -## ADR Стан `unassigned`/`setup` як явний третій стан mode - -## Context and Problem Statement -Попередні варіанти кодування mode передбачали обов'язкове визначення mode при `mt init`. З появою sentinel-файлів `a.md`/`h.md` виникла можливість третього стану — коли жоден з них не присутній. - -## Considered Options -* `mt init` без `--mode` забороняється (завжди обов'язковий) -* Відсутність сентинела = default (наприклад, human за замовчуванням) -* Відсутність обох сентинелів = окремий стан `unassigned`/`setup` - -## Decision Outcome -Chosen option: "`unassigned`/`setup` як явний стан", because вузол може бути створений до прийняття рішення хто виконує; watch нагадує про неконфігуровані вузли; UI може виводити "ці вузли потребують конфігурації" без читання вмісту. - -### Consequences -* Good, because transcript фіксує очікувану користь: корисний для UI, не блокує `mt init` без обов'язкових параметрів. -* Bad, because transcript фіксує ризик: у повністю автономному pipeline вузли у `unassigned` блокують виконання без механізму auto-assignment. - -## More Information -Файли: `npm/docs/mt.md` рядок 20 (список станів), рядки 409–415 (таблиця станів). Поточний документ використовує `setup` у списку станів і `unassigned` у таблиці — неконсистентність потребує вирішення. diff --git "a/docs/adr/260607-2127-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\270\320\271-sentinel-runningpiduntilts-\320\264\320\273\321\217-\320\262\321\226\320\264\321\201\321\202\320\265\320\266\320\265\320\275\320\275\321\217-running.md" "b/docs/adr/260607-2127-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\270\320\271-sentinel-runningpiduntilts-\320\264\320\273\321\217-\320\262\321\226\320\264\321\201\321\202\320\265\320\266\320\265\320\275\320\275\321\217-running.md" deleted file mode 100644 index c253c58..0000000 --- "a/docs/adr/260607-2127-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\270\320\271-sentinel-runningpiduntilts-\320\264\320\273\321\217-\320\262\321\226\320\264\321\201\321\202\320\265\320\266\320\265\320\275\320\275\321\217-running.md" +++ /dev/null @@ -1,72 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:27:41+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Гібридний sentinel `running_<pid>_until_<ts>` для відстеження `running`/`stalled` - -## Context and Problem Statement -Вузол DAG потрапляє у стан `running` коли wrapper запускає агента і стартує worktree. Якщо процес вбивається аномально (`kill -9`, OOM, збій хоста), wrapper не встигає прибрати sentinel-файл, і вузол назавжди залишається у стані `stalled` без автоматичного відновлення. - -## Considered Options -* Окремий `running_until_<ts>` sentinel (deadline без PID) -* Гібрид A+B: `running_<pid>_until_<ts>` + cleanup при старті нового run і в watch-loop -* PID-файл окремо від deadline-файлу - -## Decision Outcome -Chosen option: "Гібридний sentinel `running_<pid>_until_<ts>` (A+B)", because об'єднання PID і deadline в одному filename дозволяє детектувати `running` vs `stalled` та живий/мертвий процес виключно через `ls` + `kill -0 <pid>` — без читання вмісту — і cleanup відбувається автоматично в двох точках: при старті нового run та при кожному скані watch. - -### Consequences -* Good, because стан вузла (`running` vs `stalled` vs zombie) детектується без читання файлів — інваріант "стан з listing" зберігається. -* Good, because watch та wrapper мають чіткий алгоритм cleanup: `kill -0 <pid>` → якщо мертвий → видалити sentinel, записати `run_NNN.md(reason: crash)`, перейти у `failed`. -* Bad, because `budget_hard_sec: 0` (без hard kill) вимагає окремої конвенції для ts щоб уникнути sentinel із ts у минулому одразу після створення. - -## More Information -Файли: `npm/docs/mt.md` — розділи "Стани вузла", "Wrapper-скрипт", "Watch daemon". Команди: `kill -0 <pid>` для перевірки живості процесу без вбивства. Формат: `tasks/<node>/running_<pid>_until_<unix-ts>` (git-ignored). - ---- - -## ADR `human-pending` охоплює всі `h.md`-вузли незалежно від наявності плану - -## Context and Problem Statement -Специфікація маппила `h.md` + `plan_*.md` + deps resolved на стан `waiting` — той самий стан що і для `a.md`-вузлів. Runner автоматично запускає `waiting`-вузли з `a.md`, але повністю ігнорує `waiting`-вузли з `h.md`. Зовнішній monitor або людина бачать два `waiting`-вузли і не можуть (без читання файлів) визначити що один ніколи не запуститься автоматично. - -## Considered Options -* Залишити `waiting` для обох типів, додати суфікс у вивід (`waiting:agent` / `waiting:human`) -* Ввести окремий стан `ready-human` для `h.md` + plan -* Розширити `human-pending` на всі `h.md`-вузли (з планом і без) - -## Decision Outcome -Chosen option: "Розширити `human-pending` на всі `h.md`-вузли", because стан вже однозначно закодований у присутності `h.md` — зайвий новий стан не потрібен; `human-pending` семантично точний: і без плану, і з планом вузол чекає дії від людини (відповідно `mt plan` або `mt run --actor human`). `waiting` стає виключно `a.md` + deps resolved. - -### Consequences -* Good, because `waiting` = виключно агентський; runner не має ambiguity щодо того які вузли підхопити. -* Good, because контракт "стан з listing" зберігається: `h.md` є → `human-pending`; деталь (є план чи ні) — підказка у `mt status`, а не окремий стан машини. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файли: `npm/docs/mt.md` — таблиця станів атомарного вузла, рядки пріоритетів. Відповідний рядок CLI: `mt run --auto` пропускає всі `human-pending` (h.md незалежно від плану). `mt status` може показувати підказку: `human-pending: plan є → mt run <path> --actor human`. - ---- - -## ADR Інваріант: всі стани вузла визначаються виключно переліком файлів і директорій - -## Context and Problem Statement -До формалізації інваріанту ряд станів (зокрема `human-pending` та `waiting`) вимагали читання frontmatter `task.md` для поля `mode:` та поля `deps:`. На великих графах це означало N reads при кожному скані оркестратора. Крім того, відсутність явного контракту дозволяла стан-детекцію "протікати" у читання вмісту без помітки в специфікації. - -## Considered Options -* Зберегти `mode:` у frontmatter `task.md` (читання вмісту для `human-pending` vs `waiting`) -* Замінити `deps:` frontmatter на `deps/` директорію; `mode:` → окремі sentinel-файли `a.md`/`h.md` - -## Decision Outcome -Chosen option: "Sentinel-файли `a.md`/`h.md` + директорія `deps/`", because це дозволяє встановити формальний інваріант: будь-який стан будь-якого вузла детектується виключно через `ls` (присутність файлів + парсинг filename) — без відкриття жодного файлу. - -### Consequences -* Good, because watch-loop — O(file count), без I/O на читання; відновлення графу після збою — scan без парсингу. -* Good, because зовнішні інструменти (CI, dashboard, shell scripts) можуть читати граф без знання схеми frontmatter. -* Good, because transcript фіксує очікувану користь: мутабельні прапори `a.md`/`h.md` дозволяють перемикати mode без `git mv` і без втрати history `task.md`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файли: `npm/docs/mt.md` — рядок 405 (формальний інваріант), схеми `a.md`, `h.md`, `deps/` (рядки 189–259). Мутабельні прапори: `a.md`, `h.md`, `invalidated`, `running_until_*`. Immutable: `task.md`, `plan_NNN.md`, `run_NNN.md`, `fact_NNN.md`, файли у `deps/`. diff --git "a/docs/adr/260607-2130-\320\264\320\265\321\202\320\265\321\200\320\274\321\226\320\275\320\276\320\262\320\260\320\275\320\270\320\271-\321\201\321\202\320\260\320\275-\320\262\321\203\320\267\320\273\320\260-\321\207\320\265\321\200\320\265\320\267-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-listing-ls-only-inv.md" "b/docs/adr/260607-2130-\320\264\320\265\321\202\320\265\321\200\320\274\321\226\320\275\320\276\320\262\320\260\320\275\320\270\320\271-\321\201\321\202\320\260\320\275-\320\262\321\203\320\267\320\273\320\260-\321\207\320\265\321\200\320\265\320\267-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-listing-ls-only-inv.md" deleted file mode 100644 index 0e3db0f..0000000 --- "a/docs/adr/260607-2130-\320\264\320\265\321\202\320\265\321\200\320\274\321\226\320\275\320\276\320\262\320\260\320\275\320\270\320\271-\321\201\321\202\320\260\320\275-\320\262\321\203\320\267\320\273\320\260-\321\207\320\265\321\200\320\265\320\267-\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-listing-ls-only-inv.md" +++ /dev/null @@ -1,498 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:30:26+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## Ризик №1 — Кількість worktrees і disk saturation - -### Опис - -Кожен активний вузол займає окремий git worktree. Worktree = повна копія робочого дерева репозиторія (всі файли, не тільки зміни). При великих репозиторіях — кожен worktree може важити сотні MB. - -`mt run --auto` запускає всі ready вузли паралельно через `mkdir lock`. Немає вбудованого ліміту кількості одночасних worktrees. - -### Конкретний сценарій - -``` -Репозиторій: 500MB (код + assets + node_modules через symlinks) -Граф: 50 ready вузлів після deps resolution - -mt run --auto: -→ mkdir .worktrees/node-01-deadline/ ← 500MB -→ mkdir .worktrees/node-02-deadline/ ← 500MB -→ ... -→ mkdir .worktrees/node-50-deadline/ ← 500MB - -Разом: 50 × 500MB = 25GB disk за ~30 секунд -MacBook Pro 512GB SSD: 25GB для worktrees alone -Якщо в репо є large binary assets або MLmodels → ще гірше -``` - -Крім disk — паралельні агенти одночасно пишуть файли, запускають компілятори, linters, тести. CPU і IO saturation на звичайному dev-ноутбуці. Якщо `budget_hard_sec = 3600` і 50 агентів одночасно — система може стати некерованою. - -Друга проблема: git worktrees мають overhead на рівні `.git/worktrees/` — кожен реєструється у git index. Багато worktrees уповільнює `git status`, `git log`, навіть `git add` у основному репозиторії. - -### Потенційне вирішення - -**Варіант A — `max_worktrees` у конфігурації + back-pressure queue** - -```json -// .n-cursor.json -{ -"max_worktrees": 5, -"queue_strategy": "fifo" -} -``` - -`mt run --auto` запускає максимум N вузлів паралельно. Решта — у черзі. Коли один завершується → наступний стартує. Back-pressure природна. - -**Варіант B — Shallow worktree (тільки tracked files, без node_modules)** - -```bash -git worktree add --no-checkout .worktrees/node-01/ -git -C .worktrees/node-01/ checkout HEAD -- src/ package.json -``` - -Worktree містить тільки файли потрібні для задачі, не весь репозиторій. Агент сам вирішує що йому потрібно (або orchestrator на основі `task.md`). - -Складніше в реалізації, але worktree може важити 10MB замість 500MB. - -**Варіант C — Worktree sharing між вузлами одного composite** - -Дочірні вузли одного composite sharing один worktree (різні branch або subdirectory). Зменшує кількість worktrees, але ускладнює ізоляцію — вузли можуть конфліктувати. - -**Рекомендую Варіант A** — `max_worktrees` + FIFO queue. Найпростіше в реалізації, передбачувана поведінка, не потребує змін в git або worktree протоколі. Default: `max_worktrees: 4` (залишає ресурси для основного середовища). Значення налаштовується per-machine або per-project. Черга nature — `mt scan` при кожному тіку підхоплює наступний ready вузол після завершення попереднього. - ---- - -Готовий до **Ризику №2**? -[user] -А зафіксовано переходимо до ризику №2 -[assistant] -## Ризик №2 — Каскадна інвалідація кореня - -### Опис - -`invalidated` стан поширюється: якщо вузол-батько інвалідований — всі його нащадки теж мають бути інвалідовані (їх результати засновані на недійсних даних). Якщо кореневий вузол або вузол з багатьма залежними отримує `invalidated` — весь або більша частина графу скидається. Весь накопичений прогрес втрачається, budget витрачається на повторні run. - -### Конкретний сценарій - -``` -tasks/ -data-pipeline/ ← composite, корінь -collect-raw/ ← resolved (fact_001.md, 2 год роботи) -clean-data/ ← resolved (fact_001.md, 1 год, deps: collect-raw) -normalize/ ← resolved (fact_001.md, 1 год, deps: clean-data) -train-model/ ← resolved (fact_001.md, 4 год, deps: normalize) -evaluate/ ← resolved (fact_001.md, 30 хв, deps: train-model) -generate-report/ ← resolved (fact_001.md, deps: evaluate) - -Аудитор перевіряє collect-raw → FAIL (дані були некоректними) -→ Вада №6 (А): audit-result FAIL → пише invalidated у collect-raw - -collect-raw invalidated → -clean-data deps: collect-raw → resolved → тепер deps NOT resolved -normalize deps: clean-data → теж -train-model deps: normalize → теж -evaluate deps: train-model → теж -generate-report deps: evaluate → теж - -Весь граф: 8.5 годин роботи скасовано. -Всі 6 вузлів мають повторити повний цикл. -``` - -Два окремі проблеми всередині ризику: - -**1. Propagation не автоматична** — специфікація не описує хто і коли propagate `invalidated` до залежних вузлів. Якщо orchestrator не виконує propagation — `clean-data` залишається `resolved` хоча його вхідні дані змінились. Тихий баг: граф завершується з некоректними результатами. - -**2. Propagation занадто агресивна** — якщо `invalidated` автоматично каскадується — весь граф скидається при будь-якій помилці в основі. Немає часткового відновлення. - -### Потенційне вирішення - -**Варіант A — Propagation тільки до прямих залежних (shallow)** - -`invalidated` у вузлі X → orchestrator знаходить всі вузли що мають `deps/X.md` → їм ставить `invalidated`. - -Але тільки один рівень. Чи propagate далі — вирішує людина або explicit `mt invalidate --deep`. - -Контролює cascading, але може залишити граф у несумісному стані якщо shallow недостатньо. - -**Варіант B — Differential invalidation: тільки якщо outputs змінились** - -При `invalidated` → re-run → новий `fact_NNN.md`. Orchestrator порівнює новий факт зі старим (hash або semantic diff). Якщо результат ідентичний — залежні вузли не інвалідуються. - -Складна реалізація, потребує content comparison. Але найефективніша: половина графу може пережити re-run кореня без змін. - -**Варіант C — `invalidation_scope` у `task.md`** - -```yaml -invalidation_scope: shallow | deep | none -``` - -Per-node конфігурація: деякі вузли детерміновані (ті самі inputs → той самий output) → `invalidation_scope: none`. Деякі критичні → `deep`. Default: `shallow`. - -**Варіант D — Manual propagation через `mt invalidate --cascade`** - -Автоматичного каскаду немає. Інженер вирішує явно: `mt invalidate --cascade tasks/collect-raw/`. Граф не інвалідується без явного рішення. - -**Рекомендую Варіант D + B як roadmap** — почати з ручного `mt invalidate --cascade` (простіше, менше ризику тихих помилок від автоматики). Додати differential invalidation (Варіант B) як оптимізацію у v2 коли є реальні дані про частоту змін outputs. Ручне управління примушує людину думати про scope інвалідації — це feature, не bug, при складних графах де ціна re-run висока. - ---- - -Готовий до **Ризику №3**? -[user] -D зафіксовано переходимо до ризику №3 -[assistant] -## Ризик №3 — LLM non-determinism у composite planning - -### Опис - -Коли агент виконує Stage 1 для composite вузла — він пише `plan_NNN.md` з `decision: composite` і одночасно створює дочірні вузли зі своєю структурою. Ця структура повністю визначається LLM в момент виконання — оркестратор "не втручається". - -LLM недетерміновані. Той самий `task.md` при різних запусках може дати різну декомпозицію: різна кількість дочірніх вузлів, різні назви, різна топологія deps. - -### Конкретний сценарій - -``` -tasks/implement-auth/task.md: -## Task: Реалізувати систему автентифікації - -Run 1 (після mt kill та re-plan): -plan_002.md: decision: composite -→ implement-auth/ -jwt-service/ -session-store/ -middleware/ -tests/ - -Run 2 (після ще одного mt kill): -plan_003.md: decision: composite -→ implement-auth/ -token-manager/ ← інша назва -cache-layer/ ← новий вузол -auth-middleware/ ← merge двох попередніх -``` - -Після першого run залишились артефакти: `jwt-service/run_001.md`, `session-store/fact_001.md` (частково resolved). Після re-plan з'явилась нова структура де `session-store` — зник, `cache-layer` — новий. - -`session-store/fact_001.md` — залишається в файловій системі. Orchestrator при скані знаходить директорію з `task.md` і `fact_001.md` → вузол `resolved`. Але в новому `plan_003.md` цей вузол не згадується. Orphan resolved вузол — виконана робота що нікому не потрібна, але займає місце і плутає scan. - -Більш небезпечний варіант: новий вузол `cache-layer` має `deps/session-store.md` — але `session-store` з попереднього плану вже resolved. Orchestrator вважає dep resolved. Але `cache-layer` у плані 2 мав інші очікування від session store ніж план 1 написав у fact. Семантична несумісність без помилки. - -### Потенційне вирішення - -**Варіант A — `mt kill` видаляє дочірні вузли (повний reset піддерева)** - -При `mt kill tasks/implement-auth/`: -- Видаляє всі `plan_*.md` з `implement-auth/` -- Рекурсивно видаляє всі дочірні директорії які не мають зовнішніх залежностей - -Чиста slate для нового plan. Мінус: вся виконана робота дочірніх вузлів — втрачена. Агресивно але передбачувано. - -**Варіант B — "Pin plan" механізм** - -Після успішного composite planning людина виконує `graph pin tasks/implement-auth/`. Система фіксує поточну топологію. Повторний `mt plan` не може змінити структуру — тільки оновити budget або деталі всередині вже існуючих вузлів. - -Топологія стабільна, LLM не може "переосмислити" структуру без явного `graph unpin`. - -**Варіант C — Структура composite описана у `task.md` людиною** - -Для критичних composite вузлів — людина прописує очікувану структуру у `## Structure` секції `task.md`. Агент при planning зобов'язаний слідувати цій структурі. LLM свобода тільки там де структура не вказана. - -```markdown -## Structure (optional) -- jwt-service: JWT token generation and validation -- session-store: Redis-backed session management -- middleware: Express middleware integration -``` - -**Рекомендую Варіант B** — `graph pin` після першого успішного planning. Відповідає реальному workflow: спочатку агент пропонує структуру, людина погоджується (або виправляє), потім фіксує. Re-run не змінює структуру. `mt kill` без `--unpin` — скидає тільки плани і результати, не топологію. Це додає один explicit крок у workflow але захищає від тихого переосмислення структури при кожному re-run. - ---- - -Готовий до **Ризику №4**? -[user] -В зафіксовано переходимо до ризику №4 -[assistant] -## Ризик №4 — Clock skew на distributed FS - -### Опис - -`running_<pid>_until_<ts>` — deadline у назві файлу. Логіка визначення стану: - -``` -stalled = running_until_<ts> EXISTS AND ts ≤ now() -running = running_until_<ts> EXISTS AND ts > now() -``` - -`now()` береться на машині яка виконує `mt scan` або `mt watch`. Файл створювався на машині яка запускала wrapper. - -Якщо це різні машини (або навіть та сама машина з NTP drift) — `now()` може відрізнятись від реального `now()` в момент створення файлу. - -### Конкретний сценарій - -``` -Distributed setup: -machine-A: запускає wrapper → пише running_<pid>_until_1749200400 -(машина A: now = 1749196800, deadline = now + 3600) -machine-B: запускає watch → перевіряє ts ≤ now() -(машина B: now = 1749200600, clock на 200 сек вперед) - -Watch на machine-B: -1749200400 ≤ 1749200600 → stalled! - -Але насправді: -Процес на machine-A ще має 200 секунд до deadline. -Watch вважає що вузол завис і надсилає Telegram alert. -Можливо — kill process (якщо watch має auto-kill). - -False positive: живий процес вбито через clock skew. -``` - -Менш небезпечний але частіший варіант — той самий MacBook з NTP resync: - -``` -Wrapper пише файл: now=T, deadline=T+3600 -NTP resync через годину: системний час стрибає на +30 секунд -Watch через 5 хв: now=T+3605 (через NTP), deadline=T+3600 -→ T+3600 ≤ T+3605 → stalled -→ Але процес завершився 5 секунд тому нормально -→ running_until_* вже видалений wrapper'ом → false positive не виникає -``` - -На одній машині з нормальним NTP — skew зазвичай < 1 сек, не проблема. Реальний ризик — тільки при distributed setup або VM migration між хостами. - -### Потенційне вирішення - -**Варіант A — `stale_grace_sec` буфер** - -```json -// .n-cursor.json -{ "stale_grace_sec": 60 } -``` - -Watch вважає `stalled` тільки якщо `now() > ts + grace`. 60 секунд буфер покриває NTP drift і більшість clock skew сценаріїв без додаткової складності. - -**Варіант B — Timestamp у UTC + monotonic check** - -Файл `running_<pid>_until_<ts>` де `ts` — unix epoch UTC. Watch порівнює з `Date.now() / 1000`. На одній машині це ідентично, на різних — залежить від синхронізації. - -Не вирішує проблему але унеможливлює timezone помилки. - -**Варіант C — Не вирішувати (scope обмеження)** - -Специфікація явно документує: "distributed FS з різними machine clocks — не підтримується". `n-cursor` = single-machine або single-cluster з NTP. Для distributed setup — потрібен зовнішній coordinator (Redis, etcd). - -**Рекомендую Варіант C + A** — явно задокументувати що distributed multi-machine setup поза scope, але додати `stale_grace_sec: 60` як захист від NTP jitter на одній машині. Не ускладнювати архітектуру для сценарію що виходить за межі цільового використання. Якщо distributed стане реальною вимогою — окремий ADR з coordinator-based рішенням. - ---- - -Всі 4 ризики розібрані. Загальна картина: - -| # | Зафіксовано | -|---|---| -| Ризик 1 | `max_worktrees: 4` + FIFO queue | -| Ризик 2 | `mt invalidate --cascade` (manual) | -| Ризик 3 | `graph pin` після першого composite planning | -| Ризик 4 | Scope: single-machine + `stale_grace_sec: 60` | - -Хочеш внести всі зафіксовані рішення (по вадах і ризиках) у `npm/docs/mt.md`? - ---- - -## ADR — Детермінований стан вузла через файловий listing (ls-only invariant) - -## Context and Problem Statement -Дизайн `mt` зберігає стан кожного вузла у файловій системі. Виникло питання: які стани не можна визначити без читання вмісту файлів? Аналіз показав що `human-pending` vs `waiting` неможливо розрізнити без читання `mode:` з `task.md`, а `deps`-список також потребує читання frontmatter. При великих графах це перетворюється на O(n) reads при кожному скані. - -## Considered Options -* Зберегти `mode:` і `deps:` у frontmatter `task.md` (поточна схема) -* Перекодувати `mode` у назву файлу (`task_h.md` / `task_a.md`) -* `a.md` / `h.md` як mutable sentinel-файли + `deps/` директорія замість frontmatter - -## Decision Outcome -Chosen option: "sentinel-файли `a.md`/`h.md` + `deps/` директорія", because це дозволяє визначити всі стани вузла виключно через `ls` без читання вмісту будь-якого файлу, при цьому `task.md` залишається стабільним (не перейменовується при зміні mode), а зміна mode = `rm h.md && touch a.md` без порушення git history. - -### Consequences -* Good, because `mt scan` — O(file count), без парсингу; відновлення після збою тривіальне; зовнішні інструменти читають стан без знання протоколу. -* Bad, because кількість файлів на вузол зросла: замість одного `task.md` — `task.md` + `a.md`/`h.md` + `deps/` директорія. - -## More Information -Формальний інваріант зафіксовано у `npm/docs/mt.md`: "всі стани визначаються виключно переліком файлів і директорій — без читання вмісту". Sentinel-файли: `a.md`, `h.md`, `invalidated`, `running_<pid>_until_<ts>` — mutable. Immutable: `task.md`, `plan_NNN.md`, `run_NNN.md`, `fact_NNN.md`, `pending-audit_NNN.md`, `audit-result_NNN.md`, файли в `deps/`. - ---- - -## ADR — `running_<pid>_until_<ts>` як sentinel для running/stalled станів - -## Context and Problem Statement -У дизайні відсутній стан "завис" (`stalled`). Вузол міг бути `running` (worktree існує) але перевищити ліміт часу, і ніде це не фіксувалось — тільки `running` → `failed` без проміжного стану. Виникло питання: як детектувати `stalled` без читання вмісту? - -## Considered Options -* Implicit: watch вбиває при timeout, явного стану немає -* Sentinel файл `stalled` який пише watch -* Deadline у назві sentinel-файлу + PID для live-process detection - -## Decision Outcome -Chosen option: "`running_<pid>_until_<ts>`", because deadline і PID у назві файлу дозволяють детектувати `running` vs `stalled` чистим `ls` + порівнянням timestamp; PID дозволяє `kill -0 <pid>` для перевірки чи процес живий без читання вмісту. - -### Consequences -* Good, because `stalled` = presence check + filename parse (ts ≤ now()); cleanup при краші — автоматичний через гібрид: wrapper робить cleanup при старті нового run (Варіант A), watch перевіряє PID при кожному скані (Варіант B). -* Bad, because `budget_hard_sec: 0` (вимкнено) потребує спеціальної обробки щоб не створити sentinel з deadline у минулому; clock skew на multi-machine setups — поза scope (single-machine + `stale_grace_sec: 60`). - -## More Information -Файл: `tasks/<node>/running_<pid>_until_<ts>`. Git-ignored. Видаляється wrapper'ом при нормальному завершенні. Пріоритет станів: `invalidated` > `resolved` > `pending-audit` > `stalled` > `running` > `waiting`/`blocked` > `human-pending` > `unassigned` > `failed`. Зафіксовано у `npm/docs/mt.md`. - ---- - -## ADR — `deps/` директорія замість `deps:` frontmatter + siblings-only контракт - -## Context and Problem Statement -Список залежностей вузла зберігався у `deps:` полі frontmatter `task.md`. Це порушувало ls-only інваріант: щоб дізнатись залежності — треба читати `task.md`. Також виникло питання про scope: чи можуть вузли залежати від вузлів на інших рівнях ієрархії? - -## Considered Options -* `deps:` у frontmatter `task.md` (поточна схема) -* `deps/` директорія де ім'я файлу = dep-id + відносні або абсолютні шляхи -* `deps/` директорія зі siblings-only контрактом (dep-id = ім'я сусіда) - -## Decision Outcome -Chosen option: "`deps/` директорія зі siblings-only контрактом", because список deps видно з `ls deps/` без читання вмісту; siblings-only усуває неоднозначність імен і не потребує парсингу шляхів; composite-батько відповідає за координацію між гілками. - -### Consequences -* Good, because deps satisfaction = `ls deps/` → перевірити `fact_*.md` у `tasks/<dep-id>/`; файли в `deps/<dep-id>.md` можуть містити опціональний ref + контекст для агента (читається тільки агентом, не orchestrator'ом). -* Bad, because залежності між вузлами на різних рівнях ієрархії неможливі без реструктуризації графу; якщо є два вузли з однаковою назвою у різних піддеревах — конфлікт dep-id. - -## More Information -Конвенція: файли в `deps/` мають розширення `.md` (`deps/collect-data.md`). dep-id = `basename без .md`. Immutable після worktree. Зафіксовано у `npm/docs/mt.md` рядки 236–252. - ---- - -## ADR — `fact_NNN.md` для composite вузлів (orchestrator-written) - -## Context and Problem Statement -Composite вузол не мав `fact_NNN.md` — його "resolved" стан визначався рекурсивним обходом всіх нащадків. Це робило перевірку стану composite O(n) замість O(1) і ускладнювало deps satisfaction між гілками. - -## Considered Options -* Implicit resolved: composite resolved = всі діти resolved (рекурсивний обхід) -* `fact_NNN.md` для composite написаний orchestrator'ом при завершенні всіх дітей -* Порожній sentinel `.resolved` - -## Decision Outcome -Chosen option: "`fact_NNN.md` для composite написаний orchestrator'ом", because уніфікує протокол — всі resolved вузли (atomic і composite) мають `fact_NNN.md`; deps satisfaction однаковий для обох типів; O(1) перевірка стану. - -### Consequences -* Good, because `resolved` = presence check для обох типів; NNN-нумерація і трасовуваність збережена; orchestrator вже відслідковує completion дітей — один додатковий `write` при переході. -* Bad, because orchestrator стає writer'ом `fact_NNN.md`, а не тільки агент — два різні writer'и для одного типу файлу залежно від типу вузла. - -## More Information -Зміст composite `fact_NNN.md` = summary дочірніх результатів (written by orchestrator). Зафіксовано у рішенні по Ваді №4 сесії 2026-06-07. - ---- - -## ADR — Audit FAIL → `invalidated` + новий цикл - -## Context and Problem Statement -При audit FAIL специфікація не описувала що відбувається далі. `pending-audit_NNN.md` та `audit-result_NNN.md` immutable, NNN зайнятий. Повторний аудит того самого `fact_NNN.md` неможливий без порушення immutable контракту. Вузол залишався `resolved` (є `fact_*.md`) попри FAIL аудиту. - -## Considered Options -* Повторний audit з новою нумерацією (`pending-audit_NNN_attempt_M.md`) -* Аудит FAIL = тільки нотифікація, без автоматичного state transition -* Аудит FAIL → `invalidated` → новий run цикл → новий NNN - -## Decision Outcome -Chosen option: "Аудит FAIL → orchestrator пише `invalidated`", because використовує існуючий механізм `invalidated`; immutable не порушується; повторний цикл отримує новий NNN природно; повна трасовуваність: `invalidated` + `audit-result_NNN.md(FAIL)`. - -### Consequences -* Good, because протокол залишається консистентним; старий `fact_NNN.md` + `audit-result_NNN.md(FAIL)` + `invalidated` = повна картина події в git history. -* Bad, because `invalidated` тепер може бути виставлений двома різними тригерами (ручна інвалідація і audit FAIL) — логіка orchestrator має розрізняти причину якщо потрібна різна подальша дія. - -## More Information -Файл `audit-result_NNN.md` має поле `result: pass | fail`. При `fail` orchestrator пише `invalidated`. Зафіксовано у рішенні по Ваді №6 сесії 2026-06-07. - ---- - -## ADR — Context window агента: `max_context_runs` + тільки останні N run - -## Context and Problem Statement -Агент отримує у context всі `run_NNN.md` накопичені вузлом. Після 10+ failed спроб context window заповнюється власною невдалою історією, залишаючи менше простору для реального виконання. LLM може також "навчитись" помилкам з попередніх run. - -## Considered Options -* Всі `run_NNN.md` у context (поточна схема) -* Тільки останній `run_NNN.md` -* `max_context_runs: N` у конфігурації + тільки останні N - -## Decision Outcome -Chosen option: "`max_context_runs: N` (default: 3) + тільки останній run", because N=3 достатньо для розуміння "що нещодавно пішло не так"; налаштовується per-node для складних задач; не потребує змін у форматі файлів. - -### Consequences -* Good, because context window агента bounded; ранні failed run не забруднюють context; архів всіх run_NNN.md залишається у filesystem для аудиту. -* Bad, because агент може не знати про помилку зроблену 4+ runs тому якщо max_context_runs=3 — може повторити її. - -## More Information -`max_context_runs` у `.n-cursor.json` (global) або `a.md` (per-node). Orchestrator включає останні min(N, total_runs) файлів. Зафіксовано у рішенні по Ваді №9 сесії 2026-06-07. - ---- - -## ADR — `max_worktrees` + FIFO queue для паралельних worktrees - -## Context and Problem Statement -`mt run --auto` запускає всі ready вузли паралельно без ліміту. При великому графі і великому репозиторії — одночасне створення 50+ worktrees може спустошити disk і перевантажити CPU/IO на dev-машині. - -## Considered Options -* Без ліміту (поточна схема) -* `max_worktrees` + back-pressure FIFO queue -* Shallow worktree (тільки необхідні файли) - -## Decision Outcome -Chosen option: "`max_worktrees: 4` (default) + FIFO queue", because найпростіше в реалізації; передбачувана поведінка; не потребує змін у git або worktree протоколі; черга природна — `mt scan` при кожному тіку підхоплює наступний ready вузол. - -### Consequences -* Good, because disk і CPU використання bounded; default 4 залишає ресурси для основного середовища розробки. -* Bad, because при max_worktrees=4 і 50 ready вузлах — загальний час виконання збільшується лінійно; немає priority queue (критичні вузли чекають поряд зі звичайними). - -## More Information -`max_worktrees` у `.n-cursor.json`. Default: 4. FIFO: `mt scan` додає у чергу в порядку topological sort (вузли без залежностей — перші). Зафіксовано у рішенні по Ризику №1 сесії 2026-06-07. - ---- - -## ADR — `mt invalidate --cascade` (manual) для каскадної інвалідації - -## Context and Problem Statement -При інвалідації вузла-основи всі залежні вузли мають теж бути інвалідовані (їх результати засновані на недійсних даних). Автоматична propagation ризикує скинути весь граф при будь-якій помилці. Відсутність propagation залишає граф у несумісному стані. - -## Considered Options -* Автоматична каскадна propagation при `invalidated` -* Differential invalidation (порівняти outputs до і після re-run) -* `mt invalidate --cascade` (ручна explicit команда) - -## Decision Outcome -Chosen option: "`mt invalidate --cascade` (ручна explicit команда)", because примушує людину думати про scope інвалідації; не ризикує автоматично скинути весь граф; differential invalidation додається як оптимізація у v2. - -### Consequences -* Good, because інженер контролює що саме інвалідується; немає тихих каскадних скидань великих частин графу. -* Bad, because людина може забути виконати `--cascade` після інвалідації основи — граф залишиться у несумісному стані без автоматичного попередження. - -## More Information -`mt invalidate --cascade tasks/<node>/` — рекурсивно пише `invalidated` у всі вузли що мають `deps/<node>.md`. Команда ідемпотентна. Differential invalidation (порівняння hash outputs) — roadmap v2. Зафіксовано у рішенні по Ризику №2 сесії 2026-06-07. - ---- - -## ADR — `graph pin` для стабілізації composite топології - -## Context and Problem Statement -При повторному `mt plan` для composite вузла LLM може запропонувати іншу декомпозицію ніж попередній раз. Старі дочірні вузли стають orphan (виконана робота втрачається або плутає scan). Нова топологія може семантично несумісно використати результати старих вузлів через deps. - -## Considered Options -* Дозволити зміну топології при кожному re-plan (поточна схема) -* `graph pin` після першого successful planning — фіксує топологію -* Структура composite у `## Structure` секції `task.md` (людина контролює) - -## Decision Outcome -Chosen option: "`graph pin` після першого composite planning", because відповідає реальному workflow (агент пропонує → людина погоджується → фіксує); re-run не змінює топологію без explicit `graph unpin`; `mt kill` без `--unpin` скидає тільки плани і результати, не топологію. - -### Consequences -* Good, because топологія стабільна між re-run; orphan вузлів не виникає; семантична сумісність deps збережена. -* Bad, because додає один explicit крок у workflow; якщо перша декомпозиція хибна — потрібен `graph unpin` + `mt kill` + `mt plan`. - -## More Information -`graph pin <path>` — пише `.pinned` sentinel у директорію composite вузла. `mt plan` при наявності `.pinned` — оновлює тільки budget/деталі існуючих дочірніх, не структуру. Зафіксовано у рішенні по Ризику №3 сесії 2026-06-07. diff --git "a/docs/adr/260607-2130-\321\226\320\275\320\262\320\260\321\200\321\226\320\260\320\275\321\202-\321\201\321\202\320\260\320\275-\320\262\321\203\320\267\320\273\320\260-\320\262\320\270\320\267\320\275\320\260\321\207\320\260\321\224\321\202\321\214\321\201\321\217-\320\262\320\270\320\272\320\273\321\216\321\207\320\275\320\276-\320\277\320\265\321\200\320\265\320\273\321\226\320\272\320\276\320\274-\321\204\320\260\320\271\320\273\321\226\320\262.md" "b/docs/adr/260607-2130-\321\226\320\275\320\262\320\260\321\200\321\226\320\260\320\275\321\202-\321\201\321\202\320\260\320\275-\320\262\321\203\320\267\320\273\320\260-\320\262\320\270\320\267\320\275\320\260\321\207\320\260\321\224\321\202\321\214\321\201\321\217-\320\262\320\270\320\272\320\273\321\216\321\207\320\275\320\276-\320\277\320\265\321\200\320\265\320\273\321\226\320\272\320\276\320\274-\321\204\320\260\320\271\320\273\321\226\320\262.md" deleted file mode 100644 index ffa9327..0000000 --- "a/docs/adr/260607-2130-\321\226\320\275\320\262\320\260\321\200\321\226\320\260\320\275\321\202-\321\201\321\202\320\260\320\275-\320\262\321\203\320\267\320\273\320\260-\320\262\320\270\320\267\320\275\320\260\321\207\320\260\321\224\321\202\321\214\321\201\321\217-\320\262\320\270\320\272\320\273\321\216\321\207\320\275\320\276-\320\277\320\265\321\200\320\265\320\273\321\226\320\272\320\276\320\274-\321\204\320\260\320\271\320\273\321\226\320\262.md" +++ /dev/null @@ -1,111 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:30:33+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Інваріант: стан вузла визначається виключно переліком файлів - -## Context and Problem Statement -У дизайні `mt` стан кожного вузла в DAG мав визначатися через читання вмісту `task.md` (поля `mode:`, `deps:`). Це робило сканування дорогим і ускладнювало відновлення після збоїв — парсинг YAML у кожному вузлі при кожному watch-скані. - -## Considered Options -* Читати `task.md` для визначення режиму та залежностей (поточний підхід до цього рішення) -* Формальний інваріант: всі стани — виключно з `ls` (file presence + filename parse), без читання вмісту - -## Decision Outcome -Chosen option: "Формальний інваріант — стани тільки з `ls`", because watch-loop стає O(file count) без парсингу, відновлення після збоїв тривіальне, зовнішні інструменти читають граф без знання протоколу. - -### Consequences -* Good, because transcript фіксує очікувану користь: детермінований стан без content reads; будь-який shell script або CI може читати граф без розуміння схеми файлів. -* Bad, because transcript не містить підтверджених негативних наслідків. (Теоретично: складніша еволюція схеми — нові стани вимагають нових файлів або нових патернів у іменах.) - -## More Information -Інваріант зафіксовано у `npm/docs/mt.md` як окремий блок перед таблицею станів: *"Інваріант: всі стани визначаються виключно переліком файлів і директорій — без читання вмісту."* - ---- - -## ADR Мутабельні sentinel-файли `a.md`/`h.md` для кодування режиму виконавця - -## Context and Problem Statement -Режим вузла (агент vs людина) потрібно зчитувати без читання вмісту `task.md`. Додатково: режим може змінюватись у будь-який момент часу (перевести з людини на агента і навпаки) — тому рішення повинно допускати зміну без деструктивних операцій над основним файлом задачі. - -## Considered Options -* Поле `mode: human | agent` у фронтматері `task.md` (вимагає content read) -* Перейменовані файли `task_h.md` / `task_a.md` (режим у назві основного файлу — git history рветься при `mv`) -* Окремі мутабельні sentinel-файли `a.md` / `h.md` поруч із стабільним `task.md` - -## Decision Outcome -Chosen option: "Окремі sentinel-файли `a.md`/`h.md`", because `task.md` залишається стабільним (git history збережена), зміна режиму — `rm h.md && touch a.md` без торкання місії вузла; відсутність обох файлів утворює третій корисний стан `unassigned`. - -### Consequences -* Good, because transcript фіксує очікувану користь: mode-switch без деструкції, `unassigned` стан — явний сигнал "вузол ще не сконфігурований". -* Bad, because transcript не містить підтверджених негативних наслідків. (Теоретично: два файли замість одного — трохи більше інодів; можливий стан `a.md + h.md` одночасно → треба валідація.) - -## More Information -Файли задокументовані в `npm/docs/mt.md`: `a.md` містить `model_tier`, `skills`; `h.md` містить `qualification`. Обидва — mutable flags (не immutable artifacts). Перелік мутабельних прапорів: `a.md`, `h.md`, `invalidated`, `running_*`. - ---- - -## ADR Директорія `deps/` замість поля `deps:` у фронтматері - -## Context and Problem Statement -Залежності між вузлами DAG зберігалися як поле `deps:` у фронтматері `task.md`. Це порушувало інваріант — для відновлення топології граф-сканер мусив читати вміст кожного `task.md`. Додатково зміна переліку залежностей вимагала мутації immutable-файлу. - -## Considered Options -* Поле `deps:` у фронтматері `task.md` -* Директорія `deps/` з одним файлом на залежність (ім'я файлу = dep-node-id, вміст — опціональний контекст) - -## Decision Outcome -Chosen option: "Директорія `deps/`", because `ls deps/` дає повний список залежностей без читання вмісту; вміст файлу читається агентом тільки коли потрібен контекст (lazy); видалення/додавання залежності = атомарна операція з одним файлом, не патч YAML. - -### Consequences -* Good, because transcript фіксує очікувану користь: deps-satisfaction check = `ls deps/` + перевірка `fact_*.md` у кожному dep-вузлі — все через presence. -* Bad, because transcript не містить підтверджених негативних наслідків. (Теоретично: deps між вузлами різних рівнів ієрархії — не описані у специфікації; ім'я файлу без шляху обмежує deps лише сусідами.) - -## More Information -Схема `deps/<dep-node-id>.md` зафіксована в `npm/docs/mt.md`. Задоволеність залежності: `ls deps/` → для кожного id → `fact_*.md` у `tasks/<dep-id>/`. Приклад: `deps/collect-data.md` з `ref: ../collect-data/fact_001.md`. - ---- - -## ADR Hybrid A+B cleanup для `running_<pid>_until_<ts>` sentinel - -## Context and Problem Statement -Sentinel-файл `running_until_<ts>` позначає активний worktree та кодує deadline у назві. При аномальному завершенні процесу (`kill -9`, OOM) wrapper не встигає видалити файл — вузол залишається у стані `stalled` назавжди без автоматичного відновлення. - -## Considered Options -* Варіант A — cleanup як перший крок нового `mt run`: при старті перевіряти існуючий sentinel, прибирати якщо процес мертвий -* Варіант B — PID у назві sentinel: `running_<pid>_until_<ts>`, що дозволяє `kill -0 <pid>` без читання вмісту -* Гібрид A+B: PID у назві + cleanup-on-start у wrapper і watch - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві файлу (детектується з `ls`, без читання) + cleanup в обох точках входу (wrapper при старті нового run, watch при кожному скані) — автоматичне відновлення без ручного втручання у більшості сценаріїв краша. - -### Consequences -* Good, because transcript фіксує очікувану користь: `kill -0 <pid>` — нульовий сигнал (тільки перевірка існування процесу), cleanup автоматичний, жодних ручних операцій для типового краша. -* Bad, because transcript не містить підтверджених негативних наслідків. (Теоретично: PID reuse на довгих сесіях — мертвий процес з тим самим PID може дати false negative; потребує аналізу для distributed FS зі clock skew.) - -## More Information -Нова конвенція імені: `running_<pid>_until_<ts>` у `tasks/<node>/`. Cleanup-логіка: якщо `ts ≤ now()` або `kill -0 <pid>` повертає ESRCH — видалити sentinel, видалити orphan worktree, записати `run_NNN.md` з `result: failed (timeout-or-crash)`. - ---- - -## ADR Розбиття стану `waiting` на `waiting-plan` та `waiting-run` - -## Context and Problem Statement -Стан `waiting` охоплював два принципово різних сценарії: вузол без плану (потрібен `mt plan`) і вузол з планом та задоволеними deps (потрібен `mt run`). Одночасно обидва варіанти були присутні для `a.md` і `h.md`. Це змішувало два ортогональних питання в одному стані: *що потрібно зробити* (план чи виконання) і *хто це робить* (агент чи людина). Runner поводився по-різному для однойменних `waiting`-вузлів залежно від вмісту файлів. - -## Considered Options -* Один стан `waiting` для всіх випадків + runner читає `a.md`/`h.md` щоб вирішити що робити -* Стан `ready-human` як окремий від `waiting` -* `waiting-plan` (потрібен план) / `waiting-run` (план є, потрібне виконання) — ортогонально до `a.md`/`h.md` - -## Decision Outcome -Chosen option: "`waiting-plan` / `waiting-run` + `a.md`/`h.md` як окремі виміри", because стан відповідає на питання *що потрібно*, файл `a.md`/`h.md` відповідає на питання *хто робить*. Runner завжди: стан → визначає дію; файл → визначає виконавця. Жодного дублювання. - -### Consequences -* Good, because transcript фіксує очікувану користь: зовнішній monitor, CI, dashboard — однозначна семантика без читання файлів. Стара `human-pending` і `needs-plan` зникають — обидва стають `waiting-plan`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Повна таблиця станів у `npm/docs/mt.md`. Runner-матриця: `waiting-plan + a.md` → auto `mt plan --mode agent`; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto `mt run`; `waiting-run + h.md` → skip + notify. diff --git "a/docs/adr/260607-2132-teleport-\321\217\320\272-ssh-gateway-\320\267-kubernetes-rbac-\320\264\320\273\321\217-\320\264\320\276\321\201\321\202\321\203\320\277\321\203-\321\200\320\276\320\267\321\200\320\276\320\261.md" "b/docs/adr/260607-2132-teleport-\321\217\320\272-ssh-gateway-\320\267-kubernetes-rbac-\320\264\320\273\321\217-\320\264\320\276\321\201\321\202\321\203\320\277\321\203-\321\200\320\276\320\267\321\200\320\276\320\261.md" deleted file mode 100644 index 5955b2e..0000000 --- "a/docs/adr/260607-2132-teleport-\321\217\320\272-ssh-gateway-\320\267-kubernetes-rbac-\320\264\320\273\321\217-\320\264\320\276\321\201\321\202\321\203\320\277\321\203-\321\200\320\276\320\267\321\200\320\276\320\261.md" +++ /dev/null @@ -1,86 +0,0 @@ ---- -session: bce336cc-aa1a-406e-9d06-59ac3091f37c -captured: 2026-06-07T21:32:06+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/bce336cc-aa1a-406e-9d06-59ac3091f37c.jsonl ---- - ---- - -## ADR Teleport як SSH gateway з Kubernetes RBAC для доступу розробників до dev pods - -## Context and Problem Statement -Розробники без прав `kubectl` потребують SSH-доступу до dev pods у кластері де живуть файли задач (`tasks-pvc`). Бекенд `nitra/task` повинен самостійно вирішувати, чи має конкретний розробник право підключитися, без делегування цього рішення на рівень k8s RBAC. - -## Considered Options -* Teleport (identity-aware SSH proxy з RBAC через labels) -* `kubectl port-forward` з SSH у поді - -## Decision Outcome -Chosen option: "Teleport як SSH gateway", because `kubectl port-forward` потребує прав `kubectl` у розробника і не дає серверного контролю авторизації; Teleport дозволяє бекенду `nitra/task` контролювати доступ через label `owner: email` без надання розробникам прав до k8s API. - -### Consequences -* Good, because transcript фіксує очікувану користь: короткоживучі X.509/SSH сертифікати (TTL 8–24 год), label-based RBAC де `owner == email` юзера підставляється динамічно з GitHub identity, audit log з коробки. -* Good, because Zed, VS Code і Cursor підключаються через стандартний SSH з `ProxyCommand tsh proxy ssh` у `~/.ssh/config` без патчів до редакторів; VS Code і Cursor підтримують URI deep link (`vscode://`, `cursor://`) для одноклікового відкриття. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Маніфести у `/Users/vitaliytv/www/nitra/task/k8s/`: -- `namespace.yaml`, `cnpg/cluster.yaml` (PostgreSQL 3 instances через CloudNativePG) -- `teleport/configmap.yaml` — `storage.type: postgresql`, `conn_string: "${PG_CONN_STRING}"` -- `teleport/statefulset.yaml` — 2 репліки Auth+Proxy, `volumeClaimTemplates: 1Gi`, env `PG_CONN_STRING` із CNPG secret `teleport-postgres-app` -- `teleport/rbac.yaml`, `teleport/roles.yaml` — Teleport Role `developer` дозволяє доступ лише до вузлів де `owner: "{{internal.logins}}"` -- `dev-pod/template.yaml` — Pod шаблон із sidecar `teleport-node` (k8s join method), монтує `tasks-pvc` -- Бекенд spawn flow: `POST /api/tasks/:id/open-editor` → `kubectl apply` dev pod → Teleport реєструє ноду → повертає hostname - ---- - -## ADR Завдання системи `nitra/task` — вузловий task.md файл як одиниця роботи - -## Context and Problem Statement -Потрібна структура для зберігання та запуску агентних задач у системі `mt`. Задачі мають описувати що зробити, критерії завершення та вхідні дані, не прив'язуючись до конкретного виконавця. - -## Considered Options -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Директорія-вузол із `task.md` із YAML frontmatter", because стан вузла визначається наявністю файлів (`waiting` = тільки `task.md`; `running` = є `run_*.md`; `resolved` = є `outputs_*.md`) без зовнішньої БД стану, що відповідає архітектурі рекурсивного складеного ОАГ описаній у `npm/docs/mt.md`. - -### Consequences -* Good, because transcript фіксує очікувану користь: вузли незалежні, стан читається через `ls`, задачі запускаються через `mt run tasks/<name>`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Створені вузли у `/Users/vitaliytv/www/nitra/cursor/tasks/`: -- `ui-task-view/task.md` — UI перегляду задач, budget 3600 сек -- `coverage-skill-test/task.md` — тестування `n-coverage-fix` після міграції на `pi`, budget 1800 сек -- `skills-orchestrator-migration/task.md` — міграція `npm/skills/` на JS-оркестратор паттерн, budget 7200 сек - -Створений вузол у `/Users/vitaliytv/www/nitra/task/tasks/`: -- `open-in-editor/task.md` — кнопка "Open in Editor" з підтримкою VS Code, Cursor, Zed через `POST /api/tasks/:id/open-editor` - -Frontmatter: `created_at` (ISO 8601), `budget_sec`. Секції: `## Task`, `## Done when`, `## Inputs`. - ---- - -## ADR CloudNativePG (CNPG) як PostgreSQL backend для Teleport замість SQLite - -## Context and Problem Statement -SQLite backend Teleport обмежує кількість реплік Auth Server до одного екземпляра. Потрібен HA-розгортання з rolling update без downtime. - -## Considered Options -* CloudNativePG (PostgreSQL operator для k8s) -* SQLite (файловий backend, початковий варіант) - -## Decision Outcome -Chosen option: "CloudNativePG", because PostgreSQL backend дозволяє запускати 2 репліки Teleport StatefulSet з rolling update без downtime; CNPG автоматично керує primary/replica failover і створює secret `teleport-postgres-app` із `uri` полем яке Teleport отримує через `${PG_CONN_STRING}`. - -### Consequences -* Good, because transcript фіксує очікувану користь: StatefulSet з replicas: 2, rolling update, failover при падінні поду; `volumeClaimTemplates: 1Gi` на кожну репліку для host-сертифікатів. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`k8s/cnpg/cluster.yaml` — `instances: 3` (1 primary + 2 replicas), namespace `teleport`. -`k8s/teleport/statefulset.yaml` — замінює `deployment.yaml`; env `PG_CONN_STRING` з `secretKeyRef: teleport-postgres-app / uri`. -`k8s/teleport/configmap.yaml` — `storage: {type: postgresql, conn_string: "${PG_CONN_STRING}"}`. -Видалено: `k8s/teleport/pvc.yaml` (SQLite PVC), `k8s/teleport/deployment.yaml`. -Порядок деплою: `cnpg/cluster.yaml` → дочекатись CNPG Ready → `teleport/statefulset.yaml`. diff --git "a/docs/adr/260607-2134-\320\264\320\265\321\202\320\265\320\272\321\202\321\203\320\262\320\260\320\275\320\275\321\217-stalled-\320\262\321\203\320\267\320\273\320\260-\321\207\320\265\321\200\320\265\320\267-runningpiduntilts.md" "b/docs/adr/260607-2134-\320\264\320\265\321\202\320\265\320\272\321\202\321\203\320\262\320\260\320\275\320\275\321\217-stalled-\320\262\321\203\320\267\320\273\320\260-\321\207\320\265\321\200\320\265\320\267-runningpiduntilts.md" deleted file mode 100644 index 8c0feca..0000000 --- "a/docs/adr/260607-2134-\320\264\320\265\321\202\320\265\320\272\321\202\321\203\320\262\320\260\320\275\320\275\321\217-stalled-\320\262\321\203\320\267\320\273\320\260-\321\207\320\265\321\200\320\265\320\267-runningpiduntilts.md" +++ /dev/null @@ -1,111 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:34:01+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Детектування `stalled` вузла через `running_<pid>_until_<ts>` - -## Context and Problem Statement -У дизайні `mt` (`npm/docs/mt.md`) стан `stalled` (процес завис або впав) мав бути відрізнений від `running` без читання вмісту файлів. Додатково: якщо процес завершується аномально (`kill -9`, OOM), sentinel-файл залишається на диску й блокує будь-який повторний запуск вузла. - -## Considered Options -* **Варіант A** — cleanup-on-startup: wrapper перевіряє sentinel при старті нового run і прибирає orphan -* **Варіант B** — PID у назві файлу: `running_<pid>_until_<ts>` для перевірки `kill -0 <pid>` з `ls` -* **Варіант C** — окремий cleanup daemon - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві файлу дозволяє детектувати живість процесу через `kill -0 <pid>` без читання вмісту, а cleanup-on-startup у wrapper і в `mt watch` гарантує прибирання orphan-sentinel після аномального завершення в обох точках входу. - -### Consequences -* Good, because transcript фіксує очікувану користь: стан `running` vs `stalled` визначається з `ls` без читання вмісту; orphan-sentinel після краша прибирається автоматично без ручного втручання. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл sentinel: `tasks/<node>/running_<pid>_until_<ts>` (git-ignored). Cleanup-логіка: wrapper і `mt watch` виконують `kill -0 <pid>`; якщо процес мертвий — видаляють sentinel і orphan worktree, пишуть `run_NNN.md` з `result: failed (crash)`. Поле `budget_hard_sec: 0` потребує окремої обробки: `ts = started_at + 0` означає deadline у минулому — sentinel не повинен створюватись або `0` треба трактувати як "без ліміту". - ---- - -## ADR Інваріант: всі стани вузла — виключно з file listing - -## Context and Problem Statement -Аудит дизайну `npm/docs/mt.md` показав: декілька станів (`waiting`, `blocked`, визначення `deps`) вимагали читання вмісту файлів (frontmatter `deps:`, поле `mode:`), що унеможливлювало O(1) скан стану без парсингу YAML. - -## Considered Options -* Залишити часткове читання вмісту для окремих станів -* Ввести формальний інваріант і перепроектувати всі стани під нього - -## Decision Outcome -Chosen option: "Формальний інваріант без читання вмісту", because детермінований стан з `ls` дає безкоштовне відновлення після збоїв, сумісність будь-якого зовнішнього інструменту (IDE, CI, shell script) без знання протоколу, і O(file count) складність watch-loop. - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt watch`, `mt scan`, зовнішні monitors отримують повний граф станів через `ls` без парсингу. -* Bad, because інваріант накладає обмеження на майбутні розширення: будь-яка нова метадана яка повинна впливати на стан — мусить кодуватись у присутності або назві файлу, а не у вмісті. - -## More Information -Інваріант зафіксований у `npm/docs/mt.md` рядок 405: "Інваріант: всі стани визначаються виключно переліком файлів і директорій — без читання вмісту." Реалізується через: `a.md`/`h.md` для mode, `running_<pid>_until_<ts>` для deadline, `deps/` directory для залежностей, numbered chains (`fact_NNN.md`, `run_NNN.md`) для результатів. - ---- - -## ADR Mutable sentinel-файли `a.md`/`h.md` для mode вузла - -## Context and Problem Statement -Потрібно було кодувати mode виконання вузла (agent або human) у спосіб що: 1) читається з `ls` без парсингу; 2) дозволяє зміну mode без руйнування git-history `task.md`; 3) підтримує стан "mode ще не визначено" для щойно створених вузлів. - -## Considered Options -* `task_h.md` / `task_a.md` — mode у назві основного task-файлу -* Один sentinel `agent` (відсутність = human за замовчуванням) -* Dual sentinel `a.md` / `h.md` з третім станом "ні один не присутній" - -## Decision Outcome -Chosen option: "Dual sentinel `a.md`/`h.md`", because зміна mode = `rm h.md && touch a.md` без торкання `task.md`; git-history місії зберігається; відсутність обох файлів дає корисний стан `unassigned`/`setup` (вузол існує, але ще не сконфігурований). - -### Consequences -* Good, because transcript фіксує очікувану користь: `task.md` стабільний (immutable після `mt init`); перемикання mode — атомарна файлова операція; `unassigned` стан видимий у `mt status` без читання вмісту. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`a.md` schema: frontmatter з `model_tier` (MIM|AVG|MAX) і `skills[]`. `h.md` schema: frontmatter з `qualification`. Обидва — mutable прапори (на відміну від immutable `task.md`, `plan_NNN.md`). Стан `unassigned` = `task.md` є, `a.md` і `h.md` відсутні. Зафіксовано у `npm/docs/mt.md` рядки 189–232. - ---- - -## ADR Директорія `deps/` замість поля `deps:` у frontmatter - -## Context and Problem Statement -Список залежностей вузла у форматі `deps:` у frontmatter `task.md` вимагав читання і парсингу YAML для отримання dep-переліку — порушення інваріанту file-listing. Також зміна deps після `mt init` вимагала редагування immutable файлу. - -## Considered Options -* `deps:` поле у frontmatter `task.md` -* `deps/` директорія де ім'я файлу = dep-node-id - -## Decision Outcome -Chosen option: "`deps/` директорія", because `ls deps/` = повний список залежностей без читання вмісту; deps satisfaction = перевірити `fact_*.md` у відповідній node-директорії; `deps/` відсутня або порожня = немає залежностей. - -### Consequences -* Good, because transcript фіксує очікувану користь: deps-список з `ls`; файл `deps/<dep-id>.md` може опціонально містити `ref:` і контекст для агента (але це не впливає на стан). -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`deps/collect-data.md` приклад: `ref: ../collect-data/fact_001.md` + опціональний контекст. Deps satisfaction (рядок 252 `npm/docs/mt.md`): `ls deps/` → для кожного dep-id → перевірити `fact_*.md` у `tasks/<dep-id>/`. Файли у `deps/` — immutable після worktree. Залишилось відкрите питання про cross-sibling deps (Вада №3 у розборі): Варіант C (вкладена `deps/` структура) ще не підтверджений. - ---- - -## ADR Стани `waiting-plan` і `waiting-run` замість `waiting`/`human-pending`/`needs-plan` - -## Context and Problem Statement -Стан `waiting` у попередній таблиці покривав два семантично різних випадки: `a.md` + deps resolved (runner запускає автоматично) і `h.md` + plan + deps resolved (runner ігнорує, чекає людину). Зовнішній monitor не міг розрізнити ці випадки без читання файлів. Крім того, `human-pending` і `needs-plan` дублювали різницю між "агент без плану" і "людина без плану" — хоча обидва стани означають одне: потрібен план. - -## Considered Options -* Залишити `waiting` + окремий `ready-human` стан -* Перейменувати `waiting` на `ready`, залишити `human-pending` для всіх `h.md` -* `waiting-plan` / `waiting-run` де стан кодує "що потрібно", а `a.md`/`h.md` кодують "хто робить" - -## Decision Outcome -Chosen option: "`waiting-plan` / `waiting-run`", because стани відповідають на питання "що потрібно далі" (план або запуск), а `a.md`/`h.md` відповідають на питання "хто це робить" — два ортогональних виміри не змішуються в одному стані. `human-pending` і `needs-plan` зникають як окремі стани. - -### Consequences -* Good, because transcript фіксує очікувану користь: runner читає стан (що робити) + файл mode (хто робить) — однозначна логіка без спеціальних випадків; `mt status` показує однакову семантику для людських і агентських вузлів. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Нова таблиця станів (погоджена у transcript): `unassigned` (`task.md` без `a.md`/`h.md`), `waiting-plan` (`a.md` або `h.md` без `plan_*.md`), `waiting-run` (`plan_*.md` + deps resolved), `blocked` (`plan_*.md` + deps не resolved), `running`, `stalled`, `pending-audit`, `resolved`, `failed`, `invalidated`. Runner-логіка: `waiting-plan + a.md` → auto `mt plan --mode agent`; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto `mt run`; `waiting-run + h.md` → skip + notify. Зміни ще не внесені у `npm/docs/mt.md` — pending оновлення специфікації. diff --git a/docs/adr/260607-2140-299215fa.md b/docs/adr/260607-2140-299215fa.md deleted file mode 100644 index e29bd85..0000000 --- a/docs/adr/260607-2140-299215fa.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:40:50+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -4 ADR-файли створено. Тепер зведена таблиця. - ---- - -## Зведена таблиця: стан погодженості - -### Вади - -| # | Вада | Рішення | Статус | -|---|---|---|---| -| 1 | `running_until_*` без cleanup після краша | Гібрид A+B: `running_<pid>_until_<ts>` + cleanup-on-startup + watch `kill -0` | ✅ **Погоджено** | -| 2 | `waiting` — один стан для різних поведінок | `waiting-plan` / `waiting-run`; `a.md`/`h.md` = ортогональний вимір "хто" | ✅ **Погоджено** | -| 3 | `deps/` — тільки siblings | Вкладена `deps/`: шлях у структурі дзеркалює `tasks/` ієрархію | ✅ **Погоджено** | -| 4 | Composite resolved — implicit і дорогий (O(n×depth)) | Варіант B: явний `fact_NNN.md` для composite (пише оркестратор) | ✅ **Погоджено** | -| 5 | `deps/` іменування: `.md` чи без | — | ⏳ **Не обговорювалась** | -| 6 | `pending-audit` NNN при повторному аудиті | — | ⏳ **Не обговорювалась** | -| 7 | Відсутність версійності схеми | — | ⏳ **Не обговорювалась** | -| 8 | `mode:` у `plan_NNN.md` — дублює `a.md`/`h.md` | — | ⏳ **Не обговорювалась** | -| 9 | Context агента зростає без bounds (N run_*.md) | — | ⏳ **Не обговорювалась** | -| 10 | `unassigned` без auto-assignment | — | ⏳ **Не обговорювалась** | - -### Ризики - -| # | Ризик | Статус | -|---|---|---| -| R1 | Кількість паралельних worktrees — disk saturation | ⏳ **Не обговорювався** | -| R2 | Каскадна інвалідація кореня — весь граф re-run | ⏳ **Не обговорювався** | -| R3 | LLM non-determinism — різна декомпозиція при re-plan | ⏳ **Не обговорювався** | -| R4 | Clock skew на distributed FS — `ts ≤ now()` некоректний | ⏳ **Не обговорювався** | - ---- - -Вади 1–4 задокументовані в `docs/adr/260607-100[0-3]-graph-*.md`. Залишилось 6 вад і 4 ризики — продовжуємо коли готовий. diff --git "a/docs/adr/260607-2142-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\265-\321\226\320\274\320\265\320\275\321\203\320\262\320\260\320\275\320\275\321\217-sentinel-\321\204\320\260\320\271\320\273\321\203-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-runningpiduntilts.md" "b/docs/adr/260607-2142-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\265-\321\226\320\274\320\265\320\275\321\203\320\262\320\260\320\275\320\275\321\217-sentinel-\321\204\320\260\320\271\320\273\321\203-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-runningpiduntilts.md" deleted file mode 100644 index ffa028e..0000000 --- "a/docs/adr/260607-2142-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\265-\321\226\320\274\320\265\320\275\321\203\320\262\320\260\320\275\320\275\321\217-sentinel-\321\204\320\260\320\271\320\273\321\203-\320\267\320\260\320\277\321\203\321\201\320\272\321\203-runningpiduntilts.md" +++ /dev/null @@ -1,114 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:42:38+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Гібридне іменування sentinel-файлу запуску: `running_<pid>_until_<ts>` - -## Context and Problem Statement -Sentinel-файл `running_until_<ts>` у директорії вузла сигналізує про активне виконання. Якщо процес завершився аномально (`kill -9`, OOM, crash хоста), wrapper не встигає видалити файл — вузол залишається в стані `stalled` назавжди і не може бути перезапущений без ручного втручання. - -## Considered Options -* Варіант A — cleanup як перший крок нового `mt run`: wrapper перевіряє `running_until_*` і прибирає stale sentinel перед стартом -* Варіант B — PID у назві файлу: `running_<pid>_until_<ts>`, щоб будь-хто міг перевірити `kill -0 <pid>` без читання вмісту -* Гібрид A+B — поєднати обидва: PID у назві + cleanup при старті та при кожному скані - -## Decision Outcome -Chosen option: "Гібрид A+B: `running_<pid>_until_<ts>` + cleanup-on-start", because PID у назві файлу дозволяє детектувати живий/мертвий процес через `kill -0 <pid>` без читання вмісту (інваріант зберігається), а cleanup при старті нового `mt run` і при кожному тіку `mt watch` забезпечує автоматичне відновлення після краша. - -### Consequences -* Good, because стан `running` vs `stalled` vs "мертвий процес" визначається виключно з `ls` + `kill -0 <pid>` — без читання вмісту файлу, інваріант не порушується. -* Good, because transcript фіксує очікувану користь: автоматичний cleanup у двох точках входу (wrapper старт + watch скан) усуває ручне втручання. -* Bad, because `budget_hard_sec: 0` (вимкнено) створює `running_<pid>_until_<started_at>` з deadline у минулому — sentinel миттєво виглядає як `stalled`; обробка цього edge case у специфікації не описана. - -## More Information -Sentinel-файл: `tasks/<node>/running_<pid>_until_<ts>` (git-ignored). -Перевірка живого процесу: `kill -0 <pid>` (тільки перевірка наявності, без сигналу). -Watch-логіка: при `ts ≤ now()` або `kill -0 <pid>` → ESRCH → cleanup sentinel + worktree, пише `run_NNN.md` з `result: failed (stalled-or-crash)`. - ---- - -## ADR Стани `waiting-plan` і `waiting-run` замість `waiting`/`human-pending`/`needs-plan` - -## Context and Problem Statement -Попередній дизайн мав стан `waiting` що покривав два семантично різні випадки: вузол чекає автоматичного запуску агентом, і вузол чекає ручної дії людини. Runner ігнорує `h.md`-вузли повністю, тому `waiting` з `h.md` ніколи не переходить у `running` автоматично — але назва вводить в оману зовнішні інструменти і людей. Також існував окремий стан `human-pending` для `h.md` без плану і `needs-plan` для агента без плану — два стани що виражали одне: "потрібен plan". - -## Considered Options -* Розбити `waiting` на `waiting` (агент) і `ready-human` (людина) -* Зберегти `waiting` як назву, розширити `human-pending` на всі `h.md`-вузли -* `waiting-plan` / `waiting-run` — стани відображають "що потрібно зробити", а не "хто робить" - -## Decision Outcome -Chosen option: "`waiting-plan` / `waiting-run`", because стан повинен відповідати на питання "що потрібно далі", а `a.md`/`h.md` вже відповідають на питання "хто виконує" — ці два виміри ортогональні і не повинні змішуватись в одному ідентифікаторі стану. Визначення хто виконує — відповідальність runner'а: він читає присутність `a.md` або `h.md` і діє відповідно. - -### Consequences -* Good, because transcript фіксує очікувану користь: таблиця станів стає симетричною; `waiting-plan + a.md` → auto plan, `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto run, `waiting-run + h.md` → skip + notify. -* Good, because видаляються три старі стани (`waiting`, `human-pending`, `needs-plan`) і замінюються двома більш точними — зменшується когнітивне навантаження. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Фінальна таблиця маппінгу (атомарний вузол): - -| Файли | Стан | -|---|---| -| `task.md`, немає `a.md`/`h.md` | `unassigned` | -| `a.md` або `h.md`, немає `plan_*.md` | `waiting-plan` | -| `plan_*.md`, deps resolved, немає `running_*`, немає `fact_*` | `waiting-run` | -| `plan_*.md`, deps НЕ resolved | `blocked` | - -Runner-логіка: `waiting-plan + a.md` → `mt plan --mode agent`; `waiting-plan + h.md` → skip+notify; `waiting-run + a.md` → `mt run`; `waiting-run + h.md` → skip+notify. -Файл специфікації: `npm/docs/mt.md`. - ---- - -## ADR Вкладена структура `deps/` для міжрівневих залежностей - -## Context and Problem Statement -Директорія `deps/` кодує залежності вузла: ім'я файлу = ідентифікатор dep-вузла. Але без шляху в імені файлу система може виражати тільки горизонтальні залежності (між siblings). Вузол на одній гілці дерева не може залежати від вузла на іншій гілці без штучної зміни топології графу. - -## Considered Options -* Варіант A — ім'я файлу з `__` як роздільником рівнів (`deps/research__analyze.md`) -* Варіант B — шлях у вмісті файлу (`ref: ../../research/analyze`) — порушує інваріант читання -* Варіант C — `deps/` дзеркалює структуру `tasks/`: `deps/research/analyze.md` → `tasks/research/analyze/` -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Варіант C — вкладена структура `deps/` що дзеркалює `tasks/`", because `ls -R deps/` дає повний шлях відносно `tasks/` без читання вмісту — інваріант зберігається; прості сусідні deps залишаються плоскими (`deps/collect-data.md`), крос-рівневі — вкладені (`deps/research/analyze.md`). - -### Consequences -* Good, because deps satisfaction без читання вмісту: `ls -R deps/` → шлях → шукати `tasks/<path>/fact_*.md`. -* Good, because transcript фіксує очікувану користь: зворотна сумісність — прості deps не змінюються. -* Bad, because Neutral, because transcript не містить підтвердження наслідку — вкладена структура в `deps/` ускладнює переміщення вузлів (потрібно оновити `deps/` у всіх залежних вузлах). - -## More Information -Приклад: `tasks/reporting/generate-report/deps/research/analyze.md` → залежність від `tasks/research/analyze/`. -Deps satisfaction: `ls -R deps/` → для кожного шляху → перевірити наявність `tasks/<path>/fact_*.md`. -Файл специфікації: `npm/docs/mt.md`, секція `deps/`. - ---- - -## ADR Явний `fact_NNN.md` для composite-вузлів, що пишеться `mt done` wrapper'ом - -## Context and Problem Statement -Composite-вузол не виконує роботу сам — його стан `resolved` визначався як "всі діти resolved". Це вимагало рекурсивного обходу всіх нащадків при кожному скані, що давало O(глибина × кількість вузлів) перевірок. Крім того, стан composite і атомарного вузлів перевірялися по-різному — окрема логіка без уніфікації. - -## Considered Options -* Варіант A — залишити implicit resolved, додати `.n-cursor/graph-index.json` для кешування -* Варіант B — явний `fact_NNN.md` для composite, що пишеться оркестратором автоматично -* Варіант C — `tasks/<parent>/.child-done/<child>` sentinel при переході дитини в `resolved` -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Варіант B — явний `fact_NNN.md` для composite, написаний `mt done` wrapper'ом після merge останнього дочірнього вузла", because це уніфікує перевірку стану для всіх типів вузлів (scan стає O(n) замість O(n×depth)); trigger — merge дочірнього worktree — є природною точкою де wrapper вже має контекст і може рекурсивно перевіряти батька. - -### Consequences -* Good, because стан composite визначається так само як атомарного: `fact_*.md` є → `resolved`; `ls` O(1) без рекурсії. -* Good, because transcript фіксує очікувану користь: один merge може закрити весь ланцюг composite-вузлів вгору за один рекурсивний прохід. -* Bad, because при інвалідації дитини потрібно cascade: `invalidated` sentinel у батька, потім при повторному resolve дитини — новий `fact_NNN.md` (NNN = count + 1); ця логіка каскадної інвалідації у transcript окреслена, але деталі не специфіковані. - -## More Information -Trigger: `mt done <child-path>` після успішного merge worktree. -Логіка: перевірити всі siblings → якщо всі мають `fact_*.md` → write `tasks/<parent>/fact_NNN.md`; `## Summary` = агрегація `## Summary` дітей; рекурсивно перевірити `tasks/<grandparent>/`. -NNN для composite: `count(існуючих fact_*.md) + 1` (без `run_NNN.md`). -Файл специфікації: `npm/docs/mt.md`. diff --git "a/docs/adr/260607-2151-\321\201\321\202\320\260\320\275\320\264\320\260\321\200\321\202\320\270\320\267\320\260\321\206\321\226\321\217-md-\321\200\320\276\320\267\321\210\320\270\321\200\320\265\320\275\320\275\321\217-\321\203-deps-\320\264\320\270\321\200\320\265\320\272\321\202\320\276\321\200\321\226\321\227.md" "b/docs/adr/260607-2151-\321\201\321\202\320\260\320\275\320\264\320\260\321\200\321\202\320\270\320\267\320\260\321\206\321\226\321\217-md-\321\200\320\276\320\267\321\210\320\270\321\200\320\265\320\275\320\275\321\217-\321\203-deps-\320\264\320\270\321\200\320\265\320\272\321\202\320\276\321\200\321\226\321\227.md" deleted file mode 100644 index d456e9b..0000000 --- "a/docs/adr/260607-2151-\321\201\321\202\320\260\320\275\320\264\320\260\321\200\321\202\320\270\320\267\320\260\321\206\321\226\321\217-md-\321\200\320\276\320\267\321\210\320\270\321\200\320\265\320\275\320\275\321\217-\321\203-deps-\320\264\320\270\321\200\320\265\320\272\321\202\320\276\321\200\321\226\321\227.md" +++ /dev/null @@ -1,24 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T21:51:31+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Стандартизація `.md` розширення у `deps/` директорії - -## Context and Problem Statement -У специфікації `npm/docs/mt.md` три різних місця описували іменування файлів у `deps/` суперечливо: рядок 74 показував ім'я без розширення (`<dep-node-id>`), таблиця і приклад — з `.md` (`collect-data.md`). Оскільки `mt scan` зчитує dep-id безпосередньо з імені файлу через `ls deps/`, будь-яка непослідовність призводить до некоректної побудови шляху при deps satisfaction перевірці. - -## Considered Options -* Без розширення (`collect-data`) — dep-id = ім'я файлу без обробки -* З `.md` розширенням (`collect-data.md`) — скрипт обрізає `.md` щоб отримати dep-id - -## Decision Outcome -Chosen option: "`.md` розширення у всіх файлах `deps/`", because консистентне з рештою файлів вузла (`task.md`, `a.md`, `h.md`) і природно працює з вкладеною структурою `deps/` (рішення вади №3): `ls -R deps/` → отримати `research/analyze.md` → обрізати `.md` → dep-id = `research/analyze`. - -### Consequences -* Good, because алгоритм deps satisfaction однорідний: завжди `strip(".md")` незалежно від рівня вкладеності; специфікація не має суперечливих місць. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Уніфікований алгоритм: `ls -R deps/` → для кожного `<path>.md` → dep_path = `tasks/<path>/` → перевірити `fact_*.md`. Стандарт поширюється на всі місця специфікації де описано `deps/` (рядки 74, 242, 248 у `npm/docs/mt.md`). Суміжне рішення: вкладена структура `deps/` для крос-рівневих залежностей (ADR вади №3). diff --git "a/docs/adr/260607-2200-\321\203\321\202\320\276\321\207\320\275\320\265\320\275\320\275\321\217-\321\201\321\202\320\260\320\275\321\203-waiting-waiting-plan-waiting-run-\321\201\320\265\320\274\320\260\320\275\321\202\320\270\320\272\320\260-a.md" "b/docs/adr/260607-2200-\321\203\321\202\320\276\321\207\320\275\320\265\320\275\320\275\321\217-\321\201\321\202\320\260\320\275\321\203-waiting-waiting-plan-waiting-run-\321\201\320\265\320\274\320\260\320\275\321\202\320\270\320\272\320\260-a.md" deleted file mode 100644 index 0bf29c8..0000000 --- "a/docs/adr/260607-2200-\321\203\321\202\320\276\321\207\320\275\320\265\320\275\320\275\321\217-\321\201\321\202\320\260\320\275\321\203-waiting-waiting-plan-waiting-run-\321\201\320\265\320\274\320\260\320\275\321\202\320\270\320\272\320\260-a.md" +++ /dev/null @@ -1,75 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:00:33+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Всі 10 вад і 4 ризики розібрані та зафіксовані. - -**Підсумок погоджених змін** (ще не внесені в `npm/docs/mt.md`): - -| # | Зміна | -|---|---| -| Вада 1 | `running_<pid>_until_<ts>` — PID у sentinel; cleanup при старті і в watch | -| Вада 2 | `waiting-plan` / `waiting-run` замість `waiting`/`human-pending`/`needs-plan` | -| Вада 3 | `deps/` вкладена структура для крос-рівневих залежностей | -| Вада 4 | `fact_NNN.md` для composite — пише wrapper після merge останньої дитини, cascade вгору | -| Вада 5 | `.md` розширення у `deps/` — стандарт по всьому документу | -| Вада 6 | `audit-result_NNN.md` deletable; `mt invalidate` для retry без нового run | -| Вада 7 | Закрита — семантика у структурі файлів | -| Вада 8 | Видалити `mode:` з `plan_NNN.md` | -| Вада 9 | Перший запуск — без run history; retry — останні 2 runs; `max_context_runs` у `a.md` | -| Вада 10 | Агент завжди пише `a.md`/`h.md` при `mt plan`; людина може перевизначити | -| Ризик 1 | `max_worktree_age`; `budget_hard_sec: 0` заборонено (= global default) | -| Ризик 2+3 | `mt kill --recursive`; `--keep-resolved`; orphan detection у `mt scan` | -| Ризик 4 | `grace_period: 120` у `.n-cursor.json` | - -Хочеш щоб я вніс всі ці зміни в `npm/docs/mt.md`? - ---- - -*Це той фрагмент розмови для якого запитував ADR:* - ---- - -## ADR Уточнення стану `waiting` → `waiting-plan` / `waiting-run` + семантика `a.md`/`h.md` - -## Context and Problem Statement -Стан `waiting` використовувався для двох принципово різних ситуацій: коли вузол потребує створення плану, і коли вузол готовий до виконання. Крім того, `human-pending` дублював частину семантики `waiting`. Потрібно було чітко розділити "що потрібно зробити" (стан) і "хто це робить" (`a.md`/`h.md`). - -## Considered Options -* Залишити один стан `waiting`, додати `ready-human` як окремий стан -* Розбити відповідальність: стан = фаза роботи; `a.md`/`h.md` = виконавець -* Переіменувати `waiting` без розбиття - -## Decision Outcome -Chosen option: "Розбити відповідальність: стан = фаза; sentinel = виконавець", because це усуває семантичну двозначність без введення надмірних станів. `waiting-plan` означає "потрібен план" незалежно від того хто планує; `waiting-run` означає "план є, залежності resolved". Runner перевіряє `a.md`/`h.md` щоб вирішити чи діяти автоматично або чекати людину. - -### Consequences -* Good, because таблиця станів однозначна: зовнішній monitor або CI badge завжди розуміє що потрібно без читання вмісту файлів. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`. Нова таблиця станів зафіксована у `/Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/memory/project_graph_design_review.md` (вада №2). Runner-логіка: `waiting-plan + a.md` → auto plan; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto run; `waiting-run + h.md` → skip + notify. - ---- - -## ADR `running_<pid>_until_<ts>` — гібридний sentinel для stalled detection і cleanup - -## Context and Problem Statement -Sentinel файл `running_until_<ts>` не мав механізму cleanup при аварійному завершенні процесу (`kill -9`, OOM). Після краша файл залишався, вузол вічно залишався у стані `stalled`, і новий запуск був неможливий без ручного втручання. - -## Considered Options -* Cleanup як перший крок нового запуску (перевірка при старті) -* PID у назві sentinel файлу (`running_<pid>_until_<ts>`) -* Окремий cleanup daemon - -## Decision Outcome -Chosen option: "Гібрид A+B — PID у назві + cleanup при старті і в watch", because PID у назві дозволяє детектувати живість процесу через `kill -0 <pid>` без читання вмісту (інваріант збережено). Cleanup відбувається в двох точках: wrapper при старті нового `mt run` і `mt watch` при кожному скані. - -### Consequences -* Good, because transcript фіксує очікувану користь: автоматичний cleanup без ручного втручання, збереження інваріанту "стан з listing". -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`. Нова назва sentinel: `running_<pid>_until_<ts>`. Watch: `kill -0 <pid>` → якщо мертвий → cleanup + `run_NNN.md (result: failed, crash)`. Зафіксовано у `memory/project_graph_design_review.md` (вада №1). diff --git "a/docs/adr/260607-2203-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\270\320\271-sentinel-\320\267\320\260\320\277\321\203\321\211\320\265\320\275\320\276\320\263\320\276-\320\262\321\203\320\267\320\273\320\260-runningpiduntilts.md" "b/docs/adr/260607-2203-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\270\320\271-sentinel-\320\267\320\260\320\277\321\203\321\211\320\265\320\275\320\276\320\263\320\276-\320\262\321\203\320\267\320\273\320\260-runningpiduntilts.md" deleted file mode 100644 index 8940496..0000000 --- "a/docs/adr/260607-2203-\320\263\321\226\320\261\321\200\320\270\320\264\320\275\320\270\320\271-sentinel-\320\267\320\260\320\277\321\203\321\211\320\265\320\275\320\276\320\263\320\276-\320\262\321\203\320\267\320\273\320\260-runningpiduntilts.md" +++ /dev/null @@ -1,161 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:03:38+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -<adr_hook> -</thinking> - -The transcript covers an iterative design review session for the `mt` architecture documented in `npm/docs/mt.md`. Multiple design decisions were made and confirmed. I'll emit a separate ADR for each significant, independently addressable decision. - -## ADR Гібридний sentinel запущеного вузла: `running_<pid>_until_<ts>` - -## Context and Problem Statement -Файл `running_until_<ts>` фіксує що вузол виконується, але якщо процес впав аномально (`kill -9`, OOM), wrapper не має можливості прибрати sentinel. Вузол залишається у стані `stalled` назавжди без механізму автоматичного cleanup. - -## Considered Options -* Варіант A: cleanup як перший крок нового запуску — wrapper перевіряє sentinel перед стартом -* Варіант B: PID у назві файлу (`running_<pid>_until_<ts>`) — будь-хто може перевірити `kill -0 <pid>` -* Гібрид A+B: PID і deadline в одному імені + cleanup при startup і при watch-скані - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID в імені файлу дозволяє детектувати живий/мертвий процес з `ls` без читання вмісту; wrapper при новому `mt run` і `mt watch` виконують `kill -0 <pid>` — якщо мертвий, прибирають sentinel і worktree, переводять вузол у `failed`. - -### Consequences -* Good, because стан залишається детермінованим з `ls` (інваріант збережено); cleanup автоматичний в обох точках входу без ручного втручання. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `tasks/<node>/running_<pid>_until_<ts>` (git-ignored). Cleanup: `kill -0 <pid>` → ESRCH → видалити sentinel + orphan worktree → write `run_NNN.md` з `result: failed (crash)`. - ---- - -## ADR Розподіл стану очікування: `waiting-plan` / `waiting-run` - -## Context and Problem Statement -Специфікація давала стан `waiting` для двох принципово різних ситуацій: вузол без плану (потребує планування) і вузол з планом та розв'язаними залежностями (готовий до виконання). Крім того, `h.md`-вузли runner ніколи не обробляє автоматично, але вони теж потрапляли у `waiting`, що вводило в оману зовнішні інструменти й людей. - -## Considered Options -* Залишити один `waiting` з суфіксом `:agent`/`:human` у виводі -* Розбити на `waiting` (агент) і `ready-human` (людина) -* Розбити за фазою (`waiting-plan` / `waiting-run`), де `a.md`/`h.md` визначають виконавця - -## Decision Outcome -Chosen option: "розбити за фазою: `waiting-plan` / `waiting-run`", because стан описує що потрібно зробити далі, а `a.md`/`h.md` відповідає на питання хто це зробить — runner дивиться на стан + файл виконавця; старі `human-pending` і `needs-plan` зникають. - -### Consequences -* Good, because зовнішні інструменти (`mt scan --json`, dashboard, CI) отримують однозначну семантику без читання файлів; runner ніколи не плутає `waiting-plan` з `waiting-run`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Runner-матриця: `waiting-plan + a.md` → auto `mt plan --mode agent`; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto `mt run`; `waiting-run + h.md` → skip + notify. Видалені стани: `human-pending`, `needs-plan`, старий `waiting`. - ---- - -## ADR Крос-рівневі залежності через вкладену структуру `deps/` - -## Context and Problem Statement -Файл у `deps/` іменувався як ідентифікатор dep-вузла без шляху, що дозволяло залежати тільки від сусідів у тій самій батьківській директорії. Вузли на різних гілках дерева (`tasks/research/analyze/` і `tasks/reporting/generate-report/`) не могли мати залежність між собою. - -## Considered Options -* Абсолютний шлях у назві файлу через `__` як роздільник (`research__analyze.md`) -* Вміст файлу містить `ref:` зі шляхом (порушує інваріант без читання вмісту) -* Вкладена структура в `deps/`, що дзеркалює `tasks/` (`deps/research/analyze.md`) -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "вкладена структура в `deps/`, що дзеркалює `tasks/`", because повністю зберігає інваріант (стан з `ls -R deps/`); сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі стають вкладеними (`deps/research/analyze.md`); обрізання `.md` суфікса дає dep-id у вигляді відносного шляху від `tasks/`. - -### Consequences -* Good, because deps satisfaction: `ls -R deps/` → обрізати `.md` → dep-id → шукати `tasks/<dep-id>/fact_*.md` без читання вмісту. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Deps satisfaction для `deps/research/analyze.md`: dep-id = `research/analyze`, перевірити `tasks/research/analyze/fact_*.md`. - ---- - -## ADR Явний `fact_NNN.md` для composite вузлів - -## Context and Problem Statement -Composite вузол не мав власного `fact_NNN.md` — стан `resolved` визначався рекурсивним обходом усіх нащадків при кожному `mt scan`. При глибоких деревах це O(глибина × вузли) `ls`-викликів на кожен тік watch. - -## Considered Options -* Залишити implicit resolved + додати `.n-cursor/graph-index.json` (порушує принцип без центрального файлу стану) -* Явний `fact_NNN.md` для composite, який пише оркестратор після merge останньої дитини -* Ліниве просування через `.child-done/` sentinels у батьківській директорії - -## Decision Outcome -Chosen option: "явний `fact_NNN.md` для composite", because уніфікує перевірку стану для всіх типів вузлів; wrapper після `mt done <child>` перевіряє чи всі сусіди resolved — якщо так, пише `fact_NNN.md` у батька і рекурсивно йде вгору по дереву. - -### Consequences -* Good, because scan стає O(n) замість O(n×depth); стан composite перевіряється так само як атомарного — один `ls` у директорії вузла. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -NNN для composite: `count(fact_*.md) + 1` (без `run_NNN.md`, composite не виконує роботу сам). Cascade вгору: один merge → потенційно закриває весь ланцюг composite вузлів до кореня. При інвалідації дитини → `invalidated` sentinel у батька → після повторного resolve → `fact_002.md`. - ---- - -## ADR Розширення `.md` для файлів у `deps/` - -## Context and Problem Statement -Специфікація `npm/docs/mt.md` містила суперечність у трьох місцях: рядок 74 (структура) показував `<dep-node-id>` без розширення, рядки 248 і 242 (таблиця і приклад) — `<dep-node-id>.md`. `mt scan` зчитує ім'я файлу як dep-id — непослідовність ламає parsing скрипта. - -## Considered Options -* Без розширення: dep-id = ім'я файлу напряму, без обробки -* З `.md`: обрізати суфікс при зчитуванні, консистентно з рештою контракту - -## Decision Outcome -Chosen option: "`.md` розширення у всіх файлах `deps/`", because консистентно з `task.md`, `a.md`, `h.md`; природно працює з вкладеною структурою — `deps/research/analyze.md` → обрізати `.md` → dep-id = `research/analyze`. - -### Consequences -* Good, because єдиний стандарт у всіх місцях специфікації; parsing: `ls deps/` → strip `.md` → dep-id. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Змінити рядок 74 у `npm/docs/mt.md`: `<dep-node-id>` → `<dep-node-id>.md`. - ---- - -## ADR Повторний аудит через видалення `audit-result_NNN.md` - -## Context and Problem Statement -`audit-result_NNN.md` вважався immutable. Якщо аудитор помилився або критерії змінились і потрібно перевірити той самий `fact_NNN.md` повторно — NNN вже зайнятий, механізму retry не існувало. - -## Considered Options -* Новий `run_NNN.md` + новий `fact_NNN.md` при будь-якому retry (audit fail = invalid fact) -* Sub-NNN схема: `pending-audit_003a.md`, `pending-audit_003b.md` -* Зробити `audit-result_NNN.md` deletable; `mt invalidate` видаляє його, watch перезапускає аудит - -## Decision Outcome -Chosen option: "`audit-result_NNN.md` deletable — `mt invalidate`", because audit trail зберігається через git history; команда `mt invalidate <path>` видаляє `audit-result_NNN.md` → watch бачить `pending-audit_003.md` без result → запускає новий аудит того самого факту; новий `run` потрібен тільки якщо сам факт невалідний. - -### Consequences -* Good, because простий retry без нового run; git history зберігає запис про провал аудиту. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Тільки `audit-result_NNN.md` — deletable. `pending-audit_NNN.md` і `fact_NNN.md` — immutable. Команда: `mt invalidate <path>`. - ---- - -## ADR Примусова міграція схеми при релізі нової версії - -## Context and Problem Statement -При зміні схеми файлів (`task.md`, `a.md`, `plan_NNN.md`) старі вузли в git не мали механізму міграції. `mt scan` на суміші старих і нових файлів міг давати silently некоректні стани. - -## Considered Options -* `schema_version:` у frontmatter кожного файлу + підтримка кількох версій одночасно -* Примусова одностороння міграція при релізі нової версії `n-cursor` - -## Decision Outcome -Chosen option: "примусова міграція при релізі", because при upgrade `n-cursor` запускається `graph migrate` — всі файли приводяться до поточної схеми одразу; у будь-який момент часу всі файли на одній версії; `schema_version:` у файлах непотрібний. - -### Consequences -* Good, because transcript фіксує очікувану користь: немає потреби підтримувати кілька версій схеми одночасно; `mt scan` завжди працює з однорідними файлами. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Версія схеми = версія інструменту `n-cursor`, не поле у файлі. При breaking change — `graph migrate` скрипт (разовий). diff --git "a/docs/adr/260607-2210-\320\263\321\226\320\261\321\200\320\270\320\264-ab-\320\264\320\273\321\217-sentinel-\320\267\320\260\320\277\321\203\321\211\320\265\320\275\320\276\320\263\320\276-\320\262\321\203\320\267\320\273\320\260.md" "b/docs/adr/260607-2210-\320\263\321\226\320\261\321\200\320\270\320\264-ab-\320\264\320\273\321\217-sentinel-\320\267\320\260\320\277\321\203\321\211\320\265\320\275\320\276\320\263\320\276-\320\262\321\203\320\267\320\273\320\260.md" deleted file mode 100644 index 8accf59..0000000 --- "a/docs/adr/260607-2210-\320\263\321\226\320\261\321\200\320\270\320\264-ab-\320\264\320\273\321\217-sentinel-\320\267\320\260\320\277\321\203\321\211\320\265\320\275\320\276\320\263\320\276-\320\262\321\203\320\267\320\273\320\260.md" +++ /dev/null @@ -1,175 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:10:22+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Гібрид A+B для sentinel запущеного вузла - -## Context and Problem Statement -Sentinel-файл `running_until_<ts>` у директорії вузла залишається на диску якщо процес завершується аномально (`kill -9`, OOM, segfault). Watch визначає стан `stalled`, але специфікація не описувала хто і коли видаляє цей файл — вузол міг застрягти назавжди без ручного втручання. - -## Considered Options -* Варіант A — cleanup як перший крок нового запуску (wrapper перевіряє `kill -0 <pid>`) -* Варіант B — PID у назві sentinel-файлу: `running_<pid>_until_<ts>` -* Гібрид A+B - -## Decision Outcome -Chosen option: "Гібрид A+B", because дає два незалежних захисні рівні без порушення інваріанту "стан із listing": PID і deadline закодовані в імені файлу, wrapper при старті нового run перевіряє `kill -0 <pid>` — якщо мертвий, cleanup і продовжити; watch робить те саме при скані. - -### Consequences -* Good, because transcript фіксує очікувану користь: cleanup автоматичний в обох точках входу, детектується з `ls` без читання вмісту, жодного ручного втручання для відновлення після краша. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`. Sentinel filename: `running_<pid>_until_<ts>` (git-ignored). `kill -0 <pid>` використовується як no-op перевірка існування процесу без вбивання. - ---- - -## ADR Нова таблиця станів: `waiting-plan` / `waiting-run` - -## Context and Problem Statement -Стан `waiting` покривав два семантично різні випадки: вузол готовий до автоматичного запуску агентом (`a.md`) і вузол що чекає ручної дії людини (`h.md` + plan). Runner ігнорує другий випадок, але зовнішній monitor не може розрізнити їх без читання файлів. Додатково, `human-pending` і `needs-plan` дублювали інформацію вже закодовану у `a.md`/`h.md`. - -## Considered Options -* Розбити `waiting` на `waiting` (агент) і `ready-human` (людина) -* Залишити один стан з суфіксом у machine-readable виводі -* Ввести симетричні стани `waiting-plan` / `waiting-run` де стан = "що потрібно", а `a.md`/`h.md` = "хто" - -## Decision Outcome -Chosen option: "`waiting-plan` / `waiting-run` як ортогональні виміри", because розділення "що потрібно" (стан) і "хто робить" (`a.md`/`h.md`) усуває дублювання і дає однозначну семантику: runner завжди перевіряє стан + файл присутності для визначення дії. - -### Consequences -* Good, because transcript фіксує очікувану користь: видалено `human-pending`, `needs-plan`, `waiting` — замінено симетричними `waiting-plan` (потрібен plan) і `waiting-run` (plan є, deps resolved). Runner: `waiting-plan + a.md` → auto plan; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto run; `waiting-run + h.md` → skip + notify. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція "Стани вузла". Оновлена таблиця пріоритетів у `memory/project_graph_design_review.md`. - ---- - -## ADR Крос-рівневі залежності через вкладену `deps/` структуру - -## Context and Problem Statement -`deps/` директорія ідентифікує залежні вузли за іменем файлу без шляху, що обмежує залежності виключно сусідніми вузлами в одній батьківській директорії. Вузли на різних гілках дерева (`tasks/research/analyze/` і `tasks/reporting/generate-report/`) не могли залежати один від одного без зміни логічної структури графа. - -## Considered Options -* Варіант A — абсолютний шлях у назві файлу через `__` як роздільник -* Варіант B — шлях у вмісті файлу (порушує інваріант "без читання вмісту") -* Варіант C — `deps/` може бути вкладеною; ім'я файлу = шлях відносно `tasks/` - -## Decision Outcome -Chosen option: "Варіант C — вкладена `deps/` структура", because зберігає інваріант "стан із listing": `ls -R deps/` дає повний шлях без читання вмісту. Сусідні залежності залишаються простими (`deps/collect-data.md`), крос-рівневі — вкладеними (`deps/research/analyze.md`). - -### Consequences -* Good, because transcript фіксує очікувану користь: deps satisfaction — `ls -R deps/` → обрізати `.md` → шукати `tasks/<path>/fact_*.md`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція `deps/`. Розширення файлів у `deps/` — `.md` (консистентно з рештою контракту). - ---- - -## ADR Explicit `fact_NNN.md` для composite вузлів при merge останньої дитини - -## Context and Problem Statement -Composite вузол не мав власного `fact_NNN.md` — його стан `resolved` визначався рекурсивним обходом усіх нащадків. При глибокому дереві `mt scan` мав O(глибина × вузли) складність. Не було "швидкого шляху" для перевірки стану кореня графа. - -## Considered Options -* Варіант A — залишити implicit resolved, додати `.n-cursor/graph-index.json` -* Варіант B — wrapper пише `fact_NNN.md` для composite автоматично при resolved всіх дітей -* Варіант C — child пише sentinel у батьківській директорії при resolved - -## Decision Outcome -Chosen option: "Варіант B — явний `fact_NNN.md` для composite", because уніфікує перевірку стану всіх типів вузлів до O(1) `ls`, scan стає плоским. Тригер: `mt done <child>` wrapper після merge перевіряє чи всі дочірні директорії мають `fact_*.md` — якщо так, пише `fact_NNN.md` у батька і рекурсивно перевіряє вище. - -### Consequences -* Good, because transcript фіксує очікувану користь: один merge може закрити весь ланцюг composite вузлів за один прохід; NNN для composite = `count(fact_*.md) + 1`; cascade invalidation при re-run дитини пише `invalidated` у батька, потім `fact_NNN+1.md` після нового resolve. -* Bad, because Neutral, because transcript не містить підтвердження наслідку — оркестратор, а не агент, відповідає за запис `fact_NNN.md` composite, що потребує окремого кроку в логіці `mt done`. - -## More Information -Файл: `npm/docs/mt.md`. `fact_NNN.md` composite не містить `run_NNN.md` передумови. `## Summary` = агрегація `## Summary` дітей. - ---- - -## ADR `audit-result_NNN.md` — deletable для retry аудиту - -## Context and Problem Statement -`pending-audit_NNN.md` прив'язаний до NNN відповідного `fact_NNN.md`. Якщо аудитор повернув `fail` і потрібно перезапустити аудит того самого факту (не вузла) — наприклад через помилку аудитора або зміну критеріїв — NNN вже зайнятий і специфікація не описувала цей сценарій. - -## Considered Options -* Варіант A — провальний аудит → `invalidated` вузла → новий run → новий NNN -* Варіант B — `audit-result_NNN.md` deletable; `mt invalidate` видаляє його; watch перезапускає аудит - -## Decision Outcome -Chosen option: "Варіант B — `audit-result_NNN.md` deletable", because семантично відрізняє два випадки: якщо сам факт невалідний — новий run; якщо аудитор помилився або змінились критерії — достатньо `audit-retry` без нового run. - -### Consequences -* Good, because transcript фіксує очікувану користь: audit trail зберігається через git history; `pending-audit_NNN.md` залишається immutable; watch автоматично підхоплює retry на наступному тіку. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Команда: `mt invalidate <path>`. Видаляє `audit-result_NNN.md` де NNN = останній pending без result. - ---- - -## ADR Примусова міграція при виході нової версії `n-cursor` - -## Context and Problem Statement -Специфікація не мала `schema_version:` у файлах. При зміні схеми `task.md`, `a.md`, `plan_NNN.md` між версіями інструменту тривалі графи (тижні роботи) могли мати суміш старих і нових файлів, що призводило б до silently некоректних станів при `mt scan`. - -## Considered Options -* `schema_version:` поле у кожному файлі з backward-compatible парсингом -* Примусовий `graph migrate` при upgrade `n-cursor` — всі файли приводяться до поточної схеми одразу - -## Decision Outcome -Chosen option: "Примусовий `graph migrate` при upgrade", because змішаних версій у директорії ніколи не існує після upgrade; `schema_version:` у файлах не потрібен — версія схеми = версія інструменту. - -### Consequences -* Good, because transcript фіксує очікувану користь: `graph migrate` — перший клас операції, не afterthought; `mt scan` завжди працює з однорідною схемою. -* Bad, because Neutral, because transcript не містить підтвердження наслідку — `graph migrate` потребує реалізації при кожному breaking change схеми. - -## More Information -Файл: `npm/docs/mt.md`. Принцип-доповнення: "семантичні зміни виражаються через структуру файлів, не через поля frontmatter" — забезпечує backward compatibility для non-breaking змін без міграції. - ---- - -## ADR Видалення `mode:` з `plan_NNN.md` - -## Context and Problem Statement -`plan_NNN.md` frontmatter містив поле `mode: human | agent`. Після рефакторингу mode визначається виключно через `a.md`/`h.md`. При перемиканні mode після створення плану — `plan_NNN.md` зберігав застарілу `mode: human`, тоді як `a.md` вже показував agent, що створювало суперечність у context агента. - -## Considered Options -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Видалити `mode:` з `plan_NNN.md`", because plan описує "що робити", не "хто і як" — це відповідальність `a.md`/`h.md` як єдиного джерела правди про mode. Симетрично до `task.md` який також не містить `mode:`. - -### Consequences -* Good, because transcript фіксує очікувану користь: усунуто дублювання і потенційну суперечність між `plan_NNN.md` і `a.md`/`h.md`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція `plan_NNN.md` schema. - ---- - -## ADR Резюме провалів замість повних `run_NNN.md` у context агента - -## Context and Problem Statement -Агент при запуску отримував усі `run_*.md` у context. Після багатьох невдалих спроб context window міг переповнитись — і це само по собі ставало причиною наступного `failed`. Обмеження "останні N runs" вирішувало overflow але могло приховати важливий контекст провалів з ранніх спроб. - -## Considered Options -* Передавати всі `run_NNN.md` (для моделей з великим context window) -* Передавати останні N `run_NNN.md` (фіксований або `auto` за розміром window) -* Замість повних файлів — компактне резюме секцій `## Blockers` і `## Next Attempt` з усіх failed runs - -## Decision Outcome -Chosen option: "Резюме `## Blockers` / `## Next Attempt` з усіх failed runs", because агенту не потрібен повний reasoning попередніх спроб — тільки "що не спрацювало і чому". Розмір резюме фіксований (N рядків) незалежно від кількості і розміру `run_NNN.md`. - -### Consequences -* Good, because transcript фіксує очікувану користь: покриває всю глибину провалів без обмеження по кількості; повні `run_NNN.md` залишаються для людського аудиту; `## Blockers` і `## Next Attempt` стають обов'язковими секціями при `result: failed`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція `run_NNN.md` schema. Wrapper генерує резюме перед запуском агента, читаючи тільки секції `## Blockers` і `## Next Attempt` з `run_*.md` де `result: failed`. diff --git "a/docs/adr/260607-2212-sentinel-\321\204\320\260\320\271\320\273-runningpiduntilts-\320\263\321\226\320\261\321\200\320\270\320\264-cleanup-\321\201\321\202\321\200\320\260\321\202\320\265\320\263\321\226\320\271.md" "b/docs/adr/260607-2212-sentinel-\321\204\320\260\320\271\320\273-runningpiduntilts-\320\263\321\226\320\261\321\200\320\270\320\264-cleanup-\321\201\321\202\321\200\320\260\321\202\320\265\320\263\321\226\320\271.md" deleted file mode 100644 index 9eb6306..0000000 --- "a/docs/adr/260607-2212-sentinel-\321\204\320\260\320\271\320\273-runningpiduntilts-\320\263\321\226\320\261\321\200\320\270\320\264-cleanup-\321\201\321\202\321\200\320\260\321\202\320\265\320\263\321\226\320\271.md" +++ /dev/null @@ -1,213 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:12:44+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Sentinel-файл `running_<pid>_until_<ts>` — гібрид cleanup стратегій - -## Context and Problem Statement -Дизайн використовував `running_until_<ts>` як sentinel для активного виконання. При аномальному завершенні процесу (`kill -9`, OOM) файл не видалявся, вузол застрягав у стані `stalled` без механізму автоматичного відновлення. - -## Considered Options -* Варіант A — cleanup як перший крок нового запуску (wrapper перевіряє sentinel при старті) -* Варіант B — PID у назві файлу: `running_<pid>_until_<ts>` -* Гібрид A+B — обидва механізми одночасно - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві дозволяє будь-кому перевірити `kill -0 <pid>` без читання вмісту (зберігає інваріант "стан = listing"), а cleanup-on-startup в wrapper гарантує відновлення при наступному `mt run` навіть якщо watch не встиг. - -### Consequences -* Good, because `stalled` стає автоматично відновлюваним у двох точках: watch при кожному скані та wrapper при старті нового run. Детектується з `ls` без читання вмісту. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція "Стани вузла". -Команда перевірки живого процесу: `kill -0 <pid>` (0-сигнал — не вбиває, лише перевіряє існування). -Мутабельні sentinel-файли: `a.md`, `h.md`, `invalidated`, `running_<pid>_until_<ts>`. - ---- - -## ADR Таблиця станів: `waiting-plan` і `waiting-run` замість `waiting` / `human-pending` - -## Context and Problem Statement -Стан `waiting` охоплював два семантично різних випадки: вузол без плану і вузол з планом готовий до виконання. Водночас вузли з `h.md` runner ігнорував повністю — але назва `waiting` не давала цього зрозуміти зовнішньому спостерігачеві. Виявлено, що стан `human-pending` так само надлишковий: runner ніколи не діє на `h.md`-вузли незалежно від наявності плану. - -## Considered Options -* Зберегти `waiting`, додати `ready-human` як окремий стан для `h.md` + plan -* Розширити `human-pending` на всі `h.md`-вузли (з планом і без) -* Розбити `waiting` на `waiting-plan` (немає плану) і `waiting-run` (план є, deps resolved) - -## Decision Outcome -Chosen option: "Розбити на `waiting-plan` / `waiting-run`", because стан відповідає на питання "що потрібно далі" (план чи виконання), а `a.md`/`h.md` відповідає на питання "хто". Runner комбінує обидва: `waiting-plan + a.md → auto plan`, `waiting-plan + h.md → skip + notify`. - -### Consequences -* Good, because симетрична таблиця станів; видалені `human-pending` і `needs-plan`; runner-логіка стає декларативною і однозначною для зовнішніх інструментів. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція "Стани вузла". -Видалені стани: `human-pending`, `needs-plan`, `waiting`. -Нові стани: `waiting-plan`, `waiting-run`. -Runner завжди читає пару (стан + наявність `a.md`/`h.md`) для визначення дії. - ---- - -## ADR `deps/` — вкладена структура для крос-рівневих залежностей - -## Context and Problem Statement -`deps/` директорія підтримувала лише сусідні вузли: ім'я файлу = dep-id без шляху. Вузли на різних рівнях ієрархії (наприклад `tasks/reporting/generate-report/` залежить від `tasks/research/analyze/`) не мали способу виразити залежність без порушення логічної структури графу. - -## Considered Options -* Абсолютний шлях у назві файлу з `__` як роздільником рівнів -* Вміст файлу `deps/*.md` містить `ref:` зі шляхом (порушує інваріант без читання) -* Вкладена структура `deps/` дзеркалює структуру `tasks/` - -## Decision Outcome -Chosen option: "Вкладена структура `deps/`", because `ls -R deps/` дає повний шлях відносно `tasks/` без читання вмісту, зберігає інваріант. Сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі — вкладені (`deps/research/analyze.md`). - -### Consequences -* Good, because крос-рівневі залежності тепер виразимі; інваріант "стан = listing" збережено; зворотна сумісність для сусідніх deps. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція `deps/`. -Алгоритм: `ls -R deps/` → `research/analyze.md` → обрізати `.md` → dep-id = `research/analyze` → шукати `tasks/research/analyze/fact_*.md`. -Всі файли у `deps/` мають розширення `.md` (узгоджено одночасно з Вадою №5). - ---- - -## ADR Явний `fact_NNN.md` для composite вузлів - -## Context and Problem Statement -Composite вузли не мали власного `fact_NNN.md`. Стан `resolved` визначався рекурсивним обходом усіх нащадків — O(глибина × вузли) при кожному `mt scan`. Не існувало "швидкого шляху" для перевірки стану кореня без повного обходу дерева. - -## Considered Options -* Залишити implicit resolved, додати індекс у `.n-cursor/graph-index.json` -* Явний `fact_NNN.md` для composite, який пишеться оркестратором автоматично -* Ліниве просування стану через sentinel у батьківській директорії - -## Decision Outcome -Chosen option: "Явний `fact_NNN.md` для composite", because уніфікує перевірку стану для всіх типів вузлів: `resolved` = `fact_*.md` є. Scan стає O(n) замість O(n×depth). - -### Consequences -* Good, because перевірка `resolved` для composite — O(1) `ls`; `mt done` wrapper автоматично закриває ланцюг composite вузлів вгору при merge останнього дочірнього вузла, рекурсивно до кореня. -* Bad, because потрібен додатковий крок в оркестраторі: wrapper після merge дитини перевіряє всіх siblings і пише `fact_NNN.md` у батька якщо всі resolved. Cascade рекурсивно вгору. - -## More Information -Файл: `npm/docs/mt.md`, секції "Стани вузла", "Wrapper-скрипт". -Тригер: `mt done <child>` → `ls <parent>/*/fact_*.md` → якщо всі resolved → write `<parent>/fact_NNN.md`. -NNN для composite = `count(fact_*.md) + 1` (без `run_NNN.md`, бо composite не виконує роботу сам). - ---- - -## ADR `audit-result_NNN.md` — deletable при retry аудиту - -## Context and Problem Statement -Всі артефакти вузла вважалися immutable. При провалі аудиту і потребі повторної перевірки того самого `fact_NNN.md` — NNN вже зайнятий `audit-result_NNN.md`. Специфікація не описувала цей сценарій. - -## Considered Options -* Новий run + новий `fact_NNN.md` при кожному повторному аудиті -* Sub-NNN нумерація (`pending-audit_003a.md`) -* `audit-result_NNN.md` deletable; `pending-audit_NNN.md` залишається - -## Decision Outcome -Chosen option: "`audit-result_NNN.md` deletable", because `mt invalidate <path>` видаляє лише result; watch бачить `pending-audit_NNN.md` без відповідного `audit-result_NNN.md` → запускає новий аудит того самого `fact_NNN.md`. Audit trail зберігається у git history. - -### Consequences -* Good, because простий retry без нового run; NNN не конфліктує; git history зберігає видалені результати. -* Bad, because `audit-result_NNN.md` — єдиний артефакт-файл вузла який є deletable, що порушує загальний принцип immutability артефактів. - -## More Information -Файл: `npm/docs/mt.md`, секції `pending-audit_NNN.md`, `audit-result_NNN.md`. -Команда: `mt invalidate <path>` → `rm audit-result_NNN.md` де NNN = останній pending без result. -Оновлений список mutable файлів: `a.md`, `h.md`, `invalidated`, `running_<pid>_until_<ts>`, `audit-result_NNN.md` (deletable only). - ---- - -## ADR Примусова міграція схеми при релізі `n-cursor` - -## Context and Problem Statement -Відсутність `schema_version:` у файлах створювала ризик silently некоректних станів при змішаній версії файлів після оновлення `n-cursor`. Тривалі графи (тижні роботи) неминуче містили б файли різних версій. - -## Considered Options -* `schema_version:` у frontmatter кожного файлу -* Принцип "семантика у структурі файлів" без версійності -* Примусова міграція (`graph migrate`) при кожному major upgrade - -## Decision Outcome -Chosen option: "Примусова міграція при релізі", because `graph migrate` при upgrade приводить всі файли до поточної схеми одразу. Змішаних версій у директорії ніколи не існує. `schema_version:` у файлах не потрібен. - -### Consequences -* Good, because завжди одна версія схеми; немає conditional logic у `scan`/`status`/`run`. -* Bad, because `graph migrate` — обов'язковий крок при major upgrade. CLI потребує guard: якщо виявлено файли старої схеми → вимагати `graph migrate` перед продовженням. - -## More Information -Файл: `npm/docs/mt.md`. -Команда: `graph migrate` — обходить усі `task.md`, оновлює структуру до поточної схеми. - ---- - -## ADR `plan_NNN.md` не містить поля `mode:` - -## Context and Problem Statement -`plan_NNN.md` frontmatter містив `mode: human | agent`. Після введення `a.md`/`h.md` як єдиного джерела правди для mode — поле стало дублюванням. Перемикання mode після створення плану (видалили `h.md`, створили `a.md`) лишало в плані застарілу `mode: human`, що суперечило поточному стану. - -## Considered Options -* Залишити `mode:` у плані як snapshot mode на момент планування -* Видалити `mode:` з `plan_NNN.md` - -## Decision Outcome -Chosen option: "Видалити `mode:` з `plan_NNN.md`", because план описує "що робити", не "хто і як". Агент завжди читає `a.md`/`h.md` для актуального mode. Одне джерело правди для mode. - -### Consequences -* Good, because усунуто ризик суперечності між планом і поточним mode; `plan_NNN.md` симетричний до `task.md` — обидва без `mode:`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція `plan_NNN.md`. - ---- - -## ADR Summary невдалих спроб замість повного контексту `run_NNN.md` - -## Context and Problem Statement -Агент при запуску отримував всі `run_NNN.md` у context. Після 10+ невдалих спроб розмір context міг перевищити window моделі, що само по собі ставало причиною наступного `failed`. Навіть на моделях з великим вікном старі спроби створювали шум, а не цінність. - -## Considered Options -* Передавати останні N `run_NNN.md` (N=2 як default) -* `context_runs: auto` — передавати всі якщо сума < 50% context window -* Wrapper витягує `## Blockers` і `## Next Attempt` з кожного failed run → компактне summary - -## Decision Outcome -Chosen option: "Summary невдалих спроб", because агенту потрібно знати "що не треба робити", а не повний reasoning. Розмір summary — O(N рядків) незалежно від кількості і розміру оригінальних файлів. Покриває всю глибину провалів. - -### Consequences -* Good, because context фіксованого розміру; повні `run_NNN.md` залишаються для людського аудиту; охоплює всі провали, не тільки останні N. -* Bad, because `## Blockers` і `## Next Attempt` стають обов'язковими при `result: failed`. Агент що не заповнив ці секції ламає ланцюг знань для наступної спроби. - -## More Information -Файл: `npm/docs/mt.md`, секції "Wrapper-скрипт", схема `run_NNN.md`. -`## Blockers` і `## Next Attempt` — змінено статус з опціональних на обов'язкові при `result: failed`. - ---- - -## ADR Composite planning призначає `a.md`/`h.md` дочірнім вузлам - -## Context and Problem Statement -`mt init` без `--mode` створює `task.md` без `a.md`/`h.md` → стан `unassigned`. При composite decomposition агент породжував дочірні `task.md` без sentinel файлів → всі діти залишались `unassigned` → runner пропускав → граф стояв без жодного сигналу. - -## Considered Options -* Агент пише `a.md`/`h.md` для кожної дитини як частину planning output -* `mt spawn` автоматично додає `a.md` всім дітям якщо не вказано - -## Decision Outcome -Chosen option: "Агент пише `a.md`/`h.md` при плануванні", because планування = визначення декомпозиції + призначення виконавця. Агент визначає `model_tier` і `skills` для кожного підзавдання. `unassigned` залишається валідним тільки для кореневих вузлів де людина явно призначає виконавця. - -### Consequences -* Good, because після `mt spawn` всі дочірні вузли одразу у `waiting-plan` → runner підхоплює без ручного втручання. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секції "mt plan", "Декомпозиція composite". -Перевизначення після планування: `graph mode human <path>` або ручне заміщення `a.md` → `h.md`. diff --git a/docs/adr/260607-2213-299215fa.md b/docs/adr/260607-2213-299215fa.md deleted file mode 100644 index 330427c..0000000 --- a/docs/adr/260607-2213-299215fa.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:13:46+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Два хороших питання. Розберемо по черзі. - ---- - -## Додаткове питання А — Приватні поля / зовнішня видимість вузла - -**Поточна ситуація:** директорія вузла повністю прозора — будь-хто що робить `ls tasks/<node>/` бачить весь стан. - -Фактично дизайн вже має неявне розділення: - -| Файл | Роль у "публічному API" | -|---|---| -| `task.md` | Публічна місія — читають всі | -| `fact_NNN.md` `## Summary` | **Єдиний публічний output** — те що dep-вузли читають | -| `a.md`, `h.md` | Публічний стан (хто виконує) | -| `plan_NNN.md`, `run_NNN.md` | Внутрішні — формально видимі, але не частина контракту для сусідів | -| Робочі файли агента | Не описані в специфікації взагалі | - -Питання: чи потрібне **явне** поняття "private" — наприклад директорія `private/` куди агент пише проміжні дані що не є частиною публічного output? - -**Практичний сценарій:** агент при виконанні `implement-api/` пише великий проміжний файл `cache/api-schema-parsed.json` (4MB). Сусідній вузол `test/` не повинен його бачити у своєму context — тільки `## Summary` з `fact_NNN.md`. Але зараз немає механізму це обмежити. - -**Моя думка:** явне `private/` не потрібно якщо агент дисципліновано пише тільки у `fact_NNN.md` те що є public output, а все інше — у `run_NNN.md` або взагалі за межами `tasks/`. Специфікація повинна явно сказати: "deps-вузли читають ТІЛЬКИ `fact_NNN.md` — більше нічого з директорії сусіда." - ---- - -## Додаткове питання Б — Зовнішні події - -**Поточний дизайн:** замкнута система. Всі зміни стану — через n-cursor CLI (wrapper, agent, watch, human). Зовнішні системи не мають точки входу. - -**Сценарії де це проблема:** - -``` -1. CI webhook: "build passed" → треба перевести вузол з waiting-run → resolved -Зараз: немає способу. Людина вручну пише fact_NNN.md? Це неправильно. - -2. Зовнішні дані: API повернув CSV → треба покласти у deps вузла який чекає -Зараз: немає механізму. Людина вручну копіює файл у deps/? - -3. Людина напряму редагує task.md після mt init -Зараз: "immutable після init" — але enforcement немає. Специфікація просто каже не робити. - -4. Зовнішній scheduler: "кожну ніч запускай вузол daily-report/" -Зараз: mt watch + cron. Але watch не знає про зовнішній trigger. -``` - -**Три підходи:** - -**A — Все через CLI** (поточний implicit підхід) -Зовнішня система викликає `mt done <path>` або `mt run <path>`. Webhook → shell скрипт → CLI. Просто, але потребує доступу до машини де граф. - -**B — Event drop box** (`.n-cursor/events/`) -Зовнішня система пише файл у `.n-cursor/events/ci-build-done.json`. Watch на кожному тіку обробляє events → транслює у відповідні команди. -Перевага: асинхронно, не потребує прямого доступу до CLI. -Недолік: новий механізм поза основним контрактом. - -**C — Out of scope MVP** -Зовнішні події не розглядаються. Документується як known limitation: "граф — closed system, зовнішня інтеграція через CLI wrapper." - -**Моя думка:** варіант A достатній для MVP і більшості реальних сценаріїв. B — цікавий але ускладнює. C — чесно якщо обмеження прийнятне. - ---- - -Що думаєш по обох питаннях? -[user] -Давай окремо розглянемо обидва, і по першому, хочу зрозуміти, у нас є private або немає. Якщо агент у воркспейсі пише якийсь файл, то по-перше, де він його пише, і по-друге, чи будуть якийсь механізм, як ці файли в нас стануть частиною результату? diff --git "a/docs/adr/260607-2213-\320\267\320\260\320\274\321\226\320\275\320\260-runninguntilts-\320\275\320\260-runningpiduntilts-\320\267-cleanup-\320\277\321\200\320\270-\321\201\321\202\320\260.md" "b/docs/adr/260607-2213-\320\267\320\260\320\274\321\226\320\275\320\260-runninguntilts-\320\275\320\260-runningpiduntilts-\320\267-cleanup-\320\277\321\200\320\270-\321\201\321\202\320\260.md" deleted file mode 100644 index 9f0bdd6..0000000 --- "a/docs/adr/260607-2213-\320\267\320\260\320\274\321\226\320\275\320\260-runninguntilts-\320\275\320\260-runningpiduntilts-\320\267-cleanup-\320\277\321\200\320\270-\321\201\321\202\320\260.md" +++ /dev/null @@ -1,234 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:13:21+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Всі зміни застосовані. Документ `npm/docs/mt.md` тепер відображає всі 10 погоджених вад і 4 ризики: - -- Стани оновлені: `unassigned | waiting-plan | waiting-run | blocked | ...` -- `running_<pid>_until_<ts>` + startup cleanup -- `run-summary.md` у структурі і context агента -- Composite `fact_NNN.md` при merge останнього дочірнього -- `deps/` вкладена структура для cross-level deps -- `audit-result_NNN.md` deletable + `mt invalidate` -- `graph migrate` + `graph_schema` у `.n-cursor.json` -- `mode:` видалено з `plan_NNN.md` -- Runner таблиця: `waiting-plan/waiting-run × a.md/h.md` -- `stale_grace_period_sec`, `default_mode`, `max_worktrees` у конфізі - ---- - -The session that just ended documented a multi-hour design review of the `mt` architecture spec in `npm/docs/mt.md`. Here's the ADR output: - ---- - -## ADR Заміна `running_until_<ts>` на `running_<pid>_until_<ts>` з cleanup при старті - -## Context and Problem Statement -Sentinel-файл `running_until_<ts>` у директорії вузла не дає змоги визначити чи процес-виконавець ще живий без читання вмісту. При аварійному завершенні процесу (`kill -9`, OOM) файл залишається й вузол застряє у стані `stalled` назавжди — без автоматичного відновлення. - -## Considered Options -* Варіант A — cleanup як перший крок нового `mt run` (перевірка при старті) -* Варіант B — PID у назві файлу: `running_<pid>_until_<ts>` -* Гібрид A+B — обидва механізми одночасно - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві дозволяє `kill -0 <pid>` для перевірки живості процесу без читання вмісту (інваріант збережено), а cleanup при старті нового `mt run` забезпечує автоматичне відновлення після краша на тому ж хості. - -### Consequences -* Good, because transcript фіксує очікувану користь: стан `stalled` тепер автоматично вирішується — wrapper при старті або watch при скані видаляють orphan sentinel якщо `kill -0 <pid>` повертає "no such process". -* Bad, because на distributed FS PID не унікальний між хостами — `kill -0` не застосовується. Вирішується через `stale_grace_period_sec` (Ризик №4). - -## More Information -Файл: `npm/docs/mt.md`, секція "Стани вузла" та "Wrapper-скрипт". Конфіг: `stale_grace_period_sec: 30` у `.n-cursor.json`. Команда перевірки: `kill -0 <pid>` (0-сигнал, не вбиває). - ---- - -## ADR Розбиття стану `waiting` на `waiting-plan` і `waiting-run` - -## Context and Problem Statement -Стан `waiting` позначав два принципово різних сценарії: вузол без плану (потрібне планування) і вузол з планом готовий до виконання. Runner поводився по-різному залежно від `a.md`/`h.md`, але зовнішній monitor або dashboard не міг розрізнити ці випадки без читання файлів. - -## Considered Options -* Залишити `waiting`, додати `ready-human` як окремий стан -* Перейменувати `waiting` на `waiting-run`, додати `waiting-plan` -* Використовувати `waiting:agent` / `waiting:human` як суфікси в machine-readable виводі - -## Decision Outcome -Chosen option: "Перейменувати `waiting` на `waiting-run`, додати `waiting-plan`", because стан відповідає на питання "що потрібно зробити далі" (plan чи run), а `a.md`/`h.md` відповідають на "хто це робить" — ортогональні виміри не повинні змішуватись в одному стані. - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt status` виводить однозначний стан; runner-логіка стає симетричною таблицею `waiting-plan/waiting-run × a.md/h.md`; видалено `human-pending` і `needs-plan` як окремі стани. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Таблиця runner-поведінки (`npm/docs/mt.md`, секція "Оркестрація"): `waiting-plan + a.md → auto plan`, `waiting-plan + h.md → skip + notify`, `waiting-run + a.md → auto run`, `waiting-run + h.md → skip + notify`. - ---- - -## ADR Вкладена структура `deps/` для крос-рівневих залежностей - -## Context and Problem Statement -`deps/` директорія вузла містила плоский список файлів де ім'я = id сусіднього вузла. Це обмежувало топологію: вузол міг залежати лише від прямих сусідів у тому ж батьківському вузлі. Залежності між вузлами на різних рівнях ієрархії були неможливі без зміни логічної структури графу. - -## Considered Options -* Варіант A — абсолютний шлях у назві файлу через `__` роздільник -* Варіант B — шлях у вмісті файлу (порушує інваріант "без читання вмісту") -* Варіант C — `deps/` може бути вкладеною; шлях у `deps/` дзеркалює шлях у `tasks/` - -## Decision Outcome -Chosen option: "Варіант C", because зберігає інваріант "всі стани з `ls`": `ls -R deps/` → обрізати `.md` → dep-id = відносний шлях від `tasks/`. Сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі стають вкладеними (`deps/research/analyze.md`). - -### Consequences -* Good, because transcript фіксує очікувану користь: інваріант збережено, топологія необмежена, `.md` розширення консистентне з рештою файлів вузла. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція "`deps/`". Deps satisfaction check: `ls -R deps/ → strip .md → tasks/<dep-id>/fact_*.md`. - ---- - -## ADR Явний `fact_NNN.md` для composite вузлів - -## Context and Problem Statement -Composite вузол не мав власного `fact_NNN.md` — його стан `resolved` визначався рекурсивним обходом усіх нащадків. При глибоких деревах `mt scan` ставав O(глибина × кількість вузлів). Зовнішній інструмент не міг перевірити стан кореня без обходу всього графу. - -## Considered Options -* Залишити implicit resolved + додати `.n-cursor/graph-index.json` індекс -* Явний `fact_NNN.md` для composite, що пишеться автоматично при merge останнього дочірнього -* Ліниве просування стану вгору через `.child-done/` sentinel у батьківській директорії - -## Decision Outcome -Chosen option: "Явний `fact_NNN.md` для composite", because уніфікує перевірку стану для всіх типів вузлів — scan стає O(n) замість O(n×depth). `mt done <last-child>` wrapper пише `fact_NNN.md` у батька автоматично і рекурсивно перевіряє батька батька. - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt scan` плоский; composite resolved перевіряється так само як атомарний — O(1) `ls`. -* Bad, because wrapper потребує логіки "перевірити чи всі діти resolved" після кожного merge. При cascade-resolved довгого ланцюга — один merge може тригерити запис `fact_NNN.md` для N батьків. - -## More Information -Файл: `npm/docs/mt.md`, секція "Складений вузол". NNN для composite = `count(fact_*.md) + 1`. При інвалідації дочірнього — cascade `invalidated` sentinel у батька, новий `fact_NNN.md` після повторного resolve. - ---- - -## ADR `audit-result_NNN.md` як deletable файл з командою `mt invalidate` - -## Context and Problem Statement -Специфікація позначала всі `*_NNN.md` файли як immutable artifacts. При failed audit (аудитор помилився або критерії змінились) не існувало механізму повторного аудиту того самого `fact_NNN.md` без створення нового run. - -## Considered Options -* Повторний аудит = завжди новий `run_NNN.md` → новий `fact_NNN.md` (Варіант A) -* `audit-result_NNN.md` deletable; `mt invalidate` видаляє файл → watch підхоплює - -## Decision Outcome -Chosen option: "`audit-result_NNN.md` deletable", because дозволяє розрізнити два сценарії: аудитор помилився → `audit-retry` без нового run; факт справді неправильний → новий run → новий fact. Audit trail зберігається через git history. - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt invalidate <path>` — одна команда; watch підхоплює автоматично без додаткових змін протоколу. -* Bad, because `audit-result_NNN.md` виходить з immutable-гарантії — потребує окремого пояснення в специфікації. - -## More Information -Файл: `npm/docs/mt.md`, секція "CLI" — команда `mt invalidate <path>`. Відрізняється від `mt invalidate` (який скидає сам факт). - ---- - -## ADR `run-summary.md` — LLM-генерований summary попередніх failed спроб у context агента - -## Context and Problem Statement -Агент отримував у context всі попередні `run_NNN.md` файли. Після багатьох failed спроб накопичений контекст міг перевищити context window, що само по собі ставало причиною нових збоїв. - -## Considered Options -* Передавати тільки останні 2 `run_NNN.md` (truncation) -* Генерувати LLM-summary попередніх спроб через дешеву модель перед кожним запуском - -## Decision Outcome -Chosen option: "LLM-generated `run-summary.md`", because summary виявляє патерни ("tried approach X three times, consistently fails at step Y") що є ціннішою інформацією ніж просто 2 останніх логи. Context залишається O(1) незалежно від кількості спроб. - -### Consequences -* Good, because transcript фіксує очікувану користь: context завжди bounded; агент отримує дистильовану інформацію про попередні помилки. -* Bad, because кожен запуск агента при 2+ failed спробах потребує додаткового LLM-виклику (audit_model tier). - -## More Information -Файл: `npm/docs/mt.md`, секція "Запуск агента". `run-summary.md` — mutable файл, видаляється при `mt kill`. Конфіг: `audit_model` у `.n-cursor.json`. - ---- - -## ADR Версійність схеми через `graph_schema` у `.n-cursor.json` з `graph migrate` chain - -## Context and Problem Statement -Файли вузлів не мали версійних полів. При зміні специфікації старі вузли не мали механізму виявлення чи міграції. - -## Considered Options -* `schema_version:` у кожному файлі вузла -* Один `graph_schema` у `.n-cursor.json` + примусова міграція при новому релізі - -## Decision Outcome -Chosen option: "Один `graph_schema` у `.n-cursor.json`", because примусова міграція при кожному релізі усуває необхідність підтримки кількох версій одночасно. `graph migrate` читає поточну версію і застосовує chain: `migrate_v1_to_v2.mjs → migrate_v2_to_v3.mjs`. - -### Consequences -* Good, because transcript фіксує очікувану користь: файли вузлів без version поля — простіше; один `graph migrate` скрипт на релізі. -* Bad, because примусова міграція блокує оновлення n-cursor до виконання `graph migrate`. - -## More Information -Файл: `npm/docs/mt.md`, секція "CLI" — команда `graph migrate`. Конфіг: `graph_schema: 1` у `.n-cursor.json` (автооновлюється після міграції). - ---- - -## ADR Диференціальна інвалідація через зворотний dep-ланцюг - -## Context and Problem Statement -Інвалідація вузла потенційно робила stale весь граф включно з незалежними гілками. При великих графах це означало повний re-run навіть для вузлів що не залежать від інвалідованого. - -## Considered Options -* Повний re-run (простіше) -* Differential re-run через зворотний обхід dep-ланцюга - -## Decision Outcome -Chosen option: "Differential re-run", because `deps/` дизайн вже підтримує це природньо: `mt invalidate <path>` шукає `ls tasks/**/deps/<node-id>*` (зворотний dep-граф) і ставить `invalidated` тільки на залежних вузлах. Незалежні гілки залишаються `resolved`. - -### Consequences -* Good, because transcript фіксує очікувану користь: незалежні частини графу не перераховуються — економія бюджету і часу при точковій інвалідації. -* Bad, because зворотний dep-ланцюг потребує повного сканування `tasks/**/deps/` при кожній інвалідації. - -## More Information -Файл: `npm/docs/mt.md`, секція "CLI" — команда `mt invalidate <path>`. - ---- - -## ADR Throttling watch через підрахунок активних worktrees + critical path sort - -## Context and Problem Statement -`mt run --auto` міг спавнити необмежену кількість worktrees якщо watch не рахував поточні активні процеси. `max_worktrees` у конфізі згадувався але без механізму enforcement. - -## Considered Options -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Watch рахує активні `running_<pid>_until_*` перед spawn", because це природній throttle без черги: `count(running_<pid>_until_<ts> де ts + grace_period > now())` ≥ `max_worktrees` → skip до наступного тіку. `--auto` сортує `waiting-run` вузли за critical path. - -### Consequences -* Good, because transcript фіксує очікувану користь: `max_worktrees` дійсно дотримується; критичні вузли виконуються першими при обмеженому паралелізмі. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція "Watch". Конфіг: `max_worktrees: 5`, `stale_grace_period_sec: 30` у `.n-cursor.json`. - ---- - -## ADR `stale_grace_period_sec` для захисту від clock skew при stalled detection - -## Context and Problem Statement -`ts ≤ now()` для визначення stalled стану може бути некоректним на distributed FS де різні хости мають різний системний час. Хибне `stalled` алертування для живих процесів. - -## Considered Options -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "`stale_grace_period_sec` + NTP requirement", because 30-секундний буфер поглинає більшість практичних clock skew. NTP типово забезпечує точність < 1s — разом це достатньо для реальних умов без додаткової інфраструктури. - -### Consequences -* Good, because transcript фіксує очікувану користь: хибні stalled alarm усуваються при типовому clock skew. -* Bad, because `kill -0 <pid>` не застосовується на distributed FS (PID не унікальний між хостами) — покладаємось виключно на timestamp + grace period. - -## More Information -Конфіг: `stale_grace_period_sec: 30` у `.n-cursor.json`. Файл: `npm/docs/mt.md`, секція "Wrapper-скрипт — Startup cleanup". diff --git "a/docs/adr/260607-2216-sentinel-\321\204\320\260\320\271\320\273\320\270-\321\200\320\265\320\266\320\270\320\274\321\203-a-md-h-md-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-taskh-md-taska-md.md" "b/docs/adr/260607-2216-sentinel-\321\204\320\260\320\271\320\273\320\270-\321\200\320\265\320\266\320\270\320\274\321\203-a-md-h-md-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-taskh-md-taska-md.md" deleted file mode 100644 index be3a11f..0000000 --- "a/docs/adr/260607-2216-sentinel-\321\204\320\260\320\271\320\273\320\270-\321\200\320\265\320\266\320\270\320\274\321\203-a-md-h-md-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-taskh-md-taska-md.md" +++ /dev/null @@ -1,153 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:16:12+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Всі зміни застосовано коректно: - -- `mt kill` → завжди cascade + checkpoint commit, undo через `git revert` -- `audit-result_NNN.md` → позначено як **Deletable** -- `composite_NNN.md` → повністю видалено, composite resolved = `fact_NNN.md` -- Clock skew TODO → додано в секцію загроз - -`npm/docs/mt.md` тепер відображає всі погоджені рішення з нашого розбору. - ---- - -The transcript ends here. - ---- -</thinking> - -## ADR Sentinel-файли режиму `a.md`/`h.md` замість `task_h.md`/`task_a.md` - -## Context and Problem Statement -Архітектура `mt` потребувала способу кодувати режим виконавця вузла (людина або агент) у структурі файлів. Перший підхід — режим у frontmatter `task.md`; другий — у назві основного файлу (`task_h.md`/`task_a.md`). Перейменування основного файлу при зміні режиму руйнує git history, а один файл без окремого прапора не підтримує стан "режим ще не визначено". - -## Considered Options -* Поле `mode:` у frontmatter `task.md` -* Назва файлу `task_h.md` / `task_a.md` (режим кодується в імені) -* Окремі sentinel-файли `h.md` / `a.md` + стабільний `task.md` - -## Decision Outcome -Chosen option: "Окремі sentinel-файли `h.md` / `a.md` + стабільний `task.md`", because зміна режиму — це `rm h.md && touch a.md` без торкання основного файлу місії; `task.md` залишається незмінним після `mt init`; відсутність обох файлів — природний третій стан `unassigned`/`setup`, коли режим ще не призначено. - -### Consequences -* Good, because transcript фіксує очікувану користь: git history `task.md` не переривається при перемиканні режиму; операція зміни режиму атомарна на рівні файлової системи. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл специфікації: `npm/docs/mt.md`. Схеми `a.md` і `h.md` описано у секціях `#### a.md` і `#### h.md`. Пов'язане рішення про `unassigned` стан (відсутність обох sentinel) фіксується там само. Мutability обох прапорів явно зазначена: "Мутабельний прапор". - ---- - -## ADR Таблиця станів вузла: `waiting-plan`/`waiting-run` і принцип "стан = що, файл = хто" - -## Context and Problem Statement -Стан `waiting` у початковому дизайні покривав два семантично різні сценарії: вузол з `h.md` (runner ігнорує, чекає людини) та вузол з `a.md` (runner запускає агента автоматично). Однаковий стан при протилежній поведінці порушував читабельність статусу і унеможливлював однозначний зовнішній моніторинг. - -## Considered Options -* Залишити один стан `waiting`, розрізняти через вміст файлів -* Два окремих стани: `ready-human` та `waiting` -* Принцип ортогональності: стан = що потрібно (план чи запуск), `a.md`/`h.md` = хто виконує - -## Decision Outcome -Chosen option: "Принцип ортогональності: стан = що потрібно, `a.md`/`h.md` = хто виконує", because стан відповідає на питання "що потрібно зробити далі", а `a.md`/`h.md` — "хто це робить"; runner завжди дивиться на стан + файл виконавця і комбінує їх для вибору дії. - -### Consequences -* Good, because transcript фіксує очікувану користь: усунення дублювання семантики; зовнішні інструменти отримують однозначний стан без читання вмісту файлів. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Фінальна таблиця станів у `npm/docs/mt.md`, секція "Стани вузла". Runner-логіка: `waiting` + `a.md` → auto plan/run; `pending` + `h.md` → skip + notify. Пріоритет: `invalidated` > `resolved` > `pending-audit` > `stalled` > `running` > `waiting`/`blocked` > `pending` > `unassigned` > `failed`. - ---- - -## ADR `running_<pid>_until_<ts>` — гібридний sentinel для стану виконання - -## Context and Problem Statement -Стан "вузол виконується" потрібно визначати без читання вмісту файлів (інваріант контракту). Водночас необхідно детектувати "завис" (stalled) коли процес вийшов аномально (`kill -9`, OOM) без очищення sentinel-файлу. Базовий `running_until_<ts>` не дає змоги перевірити, чи процес ще живий. - -## Considered Options -* `running_until_<ts>` — тільки deadline у назві -* `running_<pid>_until_<ts>` — PID і deadline в назві (Варіант B) -* Cleanup як перший крок нового запуску (Варіант A) -* Гібрид A+B - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві дозволяє `kill -0 <pid>` (без вбивства) щоб перевірити чи процес живий; cleanup відбувається автоматично при старті нового `mt run` і при кожному тіку `mt watch`. - -### Consequences -* Good, because transcript фіксує очікувану користь: граф не застрягає при аномальному завершенні; стан визначається з `ls` без читання вмісту. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл: `npm/docs/mt.md`, секція "Стани вузла", рядки `running_<pid>_until_<ts>`. Cleanup-логіка описана у секції "Wrapper-скрипт": `kill -0 <pid>` → якщо мертвий → видалити sentinel + orphan worktree. `budget_hard_sec: 0` заборонено — означає "використати global default". - ---- - -## ADR Explicit `fact_NNN.md` для composite вузлів - -## Context and Problem Statement -Composite вузол не виконує роботу сам — його стан `resolved` визначався агрегацією стану всіх нащадків. Це робило перевірку стану composite вузла операцією O(глибина×вузли) при кожному скані, і порушувало уніфікованість: атомарний resolved = `fact_*.md` є; composite resolved — implicit через рекурсію. - -## Considered Options -* Залишити implicit aggregate (без окремого файлу) -* Окремий `composite_NNN.md` sentinel -* `fact_NNN.md` для composite (так само як атомарного) - -## Decision Outcome -Chosen option: "`fact_NNN.md` для composite", because уніфікує перевірку resolved стану для всіх типів вузлів; scan стає плоским O(n); wrapper автоматично пише `fact_NNN.md` після merge останньої дитини через `mt done <last-child>`. - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt scan` O(n) замість O(n×depth); єдина умова resolved для атомарних і composite. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Тригер: `mt done <child>` перевіряє батька — якщо всі піддиректорії мають `fact_*.md` → wrapper пише `tasks/<parent>/fact_NNN.md`, рекурсивно вгору. NNN = count існуючих + 1. `## Summary` = агрегація дітей. Файл `npm/docs/mt.md`, секція composite вузла. - ---- - -## ADR `deps/` — вкладена структура для крос-рівневих залежностей - -## Context and Problem Statement -Початковий дизайн `deps/` дозволяв залежності лише між вузлами-сусідами: ім'я файлу = ID сусіда. Вузол на іншому рівні ієрархії (`tasks/reporting/generate-report` залежить від `tasks/research/analyze`) не міг бути виражений без зміни топології графу. - -## Considered Options -* Абсолютні шляхи у назві файлу через `__` як роздільник -* Вміст файлу містить `ref:` шлях (читання вмісту) -* Вкладена структура `deps/` дзеркалює `tasks/` - -## Decision Outcome -Chosen option: "Вкладена структура `deps/` дзеркалює `tasks/`", because `ls -R deps/` дає повний список залежностей без читання вмісту; сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі виражаються через вкладеність (`deps/research/analyze.md`); інваріант контракту збережено. - -### Consequences -* Good, because transcript фіксує очікувану користь: підтримка довільної топології графу без порушення принципу "стан з listing". -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Deps satisfaction: `ls -R deps/` → отримати відносний шлях → обрізати `.md` → dep-id → перевірити `tasks/<dep-id>/fact_*.md`. Всі файли в `deps/` — `.md` розширення. Файл `npm/docs/mt.md`, секція `#### deps/`. - ---- - -## ADR `mt kill` — завжди cascade + checkpoint git commit - -## Context and Problem Statement -При інвалідації батьківського вузла дочірні вузли могли залишатися без `invalidated` sentinel, формуючи некоректний граф. Механізм відновлення (undo kill) був відсутній, крім ручних git-операцій. Початковий `mt kill` мав `--cascade` як опціональний прапор. - -## Considered Options -* `mt kill` без флага + `--recursive` як opt-in -* `--only-self` флаг для виключення cascade -* Implicit ancestry check у `mt scan` (без запису файлів нащадкам) -* Завжди cascade + checkpoint commit; undo через `git revert` - -## Decision Outcome -Chosen option: "Завжди cascade + checkpoint commit; undo через `git revert`", because задачі не мають частих оновлень — один commit per kill є прийнятним; `git revert <kill-commit>` повертає весь піддерево без окремої команди `graph reset`; `--only-self` виключено як такий, що може зламати консистентність графу. - -### Consequences -* Good, because transcript фіксує очікувану користь: граф завжди консистентний після kill; повний audit trail через `git log --grep="graph: kill"`. -* Bad, because `mt kill` вимагає clean working tree — помилка при незакомічених змінах. - -## More Information -Checkpoint commit: `git add tasks/<path>/**/invalidated && git commit -m "graph: kill tasks/<path>"`. Тільки `invalidated` файли, uncommitted changes не потрапляють. Файл `npm/docs/mt.md`, секція `mt kill`. Пов'язане рішення: `mt invalidate` команда видалена — covered by `mt kill`. diff --git "a/docs/adr/260607-2218-n-cursor-graph-\320\264\320\270\320\267\320\260\320\271\320\275-\320\262\320\260\320\264\320\270-\321\202\320\260-\321\200\321\226\321\210\320\265\320\275\320\275\321\217.md" "b/docs/adr/260607-2218-n-cursor-graph-\320\264\320\270\320\267\320\260\320\271\320\275-\320\262\320\260\320\264\320\270-\321\202\320\260-\321\200\321\226\321\210\320\265\320\275\320\275\321\217.md" deleted file mode 100644 index 03dd168..0000000 --- "a/docs/adr/260607-2218-n-cursor-graph-\320\264\320\270\320\267\320\260\320\271\320\275-\320\262\320\260\320\264\320\270-\321\202\320\260-\321\200\321\226\321\210\320\265\320\275\320\275\321\217.md" +++ /dev/null @@ -1,222 +0,0 @@ -**Status:** Accepted -**Date:** 2026-06-07 - -## ADR mt — виправлення вад дизайну та архітектурні рішення - -## Context and Problem Statement - -Специфікація `npm/docs/mt.md` описує рекурсивний складений ОАГ задач з файловим сховищем стану. Після детального ітеративного розбору виявлено 10 вад дизайну і 4 ризики масштабування. Кожна вада розібрана окремо з погодженим варіантом рішення. Цей ADR фіксує фінальні рішення по всіх пунктах. - -## Considered Options - -По кожній ваді/ризику розглядались 2–3 варіанти (описані нижче у Decision Outcome). Інші архітектурні альтернативи (централізований state store, EventSourcing, БД) у transcript не обговорювались — файловий підхід зафіксований як основа у попередніх ADR. - -## Decision Outcome - -### Вада 1 — Sentinel `running_<pid>_until_<ts>` без cleanup після краша - -**Обраний варіант: гібрид A+B** - -- Ім'я файлу: `running_<pid>_until_<ts>` — PID і deadline в одній назві, детектується через `ls` без читання вмісту. -- Wrapper при старті нового `mt run <path>` перевіряє `kill -0 <pid>`: якщо процес мертвий — cleanup sentinel + orphan worktree → продовжити запуск. -- `mt watch` при кожному скані виконує ту саму перевірку: `kill -0 <pid>`; якщо мертвий → cleanup + transition у `failed`. - -Варіант A (cleanup при старті) і B (PID у назві) обрані разом, оскільки доповнюють одне одного: B дає детекцію без читання, A забезпечує автоматичне відновлення в двох точках входу. - ---- - -### Вада 2 — Стан `waiting` неоднозначний (агент vs людина) - -**Обраний варіант: два нових стани + `a.md`/`h.md` як "хто", стан як "що"** - -Ортогональне розділення: -- **Стан** = що потрібно зробити (`waiting-plan`, `waiting-run`, `blocked`, …) -- **`a.md`/`h.md`** = хто виконує (агент або людина) - -Нова таблиця станів атомарного вузла: - -| Файли | Стан | -|---|---| -| `task.md`, немає `a.md`/`h.md` | `unassigned` | -| `a.md` або `h.md`, немає `plan_*.md` | `waiting-plan` | -| `plan_*.md`, deps resolved, без `running_*`, без `fact_*` | `waiting-run` | -| `plan_*.md`, deps НЕ resolved | `blocked` | -| `running_<pid>_until_<ts>`, `ts > now()` | `running` | -| `running_<pid>_until_<ts>`, `ts ≤ now()` | `stalled` | -| `pending-audit_N`, без `audit-result_N` | `pending-audit` | -| `fact_*.md`, без `invalidated` | `resolved` | -| `run_*.md`, без `fact_*`, без `running_*` | `failed` | -| `invalidated` є | `invalidated` | - -Runner завжди читає стан + `a.md`/`h.md`: -``` -waiting-plan + a.md → auto: mt plan --mode agent -waiting-plan + h.md → skip + notify -waiting-run + a.md → auto: mt run -waiting-run + h.md → skip + notify -``` - -Видалені старі стани: `human-pending`, `needs-plan`. Стан `waiting` замінений на `waiting-plan`/`waiting-run`. - ---- - -### Вада 3 — `deps/` тільки для siblings - -**Обраний варіант: вкладена структура `deps/`** - -`deps/` може містити піддиректорії — структура дзеркалює `tasks/`: - -``` -deps/ - collect-data.md ← сусід (tasks/<parent>/collect-data/) - research/ - analyze.md ← крос-рівень (tasks/research/analyze/) -``` - -`ls -R deps/` → `research/analyze.md` → обрізати `.md` → dep-id = `research/analyze`. Повний шлях відносно `tasks/`. Без читання вмісту. - ---- - -### Вада 4 — Composite `resolved` implicit і дорогий - -**Обраний варіант: явний `fact_NNN.md` для composite вузла** - -Коли `mt done <child>` виконує merge останньої дитини: -1. Перевіряє батька: всі дочірні директорії мають `fact_*.md`? -2. Якщо так → пише `tasks/<parent>/fact_NNN.md` (NNN = count існуючих + 1) зі `## Summary` = агрегація `## Summary` дітей. -3. Рекурсивно перевіряє батька батька (cascade вгору по одному проходу). - -Composite `fact_NNN.md` пише оркестратор автоматично, не агент. `mt scan` стає O(n) замість O(n×depth). - ---- - -### Вада 5 — Суперечність у іменуванні файлів `deps/` - -**Рішення: завжди `.md`** - -Всі файли у `deps/` мають розширення `.md`. Скрипт обрізає `.md` щоб отримати dep-id. Консистентно з `task.md`, `a.md`, `h.md`. - ---- - -### Вада 6 — Повторний аудит того самого `fact_NNN.md` - -**Рішення: `audit-result_NNN.md` deletable** - -- `audit-result_NNN.md` — не immutable, можна видалити при retry. -- `mt invalidate <path>` видаляє `audit-result_NNN.md`. Watch бачить `pending-audit_N` без `audit-result_N` → перезапускає аудит. -- Audit trail зберігається через `git log` (видалення фіксується). -- Новий `run` потрібен тільки якщо сам `fact_NNN.md` невалідний (аудитор відхилив факт, не process). - ---- - -### Вада 7 — Версійність схеми - -**Рішення: `graph migrate` при релізі нової версії** - -При релізі нової версії `n-cursor` — обов'язковий `graph migrate` приводить всі існуючі файли до нової схеми. Змішаних версій у директорії ніколи не існує. `schema_version:` у файлах не потрібен — версія = версія інструменту. - ---- - -### Вада 8 — `plan_NNN.md` дублює `mode:` - -**Рішення: видалити `mode:` з `plan_NNN.md`** - -`mode:` видаляється з frontmatter `plan_NNN.md`. Актуальний mode завжди визначається `a.md`/`h.md`. Plan описує що робити, не хто і як. - ---- - -### Вада 9 — Context агента зростає без bounds - -**Рішення: frontmatter summary у `run_NNN.md`** - -`run_NNN.md` frontmatter: -```yaml ---- -created_at: ISO8601 -result: done | failed -summary: "одноречення — що намагались зробити" -# тільки при result: failed: -blockers: - - "конкретна причина провалу" -next_attempt: "рекомендація для наступного агента" ---- -``` - -Wrapper парсить тільки frontmatter (зупиняється на `---`) — body не читається. Для наступного агента будується компактний `prior_attempts` блок з усіх failed runs: - -```yaml -prior_attempts: - - run: 001 - summary: "..." - blockers: [...] - next_attempt: "..." -``` - -`blockers` і `next_attempt` — обов'язкові поля при `result: failed`. Без них summary порожній. Body `run_NNN.md` — необмежений, тільки для людського аудиту. - ---- - -### Вада 10 — `unassigned` без auto-assignment - -**Рішення: агент пише `a.md`/`h.md` під час `mt plan`** - -При `mt plan <composite> --mode agent` агент визначає декомпозицію і для кожного дочірнього вузла пише `a.md` або `h.md` як частину planning output. Composite children завжди мають sentinel після spawn. `unassigned` залишається валідним тільки для кореневого вузла (після `mt init` без `--mode`). - ---- - -### Ризик 1 — Disk saturation від паралельних worktrees агентів - -**Рішення: `agent_concurrency` — черга агентів** - -- `agent_concurrency: N` у `.n-cursor.json` — максимум N агентських процесів одночасно. -- Watch перед spawn перевіряє: живих агентських worktrees < N → spawn; інакше — queue, чекати звільнення. -- Людські worktrees (`h.md` + `--actor human`) не рахуються і не обмежуються. -- Живі worktrees — недоторканні завжди. Orphan cleanup — через Ваду 1 (PID check). - ---- - -### Ризик 2 — Каскадна інвалідація кореня - -**Рішення: git checkpoint tags + diff-based cascade** - -- `mt done <path>` перед merge пише git tag: `checkpoint/tasks/<path>/fact_NNN`. -- Після re-run → новий tag `checkpoint/tasks/<path>/fact_NNN+1`. -- `git diff <tag-old> <tag-new>` → список змінених файлів. -- Для кожного downstream: чи `deps/<path>.md → ref:` входить у diff? Так → `invalidated`; Ні → залишається `resolved`. -- Без `ref:` у dep-файлі → conservative cascade. -- `mt invalidate` виконує diff ПЕРЕД записом `invalidated` у downstream. - ---- - -### Ризик 3 — LLM non-determinism у composite re-plan - -**Рішення: post-plan orphan detection** - -- `mt kill <composite>` → видаляє `plan_NNN.md` + cascade `invalidated` у всіх прямих нащадках (директорії не видаляє). -- `mt plan <composite>` → агент пише новий `plan_NNN+1.md` + створює нові дочірні директорії. -- Post-hook порівнює: існує у новому плані → залишаємо; тільки у старому → видаляємо директорію (після `kill -0 <pid>` перевірки). -- Cleanup відбувається ПІСЛЯ нового плану. Перетинаючі діти → перевиконуються з diff-based cascade (Ризик 2). - ---- - -### Ризик 4 — Clock skew на distributed FS - -**Рішення: MVP won't fix; `grace_period_sec` на майбутнє** - -Single-machine + local git — base scope. NTP — відповідальність OS. Якщо знадобиться multi-host: `grace_period_sec: 30` у `.n-cursor.json` — Watch вважає `stalled` тільки якщо `ts + grace_period_sec ≤ now()`. - -## More Information - -- Специфікація: `npm/docs/mt.md` — потребує оновлення відповідно до цих рішень. -- Memory: `/Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/memory/project_graph_design_review.md` -- Попередні ADR: `рекурсивний-складений-ОАГ-динамічний-розклад.md`, `файловий-стан-append-only-план-факт.md` - -## Update 2026-06-07 - -Gap-аналіз після уніфікації flow/graph зафіксував такі уточнення дизайну: - -- Composite resolved: використовується roll-up run батьківського вузла; `children-resolved` є derived state, а не окремим sentinel-файлом. -- `pending-audit_NNN.md` вважається обробленим, якщо існує auditor `run_M.md` з `created_at` пізніше за pending-audit. -- `mode: human` у headless-режимі: `mt watch` пропускає такі вузли; людина запускає їх вручну з IDE. -- Merge після аудиту: `mt watch` є wrapper, читає `.ncursor-signal` і виконує merge on success. -- Race condition між one-shot і daemon orchestration усувається тим, що єдиним оркестратором стає `mt watch`; `mt run --auto` видаляється. diff --git "a/docs/adr/260607-2219-n-cursor-graph-\320\262\320\270\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\275\321\217-\320\262\320\260\320\264-\320\264\320\270\320\267\320\260\320\271\320\275\321\203-\321\202\320\260-\320\260\321\200\321\205\321\226\321\202\320\265\320\272\321\202\321\203\321\200\320\275\321\226-\321\200\321\226\321\210\320\265\320\275.md" "b/docs/adr/260607-2219-n-cursor-graph-\320\262\320\270\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\275\321\217-\320\262\320\260\320\264-\320\264\320\270\320\267\320\260\320\271\320\275\321\203-\321\202\320\260-\320\260\321\200\321\205\321\226\321\202\320\265\320\272\321\202\321\203\321\200\320\275\321\226-\321\200\321\226\321\210\320\265\320\275.md" deleted file mode 100644 index 8387210..0000000 --- "a/docs/adr/260607-2219-n-cursor-graph-\320\262\320\270\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\275\321\217-\320\262\320\260\320\264-\320\264\320\270\320\267\320\260\320\271\320\275\321\203-\321\202\320\260-\320\260\321\200\321\205\321\226\321\202\320\265\320\272\321\202\321\203\321\200\320\275\321\226-\321\200\321\226\321\210\320\265\320\275.md" +++ /dev/null @@ -1,36 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:19:49+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -ADR створено: `docs/adr/260607-2218-n-cursor-graph-дизайн-вади-та-рішення.md` - -Документ містить всі 14 рішень (10 вад + 4 ризики) у MADR v4 форматі з погодженими варіантами. Тепер оновлюємо `npm/docs/mt.md` відповідно до цих рішень? - ---- - -Transcript ends here. - -## ADR mt — виправлення вад дизайну та архітектурні рішення - -## Context and Problem Statement - -Специфікація `npm/docs/mt.md` описує рекурсивний складений ОАГ задач з файловим сховищем стану. Після детального ітеративного розбору специфікації виявлено 10 вад дизайну і 4 ризики масштабування. Кожну ваду і кожен ризик розібрано окремо, з погодженим варіантом рішення. Цей ADR фіксує фінальні рішення по всіх пунктах як єдиний авторитетний документ. - -## Considered Options - -По кожній ваді/ризику розглядались 2–3 конкретних варіанти (описані в Decision Outcome). Загальні архітектурні альтернативи (централізований state store, EventSourcing, реляційна БД) у transcript не обговорювались — файловий підхід зафіксований як основа у попередніх ADR. - -## Decision Outcome - -Chosen option: сукупність 14 погоджених рішень, because кожне з них виправляє конкретну ваду або ризик без порушення базового інваріанту "стан вузла визначається виключно переліком файлів і директорій без читання вмісту". - -### Consequences - -* Good, because стан-машина залишається детермінованою: `ls` → стан, без читання; `a.md`/`h.md` розділяє "хто" від "що" у таблиці станів; composite resolved стає O(1) замість O(n×depth); context агента не зростає без bounds. -* Bad, because `mt done` тепер виконує рекурсивний cascade вгору при кожному merge, а `mt invalidate` виконує `git diff` перед cascade — обидва додають логіку до критичного шляху. - -## More Information - -Повні описи варіантів і детальні сценарії — у `docs/adr/260607-2218-n-cursor-graph-дизайн-вади-та-рішення.md`. Специфікація `npm/docs/mt.md` потребує оновлення відповідно до цих рішень. Попередні ADR: `рекурсивний-складений-ОАГ-динамічний-розклад.md`, `файловий-стан-append-only-план-факт.md`. diff --git "a/docs/adr/260607-2219-\321\200\320\265\321\204\320\260\320\272\321\202\320\276\321\200\320\270\320\275\320\263-\321\201\321\202\320\260\320\275\321\203-\320\262\321\203\320\267\320\273\320\260-waiting-plan-waiting-run-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-wai.md" "b/docs/adr/260607-2219-\321\200\320\265\321\204\320\260\320\272\321\202\320\276\321\200\320\270\320\275\320\263-\321\201\321\202\320\260\320\275\321\203-\320\262\321\203\320\267\320\273\320\260-waiting-plan-waiting-run-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-wai.md" deleted file mode 100644 index f73e960..0000000 --- "a/docs/adr/260607-2219-\321\200\320\265\321\204\320\260\320\272\321\202\320\276\321\200\320\270\320\275\320\263-\321\201\321\202\320\260\320\275\321\203-\320\262\321\203\320\267\320\273\320\260-waiting-plan-waiting-run-\320\267\320\260\320\274\321\226\321\201\321\202\321\214-wai.md" +++ /dev/null @@ -1,226 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:19:38+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Хороше питання. Через `deps/` — це reverse lookup: "хто з вузлів залежить від мене?" Це O(весь граф), не O(1). - -Є симетричніший варіант — `children/` директорія, аналогічна `deps/`: - -**Варіант A — `children/` directory** - -Коли `mt plan` spawns дітей → записує `children/` у батьківський вузол: - -``` -tasks/build-api/ -task.md -plan_001.md -a.md -children/ -design ← sentinel: ця дитина офіційно "усиновлена" -implement -test -design/ task.md ... -implement/ task.md ... -test/ task.md ... -``` - -Orphan = `ls tasks/build-api/*/task.md` − `ls tasks/build-api/children/` → різниця. -Pure `ls`, без читання вмісту. Симетрично з `deps/`. - -**Варіант B — Orphan неможливий за конструкцією** - -При re-plan `mt spawn` атомарно: пише нових дітей + видаляє старих з `children/`. Якщо дитина є у `children/` → не orphan ніколи. Але небезпечний якщо стара дитина вже `running`. - -**Варіант C — Не детектувати, перекласти на cascade** - -Orphan виникає тільки якщо cascade не спрацював (баг або ручна операція). `mt status --integrity` для ручної перевірки. - -**Яка ситуація тебе турбує найбільше** — LLM re-planned і породив нові дочірні, а старі залишились? Чи щось інше? -[user] -children це окрема директорія для вузла ? чи я правильно розумію ? Ні, мені здається дизайн ускладнити , поки мені подобається варіант С - ------- ---- - -OK this is the end of the session transcript that we are analyzing. - -Please produce one or more ADR blocks now. -[assistant] -## ADR Рефакторинг стану вузла: `waiting-plan` / `waiting-run` замість `waiting` / `human-pending` / `needs-plan` - -## Context and Problem Statement -Стан `waiting` охоплював дві різні ситуації: вузол готовий до автоматичного запуску агентом і вузол, що очікує дії людини. Runner ігнорував `h.md`-вузли, але зовнішній monitor або dashboard не міг розрізнити ці випадки без читання вмісту файлів. Стан `human-pending` покривав лише відсутність плану, але не готовність до виконання людиною. - -## Considered Options -* Зберегти `waiting`, додати суфікс `waiting:agent` / `waiting:human` у `--json` output -* Розбити `waiting` на `waiting` (агент) і `ready-human` (людина) -* Переосмислити семантику: стан відповідає на "що потрібно" (`waiting-plan` / `waiting-run`), а `a.md`/`h.md` відповідає на "хто" - -## Decision Outcome -Chosen option: "`waiting-plan` / `waiting-run` як ортогональні до `a.md`/`h.md`", because стан повинен описувати лише наступний крок (потрібен план чи можна запускати), а виконавець вже закодований у файлах-прапорцях. Старі стани `human-pending`, `needs-plan`, `waiting` видаляються. - -### Consequences -* Good, because runner і зовнішні інструменти отримують однозначну семантику: `waiting-plan` → потрібен план, `waiting-run` → готово до виконання; виконавець читається з `a.md`/`h.md`. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Таблиця маппінгу: -``` -task.md + no a.md/h.md → unassigned -a.md або h.md, немає plan_*.md → waiting-plan -plan_*.md, deps resolved, no running_*, no fact_* → waiting-run -plan_*.md, deps NOT resolved → blocked -``` -Runner: `waiting-plan + a.md` → auto `mt plan`; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto run; `waiting-run + h.md` → skip + notify. - ---- - -## ADR Sentinel-файл `running_<pid>_until_<ts>` з гібридним cleanup (A+B) - -## Context and Problem Statement -Попередній дизайн використовував `running_until_<ts>` для детектування `stalled`-вузлів, але не містив механізму автоматичного cleanup після аварійного завершення процесу (`kill -9`, OOM). Файл залишався на диску, граф застрягав у стані `stalled` назавжди без способу відновлення. - -## Considered Options -* Варіант A: cleanup як перший крок нового `mt run` (перевірка при старті) -* Варіант B: PID у назві файлу `running_<pid>_until_<ts>` для детектування живого процесу -* Гібрид A+B - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві дозволяє перевіряти живість процесу через `kill -0 <pid>` без читання вмісту, а cleanup при старті нового `mt run` і при кожному скані `mt watch` забезпечує автоматичне відновлення. - -### Consequences -* Good, because детектування мертвого процесу (`kill -0`) і cleanup відбуваються автоматично в двох точках входу; інваріант "стан з listing" зберігається. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Назва файлу: `running_<pid>_until_<ts>` де `ts = started_at + budget_hard_sec`. Watch: при `stalled` або `running + dead pid` → видалити sentinel, записати `run_NNN.md (result: failed)`, залишити worktree для debug. Файл `npm/docs/mt.md`. - ---- - -## ADR `deps/` як вкладена директорія з підтримкою крос-рівневих залежностей - -## Context and Problem Statement -`deps/` директорія дозволяла тільки горизонтальні залежності між siblings — вузол міг залежати лише від сусідів у тій самій батьківській директорії. Реальні графи потребують крос-рівневих залежностей між вузлами на різних гілках дерева. - -## Considered Options -* Абсолютний шлях у назві файлу (`deps/research__analyze.md` з `__` як роздільником) -* Вміст файлу містить `ref:` шлях (порушує інваріант "без читання вмісту") -* `deps/` може бути вкладеною, ім'я файлу = шлях відносно `tasks/` - -## Decision Outcome -Chosen option: "Варіант C — вкладена `deps/` директорія", because зберігає інваріант детектування стану через `ls` без читання вмісту; `ls -R deps/` дає повний шлях dep-вузла відносно `tasks/`; сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі — вкладеними (`deps/research/analyze.md`). - -### Consequences -* Good, because усуває структурне обмеження "тільки siblings"; після обрізання `.md` суфіксу dep-id відповідає шляху відносно `tasks/`; симетрично з Варіантом C для Вади №3. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Deps satisfaction: `ls -R deps/` → `research/analyze.md` → обрізати `.md` → шукати `tasks/research/analyze/fact_*.md`. Розширення `.md` — стандарт для всіх файлів у `deps/` (узгоджено з іменуванням `task.md`, `a.md`, `h.md`). Файл `npm/docs/mt.md`. - ---- - -## ADR `fact_NNN.md` для composite вузлів — явний resolved стан - -## Context and Problem Statement -Composite вузол не мав власного `fact_NNN.md` — його стан `resolved` визначався агрегацією всіх нащадків. Для перевірки стану composite `mt scan` рекурсивно обходив весь піддерево (O(глибина × вузли)), без "швидкого шляху". - -## Considered Options -* Залишити як є, додати `.n-cursor/graph-index.json` (порушує принцип відсутності центрального файлу стану) -* Явний `fact_NNN.md` для composite, що пишеться автоматично після resolve останньої дитини - -## Decision Outcome -Chosen option: "Явний `fact_NNN.md` для composite", because уніфікує перевірку стану для всіх типів вузлів; scan стає O(n) замість O(n×depth). - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt scan` не потребує рекурсивного обходу для визначення resolved стану composite вузла. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Тригер: `mt done <child>` wrapper після успішного merge перевіряє батька — якщо всі siblings мають `fact_*.md`, пише `fact_NNN.md` у батька (NNN = count існуючих + 1, `## Summary` = агрегація summary дітей). Рекурсія вгору до кореня або до composite з нерозв'язаними дітьми. При інвалідації дитини → cascade `invalidated` у батька; після re-resolve → `fact_002.md`. Файл `npm/docs/mt.md`. - ---- - -## ADR Резюме провалів замість повного контексту `run_NNN.md` - -## Context and Problem Statement -Агент при запуску отримував усі `run_NNN.md` у контексті. Після 10+ невдалих спроб обсяг historical context міг перевищити context window, що само по собі ставало причиною провалу — не через складність задачі, а через переповнення контексту. - -## Considered Options -* `context_runs: auto` — передавати всі якщо < 50% context window, інакше обрізати від старих -* Передавати тільки секції `## Blockers` і `## Next Attempt` з усіх failed `run_NNN.md` як компактне резюме - -## Decision Outcome -Chosen option: "Компактне резюме провалів", because агенту потрібно знати що вже спробували і чому не спрацювало — повний вміст `run_NNN.md` для цього не потрібен. Розмір резюме фіксований (N рядків) незалежно від кількості та обсягу провалів. - -### Consequences -* Good, because context overflow неможливий при будь-якій кількості спроб; всі провали враховуються, не тільки останні 2; повні `run_NNN.md` залишаються для людського аудиту. -* Bad, because `## Blockers` і `## Next Attempt` стають обов'язковими при `result: failed` — без них резюме буде порожнє. - -## More Information -Wrapper перед запуском агента: витягує `## Blockers` і `## Next Attempt` з усіх failed `run_NNN.md` → склеює у секцію `## Prior attempts (N failed)`. Секції `## Blockers` і `## Next Attempt` змінюють статус з опціональних на обов'язкові при `result: failed`. Файл `npm/docs/mt.md`. - ---- - -## ADR Агент призначає `a.md`/`h.md` для дочірніх вузлів під час планування - -## Context and Problem Statement -`mt plan` у composite режимі породжував дочірні вузли через `mt spawn`, але не призначав виконавця. Всі діти залишались у стані `unassigned`. Runner пропускав `unassigned`-вузли — граф застрягав без жодного повідомлення. - -## Considered Options -* Інші варіанти в transcript не обговорювалися. - -## Decision Outcome -Chosen option: "Агент пише `a.md`/`h.md` для кожної дитини як частину planning output", because призначення виконавця є природною частиною декомпозиції задачі — агент визначає не тільки структуру підграфу, але й хто виконує кожне підзавдання і з яким `model_tier`. - -### Consequences -* Good, because `unassigned` стає виключно станом кореневого вузла після `mt init`; composite children завжди мають виконавця після spawn. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`unassigned` залишається валідним для кореня (`mt init` без `--mode`). Людина може перевизначити після spawn: `graph mode human tasks/<node>/`. Файл `npm/docs/mt.md`. - ---- - -## ADR Обмеження паралельності агентів через чергу (`agent_concurrency`) - -## Context and Problem Statement -`mt run --auto` міг spawn необмежену кількість агентських worktrees паралельно. На MacBook це призводило до disk saturation і resource exhaustion. При цьому людські worktrees не потребують обмеження — людина свідомо керує своїм робочим простором. - -## Considered Options -* `max_worktrees` — загальний ліміт з `max_worktree_age` і забороною `budget_hard_sec: 0` -* Черга агентів з `agent_concurrency`, без обмежень на людські worktrees - -## Decision Outcome -Chosen option: "`agent_concurrency` у `.n-cursor.json`", because проблема — неконтрольований spawning агентів, а не worktrees як концепція. Живі worktrees недоторканні. Людина вирішує сама скільки worktrees відкрити. - -### Consequences -* Good, because чітке розділення: агентська черга обмежена, людська робота — ні; `max_worktree_age` не потрібен і не ризикує вбити живий процес. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -`agent_concurrency: 5` — default у `.n-cursor.json`. Watch перед spawn: якщо живих агентських worktrees ≥ `agent_concurrency` → queue, чекати `touch .n-cursor/wake`. Людські worktrees (`h.md` + `--actor human`) не рахуються. Файл `npm/docs/mt.md`. - ---- - -## ADR `mt kill` — повний cascade з `--no-cascade` як escape hatch - -## Context and Problem Statement -При інвалідації вузла downstream-вузли можуть залежати від змістовного результату upstream, а не тільки від структури. Недетермінізм LLM унеможливлює автоматичне розрізнення "змістовно залежить" vs "структурно незалежний". Orphan-вузли після re-plan не потребують auto-cleanup. - -## Considered Options -* Differential cascade (перевіряти чи змінився вміст `fact_NNN.md` після re-run) -* Повний cascade завжди + `--no-cascade` тільки за явним рішенням інженера -* Orphan detection через `children/` directory (симетрично до `deps/`) -* Orphan detection через `mt status --integrity` без авто-cleanup (Варіант C) - -## Decision Outcome -Chosen option: "Повний cascade + `--no-cascade` escape hatch + orphan через `--integrity`", because correctness важливіша за ефективність; інвалідація кореня — рідкісний сценарій; `children/` директорія ускладнює дизайн без суттєвої потреби на MVP. - -### Consequences -* Good, because консистентність ланцюга гарантована без необхідності читати вміст файлів; git revert відновлює вузли після `mt kill`. -* Bad, because при інвалідації кореня весь downstream перевиконується повністю. - -## More Information -`mt kill <path>` Фаза 1: пише `invalidated` у `<path>/` і всіх нащадках; якщо PID живий — wrapper при завершенні бачить `invalidated` → не мержить → `run_NNN.md (result: killed)` → cleanup worktree. Фаза 2: `git rm -r tasks/<path>/ + git commit`. Undo: `git revert <kill-commit>`. Orphan detection: `mt status --integrity` — manual, не авто-cleanup. Файл `npm/docs/mt.md`. diff --git "a/docs/adr/260607-2221-sentinel-\321\204\320\260\320\271\320\273\320\270-a-md-h-md-\320\264\320\273\321\217-\320\272\320\276\320\264\321\203\320\262\320\260\320\275\320\275\321\217-\321\200\320\265\320\266\320\270\320\274\321\203-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\217.md" "b/docs/adr/260607-2221-sentinel-\321\204\320\260\320\271\320\273\320\270-a-md-h-md-\320\264\320\273\321\217-\320\272\320\276\320\264\321\203\320\262\320\260\320\275\320\275\321\217-\321\200\320\265\320\266\320\270\320\274\321\203-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\217.md" deleted file mode 100644 index 5923f50..0000000 --- "a/docs/adr/260607-2221-sentinel-\321\204\320\260\320\271\320\273\320\270-a-md-h-md-\320\264\320\273\321\217-\320\272\320\276\320\264\321\203\320\262\320\260\320\275\320\275\321\217-\321\200\320\265\320\266\320\270\320\274\321\203-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\217.md" +++ /dev/null @@ -1,179 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:21:46+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -## ADR Sentinel-файли `a.md`/`h.md` для кодування режиму виконавця - -## Context and Problem Statement -Специфікація `npm/docs/mt.md` кодувала режим виконавця (агент/людина) в назві файлу задачі: `task_h.md` або `task_a.md`. Зміна режиму вимагала перейменування основного файлу місії, що руйнувало git history і ускладнювало трансфер задачі між виконавцями. Також не існувало способу виразити стан "режим ще не визначено". - -## Considered Options -* Зберігати режим у назві основного файлу (`task_h.md`/`task_a.md`) -* Зберігати `mode:` у frontmatter `task.md` -* Окремі sentinel-файли `h.md`/`a.md` поряд зі стабільним `task.md` - -## Decision Outcome -Chosen option: "Окремі sentinel-файли `h.md`/`a.md`", because зміна режиму зводиться до `rm h.md && touch a.md` без торкання файлу місії; git history `task.md` не рветься; відсутність обох файлів природно виражає стан `unassigned` (режим не визначено). - -### Consequences -* Good, because `task.md` залишається стабільним артефактом після `mt init`; перемикання режиму атомарне і не потребує міграції даних. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файли: `npm/docs/mt.md`, `a.md` schema (fields: `model_tier`, `skills`), `h.md` schema (field: `qualification`). Три стани через присутність: `h.md` є → `human-*`; `a.md` є → agent-flow; жодного → `unassigned`. - ---- - -## ADR Стани `waiting-plan`/`waiting-run` замість `waiting`/`human-pending`/`needs-plan` - -## Context and Problem Statement -Стан `waiting` у специфікації покривав два семантично різних випадки: вузол з `a.md` де runner запускає агента автоматично, і вузол з `h.md` + планом де runner нічого не робить. Зовнішній monitor чи dashboard не міг розрізнити ці випадки без читання вмісту файлів, що порушувало інваріант "стан визначається лише listing файлів". Також `human-pending` і `needs-plan` змішували два ортогональних питання: "що потрібно зробити" і "хто це робить". - -## Considered Options -* Один стан `waiting` з субполем `actor` у JSON-виводі -* Окремий стан `ready-human` поряд із `waiting` -* Стани `waiting-plan`/`waiting-run` де стан = "що потрібно", `a.md`/`h.md` = "хто робить" - -## Decision Outcome -Chosen option: "Стани `waiting-plan`/`waiting-run`", because стан відповідає на питання "що потрібно далі" (план чи запуск), а `a.md`/`h.md` відповідає на "хто" — без дублювання. Runner завжди перевіряє обидва: стан визначає дію, sentinel визначає виконавця. - -### Consequences -* Good, because таблиця станів стає симетричною: `waiting-plan + a.md` → auto-plan; `waiting-plan + h.md` → skip + notify; `waiting-run + a.md` → auto-run; `waiting-run + h.md` → skip + notify. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Видалені стани: `human-pending`, `needs-plan`, `waiting`. Нова повна таблиця станів атомарного вузла у `npm/docs/mt.md`. Runner-логіка: два виміри (стан × sentinel) замість одного. - ---- - -## ADR `running_<pid>_until_<ts>` — гібридний підхід для stalled detection - -## Context and Problem Statement -Sentinel-файл `running_until_<ts>` кодував deadline у назві для O(1) визначення стану `running`/`stalled` без читання вмісту. Але при аварійному завершенні процесу (`kill -9`, OOM) wrapper не міг виконати cleanup — файл залишався і вузол застрягав у `stalled` назавжди. Також не існувало способу перевірити чи процес живий без читання вмісту окремого lock-файлу. - -## Considered Options -* Cleanup як перший крок нового `mt run` (Варіант A) -* PID у назві файлу `running_<pid>_until_<ts>` для перевірки через `kill -0` (Варіант B) -* Гібрид A+B - -## Decision Outcome -Chosen option: "Гібрид A+B", because PID у назві файлу дозволяє `kill -0 <pid>` без читання вмісту; wrapper при старті нового run і watch при кожному скані виконують однаковий cleanup: якщо процес мертвий → видалити sentinel → перевести у `failed`. - -### Consequences -* Good, because стан `stalled` vs `running` детектується з `ls` (парсинг імені); cleanup автоматичний у двох точках без ручного втручання. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Формат: `running_<pid>_until_<ts>` де `ts = started_at + budget_hard_sec`. Stalled condition: `ts + grace_period ≤ now()` (`grace_period: 60` у `.n-cursor.json` для clock skew). Файл git-ignored. Cleanup: `kill -0 <pid>` → якщо ESRCH → видалити sentinel → записати `run_NNN.md` з `result: failed (crash)`. - ---- - -## ADR `deps/` директорія з вкладеною структурою для крос-рівневих залежностей - -## Context and Problem Statement -Залежності між вузлами спочатку описувались через `deps:` frontmatter у `task.md` — що вимагало читання вмісту для визначення залежностей і порушувало інваріант "стан із listing". Перехід на `deps/` директорію (ім'я файлу = dep-id) вирішив інваріант, але підтримував тільки залежності між сусідніми вузлами (siblings). Реальні складні графи потребують крос-рівневих залежностей. - -## Considered Options -* `deps/<dep-node-id>` без розширення — flat структура, тільки siblings -* `deps/<dep-node-id>.md` з `.md` — flat структура з конвенцією розширення -* Вкладена `deps/` де шлях файлу = шлях dep-вузла відносно `tasks/` - -## Decision Outcome -Chosen option: "Вкладена `deps/` з `.md` розширенням", because `ls -R deps/` + strip `.md` суфікса дає повний відносний шлях dep-вузла; сусідні deps залишаються простими (`deps/collect-data.md`), крос-рівневі виражаються вкладенням (`deps/research/analyze.md`); інваріант без читання вмісту зберігається. - -### Consequences -* Good, because deps satisfaction: `ls -R deps/` → strip `.md` → dep-id → перевірити `tasks/<dep-id>/fact_*.md`; жодного читання вмісту не потрібно. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Файл `deps/<path>.md` може містити опціональний `ref:` та контекст — але читається тільки агентом для збагачення контексту, не для визначення стану. - ---- - -## ADR Явний `fact_NNN.md` для composite вузлів - -## Context and Problem Statement -Composite вузол не мав власного `fact_NNN.md` — його стан `resolved` визначався рекурсивним обходом усіх нащадків. При глибокому дереві `mt scan` виконував O(глибина × вузли) `ls`-викликів. Зовнішній інструмент не міг перевірити стан composite вузла без обходу всього піддерева. - -## Considered Options -* Залишити implicit resolved (рекурсивний обход) -* `fact_NNN.md` для composite — пише wrapper при merge останнього дочірнього -* Ліниве просування через sentinel `.child-done/<id>` у батьківській директорії - -## Decision Outcome -Chosen option: "`fact_NNN.md` для composite", because уніфікує перевірку стану для всіх типів вузлів — `fact_*.md` є → `resolved`, незалежно від atomic чи composite; scan стає O(n) замість O(n×depth). - -### Consequences -* Good, because transcript фіксує очікувану користь: `mt done` wrapper після merge останнього дочірнього перевіряє батька, пише `fact_NNN.md`, рекурсивно перевіряє вище — один merge може закрити весь ланцюг composite вузлів за один прохід. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -NNN для composite = `count(fact_*.md) + 1`. `## Summary` = агрегація `## Summary` всіх дочірніх. При cascade invalidation: батько отримує `invalidated` sentinel; після повторного resolve дочірніх — новий `fact_NNN.md`. Реалізація: `mt done` wrapper, файл `npm/scripts/graph/done.mjs` (або аналог). - ---- - -## ADR Hash-based differential cascade при invalidation - -## Context and Problem Statement -При інвалідації вузла весь downstream граф переходить у `invalidated` і потребує повного re-run. Якщо причина інвалідації зовнішня (зміна audit policy, уточнення вимог) але результат виконання вузла незмінний — downstream ре-ранується марно. - -## Considered Options -* Full re-run (проста поведінка без оптимізацій) -* Hash у frontmatter `fact_NNN.md` — порівняння при re-run для вибіркового cascade - -## Decision Outcome -Chosen option: "Hash у frontmatter `fact_NNN.md`", because якщо вузол після invalidation видав той самий результат (однаковий hash секції `## Result`) — downstream залишається `resolved` без жодного re-run; якщо різний — каскад продовжується стандартно. - -### Consequences -* Good, because читання вмісту відбувається тільки один раз при `mt done`, не під час `watch` scans — інваріант "стан без читання" не порушується на рівні state detection. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Поле у `fact_NNN.md` frontmatter: `hash: sha256:<hash секції ## Result>`. `mt done` порівнює hash нового і попереднього `fact_NNN.md`. Однаковий → видалити `invalidated` у залежних → вони повертаються у `resolved`. Різний → залежні залишаються `invalidated`. - ---- - -## ADR "Prior attempts" резюме замість повних `run_NNN.md` у контексті агента - -## Context and Problem Statement -Агент при запуску отримував повний список `run_NNN.md` файлів у контексті. Після багатьох невдалих спроб context window заповнювався — що само по собі ставало причиною наступного `failed`. Також успішно виконані частини попередніх спроб не були явно виділені, і агент міг повторювати вже зроблену роботу. - -## Considered Options -* Передавати всі `run_NNN.md` у контекст -* Передавати тільки останні N `run_NNN.md` (фіксований ліміт) -* Адаптивний вибір на основі розміру context window -* Компактне резюме з обов'язкових секцій: `## Completed`, `## Blockers`, `## Next Attempt` - -## Decision Outcome -Chosen option: "Компактне резюме з обов'язкових секцій", because розмір резюме фіксований (N рядків) незалежно від кількості спроб; агент отримує саме те що потрібно: що вже зроблено (не повторювати), чому провалилось (не повторювати), де починати. - -### Consequences -* Good, because `## Completed` дозволяє наступному агенту пропустити вже виконану роботу; context overflow неможливий незалежно від кількості попередніх спроб. -* Bad, because `## Completed`, `## Blockers`, `## Next Attempt` стають обов'язковими при `result: failed` — агент при провалі зобов'язаний заповнити їх; без них резюме буде порожнім. - -## More Information -Повні `run_NNN.md` залишаються у директорії вузла для людського аудиту та `mt status`. При `result: success` — обов'язкові `## Completed` + `## Summary` (для composite агрегації). Wrapper генерує резюме перед запуском агента без LLM-виклику — тільки парсинг секцій. - ---- - -## ADR `audit-result_NNN.md` — deletable для повторного аудиту - -## Context and Problem Statement -Специфікація описувала `audit-result_NNN.md` як immutable artifact. При провалі аудиту (аудитор помилився або критерії змінились) потрібен повторний аудит того самого `fact_NNN.md`. NNN вже зайнятий — не існувало механізму retry без створення нового `run_NNN.md` і нового `fact_NNN.md`. - -## Considered Options -* Повторний аудит вимагає нового `run_NNN.md` → новий `fact_NNN.md` → новий NNN -* Sub-NNN (`pending-audit_003a.md`) для повторних аудитів -* `audit-result_NNN.md` — deletable; `mt invalidate` видаляє файл для retry - -## Decision Outcome -Chosen option: "`audit-result_NNN.md` — deletable", because видалення `audit-result_NNN.md` повертає вузол у стан `pending-audit`; watch автоматично перезапускає аудит того самого `fact_NNN.md`; audit trail зберігається через git history навіть після видалення. - -### Consequences -* Good, because повторний аудит не вимагає нового виконання задачі якщо сам факт валідний; команда `mt invalidate <path>` проста у реалізації. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Команда: `mt invalidate <path>` — видаляє `audit-result_NNN.md` де NNN = pending без result. `pending-audit_NNN.md` залишається (immutable). Новий `run_NNN.md` потрібен тільки якщо аудитор відхилив сам факт як некоректний. diff --git "a/docs/adr/260607-2244-\320\264\320\270\320\267\320\260\320\271\320\275-\321\204\320\260\320\271\320\273\320\276\320\262\320\276\320\263\320\276-\320\272\320\276\320\275\321\202\321\200\320\260\320\272\321\202\321\203-\320\267\320\260\320\264\320\260\321\207\320\275\320\276\320\263\320\276-\320\263\321\200\320\260\321\204\321\203-n-cursor-graph.md" "b/docs/adr/260607-2244-\320\264\320\270\320\267\320\260\320\271\320\275-\321\204\320\260\320\271\320\273\320\276\320\262\320\276\320\263\320\276-\320\272\320\276\320\275\321\202\321\200\320\260\320\272\321\202\321\203-\320\267\320\260\320\264\320\260\321\207\320\275\320\276\320\263\320\276-\320\263\321\200\320\260\321\204\321\203-n-cursor-graph.md" deleted file mode 100644 index d7548c1..0000000 --- "a/docs/adr/260607-2244-\320\264\320\270\320\267\320\260\320\271\320\275-\321\204\320\260\320\271\320\273\320\276\320\262\320\276\320\263\320\276-\320\272\320\276\320\275\321\202\321\200\320\260\320\272\321\202\321\203-\320\267\320\260\320\264\320\260\321\207\320\275\320\276\320\263\320\276-\320\263\321\200\320\260\321\204\321\203-n-cursor-graph.md" +++ /dev/null @@ -1,76 +0,0 @@ ---- -session: 299215fa-4d9f-4dab-8c36-cbc5cea3b0d6 -captured: 2026-06-07T22:44:19+03:00 -transcript: /Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/299215fa-4d9f-4dab-8c36-cbc5cea3b0d6.jsonl ---- - -Зафіксовано. Файловий контракт у memory повний. - -Готові переносити все в `npm/docs/mt.md`? Це буде суттєве оновлення — таблиця станів, схеми файлів, нові правила `deps/`, composite synthesis, `allow_replan`, history/ структура. Хочеш що я зробив це одним агентом або будемо ітерувати разом? -[user] -Одним агентом. Запусти в worktree - ---- - -[The transcript ends here. The ADR hook is now processing the above session.] -</thinking> - -## ADR Дизайн файлового контракту задачного графу (`mt`) - -## Context and Problem Statement -У процесі проєктування системи `mt` — рекурсивного самомодифікованого DAG задач із файловим зберіганням стану — виявлено 15 структурних вад і 4 ризики масштабування. Сесія присвячена їх ітеративному розбору та прийняттю рішень до початку реалізації. - -## Considered Options -* Існуючий дизайн з `task.md` frontmatter для стану, `invalidated` sentinel, одним `waiting`-станом, центральним індексом -* Гібридний файловий дизайн: sentinel-файли як носії стану, `deps/` директорія, `history/` для аудиту, дворівнева таблиця станів — описано нижче - -## Decision Outcome -Chosen option: "Гібридний файловий дизайн зі суворим listing-інваріантом", because всі стани вузла мають визначатись виключно переліком файлів і директорій без читання вмісту; підтверджено в кожному рішенні сесії. - -### Consequences - -**Таблиця станів (фінальна):** - -| # | Ключова умова | Стан | -|---|---|---| -| 1 | `task.md`, немає `a.md`/`h.md` | `unassigned` | -| 2 | `a.md` або `h.md`, немає `plan_001.md` | `waiting-plan` | -| 3 | `plan_001.md`, deps НЕ resolved | `blocked` | -| 4 | `plan_001.md`, deps resolved | `waiting-run` | -| 5a | `running_<pid>_until_<ts>`, `ts > now()` | `running` (агент) | -| 5b | `running_0_until_0` | `running` (людина) | -| 6 | `running_<pid>_until_<ts>`, `ts ≤ now()` | `stalled` | -| 7 | `run_NNN.md` | `failed` | -| 8 | `fact_NNN.md` + `pending-audit_NNN.md` | `pending-audit` | -| 9 | `fact_NNN.md` | `resolved` | - -Пріоритет: `resolved` > `pending-audit` > `stalled` > `running` > `failed` > `blocked` > `waiting-run` > `waiting-plan` > `unassigned` - -**Ключові рішення по вадах:** - -* Good, because **Вада №1**: `running_<pid>_until_<ts>` — PID + deadline в імені. Watch/wrapper перевіряє `kill -0 <pid>`; `pid == "0"` → людина, skip Unix check. -* Good, because **Вада №2**: `waiting-plan` / `waiting-run` — симетричні стани для агента і людини; `a.md`/`h.md` = хто; стан = що потрібно. -* Good, because **Вада №3+5**: `deps/` вкладена структура дзеркалює `tasks/`; всі файли з `.md`; шлях відносно `tasks/`. -* Good, because **Вада №4**: composite вузол отримує власний `fact_NNN.md` через synthesis agent після того як всі діти resolved. -* Good, because **Вади №6+14**: внутрішній LLM-аудитор видалено; агент self-verifies `## Done when`; зовнішній async аудит через `require_approval: true` → `pending-audit_NNN.md` + `mt audit approve/reject`. -* Good, because **Вада №7**: `graph migrate` при кожному релізі; `schema_version:` не потрібен. -* Good, because **Вада №8**: `mode:` видалено з `plan_001.md`; єдине джерело правди — `a.md`/`h.md`. -* Good, because **Вада №9**: wrapper витягує `## Blockers` + `## Next Attempt` з усіх failed `run_*.md` → compact summary для наступного агента; `run_*.md` і `fact_*.md` ніколи не співіснують (`fact` = успіх, `run` = тільки провал). -* Good, because **Вада №10**: при composite planning агент пише `a.md`/`h.md` для кожної дочірньої задачі. -* Good, because **Вада №13**: `running_0_until_0` для людини; `mt done --actor human` → wrapper перевіряє `## Done when` → пише `fact_NNN.md (actor: human)`. -* Good, because **Вада №15**: `allow_replan: true` у `a.md` дозволяє агенту автономно переключити `hint: atomic` → composite; за замовчуванням — `failed` з пропозицією для людини. -* Good, because **Ризики №1**: `agent_concurrency` черга агентів; людські worktrees без обмежень. -* Good, because **Ризики №2+3**: `mt kill` = архів у `<tasks-root>/.history/` + `git rm`; `mt invalidate` = архів `fact_*.md`/`run_*.md` всередині вузла; `invalidated` sentinel видалено. -* Bad, because transcript не містить підтверджених негативних наслідків; ризик №4 (clock skew на distributed FS) — out of scope для MVP. - -## More Information - -Файли: `npm/docs/mt.md`, `/Users/vitaliytv/.claude/projects/-Users-vitaliytv-www-nitra-cursor/memory/project_graph_design_review.md` - -Схеми `a.md`: `model_tier`, `skills`, `require_approval`, `allow_replan`. Схеми `fact_NNN.md`: `actor: agent|human`, `model_tier` (agent-only), `duration_sec` (agent-only). `run_NNN.md` обов'язково містить `## Blockers` та `## Next Attempt`. - -Monorepo: кожен workspace має власний `tasks/` root з `.history/` як сиблінгом. `MT_TASKS_DIR` вказує активний root. Один `mt watch` на один root. - -`plan_001.md` — завжди один файл на вузол (не `plan_002`). `graph replan` архівує до `history/<ts>-replan/` і дозволяє написати новий. - -Human flow: `graph start --actor human` можна викликати з `waiting-plan` або `waiting-run` (план опціональний для людини). diff --git "a/docs/adr/260711-2100-interactive-\320\277\320\276\320\273\320\265-\321\203-mt-claim-yml.md" "b/docs/adr/260711-2100-interactive-\320\277\320\276\320\273\320\265-\321\203-mt-claim-yml.md" deleted file mode 100644 index a4422a7..0000000 --- "a/docs/adr/260711-2100-interactive-\320\277\320\276\320\273\320\265-\321\203-mt-claim-yml.md" +++ /dev/null @@ -1,25 +0,0 @@ -## ADR Поле `interactive:` у `.mt-claim.yml` - -## Context and Problem Statement - -Архітектура 0.3.0 (git.md, «Claim») додає до `.mt-claim.yml` поле `interactive: false` і ототожнює `token = session_id`: інтерактивна сесія (attach) — це той самий run вузла, але з коротшим lease (`interactive_claim_lease_sec`, дефолт 900) і людиною за кермом. Реалізація claim-ів у `mt-core` (та graph-міст agent-server поверх неї) писала claim без цього поля — оркестратор і зовнішні спостерігачі не могли відрізнити інтерактивний claim від автономного, а отже не могли застосувати різні політики (watchdog/progress_timeout не діє на інтерактивні; бюджети — soft-alert замість kill; черга dispatch не чіпає вузол, який людина тримає в чаті). - -## Considered Options - -* Додати `interactive: bool` у `ClaimFields`/`claim_yaml` і читати його в `ClaimInfo` (schema_version лишається 1 — нове поле backward-сумісне: старі парсери читають лише відомі ключі) -* Розрізняти інтерактивність за `actor: human` (без зміни схеми) -* Підняти schema_version до 2 - -## Decision Outcome - -Chosen option: "Додати `interactive: bool` зі schema_version 1", because канон 0.3.0 явно фіксує це поле у схемі claim-а; `actor` — семантика виконавця, не режиму (агент теж може бути під інтерактивним наглядом, а `actor: human` існує й в автономному h.md-потоці); бампити schema_version немає потреби — додавання optional-поля не ламає читачів 0.2.x (fail closed стосується лише невідомих МАЙБУТНІХ версій, не невідомих полів). - -### Consequences - -* Good, because оркестратор/дашборд бачать режим run-а безпосередньо з claim ref (без евристик за lease-довжиною). -* Good, because renewal/takeover успадковують поле природно — воно частина `ClaimFields`, які контролює тримач. -* Bad, because до перегенерації старих claim-ів поле відсутнє — читачі мусять трактувати відсутність як `false` (закладено в парсер). - -## More Information - -Реалізація: `mt_core::claims::{ClaimFields, claim_yaml, parse_claim, ClaimInfo}`; автономний runner пише `interactive: false`, `agent_server::graph::attach` — `true`. Канон: npm/docs/architecture/git.md («Claim»), runtime.md («Інтерактивна сесія = run вузла»). diff --git "a/docs/adr/260713-2040-\320\277\321\226\320\264\320\277\320\270\321\201\320\276\321\207\320\275\321\226-cli-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\226-\320\262\321\203\320\267\320\273\320\260-agent-cli.md" "b/docs/adr/260713-2040-\320\277\321\226\320\264\320\277\320\270\321\201\320\276\321\207\320\275\321\226-cli-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\226-\320\262\321\203\320\267\320\273\320\260-agent-cli.md" deleted file mode 100644 index 4ef6465..0000000 --- "a/docs/adr/260713-2040-\320\277\321\226\320\264\320\277\320\270\321\201\320\276\321\207\320\275\321\226-cli-\320\262\320\270\320\272\320\276\320\275\320\260\320\262\321\206\321\226-\320\262\321\203\320\267\320\273\320\260-agent-cli.md" +++ /dev/null @@ -1,46 +0,0 @@ -# Підписочні CLI-виконавці вузла (`agent_cli`) замість власного provider-шляху в першому кільці - -**Status:** Accepted -**Date:** 2026-07-13 - -## Context and Problem Statement - -Вбудований agent-шлях `mt run` був жорстко закодований на `claude` CLI, а цільовий стек передбачав власний provider-шар (`agent-core`: `async-openai` + LiteLLM-профіль для хмарних моделей) як критичний шлях. Паралельно розглядались агентні рантайми (pi, pi_agent_rust, codex-core, Goose — останній відхилено окремим ADR) як «мозок» вузла. Питання: чий agent loop і чиї ключі потрібні MT для першого кільця dogfooding-у, якщо цільова аудиторія вже має підписки на вендорські coding-CLI (Claude Code, Codex, Cursor)? - -## Considered Options - -* Підписочні CLI (claude / codex / cursor) як вбудований agent-шлях; вибір per-node. -* Власний provider-шар (`agent-core` + async-openai + LiteLLM) як критичний шлях першого кільця. -* Прийняти зовнішній агентний рантайм (pi / pi_agent_rust / codex-core) як embedded «мозок». -* Статус-кво: жорстко закодований `claude`-шлях. - -## Decision Outcome - -Chosen option: «Підписочні CLI як вбудований agent-шлях; вибір per-node», because: - -- **Найбільше спрощення першого кільця:** agent loop, tools, sandbox, вибір моделі й білінг привозить вендорський CLI, авторизований користувачем локально під власною підпискою. MT не тримає API-ключів, не білінгує токени, не потребує LiteLLM-прокладки; власний provider-шар (`agent-core`) зсувається у друге кільце (локальні моделі omlx/Ollama, headless без підписки) і зникає з критичного шляху. -- **Юридично чиста форма:** run виконується на хості, де owner вузла сам авторизував CLI, для його задач — штатне використання підписки. Нормативне правило: підписки не пулюються і не проксюються через relay/сервер; relay передає лише події та approvals. -- **Крос-програмковий вимір vision.md стає реальним уже зараз:** `agent_cli` — per-node прапор `a.md` (секція `## Agent cli`) з user-level дефолтом (env `MT_AGENT_CLI`, ADR `260713-2110`) — спеціалізований тул на вузол. Гранулярність осей різна: `node_executor` (чий harness виконує граф) лишається глобальним; `agent_cli` (який CLI всередині вбудованого шляху) — per-node. -- **MT зберігає всю унікальну цінність:** claim/lease, worktree-ізоляція, budget/timeout, retry ladder, `## Check`, fenced publish — оркестрація не делегується. Логіка Goose-ADR («не своп, а адаптер на межі») застосована і тут. -- Побічне вирівнювання: `## Check`-гейт тепер спільний для обох шляхів (вбудованого CLI і `node_executor`) — success вбудованого шляху = fact існує **і** Check пройдено (раніше вбудований шлях мержив без Check). - -Реалізація: таблиця `AGENT_CLIS` у `npm/lib/commands/run.mjs` (claude → `--model`; codex → `codex exec -m … --full-auto`; cursor → `cursor-agent --model … --print --force`), env `MT_AGENT_CLI`, fail-fast на невідомому значенні до створення worktree, спільний генералізований читач прапор-секцій `a.md` (`## Model tier` / `## Retry ladder` / `## Agent cli`). - -**Тир → конкретна модель per-CLI.** Канон MIN/AVG/MAX не делегується CLI «на розсуд»: мапа «CLI → тир → модель» резолвить тир у конкретну модель обраного CLI (напр. codex: MIN→`gpt-5.6-luna`, AVG→`gpt-5.6-terra`, MAX→`gpt-5.6-sola`), тож retry ladder ескалює не лише тир, а й фактичну модель. CLI без мапінгу резолвить модель сам (тир — hint env `MT_MODEL_TIER`). Правило спільне для headless-викликів і ACP-сесій (`resolveModelForCli` у `npm/lib/core/config.mjs`); механіка конфігурації — user-level ENV (`MT_AGENT_CLI` / `MT_CLOUD_AGENT_CLIS` / `MT_AGENT_CLI_MODEL_MAP`), ADR `260713-2110`. - -### Consequences - -* Good, because перше кільце dogfooding їде без добудови `provider_openai.rs`/LiteLLM — менша поверхня коду і нуль секретів у MT. -* Good, because vendor-нейтральність підтверджується практикою: три взаємозамінні CLI за одним контрактом вузла. -* Bad, because телеметрія tokens/cost — best-effort (що віддає CLI), бюджети підписочного шляху — soft-alert (hard-межа — `budget_hard_sec` kill); rate limits підписки — зовнішній ресурс, оркестратор має робити backoff. -* Bad, because headless-режими не паузяться на mid-run approval — вузли з approval-гейтами вимагають сесійного транспорту; цільове рішення — ACP (Agent Client Protocol): один ACP-клієнт в agent-server, `permission-request` → `ApprovalRequest` (Ed25519). Окремий ADR після спайку. -* Bad, because матриця сумісності: headless-прапори трьох вендорських CLI змінюються швидко — потрібна `mt doctor`-перевірка наявності/версій. - -## More Information - -- `npm/docs/architecture/runtime.md` — розділ «Підписочні CLI-виконавці (`agent_cli`)»: таблиця CLI, правило підписки, ACP-намір, телеметрія. -- `npm/docs/architecture/stack.md` — «LLM-провайдери»: перше кільце (підписочні CLI) / друге кільце (власний provider-транспорт). -- `npm/docs/architecture/graph.md` — `a.md`: поле `agent_cli`, гранулярність осей `node_executor` vs `agent_cli`. -- `npm/lib/commands/run.mjs` + `npm/lib/tests/run.test.mjs` — реалізація і тести (codex-диспатч, per-node override, fail-fast, спільний `## Check`-гейт). -- `docs/adr/не-приймати-goose-block-агентний-фреймворк.md` — споріднене рішення про межу з чужими агентними фреймворками. -- Переглянути, якщо вендорські ToS обмежать headless-використання підписок або з'явиться стабільний ACP-адаптер у всіх трьох CLI (тоді headless-таблиця може зʼїхати на ACP цілком). diff --git "a/docs/adr/260713-2110-acp-\321\224\320\264\320\270\320\275\320\270\320\271-\321\202\321\200\320\260\320\275\321\201\320\277\320\276\321\200\321\202-\320\272\320\260\321\201\320\272\320\260\320\264-\321\205\320\274\320\260\321\200\320\275\320\270\321\205-\320\277\321\226\320\264\320\277\320\270\321\201\320\276\320\272-min-\321\202\320\270\321\200.md" "b/docs/adr/260713-2110-acp-\321\224\320\264\320\270\320\275\320\270\320\271-\321\202\321\200\320\260\320\275\321\201\320\277\320\276\321\200\321\202-\320\272\320\260\321\201\320\272\320\260\320\264-\321\205\320\274\320\260\321\200\320\275\320\270\321\205-\320\277\321\226\320\264\320\277\320\270\321\201\320\276\320\272-min-\321\202\320\270\321\200.md" deleted file mode 100644 index 499b757..0000000 --- "a/docs/adr/260713-2110-acp-\321\224\320\264\320\270\320\275\320\270\320\271-\321\202\321\200\320\260\320\275\321\201\320\277\320\276\321\200\321\202-\320\272\320\260\321\201\320\272\320\260\320\264-\321\205\320\274\320\260\321\200\320\275\320\270\321\205-\320\277\321\226\320\264\320\277\320\270\321\201\320\276\320\272-min-\321\202\320\270\321\200.md" +++ /dev/null @@ -1,44 +0,0 @@ -# ACP як єдиний транспорт AI-викликів, ENV-конфіг виконавців, каскад хмарних підписок і MIN-канон - -**Status:** Accepted -**Date:** 2026-07-13 - -## Context and Problem Statement - -Рішення про підписочні CLI-виконавці (`agent_cli`, ADR `260713-2040`) лишило відкриті питання: (1) локальні моделі (omlx) досі планувались через власний provider-шар `agent-core` (`async-openai` + LiteLLM) — паралельний кодовий шлях поруч із CLI-виконавцями; (2) у користувача може бути **декілька** хмарних підписок (codex + cursor), і вичерпані ліміти однієї не повинні валити run; (3) конфігурація виконавців лежала у repo-scoped `.mt.json`, хоча підписки й моделі — властивість **користувача**, спільна для всіх його репозиторіїв; (4) назва мінімального тиру `MIM` неочевидна. - -## Considered Options - -* ACP-only: власний provider-шар видалити; pi.dev CLI обгортає omlx; конфіг виконавців — user-level ENV; каскад; канон MIN без legacy. -* Те саме, але з перехідними станами: legacy-алиас `MIM`, headless як «шим», provider-шар «заморозити». -* Лишити provider-шар для локальних моделей; каскад — на рівні LiteLLM-роутера. -* Статус-кво. - -## Decision Outcome - -Chosen option: «ACP-only без перехідних станів», because: - -- **ACP (Agent Client Protocol) — єдиний транспорт усіх AI-викликів.** Виконавці — зовнішні підписочні CLI (`claude` | `codex` | `cursor` | `pi`); хмарні підключаються ACP-адаптерами, **локальні моделі — через pi.dev CLI**, який обгортає omlx-сервер і виставляє той самий ACP. `permission-request` → `ApprovalRequest` (Ed25519). Один виконавчий шлях, нуль винятків. -- **Власний provider-шар видалено фізично**, не заморожено: `agent-core` втратив agent loop, реєстр tools і provider (`agent.rs`, `provider.rs`, `provider_openai.rs`, `tools.rs`, `fs_tools.rs`, `approval_tool.rs`, залежності `async-openai`/`schemars`) і став місцем майбутнього ACP-клієнта; `agent-server` виконує ходи через `TurnRunner` (тести — `ScriptedTurnRunner`), `agent-cli serve` втратив `--base-url/--model/--api-key`. Mid-run approval-гейти повертаються ACP-шляхом. -- **Конфігурація виконавців — user-level ENV, не `.mt.json`:** `MT_AGENT_CLI` (дефолтний CLI), `MT_CLOUD_AGENT_CLIS` (каскад, comma-separated), `MT_AGENT_CLI_MODEL_MAP` (JSON «CLI → тир → модель»). `.mt.json` — виключно repo-scoped і більше не містить модельних ключів (`model_map`, `claude_model`, `audit_model` видалені з дефолтів `mt-core`). Per-node override CLI лишається у `a.md` (`## Agent cli`) — це задачна, а не користувацька властивість. -- **Каскад хмарних підписок:** вичерпані ліміти CLI (rate limit / quota / 429; поки текстова евристика `isRateLimited`, з ACP — структуровані помилки) → автоматичний перехід до наступного кандидата у порядку `[обраний agent_cli, ...MT_CLOUD_AGENT_CLIS]` без дублів. Модель тиру — per-кандидат; фактичний CLI фіксується у frontmatter `run_NNN.md` (`agent_cli`). Не-лімітні помилки каскад не запускають (штатний failed-run + retry ladder). -- **Канон тирів — `MIN`/`AVG`/`MAX` без legacy:** `MIM` не приймається ніде; жодних алиасів і перехідних станів. - -### Consequences - -* Good, because зникає ціла підсистема (власний agent loop + provider, ~1500 рядків Rust) і паралельний шлях виконання — локальний і хмарний кейси симетричні. -* Good, because один ENV-конфіг виконавців працює у всіх репозиторіях користувача; repo-конфіг `.mt.json` мінімальний і schema-валідний без спецключів. -* Good, because ліміти однієї підписки не термінальні — каскад дає self-healing run. -* Bad, because до появи ACP-клієнта інтерактивний шлях agent-server має лише echo-заглушку (approval-гейт-тести власного loop-а видалені і повернуться з ACP-клієнтом); автономний шлях (`mt run` → headless CLI) працює повноцінно. -* Bad, because rate-limit-детект тимчасово текстовий (до структурованих ACP-помилок); headless-прапори pi перевірити спайком. -* Bad, because старі вузли/конфіги з `MIM` або `.mt.json`-модельними ключами мовчки втрачають мапінг моделі (CLI резолвить сам) — свідома ціна відмови від legacy. - -## More Information - -- `npm/docs/architecture/runtime.md` — «Підписочні CLI-виконавці»: ENV-конфіг, каскад, «ACP — єдиний транспорт AI-викликів». -- `npm/docs/architecture/stack.md` — «Виконавці та AI-транспорт»; компонент `agent-core` = ACP-клієнт. -- `npm/docs/architecture/surfaces.md` — surface-профіль: `provider` → `agent_cli`. -- `npm/docs/architecture/operations.md` — `provider_profiles` видалено; конфіг виконавців — ENV. -- `npm/lib/core/config.mjs` — `loadAgentCliEnv`, `resolveModelForCli`; `npm/lib/commands/run.mjs` — `AGENT_CLIS`, `spawnAgentCliCascade`. -- `crates/agent-core/src/lib.rs` — стаб ACP-клієнта; `crates/agent-server/src/runner.rs` — `TurnRunner`/`ScriptedTurnRunner`/`EchoTurnRunner`. -- ADR `260713-2040` — базове рішення про підписочні CLI-виконавці (конфіг-механіка звідти замінена цим записом). diff --git "a/docs/adr/260714-0710-rust-\320\277\320\276\321\200\321\202-run-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\206\321\226\321\227-\320\264\320\276-\320\277\320\260\321\200\320\270\321\202\320\265\321\202\321\203-run-mjs-\321\202\320\276\320\275\320\272\320\270\320\271-\320\272\320\273\321\226\321\224\320\275\321\202.md" "b/docs/adr/260714-0710-rust-\320\277\320\276\321\200\321\202-run-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\206\321\226\321\227-\320\264\320\276-\320\277\320\260\321\200\320\270\321\202\320\265\321\202\321\203-run-mjs-\321\202\320\276\320\275\320\272\320\270\320\271-\320\272\320\273\321\226\321\224\320\275\321\202.md" deleted file mode 100644 index 831a2c5..0000000 --- "a/docs/adr/260714-0710-rust-\320\277\320\276\321\200\321\202-run-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\206\321\226\321\227-\320\264\320\276-\320\277\320\260\321\200\320\270\321\202\320\265\321\202\321\203-run-mjs-\321\202\320\276\320\275\320\272\320\270\320\271-\320\272\320\273\321\226\321\224\320\275\321\202.md" +++ /dev/null @@ -1,41 +0,0 @@ -# Rust-порт run-оркестрації доведено до паритету; run.mjs — тонкий клієнт mt-core - -**Status:** Accepted -**Date:** 2026-07-14 - -## Context and Problem Statement - -Після ADR `260713-2110` (ACP, підписочні CLI, каскад) run-оркестрація існувала у двох реалізаціях, що розійшлися: (1) `npm/lib/commands/run.mjs` — актуальний шлях із таблицею `AGENT_CLIS` (claude | codex | cursor | pi), каскадом `MT_CLOUD_AGENT_CLIS`, ENV-конфігом виконавців і спільним `## Check`-гейтом, але на **локальній** worktree-моделі 0.2.x (гілка `mt/<task-epoch>`, mkdir-lock, локальний merge); (2) `crates/mt-core/src/runner.rs` — порт спекового git-режиму (CAS claim → detached worktree від `origin/main` → watchdog → fenced publish), але із захардкодженим `DEFAULT_AGENT_CMD` `claude …`, без agent_cli/каскаду/ENV. «Правило одного коду контракту» (`npm/docs/architecture/stack.md`) забороняє дві імплементації; водночас сам stack.md суперечив собі: розділ правила казав «контракт один раз у `@7n/mt`, agent-server викликає `mt … --json`», а розділ контракт-пакета — «Rust (mt-core) — єдина імплементація, JS — тонкий клієнт». Фактично `agent-server/src/graph.rs` уже лінкує `mt-core` (claims/publish/worktree/signal) для інтерактивного шляху. - -## Considered Options - -* Довести Rust-порт до паритету і зробити `run.mjs` тонким клієнтом (napi). -* Видалити `runner.rs`/`orchestrate.rs` з mt-core до появи ACP-клієнта; канон — `run.mjs`. -* Статус-кво (дві реалізації, що розходяться далі). - -## Decision Outcome - -Chosen option: «паритет у Rust, run.mjs — тонкий клієнт», because mt-core вже є єдиною імплементацією контракт-примітивів для інтерактивного шляху (`graph.rs`), тож автономний шлях у JS робив би contract-логіку двоядерною назавжди; паритет закриває розбіжність в один бік — той, що зафіксований розділом контракт-пакета stack.md. - -- **Паритет виконавців у `mt-core`:** ENV-конфіг — `config.rs` (`AgentCliEnv`: `MT_AGENT_CLI` / `MT_CLOUD_AGENT_CLIS` / `MT_AGENT_CLI_MODEL_MAP`; `normalize_model_tier`, `resolve_model_for_cli`); `runner.rs` — таблиця CLI (claude | codex | cursor → `cursor-agent` | pi) з headless-argv, каскад за rate-limit (текстова евристика — тимчасово, до структурованих ACP-помилок), a.md-прапори (`## Model tier`, `## Retry ladder`, `## Agent cli`), retry ladder з ескалацією тиру MIN→AVG→MAX, ENV-контракт `MT_*` (у т.ч. `MT_RETRY_STRATEGY`, `MT_MODEL_TIER`, ISO `MT_STARTED_AT`), фактичний `agent_cli` у frontmatter `run_NNN.md`. `DEFAULT_AGENT_CMD`/`agent_cmd` видалені. Точка розширення `node_executor` **не портована**: її видалено з контракту паралельним рішенням (PR #48, останній консюмер мігрував на підписочні CLI) — єдиний agent-шлях у Rust-порті одразу канонічний. -- **`run.mjs` — тонкий клієнт:** napi-експорти `run_node` / `run_auto` / `run_preflight`; у JS лишаються argv, резолв `mt_dir`, human-шлях (інструкції без спавну і без claim) і мапінг помилок в exit-коди (`claim-lost` → 2 — штатний skip). Поведінкові тести run переїхали в cargo (PATH-шими фейкових CLI); vitest перевіряє wiring тонкого клієнта. -- **`mt run` переходить на git-режим спеки:** CAS claim → detached worktree від `origin/main` → fenced publish в `origin/main`; вимагає push-доступ до `origin`. Локальна модель 0.2.x (гілка `mt/<task-epoch>`, mkdir-lock, локальний merge, `max_worktrees`-гейт у run) видалена разом зі старим кодом. -- **`## Check` — спільна семантика `signal.rs`:** виконується з кореня worktree (як у `mt done`), а не з директорії вузла (стара run.mjs-поведінка відкинута). Fact із проваленим `## Check` **відкликається** (видаляється до publish): `accepted_fact_state` рахує лише файли, і опублікований fact поруч із failed-run хибно робив би вузол resolved — виправлення й для старого Rust-шляху. -- **stack.md вирівняно:** «Правило одного коду контракту» тепер прямо каже — єдина імплементація в `mt-core`, `@7n/mt` — тонкий napi-клієнт, `agent-server` лінкує crate; це і був «окремий ADR про перенесення контракту в Rust», який розділ анонсував. - -### Consequences - -* Good, because зникає остання двоядерність контракту: каскад/тири/Check однакові для автономного (runner) та інтерактивного (graph.rs) шляхів, з одними тестами в cargo. -* Good, because автономний шлях отримує спековий claim/fenced-publish (мультимашинна коректність, run ref для recovery) замість локального merge без клеймів. -* Good, because закрита діра з хибним resolved при проваленому `## Check`. -* Bad, because `mt run` тепер вимагає git-репозиторій з `origin` і push-доступом — offline/без-remote сценарій свідомо втрачено (повернеться хіба окремим рішенням про local-режим). -* Bad, because napi-виклик блокуючий (синхронний run у процесі CLI); для довгих ранів це прийнятно (CLI і так чекає), для agent-server — не використовується (він має власний шлях). -* Bad, because JS-юніт-тести більше не покривають поведінку runner-а — планка тепер у cargo-тестах (13 тестів runner, включно з каскадом через PATH-шими). - -## More Information - -- `crates/mt-core/src/runner.rs`, `crates/mt-core/src/config.rs` (`AgentCliEnv`), `crates/mt-core/src/signal.rs` (`done_fm`/`audit_fm`), `crates/mt-napi/src/lib.rs` (`run_node`/`run_auto`/`run_preflight`). -- `npm/lib/commands/run.mjs` — тонкий клієнт; `npm/lib/tests/run.test.mjs` — wiring-тести. -- `npm/docs/architecture/stack.md` — «Правило одного коду контракту» (оновлено), компонентна таблиця (`mt-core`). -- `npm/docs/architecture/runtime.md` — «Підписочні CLI-виконавці», «Зовнішній екзекутор вузла» (нормативний контракт — без змін, реалізація тепер у Rust). -- ADR `260713-2110` — ACP як єдиний транспорт, ENV-конфіг, каскад, MIN-канон; ADR `20260613-071723` — заміна JS-сканера на Rust-шим (перший крок цього ж напряму). diff --git "a/docs/adr/docgen-\320\272\320\276\320\275\320\262\320\265\321\224\321\200\320\275\320\260-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\206\321\226\321\217-js-\320\265\320\272\321\201\321\202\321\200\320\260\320\272\321\202\320\276\321\200.md" "b/docs/adr/docgen-\320\272\320\276\320\275\320\262\320\265\321\224\321\200\320\275\320\260-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\206\321\226\321\217-js-\320\265\320\272\321\201\321\202\321\200\320\260\320\272\321\202\320\276\321\200.md" deleted file mode 100644 index 8020947..0000000 --- "a/docs/adr/docgen-\320\272\320\276\320\275\320\262\320\265\321\224\321\200\320\275\320\260-\320\276\321\200\320\272\320\265\321\201\321\202\321\200\320\260\321\206\321\226\321\217-js-\320\265\320\272\321\201\321\202\321\200\320\260\320\272\321\202\320\276\321\200.md" +++ /dev/null @@ -1,49 +0,0 @@ -# Конвеєрна оркестрація docgen: детермінований JS-оркестратор + локальна LLM - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement -One-shot генерація документації локальними моделями (`gemma3:4b`, `gemma4:4b`) демонструвала три стабільних класи помилок: витік деталей реалізації (stdlib, regex, приватні імена), галюцинації в секції «Гарантії поведінки» та пропуск крайових деталей. Причина — модель одночасно відповідальна за факти, структуру і прозу; ці класи не залежали від транспорту. Окремо: бенчмарк A/B/C (A: прямий ollama без system ~71%; B: через pi ~87%; C: прямий + system-prompt ~85%) встановив, що різниця B vs C у межах шуму ±3 п.п. — якість визначає system-prompt, не транспорт. - -## Considered Options -- One-shot промпт із посиленим system-prompt (варіанти A, B, C2 з бенчмарків). -- Конвеєрна оркестрація: детермінований JS-екстрактор (Stage 0) + точкові LLM-промпти на секцію (Stage 1) + детермінована зборка (Stage 3). - -## Decision Outcome -Chosen option: "Конвеєрна оркестрація з JS-оркестратором (`docgen-gen.mjs`) як входною точкою", because JS-оркестратор усуває три класи помилок детерміновано: Stage 0 витягує імена, skip-маркери, readOnly/throwsErrors без LLM-токенів, а модель отримує лише вузьку задачу «перефразуй ці факти». Benchmark v2 підтвердив: оркестрована `gemma3:4b` ~86% vs one-shot ~80% (+6 п.п.), часово конкурентна (overlay 77с vs 61с, k8s 31с vs 45с). - -### Consequences -- Good, because Stage 0 (`docgen-extract.mjs`) детерміновано витягує JSDoc, класифікує stdlib vs internal, маркери `readOnly`/`catchesErrors`/`skips`/`caches` — заземлення без жодного LLM-токена прибирає галюцинації й витоки. -- Good, because `gemma3:4b` + оркестрація (~86%) наближається до `gemma4:4b` one-shot (~92%), лишаючись у GPU (3.3 GB, ~20 tok/s) без офлоаду. -- Good, because KV-cache ollama на стабільному system+код префіксі амортизує секційні виклики в межах одного файлу. -- Bad, because реалізація складніша: `docgen-extract.mjs` + `docgen-prompts.mjs` + `docgen-gen.mjs` + зборка — більше точок відмови. -- Bad, because Stage 0 прив'язаний до JS/MJS; `.vue`/`.py` деградують до one-shot fallback. -- Neutral, because v1 оркестрації (код у всіх секціях) була 3–5× повільніша — виправлено у v2 (код лише в секцію «Поведінка»). - -## More Information -Файли в `.worktrees/feat-docgen-orchestrator-pi/npm/skills/docgen/js/`: `docgen-extract.mjs` (Stage 0, детермінований парсер), `docgen-prompts.mjs` (Stage 1, секційно-мінімальний контекст), `docgen-gen.mjs` (Stage 2–3, входна точка; режим `--oneshot` для AB-порівняння). Гілка `feat/docgen-orchestrator-pi`. Моделі: `gemma3:4b` (3.3 GB, 100% GPU, ~20 tok/s); `gemma4:4b` q4 — alias `batiai/gemma4-e4b:q4` (~6.2 GB, 56%/44% CPU/GPU, ~11 tok/s), скопійований через `ollama cp` (спільний blob, 0 місця). Виявлений баг: `gemma4:4b` через `/api/chat` кладе вихід у `j.response`, а не `j.message.content` — фікс: `j.message?.content || j.response || ''`. Транспорт pi залишено за ергономікою (read/write tools, sessions), а не якісною перевагою. Критерій придатності моделі на 8 GB: розмір ≤ ~4.2 GB (100% GPU) або q4-квантизація ≤ 6.5 GB (допустимий частковий офлоад без своп-деградації). - -## Update 2026-06-06 - -Первісна мотивація переходу на конвеєрну оркестрацію: три стабільних класи помилок one-shot генерації (незалежно від транспорту ollama vs pi): (1) витік деталей реалізації (stdlib, regex, приватні імена); (2) галюцинації у секції «Гарантії поведінки»; (3) пропуск крайових деталей (наприклад «корінь не перевіряється»). Усі три класи є стелею архітектури «один промпт» і не усуваються поліпшенням system-prompt. - -Транспорт pi не дає якісної переваги над прямим `/api/chat` + system-prompt для конвеєрного режиму — збережено за ергономікою (read/write tools, sessions). Гілка реалізації: `.worktrees/feat-docgen-orchestrator-pi` (`feat/docgen-orchestrator-pi`). - -## Update 2026-06-06 - -### Вибір транспорту: прямий `ollama /api/chat` з явним system prompt - -Обрано прямий виклик `POST /api/chat` замість CLI-утиліти `pi`. Бенч показав різницю між pi (87%) і прямим+system (85%) у межах ~2 п.п., тоді як різниця між «без system» і «з system» — системна (+15 п.п.). Приріст pi над прямим викликом дає виключно вбудований system-prompt, а не архітектурна перевага. Усувається ~4 с overhead node-старту pi на файл (≈+1.1 год на 1042 файли). pi RPC-режим (`--mode rpc`) для амортизації старту виявився непридатним: персистентна сесія між незалежними файлами накопичує контекст. - -- Бенч-скрипти: `/tmp/docgen-bench3/run.py`, `~/docgen-bench3/duel.py`, `~/docgen-bench3/confirm.py` -- pi-конфіг провайдера: `~/.pi/agent/models.json`; overhead: ~3.8–6.4 с/виклик - -### Вибір моделі Ollama для Tier 1 docgen на Mac M2 8 GB RAM - -Обрано обидві моделі з різними сценаріями застосування: - -- `gemma3:4b` (3.3 GB, 100% GPU, ~20 tok/s, ~85% якості) — для швидких/чорнових прогонів -- `gemma4:4b` alias `batiai/gemma4-e4b:q4` (5.3 GB, 56%/44% CPU/GPU, ~11 tok/s, ~92%) — для якість-first генерації, де документацію читатимуть люди - -`gemma4:e4b` full (9.6 GB) відхилено одразу: своп, ~0.4 tok/s. Ollama alias: `ollama cp batiai/gemma4-e4b:q4 gemma4:4b`. Вимірювання: `~/docgen-bench3/g4.py` — RAM 6.2 GB, cold-load 14 с. `gemma4:4b` при частковому офлоаді дає ~2× більший час і нестабільний throughput при конкуренції за пам'ять. diff --git "a/docs/adr/engineer-agent-\320\274\320\265\321\202\320\260-\321\200\321\226\320\262\320\265\320\275\321\214-repair-\321\202\320\260-\320\265\321\201\320\272\320\260\320\273\320\260\321\206\321\226\321\217.md" "b/docs/adr/engineer-agent-\320\274\320\265\321\202\320\260-\321\200\321\226\320\262\320\265\320\275\321\214-repair-\321\202\320\260-\320\265\321\201\320\272\320\260\320\273\320\260\321\206\321\226\321\217.md" deleted file mode 100644 index 9a5c3e7..0000000 --- "a/docs/adr/engineer-agent-\320\274\320\265\321\202\320\260-\321\200\321\226\320\262\320\265\320\275\321\214-repair-\321\202\320\260-\320\265\321\201\320\272\320\260\320\273\320\260\321\206\321\226\321\217.md" +++ /dev/null @@ -1,66 +0,0 @@ -# Engineer Agent — мета-рівень, time budget та ієрархічна ескалація - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement -При помилці вузла у Recursive Compound DAG потрібен механізм самовідновлення: хто виконує відновлення і з яким рівнем доступу до структури графа, як зберігати пам'ять між спробами, як запобігти нескінченним циклам виправлень і яка структура ескалації до людини. - -## Considered Options -* EngineerAgent як вузол у графі (рекурсивна self-repair) -* EngineerAgent як мета-рівень поза графом з необмеженим доступом -* `max_attempts: N` як convergence guard -* Time budget (фіксований час, необмежена кількість спроб у межах бюджету) -* Пам'ять у стані агента (stateful engineer) -* Пам'ять на вузлі у файлі (`repair_history.json`, stateless engineer) - -## Decision Outcome -Chosen option: "EngineerAgent як мета-рівень + time budget + repair_history на вузлі", because інженер не є вузлом графа і може підніматись вгору, змінюючи будь-який рівень ієрархії; time budget (замість ліміту спроб) дозволяє адаптувати стратегію залежно від залишку часу; знання про спроби зберігаються разом із вузлом і доступні будь-якому наступному виклику агента. - -### Consequences -* Good, because інженер може виконати `replace node`, `insert nodes`, `rewire edges`, `modify inputs` на будь-якому рівні ієрархії. -* Good, because time budget дає передбачуваний максимальний час до ескалації: `depth × budget`; стратегія адаптується до `deadline - now()`. -* Good, because `repair_history.json` спільний для послідовних викликів на один вузол — нові виклики не повторюють невдалих стратегій. -* Bad, because необмежений доступ означає ризик cascade invalidation вниз по successors при патчі батьківського вузла. -* Bad, because `depth × budget` зростає лінійно з глибиною ієрархії — при глибокій вкладеності час до ескалації може бути тривалим. - -## More Information -Алгоритм відновлення: -``` -on node.state = failed: - EngineerAgent(error.json, path_from_root, repair_history.json): - analyze → GraphPatch на будь-якому рівні → retry - if deadline reached → node.state = "unresolvable" → escalate вгору -``` - -`repair_history.json` пишеться на вузлі, де внесено зміну; дочірній логує `{"triggered_parent_patch": "<patch_id>"}`: -```json -[{"attempt": 1, "engineer_reasoning": "...", "patch_applied": {}, "result": "failed", "failure_reason": "..."}] -``` - -`repair_context.json` (встановлюється при першому виклику): -```json -{"deadline": "<ISO>", "started_at": "<ISO>", "time_budget_sec": 600, "attempts": []} -``` - -Ієрархічна ескалація: кожен батьківський рівень отримує СВІЖИЙ `time_budget_sec`; root timeout → `senior_report.json`: -```json -{"failed_node": "<path від root>", "escalation_chain": [{"level": "node_7", "time_spent": "10хв", "attempts": []}], "current_graph_snapshot": "...", "suggested_next_steps": []} -``` - -Дизайн зафіксовано у `npm/docs/mt.md` (`/Users/vitaliytv/www/nitra/cursor/`). Аналоги: Dask, Prefect dynamic tasks, LangGraph. - -## Update 2026-06-06 - -- Інженер працює як мета-рівень поза графом, а не як звичайний вузол графу. -- Для аналізу збою інженеру потрібен повний path від кореня до вузла, що впав, щоб обрати рівень втручання: сам вузол, батько або root. -- Памʼять repair-процесу зберігається біля вузла, щоб майбутні виклики інженера не повторювали невдалі підходи. -- Convergence guard для інженера — часовий бюджет, а не лічильник спроб; кожен рівень ескалації отримує свіжий budget. -- При патчі вузла залежні worktree мають бути зупинені перед зміною цілі, після чого залежний каскад перезапускається. - -## Update 2026-06-06 - -- Інженерський repair-flow тригериться лише після `actor: agent` з `result: failed`, якщо `auto_engineer: true` у `.n-cursor.json`. -- Для запобігання нескінченним петлям transcript фіксує одну інженерську спробу перед ескалацією до людини. -- `budget_sec` у `task.md` є спільним бюджетом вузла для всіх акторів; при вичерпанні budget wrapper зупиняє виконання і викликає notify-flow. -- Після `actor: engineer result: failed` система має виконати `graph notify <path>`; transcript не містить підтвердження додаткових retry-циклів. diff --git "a/docs/adr/\320\262\320\276\321\200\320\272\321\202\321\200\321\226-\320\274\320\265\320\266\320\260-\320\260\321\202\320\276\320\274\320\260\321\200\320\275\320\276\321\201\321\202\321\226.md" "b/docs/adr/\320\262\320\276\321\200\320\272\321\202\321\200\321\226-\320\274\320\265\320\266\320\260-\320\260\321\202\320\276\320\274\320\260\321\200\320\275\320\276\321\201\321\202\321\226.md" deleted file mode 100644 index fee70b9..0000000 --- "a/docs/adr/\320\262\320\276\321\200\320\272\321\202\321\200\321\226-\320\274\320\265\320\266\320\260-\320\260\321\202\320\276\320\274\320\260\321\200\320\275\320\276\321\201\321\202\321\226.md" +++ /dev/null @@ -1,30 +0,0 @@ -# Ворктрі як межа атомарності для паралельного виконання агентів - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Незалежні вузли ОАГ мають виконуватись паралельно щоб не чекати один одного. Водночас потрібна гарантія що наступник не отримає частково записаний стан попередника — класичний race condition у файловій системі. - -## Considered Options - -* Git worktree на кожного агента — ізоляція через git, merge як єдина точка рішення -* Файлові блокування (flock) — складна логіка deadlock, не підходить для LLM-агентів -* Черговий запуск без паралельності — безпечно але повільно - -## Decision Outcome - -Chosen option: "Git worktree на кожного агента", because ворктрі дає файлову ізоляцію без будь-яких механізмів блокування, а merge є природною єдиною точкою де оркестратор оцінює результат. - -### Consequences - -* Good, because race condition неможливий за архітектурою: наступник стартує лише після merge попередника. -* Good, because незалежні вузли пишуть у різні директорії (`tasks/<node-id>/`) → git merge завжди чистий без конфліктів. -* Good, because кожен патч інженера — у своєму ворктрі, видаляється після завершення; аудит через git history. -* Good, because конфлікт при злитті обробляється симетрично до помилки вузла: spawn АгентМедіатор. -* Bad, because жорсткий ліміт паралельних ворктрі на MacBook обмежує реальну паралельність. - -## More Information - -Naming convention ворктрі: `.worktrees/<node-id>-run/` для виконання вузла, `.worktrees/<node-id>-patch-NNN/` для патчу інженера. Протокол патчу залежного вузла: (1) записати `патчі/001-план.md`, (2) kill залежних у топологічному порядку від листів, (3) застосувати патч, (4) записати `патчі/001-факт.md`, (5) рестарт каскаду. Ліміти ворктрі задаються при запуску системи (жорсткі мінімальні, MacBook). diff --git "a/docs/adr/\320\275\320\265-\320\277\321\200\320\270\320\271\320\274\320\260\321\202\320\270-goose-block-\320\260\320\263\320\265\320\275\321\202\320\275\320\270\320\271-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272.md" "b/docs/adr/\320\275\320\265-\320\277\321\200\320\270\320\271\320\274\320\260\321\202\320\270-goose-block-\320\260\320\263\320\265\320\275\321\202\320\275\320\270\320\271-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272.md" deleted file mode 100644 index 8db5cee..0000000 --- "a/docs/adr/\320\275\320\265-\320\277\321\200\320\270\320\271\320\274\320\260\321\202\320\270-goose-block-\320\260\320\263\320\265\320\275\321\202\320\275\320\270\320\271-\321\204\321\200\320\265\320\271\320\274\320\262\320\276\321\200\320\272.md" +++ /dev/null @@ -1,42 +0,0 @@ -# Не приймати Goose (Block) як заміну agent-core стеку - -**Status:** Accepted -**Date:** 2026-07-12 - -## Context and Problem Statement - -Власний Rust-стек агента (`crates/agent-core`, `agent-cli`, `agent-server`, `agent-protocol`) ще в розробці (M2/M3, статус «планується» за `npm/docs/architecture/stack.md`). Goose (Block, тепер Linux Foundation Agentic AI, Rust, Apache-2.0) — зрілий open-source агентний фреймворк (крейти `goose`/`goose-cli`/`goose-server`/`goose-mcp`/`goose-acp`) з готовим MCP-registry, 15+ провайдерами і recipe-системою (YAML: prompt, extensions, provider, retry-політика). Питання: чи варто прийняти Goose як залежність замість власного agent-core стеку, чи закривати прогалини (насамперед MCP) точковими рішеннями. - -## Considered Options - -* Повний swap на Goose — замінити `agent-core`+`agent-cli`+`agent-server` фреймворком Goose цілком. -* Goose лише як «мозок» усередині кастомного `agent-server`/`agent-protocol` — Goose відповідає за agent loop/providers, наш `agent-server` лишається хостом протоколу. -* Лишити власний стек, закрити MCP-прогалину крейтом `rmcp` напряму в `agent-core`. -* Статус-кво без змін. - -## Decision Outcome - -Chosen option: «Лишити власний стек, закрити MCP-прогалину крейтом `rmcp` напряму в `agent-core`», because: - -- `crates/agent-protocol` (Envelope/Event, protocol v4, Ed25519-підписи approvals) — контракт між `agent-server` (host-процес) і тонкими клієнтами (desktop/mobile Tauri, `agent-cli attach`). Повний swap на Goose вимагав би або переписати ці клієнти під Goose-івську сесійну модель, або будувати адаптер `Envelope`/`Event` ↔ Goose — велика площа змін заради фреймворка, що вже й так лише «референс» для нас. -- `npm/docs/architecture/stack.md` уже фіксує Goose (`aaif-goose/goose`) як референсну кодову базу «для рішень, не для копіювання» — структуру (core/cli/server/mcp, sessions/providers/config) уже запозичено при проєктуванні власного стеку. Це свідомий вибір, а не прогалина. -- Головний практичний аргумент за Goose — готовий MCP — закривається дешевше й без ризику для протоколу: підключити крейт `rmcp` (той самий SDK, яким користується сам Goose) напряму в `agent-core::tools.rs` (`register_external(...)` заділ уже існує), не чіпаючи agent loop і `agent-protocol`. -- Retry ladder (`MT_ATTEMPT`/`MT_RETRY_STRATEGY`) уже реалізований у JS-оркестраторі (`npm/lib/commands/run.mjs`) і не залежить від вибору Rust-агента — Goose не дав би тут додаткового виграшу. -- `crates/mt-napi` агентного стеку не торкається (Rust-агент запускається окремим підпроцесом через WS, не embedded) — це не аргумент ні за, ні проти Goose, лише знімає один з розглянутих ризиків. - -### Consequences - -* Good, because повний контроль над протоколом host↔клієнт (Envelope/Event, approval-модель) залишається в нас, без залежності від чужої еволюції протоколу. -* Good, because MCP-прогалина закривається малою, ізольованою зміною (`rmcp` у `tools.rs`), сумісною з CI-межею «`agent-core` без стороннього agent-фреймворку». -* Good, because архітектура вже узгоджена з цим рішенням (`stack.md` явно позначає Goose як «для рішень, не для копіювання») — нема потреби переглядати вже прийняте. -* Bad, because self-maintenance agent loop, provider-абстракції (`provider.rs`/`provider_openai.rs`) і подальші провайдер-фічі (напр. нативний Anthropic API замість LiteLLM-проксі) лишаються на нас, а не «безкоштовні» через Goose. -* Bad, because втрачаємо 70+ MCP-інтеграцій «з коробки», які Goose постачає як bundled extensions — доведеться підключати чи писати MCP-сервери окремо (хоча вони сумісні з чистим `rmcp`, підключення все одно ручне). - -## More Information - -- `npm/docs/architecture/stack.md` — розділ «LLM-провайдери» (OpenAI-сумісний Chat Completions як спільний знаменник, LiteLLM-профіль для хмарних моделей) і розділ «Референсні кодові бази» (Goose, `openai/codex`, `pi_agent_rust`). -- `crates/agent-protocol` — `Envelope`/`Event`, `ClientHello`/`ServerHello`, Ed25519 approvals (protocol v4). -- `crates/agent-core/src/tools.rs` — `register_external(...)` заділ під MCP, закоментований намір на `rmcp`. -- `npm/lib/commands/run.mjs` — реалізація retry ladder (`MT_ATTEMPT`/`MT_RETRY_STRATEGY`), незалежна від вибору Rust-агентного фреймворку. -- Окреме (менше за обсягом) питання — заміна `provider_openai.rs` на крейт `genai` (jeremychone/rust-genai) для прямих нативних викликів провайдерів без LiteLLM-хопа — не заборонене цим ADR, розглядається окремо як точковий рефакторинг провайдер-шару в межах `Provider`-трейта. -- Переглянути це рішення, якщо зʼявиться новий аргумент, якого не було на момент запису (напр. вартість власної MCP/multi-provider реалізації виявиться суттєво вищою за очікувану). diff --git "a/docs/adr/\321\200\320\265\320\272\321\203\321\200\321\201\320\270\320\262\320\275\320\270\320\271-\321\201\320\272\320\273\320\260\320\264\320\265\320\275\320\270\320\271-\320\236\320\220\320\223-\320\264\320\270\320\275\320\260\320\274\321\226\321\207\320\275\320\270\320\271-\321\200\320\276\320\267\320\272\320\273\320\260\320\264.md" "b/docs/adr/\321\200\320\265\320\272\321\203\321\200\321\201\320\270\320\262\320\275\320\270\320\271-\321\201\320\272\320\273\320\260\320\264\320\265\320\275\320\270\320\271-\320\236\320\220\320\223-\320\264\320\270\320\275\320\260\320\274\321\226\321\207\320\275\320\270\320\271-\321\200\320\276\320\267\320\272\320\273\320\260\320\264.md" deleted file mode 100644 index 14acd0b..0000000 --- "a/docs/adr/\321\200\320\265\320\272\321\203\321\200\321\201\320\270\320\262\320\275\320\270\320\271-\321\201\320\272\320\273\320\260\320\264\320\265\320\275\320\270\320\271-\320\236\320\220\320\223-\320\264\320\270\320\275\320\260\320\274\321\226\321\207\320\275\320\270\320\271-\321\200\320\276\320\267\320\272\320\273\320\260\320\264.md" +++ /dev/null @@ -1,59 +0,0 @@ -# Рекурсивний складений ОАГ із динамічним розкладом вузлів - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Потрібна структура для системи де оркестратор розбиває задачі на підзадачі під час виконання. Вузли верхнього рівня не знають наперед чи буде підзадача атомарною операцією чи розкладеться у цілий підграф. Директорна ієрархія проєктів може бути довільною глибиною, і на кожному рівні можуть існувати як листові задачі так і вкладені workflow. - -## Considered Options - -* Рекурсивний складений ОАГ — вузол або атомарний або містить власний підграф; рішення динамічне при запуску -* Статичний ОАГ — структура задається наперед; не підходить через unknown-depth декомпозицію -* 3D-граф із просторовими вимірами — термін описує відображення, а не структуру зв'язків - -## Decision Outcome - -Chosen option: "Рекурсивний складений ОАГ", because батьківський вузол завжди бачить однаковий інтерфейс незалежно від внутрішньої складності дочірнього, а агент вирішує тип при запуску на основі вхідних даних. - -### Consequences - -* Good, because атомарний вузол можна "розкрити" у підграф без змін у батьківському графі — повна замінюваність. -* Good, because executor однаковий на всіх рівнях рекурсії: `execute(node)` → якщо атомарний запустити, інакше виконати підграф. -* Good, because динамічний spawn дозволяє агенту додавати нові дочірні вузли під час виконання без змін у батьківській структурі. -* Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information - -Ребра несуть дані (outputs → inputs), не лише залежності. Кілька exit-вузлів у підграфі — їх виходи зливаються в outputs батька. Топологія зберігається розподілено: кожен дочірній `task.md` містить поле `deps:` зі списком попередників-siblings. Центрального файлу графу немає — оркестратор відновлює топологію скануванням `task.md`. - -Близькі реалізації: Dask, Prefect dynamic tasks, LangGraph. - -## Update 2026-06-06 - -### Sentinel-файли та ескалаційний ланцюг - -Sentinel-файли вузла: `виконується` (вузол активний), `скасовано`, `знедійснений` (invalidated). Протокол намірів перед kill залежних: `патчі/намір-*.md` — агент декларує намір до виконання операції. - -Структура запису `repair_history.md` за кожну спробу: `attempt`, `міркування`, `патч`, `результат`, `причина_збою`. - -Ескалація при timeout: кожен рівень ієрархії отримує свіжий бюджет незалежно від залишку дочірнього. Root timeout → генерується `senior_report.json` для людини з полями: `failed_node` (path від root), `escalation_chain[]`, `current_graph_snapshot`, `suggested_next_steps`. Максимальний час до втручання людини = `depth × budget`. - -## Update 2026-06-06 - -### Data-flow ребра та кілька exit-вузлів - -Ребра в DAG несуть data-flow: значення «тече» по ребру коли `from.state = resolved`. Формат ребра: `{ from: NodeId+portId, to: NodeId+portId }`. Вузол може мати кілька вхідних і вихідних портів з явно іменованими даними. - -Складений вузол може мати кілька exit-вузлів; їхні outputs зливаються в outputs батьківського вузла. - -Версіонування виходів при патчі: `вихідні-v2.md` замість мутації `вихідні.md` (append-only принцип). - -## Update 2026-06-06 - -- Документ `npm/docs/mt.md` має бути контрактом файлових схем і правил, а не архітектурним описом; архітектурні рішення переносяться в ADR. -- Динамічний граф задач використовує файловий state store: вузол є директорією, а стан визначається наявністю файлів. -- Паралельне виконання відбувається через git worktrees; merge є точкою прийняття результату. -- EngineerAgent може перепроєктовувати підграф при збої, а root timeout ескалується до SeniorEngineer. -- Важливі інваріанти: `created_at` першим у frontmatter, `ref:` замість копіювання даних, plan → action → fact для відновлення. diff --git "a/docs/adr/\321\201\321\202\320\270\320\273\321\214-\320\264\320\276\320\272\321\203\320\274\320\265\320\275\321\202\320\260\321\206\321\226\321\227-docgen-\320\277\320\276\320\262\320\265\320\264\321\226\320\275\320\272\320\276\320\262\320\270\320\271.md" "b/docs/adr/\321\201\321\202\320\270\320\273\321\214-\320\264\320\276\320\272\321\203\320\274\320\265\320\275\321\202\320\260\321\206\321\226\321\227-docgen-\320\277\320\276\320\262\320\265\320\264\321\226\320\275\320\272\320\276\320\262\320\270\320\271.md" deleted file mode 100644 index 499b900..0000000 --- "a/docs/adr/\321\201\321\202\320\270\320\273\321\214-\320\264\320\276\320\272\321\203\320\274\320\265\320\275\321\202\320\260\321\206\321\226\321\227-docgen-\320\277\320\276\320\262\320\265\320\264\321\226\320\275\320\272\320\276\320\262\320\270\320\271.md" +++ /dev/null @@ -1,23 +0,0 @@ -# Стиль документації docgen: поведінковий замість реалізаційного - -**Status:** Accepted -**Date:** 2026-06-05 - -## Context and Problem Statement -Скіл `/n-docgen` генерував документацію з секціями «Залежності» (`node:fs`, `node:path`…), «Функції» (таблиці сигнатур і типів), «Rebuild Test» (перелік із прив'язкою до внутрішніх імен). Такі доки описують реалізацію, а не поведінку й задачу файлу, що робить їх важкими для читача й нестабільними при рефакторингу. Під час рев'ю `npm/rules/abie/lib/docs/enabled.md` виник запит: «не хотілось бачити тех. деталі, які не впливають на бізнес-задачу». - -## Considered Options -- Залишити поточний «реалізаційний» стиль (перелік залежностей, таблиці типів, сигнатури). -- Перейти на «поведінковий» стиль (секції Огляд / Поведінка / Де використовується / Гарантії поведінки, без stdlib і внутрішніх імен). - -## Decision Outcome -Chosen option: "поведінковий стиль", because користувач схвалив запропонований варіант `enabled.md` — секції без `## Залежності`, без таблиць типів, без прив'язки до імен хелперів — і попросив адаптувати скіл під нього. - -### Consequences -- Good, because доки описують «що і навіщо», а не «як», тому стабільніші при рефакторингу й зрозуміліші без читання коду. -- Good, because секція `## Публічний API` пропускається для тривіальних leaf-модулів (предикати, константи) і додається лише за нетривіальної зовнішньої поверхні — менше шуму. -- Good, because `## Помилки` замінено на `## Гарантії поведінки` (read-only, fail-safe, ігнор поганих вхідних даних) — описує інваріанти, а не виключення stdlib. -- Bad, because transcript не містить підтверджених негативних наслідків. - -## More Information -Змінені файли: `npm/skills/docgen/SKILL.md`, `.cursor/skills/n-docgen/SKILL.md`, `.pi/skills/n-docgen/SKILL.md` (стаб). Слово «вичерпну» у `description:` і цілі скіла замінено на «лаконічну поведінкову». Еталонний файл: `npm/rules/abie/lib/docs/enabled.md`. Перевірка на 15 файлах `npm/rules/abie` — 3 батчі по 5 Claude-субагентів. У цій же сесії проведено перший бенчмарк локальних LLM для docgen Tier 1 на 8 GB M2: `gemma3:4b` (~85%, 100% GPU) та `qwen2.5-coder:3b` (~77%, витоки сигнатур/stdlib, мовні дефекти) порівняно з еталоном. diff --git "a/docs/adr/\321\202\320\276\320\277\320\276\320\273\320\276\320\263\321\226\321\217-\320\277\321\226\320\264\320\263\321\200\320\260\321\204\321\203-deps-\320\262-\320\274\321\226\321\201\321\226\321\217.md" "b/docs/adr/\321\202\320\276\320\277\320\276\320\273\320\276\320\263\321\226\321\217-\320\277\321\226\320\264\320\263\321\200\320\260\321\204\321\203-deps-\320\262-\320\274\321\226\321\201\321\226\321\217.md" deleted file mode 100644 index 92eb829..0000000 --- "a/docs/adr/\321\202\320\276\320\277\320\276\320\273\320\276\320\263\321\226\321\217-\320\277\321\226\320\264\320\263\321\200\320\260\321\204\321\203-deps-\320\262-\320\274\321\226\321\201\321\226\321\217.md" +++ /dev/null @@ -1,23 +0,0 @@ -# Топологія підграфу розподілена в `deps:` кожного `місія.md` - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement -При розкладанні складеного вузла на підграф потрібно вирішити де зберігається топологія (ребра між дочірніми вузлами): у центральному файлі батьківського вузла чи розподілено по кожному дочірньому. Система використовує append-only семантику — файли лише створюються, ніколи не змінюються. Динамічний spawn нових вузлів під час виконання є штатним сценарієм, а не виключенням. - -## Considered Options -* Топологія розподілена: кожен дочірній вузол у своїй `місія.md` декларує власні залежності через поле `deps:` -* Центральний `граф.md` у батьківському вузлі з append-only розширенням через `граф-розш-*.md` - -## Decision Outcome -Chosen option: "Топологія розподілена: кожен дочірній у `deps:` своєї `місія.md`", because динамічний spawn зводиться до створення нового `місія.md` — жоден існуючий файл не модифікується, append-only інваріант зберігається на всіх рівнях; оркестратор відновлює повний граф скануванням `місія.md` без центральної точки відмови. - -### Consequences -* Good, because динамічний spawn атомарний: новий дочірній вузол = новий файл, жодних оновлень батьківських файлів. -* Good, because оркестратор реконструює повний граф у будь-який момент скануванням `місія.md` без центрального файлу. -* Bad, because реконструкція топології потребує сканування всієї файлової ієрархії — дорого при великих графах (зафіксовано у SWOT). -* Neutral, because transcript не містить підтвердження щодо ускладнень при рефакторингу топології після spawn. - -## More Information -Поле `deps:` у YAML-фронтматері `місія.md`: список `{ node: <node-id>, port: <port-name> }`. Агент-автор записує `deps:` при spawn дочірнього вузла. Динамічний spawn дозволений: новий дочірній може посилатись у `deps:` на вже існуючі вузли. Дані між вузлами передаються через `ref:`-посилання у `вхідні.md`, без копіювання. Зафіксовано у `npm/docs/mt.md`. diff --git "a/docs/adr/\321\202\320\276\320\277\320\276\320\273\320\276\320\263\321\226\321\217-\320\277\321\226\320\264\320\263\321\200\320\260\321\204\321\203-\321\200\320\276\320\267\320\277\320\276\320\264\321\226\320\273\320\265\320\275\320\260-deps.md" "b/docs/adr/\321\202\320\276\320\277\320\276\320\273\320\276\320\263\321\226\321\217-\320\277\321\226\320\264\320\263\321\200\320\260\321\204\321\203-\321\200\320\276\320\267\320\277\320\276\320\264\321\226\320\273\320\265\320\275\320\260-deps.md" deleted file mode 100644 index 2d76464..0000000 --- "a/docs/adr/\321\202\320\276\320\277\320\276\320\273\320\276\320\263\321\226\321\217-\320\277\321\226\320\264\320\263\321\200\320\260\321\204\321\203-\321\200\320\276\320\267\320\277\320\276\320\264\321\226\320\273\320\265\320\275\320\260-deps.md" +++ /dev/null @@ -1,68 +0,0 @@ -# Топологія підграфу розподілена у `deps:` кожного дочірнього вузла - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -При розкладанні складеного вузла рекурсивного ОАГ на підграф необхідно визначити, де зберігати топологію (ребра між дочірніми вузлами), щоб оркестратор міг реконструювати граф у будь-який момент, а динамічний spawn нових вузлів не потребував модифікації жодного існуючого файлу. - -## Considered Options - -- Топологія розподілена: кожен дочірній вузол декларує залежності у полі `deps:` власного `місія.md` -- Централізований `граф.md` у батьківському вузлі як єдине авторитетне джерело -- Подвійне зберігання: `deps:` у `місія.md` дочірніх (локальний контекст агента) і `граф.md` у батька (авторитет оркестратора) - -## Decision Outcome - -Chosen option: "Топологія розподілена в `deps:` кожного `місія.md`", because динамічний spawn є суто append-only операцією — новий дочірній вузол записує свій `місія.md` без оновлення будь-якого існуючого файлу; оркестратор реконструює повний граф скануванням `місія.md` без центральної точки відмови. - -### Consequences - -- Good, because динамічний spawn не порушує append-only принцип: агент пише лише нові файли з унікальними іменами. -- Good, because кожен вузол самодостатній — повний контекст залежностей у власному `місія.md`. -- Bad, because реконструкція повного графу потребує сканування всієї файлової ієрархії без індексу — при великих графах дорого. -- Neutral, because варіант подвійного зберігання обговорювався як компроміс між зручністю оркестратора і простотою, але відхилений на користь розподіленої моделі. - -## More Information - -Формат поля `deps:` у YAML-фронтматері `місія.md`: - -```yaml -deps: - - node: collect-data - port: results - - node: get-sources - port: url-list -``` - -Агент-автор записує `deps:` при spawn. Динамічний spawn дозволений: новий дочірній може посилатись на вже існуючі вузли через `deps:`. Дані між вузлами передаються виключно через посилання у `вхідні.md`, без копіювання. Зафіксовано у `npm/docs/mt.md`. - -## Update 2026-06-06 - -### Атрибути YAML-фронтматеру та синтаксис `ref:` - -Всі атрибути YAML-фронтматеру файлів вузла — англійська snake_case: `id`, `parent`, `deps`, `created_at`, `occurred_at`, `type`, `nodes`, `kill_order`, `target_node`, `result`. - -Синтаксис посилань (`ref:`) у `вхідні.md`/`вихідні.md`: -- `ref: path/to/file.md` — весь файл -- `ref: path/to/file.md#section-name` — секція за заголовком -- `ref: path/to/file.md lines 10-50` — діапазон рядків - -Шляхи — відносні від кореня `tasks/`. Кілька `ref:` в одній секції допускаються (агент читає всі). - -## Update 2026-06-06 - -### Відкриті питання топології та схем файлів - -- Як виглядає `ref:` синтаксис для бінарних / не-текстових файлів? -- Чи може один вузол мати кілька `операції/spawn-*.md` (повторний spawn після патчу інженера)? -- Naming-конвенція для repair worktree: яка назва гілки при кожній спробі патчу? - -## Update 2026-06-06 - -### Специфікація `вхідні.md` - -Кожна секція `##` — один іменований вхідний порт (відповідає `port` у `deps` попередника або довільна назва для контексту). Секції з `deps`-портів заповнюються лише після переходу відповідного попередника у стан `resolved`. Секції з контекстом заповнюються при spawn (файли вже існують). Файл незмінний після створення — при інвалідації батько записує новий `вхідні.md` виключно у новому ворктрі. - -Відкрите питання: чи потрібне поле `from:` у фронтматері `вхідні.md` (явний список вузлів-попередників що мають бути resolved) або достатньо `deps:` у `місія.md`? diff --git "a/docs/adr/\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\201\321\202\320\260\320\275-append-only-\320\277\320\273\320\260\320\275-\321\204\320\260\320\272\321\202.md" "b/docs/adr/\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\201\321\202\320\260\320\275-append-only-\320\277\320\273\320\260\320\275-\321\204\320\260\320\272\321\202.md" deleted file mode 100644 index d956ecc..0000000 --- "a/docs/adr/\321\204\320\260\320\271\320\273\320\276\320\262\320\270\320\271-\321\201\321\202\320\260\320\275-append-only-\320\277\320\273\320\260\320\275-\321\204\320\260\320\272\321\202.md" +++ /dev/null @@ -1,107 +0,0 @@ -# Файловий стан, append-only інваріант і принцип план → дія → факт - -**Status:** Accepted -**Date:** 2026-06-06 - -## Context and Problem Statement - -Система виконує задачі через LLM-агентів у розподіленому середовищі де агенти можуть падати або зависати в будь-який момент. Потрібен механізм зберігання стану який (1) є LLM-friendly, (2) гарантує відновлення після збою, (3) не вимагає транзакційної бази даних. - -## Considered Options - -* Файли як state store із append-only інваріантом і принципом план/факт -* Централізована база даних (PostgreSQL, SQLite) — потребує окремого сервісу, складне відновлення -* JSON-файли що змінюються — простіше але race condition при паралельних записах і немає аудиту - -## Decision Outcome - -Chosen option: "Файли як state store із append-only інваріантом і принципом план/факт", because файлова система дає безкоштовну персистентність і git-аудит, а append-only усуває race condition при паралельному виконанні у ворктрі. - -### Consequences - -* Good, because стан вузла визначається наявністю файлів, а не полем у базі — немає single point of failure. -* Good, because `*-план.md` + `*-факт.md` дозволяє відновити будь-яку незавершену операцію після збою scan-ом директорій. -* Good, because Markdown+YAML є природним форматом для LLM — агент читає `repair/*.md` і продовжує журнал без парсингу. -* Good, because git history = безкоштовний time-travel debugging всього графу. -* Bad, because scan для відновлення стану — без індексу при великих графах дорого. - -## More Information - -Append-only інваріант: файли тільки створюються, ніколи не змінюються. Нова версія = новий файл (`вихідні-2.md`). Sentinel-файли (`invalidated`) визначають стани через наявність. Кожна операція: (1) `*-план.md` → (2) виконання → (3) `*-факт.md`. При відновленні: `scan tasks/**/*-план.md` → якщо відповідний `*-факт.md` відсутній → незавершена операція → відновити. Файли: `task.md`, `вхідні.md`, `вихідні.md`, `помилка.md`, `repair/`, `операції/`, `патчі/`. - -## Update 2026-06-06 - -Розглядалося використання Markdown + YAML-фронтматер замість JSON для файлів стану вузлів (`meta.md`, `inputs.md`, `outputs.md`, `error.md`, `repair_history.md`). - -**Аргументи за:** YAML-фронтматер містить структуровані поля (стан, тип, ребра, мітки часу), тіло Markdown дозволяє LLM читати і писати опис та міркування природною мовою без реконструкції JSON; `repair_history.md` — append-only журнал, який інженер продовжує природно; паттерн узгоджується з існуючим у проєкті (`.mdc`-файли, ADR). - -**Статус у transcript:** рішення зафіксоване в сесії 2026-06-06T11:49; остаточний вибір між `.json` і `.md` у подальших clean-файлах не підтверджений. - -## Update 2026-06-06 - -### YAML frontmatter конвенція атрибутів - -Атрибути у YAML-фронтматері всіх файлів системи — англійська, snake_case. Тіло документів — українська. Ідентифікатори (`id`, порти, шляхи) — kebab-case англійська. - -Стандартні атрибути за типом файлу: -- `місія.md`: `id`, `parent`, `deps[]` -- `вхідні.md`/`вихідні.md`: `created_at` -- `помилка.md`: `occurred_at`, `type` -- `операції/spawn-план.md`: `created_at`, `nodes[]` -- `операції/kill-план.md`: `created_at`, `kill_order[]`, `reason` -- `патчі/N-план.md`: `created_at`, `target_node` -- `патчі/N-факт.md`: `created_at`, `result` - -### Синтаксис `ref:` у `вхідні.md`/`вихідні.md` - -- `ref: path/to/file.md` — весь файл -- `ref: path/to/file.md#section-name` — секція за заголовком -- `ref: path/to/file.md lines 10-50` — діапазон рядків - -Шляхи відносні від `tasks/`. `вхідні.md`/`вихідні.md` містять лише посилання, без копіювання самих даних. - -Структура директорії вузла: `місія.md`, `вхідні.md`, `вихідні.md`, `помилка.md`, `repair_history.md`, `операції/`, `патчі/`, `підграф/<child-id>/`. - -## Update 2026-06-06 - -### Специфікація `вхідні.md` - -Файл пишеться агентом-батьком при spawn, незмінний після створення. Кожна секція `##` — один іменований вхід (відповідає `port` у `deps` попередника або довільна назва для контексту). Кілька `ref:` у секції допускається. - -- Секції з `deps`-портів заповнюються тільки після того як відповідний попередник перейшов у стан `resolved` -- Секції `context` заповнюються відразу при spawn (файли вже існують) -- При інвалідації та рестарті батько пише новий `вхідні.md` у новому ворктрі, а не модифікує існуючий - -Відкрите питання (transcript 2026-06-06): чи потрібне поле `from:` у фронтматері — явний перелік вузлів-попередників що мають бути resolved до старту? Або це повністю виводиться з `deps` у `місія.md`? Transcript рішення не зафіксував. - -## Update 2026-06-06 - -- Формат файлів state store — Markdown з YAML-frontmatter: frontmatter дає машинозчитувані поля, тіло лишається LLM-friendly. -- `created_at` має бути першим полем frontmatter у всіх файлах. -- Імена файлів і директорій — англійською; frontmatter attributes — англійською у `snake_case`. -- Секції, які парсить оркестратор, мають англійські заголовки; довільні дані можуть бути будь-якою мовою. -- Стан вузла визначається наявністю файлів, а не mutable полем `state`. - -## Update 2026-06-06 - -- `error.md` — одноразовий snapshot помилки, а не журнал спроб. -- `repair_history.md` — append-only журнал спроб відновлення, який пише інженер. -- `repair_context.md` містить інформацію, яку інженер читає перед роботою: `deadline`, `budget_sec`, `target_node`, посилання на error і task. -- `ops/spawn-plan-<ts>.md` використовує timestamp, щоб підтримати динамічний spawn; перед spawn потрібно перевіряти наявні `spawn-fact-*.md`, щоб не повторити вже виконану операцію. -- `patches/<ts>-plan.md` / `<ts>-fact.md` також використовують timestamp; перед patch потрібно перевіряти відповідний fact. - -## Update 2026-06-06 - -- Append-only інваріант починається з моменту `git worktree add`: до створення worktree файли вузла можна редагувати або видаляти вільно. -- Після створення worktree для зміни вузла потрібен протокол `kill worktree → edit → restart`. -- Топологія графу лишається розподіленою у `deps:` кожного `task.md`; центральний `graph.md` не потрібен. -- `task.md` є єдиним вхідним файлом вузла: місія, критерій завершення і `## Inputs` живуть разом. -- Kill-before-patch: перед патчем цільового вузла потрібно зупинити залежні вузли у топологічному порядку, щоб не завершити successor зі stale inputs. - -## Update 2026-06-06 - -- Уточнено межу immutability: файли є mutable до створення worktree і append-only після worktree. -- Plan → action → fact застосовується до `spawn`, `kill` і `patch`; `task.md` можна розглядати як plan агента, а `outputs.md` або `error.md` — як fact. -- `task.md` містить `## Inputs`, тому окремий `inputs.md` не потрібен. -- `deps:` у `task.md` кожного дочірнього вузла забезпечує динамічний spawn без оновлення центрального стану. -- Transcript не містить підтверджених негативних наслідків для цих уточнень. diff --git a/npm/docs/architecture/access.en.md b/docs/architecture/access.en.md similarity index 100% rename from npm/docs/architecture/access.en.md rename to docs/architecture/access.en.md diff --git a/npm/docs/architecture/access.md b/docs/architecture/access.md similarity index 100% rename from npm/docs/architecture/access.md rename to docs/architecture/access.md diff --git a/npm/docs/architecture/git.en.md b/docs/architecture/git.en.md similarity index 100% rename from npm/docs/architecture/git.en.md rename to docs/architecture/git.en.md diff --git a/npm/docs/architecture/git.md b/docs/architecture/git.md similarity index 100% rename from npm/docs/architecture/git.md rename to docs/architecture/git.md diff --git a/npm/docs/architecture/graph.en.md b/docs/architecture/graph.en.md similarity index 100% rename from npm/docs/architecture/graph.en.md rename to docs/architecture/graph.en.md diff --git a/npm/docs/architecture/graph.md b/docs/architecture/graph.md similarity index 100% rename from npm/docs/architecture/graph.md rename to docs/architecture/graph.md diff --git a/npm/docs/architecture/i18n.en.md b/docs/architecture/i18n.en.md similarity index 100% rename from npm/docs/architecture/i18n.en.md rename to docs/architecture/i18n.en.md diff --git a/npm/docs/architecture/i18n.md b/docs/architecture/i18n.md similarity index 100% rename from npm/docs/architecture/i18n.md rename to docs/architecture/i18n.md diff --git a/npm/docs/architecture/index.md b/docs/architecture/index.md similarity index 100% rename from npm/docs/architecture/index.md rename to docs/architecture/index.md diff --git a/npm/docs/architecture/mandates.md b/docs/architecture/mandates.md similarity index 100% rename from npm/docs/architecture/mandates.md rename to docs/architecture/mandates.md diff --git a/npm/docs/architecture/operations.en.md b/docs/architecture/operations.en.md similarity index 100% rename from npm/docs/architecture/operations.en.md rename to docs/architecture/operations.en.md diff --git a/npm/docs/architecture/operations.md b/docs/architecture/operations.md similarity index 100% rename from npm/docs/architecture/operations.md rename to docs/architecture/operations.md diff --git a/npm/docs/architecture/overview.en.md b/docs/architecture/overview.en.md similarity index 100% rename from npm/docs/architecture/overview.en.md rename to docs/architecture/overview.en.md diff --git a/npm/docs/architecture/overview.md b/docs/architecture/overview.md similarity index 100% rename from npm/docs/architecture/overview.md rename to docs/architecture/overview.md diff --git a/npm/docs/architecture/recurrence.md b/docs/architecture/recurrence.md similarity index 100% rename from npm/docs/architecture/recurrence.md rename to docs/architecture/recurrence.md diff --git a/npm/docs/architecture/retro.en.md b/docs/architecture/retro.en.md similarity index 100% rename from npm/docs/architecture/retro.en.md rename to docs/architecture/retro.en.md diff --git a/npm/docs/architecture/retro.md b/docs/architecture/retro.md similarity index 100% rename from npm/docs/architecture/retro.md rename to docs/architecture/retro.md diff --git a/npm/docs/architecture/runtime.en.md b/docs/architecture/runtime.en.md similarity index 100% rename from npm/docs/architecture/runtime.en.md rename to docs/architecture/runtime.en.md diff --git a/npm/docs/architecture/runtime.md b/docs/architecture/runtime.md similarity index 100% rename from npm/docs/architecture/runtime.md rename to docs/architecture/runtime.md diff --git a/npm/docs/architecture/stack.en.md b/docs/architecture/stack.en.md similarity index 100% rename from npm/docs/architecture/stack.en.md rename to docs/architecture/stack.en.md diff --git a/npm/docs/architecture/stack.md b/docs/architecture/stack.md similarity index 100% rename from npm/docs/architecture/stack.md rename to docs/architecture/stack.md diff --git a/npm/docs/architecture/surfaces.en.md b/docs/architecture/surfaces.en.md similarity index 100% rename from npm/docs/architecture/surfaces.en.md rename to docs/architecture/surfaces.en.md diff --git a/npm/docs/architecture/surfaces.md b/docs/architecture/surfaces.md similarity index 100% rename from npm/docs/architecture/surfaces.md rename to docs/architecture/surfaces.md diff --git a/npm/docs/index.en.md b/docs/index.en.md similarity index 100% rename from npm/docs/index.en.md rename to docs/index.en.md diff --git a/npm/docs/index.md b/docs/index.md similarity index 99% rename from npm/docs/index.md rename to docs/index.md index ee8803e..694eeda 100644 --- a/npm/docs/index.md +++ b/docs/index.md @@ -1,9 +1,3 @@ ---- -resource: npm/index.js -docgen: - crc: eb888d9c ---- - # Nitra MT — документація <!-- layers:L0 sources: overview/index.md aaa8992d 165e2ee3 --> diff --git a/npm/docs/layers.json b/docs/layers.json similarity index 95% rename from npm/docs/layers.json rename to docs/layers.json index 56b7ec9..2f998d9 100644 --- a/npm/docs/layers.json +++ b/docs/layers.json @@ -1,5 +1,5 @@ { - "$schema": "../../layers/schemas/layers.schema.json", + "$schema": "../layers/schemas/layers.schema.json", "version": 1, "tier": "min", "maxTokens": 4096, diff --git a/npm/docs/log.md b/docs/log.md similarity index 100% rename from npm/docs/log.md rename to docs/log.md diff --git a/npm/docs/overview/core.en.md b/docs/overview/core.en.md similarity index 100% rename from npm/docs/overview/core.en.md rename to docs/overview/core.en.md diff --git a/npm/docs/overview/core.md b/docs/overview/core.md similarity index 100% rename from npm/docs/overview/core.md rename to docs/overview/core.md diff --git a/npm/docs/overview/direction.en.md b/docs/overview/direction.en.md similarity index 100% rename from npm/docs/overview/direction.en.md rename to docs/overview/direction.en.md diff --git a/npm/docs/overview/direction.md b/docs/overview/direction.md similarity index 100% rename from npm/docs/overview/direction.md rename to docs/overview/direction.md diff --git a/npm/docs/overview/index.en.md b/docs/overview/index.en.md similarity index 100% rename from npm/docs/overview/index.en.md rename to docs/overview/index.en.md diff --git a/npm/docs/overview/index.md b/docs/overview/index.md similarity index 100% rename from npm/docs/overview/index.md rename to docs/overview/index.md diff --git a/npm/docs/overview/people.en.md b/docs/overview/people.en.md similarity index 100% rename from npm/docs/overview/people.en.md rename to docs/overview/people.en.md diff --git a/npm/docs/overview/people.md b/docs/overview/people.md similarity index 100% rename from npm/docs/overview/people.md rename to docs/overview/people.md diff --git a/npm/docs/overview/runtime.en.md b/docs/overview/runtime.en.md similarity index 100% rename from npm/docs/overview/runtime.en.md rename to docs/overview/runtime.en.md diff --git a/npm/docs/overview/runtime.md b/docs/overview/runtime.md similarity index 100% rename from npm/docs/overview/runtime.md rename to docs/overview/runtime.md diff --git a/npm/docs/roadmap.en.md b/docs/roadmap.en.md similarity index 100% rename from npm/docs/roadmap.en.md rename to docs/roadmap.en.md diff --git a/npm/docs/roadmap.md b/docs/roadmap.md similarity index 100% rename from npm/docs/roadmap.md rename to docs/roadmap.md diff --git a/npm/docs/vision.en.md b/docs/vision.en.md similarity index 100% rename from npm/docs/vision.en.md rename to docs/vision.en.md diff --git a/npm/docs/vision.md b/docs/vision.md similarity index 100% rename from npm/docs/vision.md rename to docs/vision.md diff --git "a/docs/\320\275\320\276\321\202\320\260\321\202\320\272\320\260-doc-files-\320\261\320\265\320\272\320\273\320\276\320\263-fix-timeout.md" "b/docs/\320\275\320\276\321\202\320\260\321\202\320\272\320\260-doc-files-\320\261\320\265\320\272\320\273\320\276\320\263-fix-timeout.md" deleted file mode 100644 index 40fe30b..0000000 --- "a/docs/\320\275\320\276\321\202\320\260\321\202\320\272\320\260-doc-files-\320\261\320\265\320\272\320\273\320\276\320\263-fix-timeout.md" +++ /dev/null @@ -1,42 +0,0 @@ -# Нотатка: беклог файлових док і fix-timeout у `lint doc-files` - -**Дата:** 2026-07-07 -**Upstream issue:** [nitra/cursor#16](https://github.com/nitra/cursor/issues/16) - -## Суть - -Перший масовий прогін `npx @nitra/cursor lint doc-files` (беклог 47 відсутніх док, локальна -модель `omlx/gemma-4-e2b-it-4bit`, ~40–56s/файл) не сходиться: fix-pipeline пакета -`@nitra/cursor` (14.x) обгортає весь батч worker-а backstop-таймаутом 56250ms -(`N_LOCAL_FIX_TIMEOUT_MS=45000` × 1.25), а після `fix timeout` central rollback -(`snapshot.rollback()`) видаляє **всі** вже записані доки — повторний прогін щоразу -стартує з `[1/47]`. Деталі й ланцюжок причини — в issue. - -## Як згенеровано беклог тут - -Прямим batch-CLI поза fix-pipeline (інкрементний, resumable, без rollback): - -```bash -node node_modules/@nitra/cursor/rules/doc-files/docgen-files-batch/main.mjs gen -``` - -Після нього `npx @nitra/cursor lint doc-files` бачить свіжі CRC і проходить чисто. - -## ⚠ Колізія: `npm/docs/index.md` — людська дока - -Схема `<dir>/docs/<stem>.md` мапить `npm/index.js` → `npm/docs/index.md`, а це **рукописний** -зміст документації модуля. Перший прогін генерації мовчки перезаписав його; файл відновлено -з git і йому додано мінімальний `docgen`-frontmatter (`resource: npm/index.js` + свіжий `crc`), -щоб сканер вважав доку свіжою. **Латентний ризик:** після зміни `npm/index.js` CRC розійдеться -і наступний прогін doc-files знову перезапише людський файл — до фіксу upstream -([nitra/cursor#16](https://github.com/nitra/cursor/issues/16), коментар 3) після зміни -`npm/index.js` онови `crc` у frontmatter вручну (`crc32` джерела) або перенеси людський зміст. - -## Важелі на майбутнє (поки issue не закрито) - -- `N_LOCAL_FIX_TIMEOUT_MS` — env-override local-таймауту fix-рунга (недокументований); - для великого масового прогону через `lint doc-files` треба ~60000ms × кількість файлів, і навіть - тоді один transient-збій відкочує весь прогін. -- Локальна модель одна на машину: конкурентний `adr-normalize`-батч розтягує - docgen-виклики за 300s → transient-помилки. Великий прогін краще запускати, коли - ADR-нормалізація не працює. diff --git a/eslint.config.js b/eslint.config.js index d1d22fe..8f41be5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,8 +1,5 @@ import { getConfig } from '@nitra/eslint-config' -import globals from 'globals' -// getConfig({ node: ['npm'] }) у @nitra/eslint-config задає Node globals лише для glob `npm/**/*.js` -// (не .mjs/.cjs). Для npm/**/*.mjs додаємо globals.node окремо, інакше no-undef на process і console. export default [ { ignores: [ @@ -14,29 +11,7 @@ export default [ '**/reports/stryker/**' ] }, - ...getConfig({ - node: ['npm'] - }), - { - files: ['npm/**/*.{mjs,cjs}'], - languageOptions: { - globals: { - ...globals.node - } - } - }, - // npm-module rule забороняє devDependencies у npm/package.json (compact published tarball), - // тож vitest/stryker stack живе у кореневому package.json і резолвиться через bun hoisted - // node_modules. `n/no-extraneous-import` цього не бачить — allowModules ставить exception. - { - files: ['npm/**/*.{js,mjs,cjs}'], - rules: { - 'n/no-extraneous-import': [ - 'error', - { allowModules: ['vitest', '@vitest/coverage-v8', '@stryker-mutator/vitest-runner'] } - ] - } - }, + ...getConfig(), // Тест-хелпери не потребують JSDoc. `jsdoc/require-jsdoc` (warning) автофіксом вставляє // порожні `/** */` заглушки, які oxlint (`jsdoc/require-param`/`require-returns`, deny) // потім відхиляє → `bun run lint` неідемпотентний (oxlint --fix && eslint --fix). Вимикаємо diff --git a/hk.pkl b/hk.pkl index 812fecc..36df80e 100644 --- a/hk.pkl +++ b/hk.pkl @@ -1,21 +1,6 @@ amends "package://github.com/jdx/hk/releases/download/v1.42.0/hk@1.42.0#/Config.pkl" -local linters = new Mapping<String, Step> { - ["npm-tsc-types"] { - glob = List( - "npm/tsconfig.emit-types.json", - "npm/index.js", - "npm/types/index.d.ts" - ) - check_first = false - fix = "cd npm && bunx -p typescript tsc -p tsconfig.emit-types.json && bunx oxfmt types" - } - ["npm-changelog"] { - glob = List("npm/**") - check_first = false - fix = "N_RULES_CHANGELOG_AUTOFIX=1 npx @7n/rules lint changelog" - } -} +local linters = new Mapping<String, Step> {} hooks { ["pre-commit"] { diff --git a/knip.json b/knip.json index d253c43..832d9a2 100644 --- a/knip.json +++ b/knip.json @@ -28,19 +28,12 @@ "markdownlint-cli2.config.{js,mjs,cjs,jsonc}", ".markdownlint-cli2.{jsonc,cjs,mjs}", "commitlint.config.{js,cjs,mjs}", + "stryker.config.mjs", ".pi/extensions/**/index.ts" ] }, - "npm": { - "entry": ["bin/7.js", "index.js", "stryker.config.mjs", "**/*.test.{js,mjs}"], - "project": ["**/*.{js,mjs}"] - }, - "crates/mt-napi": { - "entry": ["stryker.config.mjs", "**/*.test.{js,mjs}"], - "project": ["**/*.{js,mjs}"] - }, - "relay": { - "entry": ["lib/relay.mjs", "stryker.config.mjs", "**/*.test.{js,mjs}"], + "layers": { + "entry": ["lib/cli.mjs", "stryker.config.mjs", "**/*.test.{js,mjs}"], "project": ["**/*.{js,mjs}"] } } diff --git a/layers/bun.lock b/layers/bun.lock new file mode 100644 index 0000000..3793ed6 --- /dev/null +++ b/layers/bun.lock @@ -0,0 +1,208 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@7n/layers", + "dependencies": { + "@7n/llm-lib": "^2.5.0", + }, + "optionalDependencies": { + "@earendil-works/pi-ai": "0.80.2", + }, + }, + }, + "packages": { + "@7n/llm-lib": ["@7n/llm-lib@2.8.3", "", { "optionalDependencies": { "@7n/llm-lib-darwin-arm64": "2.8.3", "@7n/llm-lib-linux-x64": "2.8.3" }, "peerDependencies": { "@earendil-works/pi-ai": "~0.80.10", "@earendil-works/pi-coding-agent": "~0.80.10" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"], "bin": { "n-llm-chains-report": "bin/chains-report.mjs" } }, "sha512-pLK7HSVfXJ69en0gNQ+eXEUTdq/olqabX9RXymeSOAVIhE6egbDD4XNAZYdp26pLxP+Dr3t7Yomj3YihzeUgLw=="], + + "@7n/llm-lib-darwin-arm64": ["@7n/llm-lib-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2OI/wt+p1NLSbCoOSmvBxLWYJhAtkJdkTd7UUQj6zVTGV6VY00oGfzGv4pGD8Ka/NpuAcHyBq8jcbDDrQqGQGA=="], + + "@7n/llm-lib-linux-x64": ["@7n/llm-lib-linux-x64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-q21Pc86jI7lC+rc+ai24nNe+c2ng8DU5CNuPU7t1wC/xmSWWZ+k0XuSMxJvGzHmHh/ARoEyphIECbYC13l6iSw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.976.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.36", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.4", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.5", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-login": "^3.972.67", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.71", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-ini": "^3.973.5", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.4", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/token-providers": "3.1092.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.66", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.29", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.24", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.34", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.41", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.36", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ=="], + + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@smithy/core": ["@smithy/core@3.29.7", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.12", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.6.8", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg=="], + + "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1092.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ=="], + + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg=="], + } +} diff --git a/mt/lint-fix-js-eslint-04a66bfd/a.md b/mt/lint-fix-js-eslint-04a66bfd/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/lint-fix-js-eslint-04a66bfd/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/lint-fix-js-eslint-04a66bfd/task.md b/mt/lint-fix-js-eslint-04a66bfd/task.md deleted file mode 100644 index c1c1358..0000000 --- a/mt/lint-fix-js-eslint-04a66bfd/task.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-17T11:29:02.512Z -budget_sec: 1800 -audit: required -hint: atomic ---- - -## Task - -Виправити порушення правила `js` (concern `eslint`), які не закрила інлайн fix-драбина. - -## Done when - -- `js` не повідомляє порушень у target-файлах (див. ## Check). - -## Check - -npx @7n/rules lint --no-fix --cwd ../.. js - -## Inputs - -Target-файли: - -- `relay/lib/push.mjs` -- `relay/lib/signing.mjs` -- `relay/lib/tests/relay.test.mjs` -- `relay/lib/tests/server.test.mjs` diff --git a/mt/m0-dogfood-smoke/a.md b/mt/m0-dogfood-smoke/a.md deleted file mode 100644 index 2e63254..0000000 --- a/mt/m0-dogfood-smoke/a.md +++ /dev/null @@ -1,12 +0,0 @@ -## Model tier - -MIN - -## Skills - -- bash -- write-files - -## Agent cli - -codex diff --git a/mt/m0-dogfood-smoke/fact_009.md b/mt/m0-dogfood-smoke/fact_009.md deleted file mode 100644 index eb2b49e..0000000 --- a/mt/m0-dogfood-smoke/fact_009.md +++ /dev/null @@ -1,3 +0,0 @@ -## Summary - -Created the dogfood-smoke fact for run 009 and confirmed note.md contains `acp smoke ok`. diff --git a/mt/m0-dogfood-smoke/note.md b/mt/m0-dogfood-smoke/note.md deleted file mode 100644 index 6e5987f..0000000 --- a/mt/m0-dogfood-smoke/note.md +++ /dev/null @@ -1 +0,0 @@ -acp smoke ok diff --git a/mt/m0-dogfood-smoke/run_001.md b/mt/m0-dogfood-smoke/run_001.md deleted file mode 100644 index 8be623f..0000000 --- a/mt/m0-dogfood-smoke/run_001.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:01:35Z -actor: agent -result: failed -agent_cli: pi -wall_sec: 119 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран diff --git a/mt/m0-dogfood-smoke/run_002.md b/mt/m0-dogfood-smoke/run_002.md deleted file mode 100644 index 4079065..0000000 --- a/mt/m0-dogfood-smoke/run_002.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:28:15Z -actor: agent -result: failed -agent_cli: claude -wall_sec: 3 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран diff --git a/mt/m0-dogfood-smoke/run_003.md b/mt/m0-dogfood-smoke/run_003.md deleted file mode 100644 index c77cf05..0000000 --- a/mt/m0-dogfood-smoke/run_003.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:38:55Z -actor: agent -result: failed -agent_cli: pi -wall_sec: 8 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран diff --git a/mt/m0-dogfood-smoke/run_004.md b/mt/m0-dogfood-smoke/run_004.md deleted file mode 100644 index 64af880..0000000 --- a/mt/m0-dogfood-smoke/run_004.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:49:08Z -actor: agent -result: failed -agent_cli: pi -wall_sec: 10 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран - -## Executor output tail - -```text -<eos> -``` diff --git a/mt/m0-dogfood-smoke/run_005.md b/mt/m0-dogfood-smoke/run_005.md deleted file mode 100644 index 8700b8f..0000000 --- a/mt/m0-dogfood-smoke/run_005.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:50:19Z -actor: agent -result: failed -agent_cli: pi -wall_sec: 14 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран - -## Executor output tail - -```text -<eos> -``` diff --git a/mt/m0-dogfood-smoke/run_006.md b/mt/m0-dogfood-smoke/run_006.md deleted file mode 100644 index d303b20..0000000 --- a/mt/m0-dogfood-smoke/run_006.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:52:34Z -actor: agent -result: failed -agent_cli: pi -wall_sec: 9 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран - -## Executor output tail - -```text -The file `mt/task.md` does not exist in the current working directory. I cannot proceed with the task as planned because I cannot find the necessary input file. Please provide the correct path or content for `task.md`. -``` diff --git a/mt/m0-dogfood-smoke/run_007.md b/mt/m0-dogfood-smoke/run_007.md deleted file mode 100644 index b94fe68..0000000 --- a/mt/m0-dogfood-smoke/run_007.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:53:15Z -actor: agent -result: failed -agent_cli: pi -wall_sec: 9 ---- - -## Completed - -невідомо (draft відсутній) - -## Blockers - -процес завершився без fact (failed) - -## Next Attempt - -діагностувати попередній ран - -## Executor output tail - -```text -I encountered an error because the file `plan_*.md` was not found. Please provide the plan file, or clarify what step should be executed next. -``` diff --git a/mt/m0-dogfood-smoke/run_008.md b/mt/m0-dogfood-smoke/run_008.md deleted file mode 100644 index 322bf80..0000000 --- a/mt/m0-dogfood-smoke/run_008.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T05:57:45Z -actor: agent -result: failed -agent_cli: codex -wall_sec: 182 ---- - -## Completed - -fact записано, але ## Check не пройшов (fact відкликано) - -## Blockers - -## Check failed: `grep -q 'acp smoke ok' note.md` → exit 2 - -grep: note.md: No such file or directory - -## Next Attempt - -виправити і повторити done diff --git a/mt/m0-dogfood-smoke/run_009.md b/mt/m0-dogfood-smoke/run_009.md deleted file mode 100644 index 49493f5..0000000 --- a/mt/m0-dogfood-smoke/run_009.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T06:02:01Z -actor: agent -result: success -agent_cli: codex -wall_sec: 118 ---- - -## Ref - -ref: fact_009.md diff --git a/mt/m0-dogfood-smoke/task.md b/mt/m0-dogfood-smoke/task.md deleted file mode 100644 index 9b4cb29..0000000 --- a/mt/m0-dogfood-smoke/task.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T04:59:02Z -budget_sec: 600 -hint: atomic ---- - -## Mission - -Dogfood-smoke нового agent-шляху (підписочні CLI, ACP-канон): створи у директорії цього вузла файл `note.md` з одним рядком `acp smoke ok` і згенеруй fact поточної спроби. - -## Done when - -- `note.md` існує поряд із task.md і містить рядок `acp smoke ok`; -- записано `fact_NNN.md` поточної спроби з `## Summary`. - -## Check - -grep -q 'acp smoke ok' note.md - -## Context - -Перший повний цикл M0 після міграції на підписочні CLI (ADR 260713-2040/2110): перевіряємо claim → worktree → headless CLI → ## Check → fenced publish без ручного git. diff --git a/mt/m0-kill-invalidate-scanner-sync/a.md b/mt/m0-kill-invalidate-scanner-sync/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m0-kill-invalidate-scanner-sync/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m0-kill-invalidate-scanner-sync/task.md b/mt/m0-kill-invalidate-scanner-sync/task.md deleted file mode 100644 index 53d12dd..0000000 --- a/mt/m0-kill-invalidate-scanner-sync/task.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T04:47:45Z -budget_sec: 3600 -hint: atomic ---- - -## Mission - -Розсинхрон invalidate-семантики: JS `mt kill` писав sentinel `invalidated`, якого Rust-сканер (mt-core scan) не знає — вузол лишався `waiting`. Kill вузла вже мігровано на `mt_core::lifecycle::kill` (napi `killNode`, 2026-07-15); лишилась каскадна інвалідація залежних (kill.mjs крок 4 досі пише sentinel) і рішення: або сканер вчить стан `invalidated`, або каскад мігрує на mt-core з іншою семантикою (blocked-by-missing-dep). Узгодити з graph.md і зафіксувати ADR-ом. - -## Done when - -- Сканер і kill користуються однією семантикою інвалідації (одна імплементація в mt-core); -- тести: kill вузла з залежними → залежні мають узгоджений derived-стан (не waiting); -- graph.md оновлено; `cargo test --workspace` і `npx vitest run` зелені. - -## Check - -cargo test -p mt-core -q - -## Context - -kill.mjs крок 4 (каскад, sentinel `invalidated`); mt-core: lifecycle.rs kill, scan — стани derived. Виявлено при кураторстві графа 2026-07-15. diff --git a/mt/m0-ladder-cross-cli/a.md b/mt/m0-ladder-cross-cli/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m0-ladder-cross-cli/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m0-ladder-cross-cli/task.md b/mt/m0-ladder-cross-cli/task.md deleted file mode 100644 index 7aa3236..0000000 --- a/mt/m0-ladder-cross-cli/task.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T06:03:19Z -budget_sec: 3600 -hint: atomic ---- - -## Mission - -Retry ladder ескалює лише model_tier, але не виконавця: слабка локальна модель (agent_cli: pi + 2B) може не пройти вузол ніколи — 7 failed-ранів поспіль без ескалації CLI (dogfood 2026-07-15; вузол пройшов лише після ручного перемикання на codex). Додати у драбину крос-CLI ескалацію: фінальний щабель (або N-та невдача поспіль) перемикає на наступний CLI з MT_CLOUD_AGENT_CLIS — узгодити з graph.md «Retry ladder» і зафіксувати ADR-ом. - -## Done when - -- Щабель драбини може нести зміну agent_cli (напр. escalate-cli) або після вичерпання драбини runner пробує наступний CLI каскаду; -- тести драбини+каскаду; graph.md/runtime.md оновлені; ADR записано; -- `cargo test -p mt-core` зелений. - -## Check - -cargo test -p mt-core -q - -## Context - -crates/mt-core/src/runner.rs: default_retry_ladder/resolve_retry_step/cascade_order. Каскад зараз спрацьовує лише на rate-limit; невдачі якості моделі не ескалюють CLI. diff --git a/mt/m0-nnn-origin-truth/a.md b/mt/m0-nnn-origin-truth/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m0-nnn-origin-truth/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m0-nnn-origin-truth/task.md b/mt/m0-nnn-origin-truth/task.md deleted file mode 100644 index a5e61a4..0000000 --- a/mt/m0-nnn-origin-truth/task.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T06:03:19Z -budget_sec: 3600 -hint: atomic ---- - -## Mission - -NNN наступного run обчислюється з ЛОКАЛЬНОГО дерева (mt-core runner preflight), а істина — origin/main: при розсинхроні runner повторно публікує run_NNN з тим самим номером і ПЕРЕЗАПИСУЄ immutable-файл на main (dogfood 2026-07-15: два коміти «run 003», перезапис run_004). NNN має рахуватись від стану worktree/base_sha (origin/main) після fetch — до створення claim. - -## Done when - -- preflight/run_node рахують NNN від origin/main (base_sha), не від локального дерева; -- тест: локальне дерево відстає на 2 run-и → новий run отримує наступний вільний NNN; -- `cargo test -p mt-core` зелений. - -## Check - -cargo test -p mt-core -q - -## Context - -crates/mt-core/src/runner.rs: preflight (nnn/attempt) і run_node (base_sha = rev-parse origin/main ПІСЛЯ обчислення nnn — інвертувати порядок або рахувати з worktree). Порушення інваріанта immutability graph.md. diff --git a/mt/m1-acp-adapters/a.md b/mt/m1-acp-adapters/a.md deleted file mode 100644 index d80815e..0000000 --- a/mt/m1-acp-adapters/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -MAX - -## Skills - -- bash -- write-files diff --git a/mt/m1-acp-adapters/task.md b/mt/m1-acp-adapters/task.md deleted file mode 100644 index 8e50363..0000000 --- a/mt/m1-acp-adapters/task.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T04:47:45Z -budget_sec: 3600 -hint: atomic ---- - -## Mission - -Знайти й зафіксувати робочі ACP-адаптери для підписочних CLI (claude / codex / cursor / pi): спайк 2026-07-14 показав, що жоден із чотирьох CLI не має вбудованого ACP-режиму у `--help` — адаптери зовнішні (напр. claude-code-acp). Для кожного CLI визначити команду адаптера для env `MT_ACP_AGENT_CMD`, перевірити живою сесією `agent-cli serve --acp-cmd …` + `attach` (хід зі стрімом `AgentTextDelta`), задокументувати таблицю адаптерів у npm/docs/architecture/runtime.md. - -## Done when - -- Таблиця «agent_cli → команда ACP-адаптера» у runtime.md, перевірена живими сесіями щонайменше для двох CLI; -- Зафіксовані розбіжності реальних адаптерів із v1-підмножиною клієнта (crates/agent-core/src/acp.rs) — issues або правки клієнта; -- `cargo test --workspace` зелений. - -## Check - -cargo test -p agent-core -p agent-server -q - -## Context - -ACP-клієнт: crates/agent-core/src/acp.rs (initialize/session\/new/session\/prompt/request_permission, ndjson JSON-RPC); runner: crates/agent-server/src/runner.rs AcpTurnRunner; wiring: crates/agent-cli (`serve --acp-cmd`, env MT_ACP_AGENT_CMD). ADR 260713-2110. diff --git a/mt/m1-agent-protocol/a.md b/mt/m1-agent-protocol/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m1-agent-protocol/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m1-agent-protocol/task.md b/mt/m1-agent-protocol/task.md deleted file mode 100644 index 7ca0eb9..0000000 --- a/mt/m1-agent-protocol/task.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-08T04:31:30Z -budget_sec: 7200 -hint: atomic ---- - -## Mission - -Заскафолдити Rust crate `agent-protocol` (перший компонент M1 з roadmap): типи `Envelope`/`Event` протоколу v4 за npm/docs/architecture/runtime.md (serde-серіалізація), Ed25519-підписи approvals за npm/docs/architecture/access.md (`ed25519-dalek`), константа `PROTOCOL_VERSION = 4` і перевірка сумісності хендшейку з явною помилкою. - -## Done when - -- `crates/agent-protocol/` компілюється у workspace (`cargo check -p agent-protocol`); -- усі варіанти `Event` з runtime.md представлені типами; roundtrip serde-тест на кожен; -- підпис/верифікація `(request_id, approved, node_hash, run_token)` покриті тестом (валідний + зіпсований підпис); -- `ClientHello` без `lang` не десеріалізується (обовʼязкове поле v4); -- crate НЕ залежить від tokio/tauri (`cargo tree -p agent-protocol -e normal` чистий — фізична межа зі stack.md); -- `cargo test -p agent-protocol` зелений. - -## Context - -- Нормативні джерела: npm/docs/architecture/runtime.md (протокол, Envelope/Event, хендшейк), npm/docs/architecture/access.md (підписи, три гейти), npm/docs/architecture/stack.md (межі crate, залежності). -- Референсні кодові бази для рішень (не для копіювання) — перелік у stack.md. -- Це перша задача M0-dogfood: тертя контракту MT, помічене під час виконання, занотувати у run-нотатках. diff --git a/mt/m1-agent-server/a.md b/mt/m1-agent-server/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m1-agent-server/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m1-agent-server/task.md b/mt/m1-agent-server/task.md deleted file mode 100644 index f268856..0000000 --- a/mt/m1-agent-server/task.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-11T11:05:00Z -budget_sec: 10800 -hint: atomic ---- - -## Mission - -Мінімальний `agent-server` (M1, одна машина, локальний WS, без relay) + тонкий `agent-cli` (`serve`/`attach`): session host за runtime.md — збірка `Envelope` (seq/ts/адресація) навколо подій `agent-core`, журнал `session.jsonl`, broadcast клієнтам, реплей за `want_replay_from`, фільтрація за capabilities, хендшейк v4, port-file discovery. - -## Done when - -- `crates/agent-server`: компілюється, `cargo test -p agent-server` зелений; -- session host: `seq` монотонний у межах run (призначає хост), ефемерні події (`AgentTextDelta`, `PreviewScreenshot`) НЕ журналяться (журналиться `AgentTextDone`), решта — append у `session.jsonl`; -- WS-хендшейк: перший кадр `ClientHello` → перевірка `PROTOCOL_VERSION` (несумісна → `Error` + закриття), відповідь `ServerHello { session_list }`; реплей журнальованих подій від `want_replay_from`; -- capability-фільтр: `PreviewScreenshot` лише клієнтам із «preview»; -- інтеграція agent-core: `UserMessage` → `TurnRunner` (референс — `AgentTurnRunner` поверх `Agent`/`Provider`) → події ходу в сесію; офлайн-тест через `MockProvider`; -- discovery: port-file (`server.port`: port + pid + sha256-хеш токена) + token-файл 0600 + lock; шлях конфігурується (тести — tempdir); -- `crates/agent-cli` (clap): `serve` (стартує сервер), `attach <node>` (читає discovery, хендшейк, REPL: stdin → UserMessage, друк дельт); -- інтеграційний WS-тест: реальний сервер на ефемерному порту, tungstenite-клієнт: хендшейк, хід із MockProvider, реплей після реконекту, відмова несумісної версії; -- без tauri у `cargo tree` обох крейтів. - -## Context - -- Нормативні джерела: npm/docs/architecture/runtime.md (протокол, хендшейк, backpressure/реплей, discovery), stack.md (axum + tokio-tungstenite; правило одного коду контракту — graph-операції ЛИШЕ через `mt … --json`, у цій задачі graph-операції не потрібні), git.md (журнал сесії; push run ref — окрема задача інтеграції з `@7n/mt`). -- Поза скоупом M1-заділу: relay, міграція між хостами, підписи approvals у потоці (типи вже в agent-protocol), push run ref у git (наступна задача — інтеграція wrapper/`mt`). diff --git a/mt/m1-attach-graph/a.md b/mt/m1-attach-graph/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m1-attach-graph/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m1-attach-graph/task.md b/mt/m1-attach-graph/task.md deleted file mode 100644 index 5838aa3..0000000 --- a/mt/m1-attach-graph/task.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-11T15:54:04Z -budget_sec: 7200 -hint: atomic ---- - -## Mission - -Міст agent-server ↔ граф: інтерактивний run вузла (`mt attach`-семантика з runtime.md) поверх `mt-core` — attach (CAS claim + detached worktree від base_sha + push run ref), коміт ходу (файли + `session.jsonl` → push run ref), renewal lease, `done` (fenced publish зі стрипом `.nitra/`), release (пауза/відпустити claim). Без реімплементації контракту: всі graph-операції — виклики `mt-core` (та сама реалізація, що її використовує `@7n/mt` через napi). - -## Done when - -- модуль `graph` у `crates/agent-server`: `GraphBridge::attach(node)` → `InteractiveRun { node_hash, token, claim_sha, base_sha, worktree }`; -- другий attach того самого вузла → явна відмова claim-lost (CAS, не помилка транспорту); -- `commit_turn`: пише `.nitra/session.jsonl` у worktree, комітить і пушить run ref (recovery/handoff); -- `renew`: подовжує lease тим самим token/generation (CAS від попереднього claim SHA); -- `done`: перед fenced publish прибирає `.nitra/` з індексу (інваріант git.md — `.nitra/` ніколи не потрапляє у main) → publish просуває `main`, видаляє claim/run ref; -- `release`: CAS-delete claim + прибирає worktree (пауза без publish); -- тести на герметичній фікстурі (bare-репо як origin, як у mt-core test_support): attach/claim-lost/commit_turn/done/release, включно з перевіркою, що `.nitra/` немає у `main`; -- `cargo test -p agent-server` зелений; без tauri. - -## Context - -- Нормативні джерела: npm/docs/architecture/runtime.md («Інтерактивна сесія = run вузла»), git.md (claim CAS, run ref і журнал сесії, fenced publish, `.nitra/` поза main), stack.md (правило одного коду контракту — реалізація в mt-core). -- Використати: `mt_core::claims` (acquire/renew_or_takeover/release, node_hash), `mt_core::worktree` (create_run_worktree, push_run_ref, remove_run_worktree), `mt_core::publish::fenced_publish`. -- Поза скоупом: протокольна команда done/detach від клієнта (розширення Event — окрема задача), автоматичний renewal-цикл у serve, `interactive:`-поле у `.mt-claim.yml` (0.3.0-дельта схеми — окремий ADR). diff --git a/mt/m1-interactive-done-check/a.md b/mt/m1-interactive-done-check/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m1-interactive-done-check/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m1-interactive-done-check/task.md b/mt/m1-interactive-done-check/task.md deleted file mode 100644 index 99d3af6..0000000 --- a/mt/m1-interactive-done-check/task.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T04:47:45Z -budget_sec: 3600 -hint: atomic ---- - -## Mission - -`## Check`-гейт перед done інтерактивного run: на `DoneSession` хост ганяє команди секції `## Check` вузла через `mt_core::signal::run_check` у worktree run-а; невдача → відмова сигналу (Event::Error у кімнату), run лишається живим для виправлення. Контракт graph.md: «## Check ганяється wrapper-ом перед done/audit; fail → відмова сигналу». - -## Done when - -- `DoneSession` із невдалим `## Check` НЕ публікує fact і шле Error з причиною; успішний Check → штатний fenced publish; -- інтеграційний тест WS+graph: вузол із `## Check` false → done відхилено; true → done проходить; -- `cargo test --workspace` зелений. - -## Check - -cargo test -p agent-server -q - -## Context - -Точка інтеграції: crates/agent-server/src/ws.rs (обробка DoneSession) + crates/agent-server/src/graph.rs; run_check — crates/mt-core/src/signal.rs. diff --git a/mt/m1-session-graph-wiring/a.md b/mt/m1-session-graph-wiring/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m1-session-graph-wiring/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m1-session-graph-wiring/task.md b/mt/m1-session-graph-wiring/task.md deleted file mode 100644 index d629284..0000000 --- a/mt/m1-session-graph-wiring/task.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-11T16:54:17Z -budget_sec: 10800 -hint: atomic ---- - -## Mission - -Зшити WS-сесії agent-server із graph-мостом — серцевина demo-критерію M1 («`mt attach <node>` відкриває чат із задачею; `mt done` публікує fact тим самим fenced publish»): перший `UserMessage` вузла робить graph-attach (CAS claim + worktree), кожен хід комітиться у run ref разом із журналом сесії, нові протокольні команди `DoneSession`/`ReleaseSession` (мінорне розширення Event v4 — канон runtime.md оновити), авто-renewal lease у фоні. - -## Done when - -- `agent-protocol`: нові client→host варіанти `DoneSession {}` і `ReleaseSession {}`; roundtrip-тести; runtime.md (канон) доповнено; старі клієнти сумісні (невідомий варіант ігнорується); -- `agent-server`: `AppState.graph: Option<GraphConfig>`; перший `UserMessage` вузла → `graph::attach` (невдача → `Event::Error` у сесію, хід не виконується); хід агента виконується з `workdir = worktree`; після ходу — `commit_turn` (журнал сесії + правки файлів → push run ref); -- `DoneSession` → strip `.nitra/` + fenced publish → `Committed { commit_hash }` у сесію; `ReleaseSession` → release claim → `ClaimChanged { holder_device_id: None }`; -- авто-renewal: фонова задача на attach (перiод ~lease/3); `renew == false` → `Error` claim-lost у сесію, run прибирається; -- `agent-cli` attach: `/done` і `/release` у REPL шлють відповідні команди; -- інтеграційний тест: git-фікстура + WS: UserMessage → claim ref зʼявився, run ref має журнал; DoneSession → main просунувся (без `.nitra/`), claim/run ref прибрані; ReleaseSession → вузол знову вільний; -- `cargo test --workspace` зелений; без tauri. - -## Context - -- Нормативні джерела: npm/docs/architecture/runtime.md («Інтерактивна сесія = run вузла», «Протокол подій» — мінорні розширення = нові Event-варіанти), git.md (журнал сесії у run ref, `.nitra/` поза main). -- Побудовано на: `agent_server::graph` (attach/commit_turn/renew/done/release, PR #28), `agent_server::session`/`ws` (PR #25). -- Поза скоупом: `## Check`-виконання перед done (потребує контракту виконання Check — окрема задача), handoff/relay (M2), `interactive:` у `.mt-claim.yml` (окремий ADR). diff --git a/mt/m2-acp-approval-e2e/a.md b/mt/m2-acp-approval-e2e/a.md deleted file mode 100644 index d80815e..0000000 --- a/mt/m2-acp-approval-e2e/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -MAX - -## Skills - -- bash -- write-files diff --git a/mt/m2-acp-approval-e2e/task.md b/mt/m2-acp-approval-e2e/task.md deleted file mode 100644 index 827c284..0000000 --- a/mt/m2-acp-approval-e2e/task.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-15T04:47:45Z -budget_sec: 5400 -hint: atomic ---- - -## Mission - -Наскрізний mid-run approval через ACP (замінює вбитий m2-tool-approval-policy, що описував видалений власний tool-гейт): fake ACP-агент шле `session/request_permission` → хост шле `ApprovalRequest` у кімнату → верифікований `ApprovalResponse` (Ed25519; dev-політика без relay — непідписаний приймається) → агент отримує allow/reject-option → `ToolResult { ok }` у стрічці. Таймаут approve (120s) → відмова. - -## Done when - -- fake-acp-agent (crates/agent-server/src/bin/fake-acp-agent.rs) розширено сценарієм request_permission; -- інтеграційний WS-тест: approve → ToolResult ok:true; deny → ok:false; таймаут → відмова без падіння ходу; -- `cargo test --workspace` зелений. - -## Check - -cargo test -p agent-server -q - -## Context - -PermissionHandler-ланцюг: acp.rs handle_agent_request → AcpTurnRunner permission_factory → agent-cli request_approval (approvals_gate). Unit-рівень уже покрито в agent-core::acp::tests::permission_request_routes_through_handler. diff --git a/mt/m2-approvals-flow/a.md b/mt/m2-approvals-flow/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m2-approvals-flow/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m2-approvals-flow/task.md b/mt/m2-approvals-flow/task.md deleted file mode 100644 index 97f1f7a..0000000 --- a/mt/m2-approvals-flow/task.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-12T06:36:14Z -budget_sec: 10800 -hint: atomic ---- - -## Task - -M2, підписані approvals у потоці (access.md, «Approvals: три гейти, один механізм»): хост шле `ApprovalRequest` у кімнату → пристрій учасника approver+ підписує `(request_id, approved, node_hash, run_token)` → хост звіряє підпис із pubkey-кешем relay (підпис поза списком → відмова + `Error`) → запит-очікування завершується. Pubkey-кеш наповнюється мостом через новий relay-кадр `pubkeys`. - -## Done when - -- relay: WS-кадр `{kind:"pubkeys", root}` → `{kind:"pubkeys", root, pubkeys:[{device_id, account_id, pubkey}]}` (ядро `core.pubkeys` вже є); vitest-тест; -- agent-server: `ApprovalGate` — реєстрація pending-запиту (`request_approval` публікує `ApprovalRequest` у сесію, повертає oneshot), `resolve` верифікує Ed25519-підпис (`agent_protocol::verify_approval`) ключем пристрою з кешу за `device_id`; невалідний/невідомий → `Error` у сесію, pending лишається (можна повторити валідним підписом); -- політика: `require_signed` вмикається разом із relay-мостом; без нього (локальний dev) порожній підпис приймається; -- міст: після subscribe запитує `pubkeys`; вхідний `pubkeys`-кадр оновлює кеш і вмикає `require_signed`; -- `handle_client_frame`: гілка `ApprovalResponse` → `ApprovalGate::resolve`; -- тести: unit ApprovalGate (валідний/зіпсований/чужий ключ/непідписаний у двох політиках); інтеграційний із mock-relay: bridge запитує pubkeys → ApprovalRequest у кімнаті → підписаний ApprovalResponse віддаленого пристрою → oneshot true; невалідний підпис → Error-кадр у стрічці; -- `cargo test --workspace` і `npx vitest run relay` зелені. - -## Check - -cargo test -p agent-server -q -npx vitest run relay - -## Inputs - -- Нормативні: access.md (потік гейтів, pubkey-кеш із TTL, «підпис поза списком → відмова + Error»; матеріалізація у файли вузла — ОКРЕМА задача: потребує синтезу `run_NNN.md` в інтерактивному done), stack.md (CI-кейс «відхилення підпису пристрою поза pubkey-списком»). -- Готове: `agent_protocol::approvals` (sign/verify, ApprovalPayload), relay-міст (PR #35), `store.pubkeysFor` (PR #34). -- Поза скоупом: TTL-refresh кешу (кеш оновлюється на pubkeys-кадр; періодичний refresh — разом із presence), тригер деструктивного ToolCall (гейт викликається програмно; звʼязка з tool-політикою — окрема задача), plan-review/аудит-вердикт гейти (той самий механізм — після синтезу файлів). diff --git a/mt/m2-attach-ws-wiring/a.md b/mt/m2-attach-ws-wiring/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m2-attach-ws-wiring/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m2-attach-ws-wiring/task.md b/mt/m2-attach-ws-wiring/task.md deleted file mode 100644 index 5220984..0000000 --- a/mt/m2-attach-ws-wiring/task.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-12T15:43:09Z -budget_sec: 10800 -hint: atomic ---- - -## Mission - -M2, ws-рівень кооперативного handoff (продовження #39: graph-рівень `InteractiveRun::handoff`/`graph::attach_resume` вже готовий, ws-обвʼязка — з явних «поза скоупом» пунктів). Мета: `AppState`-рівень API, що знімає run з обліку сесій, викликає `handoff`/`attach_resume`, і — ключова частина — засіває журнал сесії на новому хості так, щоб `SessionHost`/`Session` продовжили той самий seq, що й до передачі (реплей для клієнтів, що реконектяться, залишається безшовним). Wire-протокол для «хто ініціює handoff» (relay `HandoffRequest`, новий client→host Event) — свідомо ПОЗА цією задачею: `Event`-варіантів для handoff в agent-protocol ще немає, і його дизайн — окреме рішення разом із relay-маршрутизацією. - -## Done when - -- `SessionHost::seed_journal(&self, node: &str, jsonl: &str) -> Result<(), String>`: пише вміст напряму у локальний файл сесії (`<state_dir>/<node>.session.jsonl`) ДО першого відкриття; помилка, якщо сесія для цього ключа вже відкрита (живий стан у пам'яті заднім числом не перечитується). Той самий формат, що й `.nitra/session.jsonl` (по Envelope на рядок) — `Session::open` після сіву продовжує seq природно (як після рестарту хоста); -- `AppState::handoff_node(node) -> Result<HandoffTicket, String>`: знімає run з `runs`-мапи, викликає `InteractiveRun::handoff`, публікує `ClaimChanged { holder_device_id: None, .. }` у сесію (той самий сигнал, що й release — деталь «це handoff, не пауза» лишається в `run_NNN.md`); -- `AppState::resume_node(node, &ticket) -> Result<(), String>`: `graph::attach_resume` → читає `.nitra/session.jsonl` відновленого worktree → `seed_journal` (найкраще зусилля: помилка сіву не валить resume — сесія просто почне з чистого seq) → вкладає run у `runs`-мапу → `spawn_renewal`; -- тести: unit `seed_journal` (сіяний журнал підхоплюється `get_or_open`, seq продовжується; сів після відкриття — явна помилка); інтеграційний на двох `AppState` (той самий bare-origin, різні `state_dir` — симуляція «двох хостів»): attach на «хості 1» → хід → `handoff_node` → `resume_node` на «хості 2» з тим самим тікетом → `get_or_open` на хості 2 віддає `replay_from(0)`, що включає успадковані Envelope з хоста 1, і НАСТУПНИЙ хід продовжує seq без розривів; -- `cargo test --workspace` зелений; без tauri. - -## Check - -cargo test -p agent-server -q - -## Context - -- Нормативні джерела: npm/docs/architecture/runtime.md («Міграція сесії між хостами», кроки 2-3 — «клієнти... продовжують у тій самій кімнаті з новим активним хостом»), git.md (`handoff`). -- Побудовано на: `graph::{InteractiveRun::handoff, attach_resume, HandoffTicket}` (PR #39), `session::{SessionHost, Session}` (формат журналу — PR #25/#30). -- Поза скоупом (наступні задачі): будь-який новий `Event`-варіант для handoff і relay `HandoffRequest`-маршрутизація (окреме рішення дизайну протоколу), CLI-поверхня (`agent-cli handoff`/`resume`) — потребує саме wire-протоколу, checkpoint-режим, GC старого run ref-а після успішного resume+done. diff --git a/mt/m2-handoff-core/a.md b/mt/m2-handoff-core/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m2-handoff-core/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m2-handoff-core/task.md b/mt/m2-handoff-core/task.md deleted file mode 100644 index bb20cd6..0000000 --- a/mt/m2-handoff-core/task.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-12T14:56:40Z -budget_sec: 10800 -hint: atomic ---- - -## Task - -M2, graph-рівень міграції сесії між хостами (runtime.md, «Міграція сесії між хостами», кроки 2-3; git.md, claim-операція `handoff`): кооперативна передача claim-а — тримач пише `run_NNN.md (result: handoff)` + push run ref → CAS-delete claim; новий хост attach-иться заново, але worktree матеріалізується зі стану СТАРОГО run ref (не `origin/main`) — журнал `.nitra/session.jsonl` і мідфлайт-правки успадковані, розмова продовжується. Generation продовжує лічильник через handoff (git.md: «новий хост: create, generation+1»), хоч механічно це create-only CAS (старий claim уже видалено). - -## Done when - -- `graph::HandoffTicket { run_token, generation }` (Serialize/Deserialize — піде через relay у наступній задачі); -- `InteractiveRun::handoff(self) -> Result<HandoffTicket, String>`: синтезує `run_NNN.md (result: handoff)` через `mt_core::signal::write_run_fm` (той самий NNN-лічильник, що й success/fail), комітить, пушить run ref БЕЗ стрипу `.nitra/` (checkpoint-режим — окрема задача), потім CAS-delete claim; повертає тікет; -- `graph::attach_resume(config, node, &ticket) -> Result<InteractiveRun, String>`: fetch старого run ref за `ticket.run_token` (недоступний → явна помилка, не паніка) → CAS-create claim з `generation = ticket.generation + 1` → worktree від tip старого run ref (не `origin/main`) → push нового run ref новим token; -- рефакторинг: `attach`/`attach_resume` діляться спільною реалізацією (`resume_token: Option<&str>` перемикає worktree-базу і generation), дублювання виключено; -- тести (герметична фікстура, патерн наявних у graph.rs): (1) handoff пише run-файл result:handoff, `.nitra/session.jsonl` присутній у run ref, claim знято, worktree прибрано; (2) attach_resume після handoff — успіх, generation = old+1, worktree містить мідфлайт-файл і journal з попереднього ходу, новий run ref існує; (3) attach_resume з тікетом на неіснуючий run ref → явна помилка (не паніка); (4) наскрізний: attach → хід → handoff → attach_resume → done — публікує ту саму серію NNN без розривів; -- `cargo test --workspace` зелений; без tauri. - -## Check - -cargo test -p agent-server -p mt-core -q - -## Inputs - -- Нормативні: npm/docs/architecture/runtime.md («Міграція сесії між хостами»), git.md (таблиця claim-операцій, рядок `handoff`; «Checkpoint-handoff» — checkpoint-режим ПОЗА цією задачею). -- Побудовано на: graph.rs (`attach`/`done`/`release`, PR #28/#32/#37), `mt_core::signal::{next_run_nnn, write_run_fm}` (pub з PR #37). -- Поза скоупом (наступні задачі): relay-кадр `HandoffRequest`/маршрутизація (крок 1 протоколу), ws-рівень (виклик handoff/attach_resume з `agent-server::ws`, реплей `.nitra/session.jsonl` у нову `SessionHost`-сесію так, щоб клієнти бачили безшовне продовження), checkpoint-режим (дистильований summary замість повного журналу), lease-expiry takeover-шлях (уже покритий `renew`/CAS у claims.rs — тут лише кооперативний шлях). diff --git a/mt/m2-interactive-run-file/a.md b/mt/m2-interactive-run-file/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m2-interactive-run-file/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m2-interactive-run-file/task.md b/mt/m2-interactive-run-file/task.md deleted file mode 100644 index fa01f2d..0000000 --- a/mt/m2-interactive-run-file/task.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-12T06:59:32Z -budget_sec: 7200 -hint: atomic ---- - -## Task - -Синтез контрактних артефактів в інтерактивному done: `run_NNN.md` (кожна спроба має run-файл — контракт graph.md) із секцією `## Approvals` (матеріалізація верифікованих підписів — access.md) і мінімальний `fact_NNN.md`, якщо виконавець його не створив (без fact вузол після publish не стає resolved — семантична дірка поточного done). - -## Done when - -- mt-core: `next_run_nnn`/`write_run_fm` публічні (одна реалізація формату run-файлу — нею користується і graph-міст agent-server); -- `InteractiveRun`: накопичення approval-рядків (`add_approval`); ws-гілка `ApprovalResponse` після успішної верифікації додає рядок (ts, device_id, approved, request_id, hex-підпис) у run вузла; -- `done()`: після `## Check` і фіксації run_ref SHA — синтез `run_NNN.md` (actor з конфігу, result success, `## Approvals` за наявності) + `fact_NNN.md` якщо відсутній → коміт → strip `.nitra/` → fenced publish; -- наявний fact виконавця з тим самим NNN НЕ перезаписується; -- тести: unit (done → main містить run_001/fact_001; approvals-рядок у run-файлі; власний fact збережено), інтеграційний graph_wiring оновлено (main після DoneSession містить run/fact); -- `cargo test --workspace` зелений. - -## Check - -cargo test -p agent-server -p mt-core -q - -## Inputs - -- Контракт: graph.md (`run_NNN.md` — спроба виконавця; `## Approvals` опційно; fact_NNN — успішний результат, NNN = NNN run-а), access.md (матеріалізація підписів у файли вузла). -- Побудовано на: ApprovalGate (PR #36), graph-міст done (PR #28/#32). -- Поза скоупом: телеметрія wall_sec/tokens/cost із ходів (окрема задача), audit-сигнал (`mt audit`) в інтерактиві. diff --git a/mt/m2-relay-bridge/a.md b/mt/m2-relay-bridge/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m2-relay-bridge/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m2-relay-bridge/task.md b/mt/m2-relay-bridge/task.md deleted file mode 100644 index 73e1d4e..0000000 --- a/mt/m2-relay-bridge/task.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-12T05:26:51Z -budget_sec: 10800 -hint: atomic ---- - -## Task - -M2, міст agent-server ↔ relay — транспорт (в) із runtime.md («relay-клієнт — вихідне wss:// до relay для віддалених клієнтів»): хост підключається до relay, ретранслює host-події сесій у кімнату і приймає клієнтські кадри (UserMessage/DoneSession/ReleaseSession/ApprovalResponse) від віддалених пристроїв у штатну обробку. Захист від зациклення: relay ставить `from_host` на кадри пристроїв role=host — міст ігнорує host-ехо; тонкі клієнти рендерять лише host-кадри (з seq, який призначає хост). - -## Done when - -- relay (`relay/lib`): кадр кімнати `{kind:"envelope", envelope, from_host}` — `from_host` ставить relay за `device.role === 'host'` (НЕ з кадру клієнта — спуфінг виключено); тести оновлені; -- agent-server: модуль `relay_client` — `spawn_relay_bridge(state, config)`: reconnect із backoff, hello (device_token) → subscribe(root) → двонаправлена ретрансляція: broadcast сесій → relay; вхідні `!from_host` кадри → штатна обробка кадру клієнта (device_id — з envelope); -- зациклення виключено: host-ехо, що повертається з relay, ігнорується (тест: mock-relay ехоїть host-кадри назад — UserMessage у журналі рівно один); -- agent-cli serve: `--relay-url`/`--relay-token`/`--relay-root` вмикають міст; -- інтеграційний Rust-тест із mock-relay (tungstenite-сервер у тесті, кадровий протокол relay): віддалений UserMessage → хід агента → host-кадри доїжджають у relay; -- `cargo test --workspace` і `npx vitest run relay` зелені; без tauri. - -## Check - -cargo test -p agent-server -q -npx vitest run relay - -## Inputs - -- Нормативні: runtime.md (транспорти клієнтів; хост — єдиний тримач seq), access.md (кімната = задача, ролі). -- Побудовано на: relay/lib (PR #34), agent_server::{session,ws} (PR #25/#30). -- Поза скоупом: TLS/wss-конфіг (dev — ws до локального relay), FCM push, PostgreSQL-store, handoff. diff --git a/mt/m2-relay-core/a.md b/mt/m2-relay-core/a.md deleted file mode 100644 index ab5ea4f..0000000 --- a/mt/m2-relay-core/a.md +++ /dev/null @@ -1,8 +0,0 @@ -## Model tier - -AVG - -## Skills - -- bash -- write-files diff --git a/mt/m2-relay-core/task.md b/mt/m2-relay-core/task.md deleted file mode 100644 index 024ca6a..0000000 --- a/mt/m2-relay-core/task.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-11T17:50:50Z -budget_sec: 10800 -hint: atomic ---- - -## Task - -Старт M2 (mission control): ядро relay — Bun-сервіс `relay/` (plain JS + JSDoc, БЕЗ TypeScript) за access.md/stack.md. Обовʼязки: auth акаунтів/пристроїв (інтерфейс `verifySession`, dev — magic tokens), membership задач + запрошення (invite → accept → MemberChanged), кімнати з пересилкою Envelope (не парсить payload далі роутінгових полів), буфер останніх ~200 Envelope на run, роздача pubkey-ів `approver+`. НЕ робить: журнали сесій, git-проксі, lease (істина — git claim), виконання агентів. - -## Done when - -- `relay/lib`: store-інтерфейс (accounts/devices/tasks/task_members/invitations за схемою access.md) з in-memory реалізацією (PostgreSQL — окрема задача за тим самим інтерфейсом); -- auth: `verifySession(token) → {account_id}` інтерфейс + dev-реалізація magic tokens; реєстрація пристрою `{name, role, pubkey} → device_token`; -- кімнати: підписка лише пристроям учасників кореневого вузла задачі; broadcast Envelope підписникам; буфер ≤200 Envelope на кімнату, реплей при підписці; -- ролі: viewer-клієнт НЕ шле клієнтські події (relay відхиляє, включно з CancelTurn); host+ шле; `GET pubkeys` — pubkey-и пристроїв учасників approver+ (доступ лише учасникам); -- membership API: invite (owner) → accept/decline → broadcast MemberChanged; transfer ownership; -- WS-сервер (пакет `ws` — працює під vitest/node і bun): hello з device_token → subscribe/envelope-кадри; ліміт кадру 2 МБ; -- vitest-тести: membership-роутінг кімнат, viewer не шле клієнтські події, invite→accept→MemberChanged, transfer ownership, буфер/реплей, відмова підписки не-учаснику. - -## Check - -npx vitest run relay - -## Inputs - -- Нормативні: npm/docs/architecture/access.md (обовʼязки/межі relay, схема даних, ролі, membership API), stack.md («Relay-інфраструктура»: Bun+Postgres, auth-інтерфейс, ліміти: кадр ≤2 MB, буфер ≤200 Envelope/run), runtime.md (транспорт (в) relay-клієнт). -- Поза скоупом: PostgreSQL-реалізація store, FCM push (інтерфейс-заглушка), Ory Kratos, deploy (Dockerfile/k8s), інтеграція agent-server як relay-клієнта, E2E-шифрування (свідомо не 0.3.0). diff --git a/npm/CHANGELOG.md b/npm/CHANGELOG.md deleted file mode 100644 index 4cb8b21..0000000 --- a/npm/CHANGELOG.md +++ /dev/null @@ -1,305 +0,0 @@ -# Changelog - -## [0.28.0] - 2026-07-22 - -### Added - -- Шарова документація npm/docs: layers.json-топологія, агреговані огляди overview/ (L2→L1→L0-резюме в index.md) і англійські переклади - -## [0.27.1] - 2026-07-22 - -### Added - -- docs: глава архітектури `recurrence.md` — повторювані задачі через шаблон (`.mt/templates/`) + інстанси-кореневі вузли; політики overlap/catchup/retention, CLI `mt template list|run`; перехресні оновлення index/overview/operations/log - -## [0.27.0] - 2026-07-17 - -### Added - -- Протокол v4 для multi-owner (owner-app, спека 260714): WS-кадри membership (invite/accept/decline/transfer_ownership/bootstrap_owners), Ed25519-підписаний акт transfer (mt-transfer-v4, дзеркальні sign_transfer/verify_transfer у agent-protocol і signing.mjs relay), push-модуль (тип 2 «запрошено», тип 3 «потребує уваги» + адресна Escalation), Event::Escalation у протоколі, directory-модуль mt-core (.mt/directory.json, handle → email поза git), валідація hex-pubkey пристроїв - -### Changed - -- Зафіксовано поточні зміни npm-пакета. - -## [0.26.2] - 2026-07-17 - -### Changed - -- fix(agent-core): фоновий читач стріму AcpClient — не приліплює prelude-банер - -## [0.26.1] - 2026-07-16 - -### Changed - -- fix(agent-server): AcpTurnRunner без workdir бере абсолютний cwd поточного процесу замість "." — claude-agent-acp відкидав відносний шлях (ACP-спека вимагає абсолютний); задокументовано таблицю ACP-адаптерів (cursor/codex/claude/pi) у runtime.md, перевірено живими сесіями для трьох CLI - -## [0.26.0] - 2026-07-15 - -### Added - -- ACP-клієнт (agent-core: ndjson JSON-RPC v1-підмножина initialize/session/prompt/request_permission) + AcpTurnRunner в agent-server і agent-cli serve --acp-cmd / MT_ACP_AGENT_CMD; mt kill мігровано на mt-core lifecycle::kill (вузол без run-історії видаляється, сканер більше не бачить його waiting); headless-прапори CLI звірені живим спайком (claude --no-session-persistence, codex --sandbox workspace-write --ephemeral, pi --no-session) - -## [0.25.3] - 2026-07-14 - -### Changed - -- Прибрано мертвий ключ `node_executor` із `.mt.json` (точку розширення видалено у PR #48; `mt-run-node` видалено з @7n/rules) і згадку зовнішнього екзекутора з файлової доки run - -## [0.25.2] - 2026-07-14 - -### Changed - -- run.mjs — тонкий клієнт Rust-раннера (mt-core/napi run_node/run_auto); makeWorktreeName видалено з core/worktree.mjs (іменування run-worktree — у Rust) - -## [0.25.1] - 2026-07-14 - -### Changed - -- feat(mt): Rust-порт run-оркестрації до паритету; run.mjs — тонкий клієнт mt-core (#47) - -## [0.25.0] - 2026-07-14 - -### Added - -- docs: нова глава architecture/mandates.md (карта мандатів, профілі людей/моделей, decision-request, ескалація за важелем) + оновлені vision/roadmap/index/overview - -### Changed - -- docs: профілі людей — синтез C+E: org-репо people-profiles (читання колективу, пише лише агрегатор), positive-only досьє підтверджень з evidence, dispute через підпис власника мандата -- docs: інверсія делегування в тезі vision.md + пакетна межа contract/napi/mt у stack.md (інтеграція spec-ів з PR #22/#23 у канон) -- docs: stack.md — пакетний поділ contract/napi/mt прибрано (YAGNI без зовнішніх споживачів); лишається @7n/mt-contract + conformance-suite двома add-only PR -- ACP — єдиний транспорт AI-викликів: конфіг виконавців user-level ENV (MT_AGENT_CLI / MT_CLOUD_AGENT_CLIS / MT_AGENT_CLI_MODEL_MAP), каскад хмарних підписок за rate-limit, канон тирів MIN/AVG/MAX без legacy, підписочні CLI claude|codex|cursor|pi, спільний ## Check-гейт - -### Removed - -- Точку розширення `node_executor` видалено (`.mt.json`-ключ, `spawnNodeExecutor`/`resolveExecutorResult`/`parseExecutorSpec` у `mt run`, розділ «Зовнішній екзекутор вузла» runtime.md): останній консюмер `n-cursor mt-run-node` мігрував на вбудований шлях підписочних CLI (`claude|codex|cursor|pi`, user-level ENV-конфіг) за ADR `260713-2110` «ACP — єдиний транспорт AI-викликів» — паралельний виконавчий шлях більше не потрібен - -## [0.24.1] - 2026-07-12 - -### Changed - -- feat(M2): ws-рівень кооперативного handoff — seed_journal + AppState API (#40) - -## [0.24.0] - 2026-07-12 - -### Changed - -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді - -## [0.23.0] - 2026-07-12 - -### Changed - -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді - -## [0.22.0] - 2026-07-12 - -### Changed - -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді - -## [0.21.0] - 2026-07-12 - -### Changed - -- Нема змін у коді -- Нема змін у коді -- Нема змін у коді - -## [0.20.0] - 2026-07-12 - -### Changed - -- Нема змін у коді -- Нема змін у коді - -## [0.19.0] - 2026-07-12 - -### Changed - -- Нема змін у коді - -## [0.18.0] - 2026-07-11 - -### Changed - -- Нема змін у коді -- Нема змін у коді - -## [0.17.0] - 2026-07-11 - -### Changed - -- Нема змін у коді - -## [0.16.0] - 2026-07-11 - -### Changed - -- docs(runtime.md): протокол v4 — мінорне розширення Event: `DoneSession {}` (завершити run — fenced publish fact) і `ReleaseSession {}` (пауза — CAS-delete claim, журнал лишається в run ref) - -## [0.15.0] - 2026-07-11 - -### Added - -- ✨ feat(runner): точка розширення `node_executor` — виконання вузла зовнішньою командою замість вбудованого Claude-шляху (тир-канон через MT_MODEL_TIER, ## Check + синтез fact лишаються за MT) - -## [0.14.3] - 2026-07-11 - -### Changed - -- ✨ feat(agent-server): graph-міст — інтерактивний run вузла поверх mt-core (M1) (#28) - -## [0.14.2] - 2026-07-11 - -### Changed - -- 📝 docs(adr): нормалізація чернеток — консолідація 10 драфтів у 3 фінальні записи (#21) - -## [0.14.1] - 2026-07-11 - -### Changed - -- 📝 docs(adr): нормалізація чернеток — консолідація 10 драфтів у 3 фінальні записи (#21) - -## [0.14.0] - 2026-07-08 - -### Changed - -- chore: .mt.json закомічено (схема в @nitra/cursor 14.12.1), бамп @nitra/cursor, oxfmt у hk-ланцюжку npm-tsc-types - -## [0.13.0] - 2026-07-08 - -### Changed - -- docs: retro.md — інновації з baseline та impact-вимірюванням, заохочення людей (профіль вкладу) і агентів (відбір); roadmap M5 impact-критерій - -## [0.12.0] - 2026-07-08 - -### Added - -- docs: глава retro.md (мета-цикл) + M5 у roadmap; M0 dogfood: mt/ ініціалізовано, перша задача m1-agent-protocol - -## [0.11.1] - 2026-07-08 - -### Added - -- docs: файлові поведінкові доки (`<dir>/docs/<stem>.md`) для 47 кодових файлів — згенеровано локальним docgen-конвеєром; нотатка про upstream-баг rollback-у lint doc-files (nitra/cursor#16) - -## [0.11.0] - 2026-07-08 - -### Changed - -- docs: пакет рішень по 12 відкритих питаннях — нові surfaces.md і roadmap.md, протокол v4 (lang), гібридний live-i18n, checkpoint-handoff, життєвий цикл ключів, design envelope -- docs: курування — видалено review-response.md і mt-impl.md (приклад перенесено у graph.md + 0.3.0-продовження), mt.md заморожено; додано глосарій, конфіг-довідник, trust-матрицю, протокольні помилкові гілки, схему mcp_servers, розділ «Ніша» - -### Fixed - -- CI: n-cursor lint ga/text (новий синтаксис CLI), eslint-помилки unicorn у lib, стабілізація rmSync у тестах, лічильник ADR 175 - -## [0.10.0] - 2026-07-07 - -### Changed - -- docs: vision — мета-цикл ретроспективного самопокращення процесу (аналіз audit trail, пропозиції кращих skills/інструментів) - -### Fixed - -- CI: n-cursor lint ga/text (новий синтаксис CLI), eslint-помилки unicorn у lib, стабілізація rmSync у тестах, лічильник ADR 175 - -## [0.9.0] - 2026-07-07 - -### Added - -- docs: глава архітектури i18n.md — багатомовність (base-канон, derived-переклади у refs/mt/i18n, worktree-матеріалізація, contract-aware перекладач) - -## [0.8.0] - 2026-07-07 - -### Added - -- docs: зафіксовано мету проєкту (vision.md) — платформа задач для людей і ШІ, пʼять крос-вимірів - -## [0.7.0] - 2026-07-07 - -### Changed - -- docs: об'єднана цільова архітектура 0.3.0-draft — мердж графа задач mt.md і scaffold-spec v4 (пристрої/сесії/relay); реструктуризовано у глави docs/architecture/ з OKF-індексами (docs/index.md, docs/log.md) та frontmatter; mt.md позначено deprecated як цільова картина, лишається контрактом @7n/mt@0.2.x - -## [0.6.0] - 2026-07-04 - -### Changed - -- Ядро (nnn, frontmatter, state, config, worktree, scanner) перенесено в Rust-крейт mt-core; lib/core — тонкі обгортки над napi-addon (native.mjs loader), vitest-сюїта як conformance gate - -## [0.5.1] - 2026-06-18 - -### Fixed - -- worktree: lint-чистота нового `mt worktree` (oxlint+eslint) — повний JSDoc, case-дужки, `catch error`, `Object.hasOwn`, static regex, noop-мок. Файл уперше проходить CI-лінт (раніше був untracked). Поведінка незмінна, 17/17 тести. - -## [0.5.0] - 2026-06-18 - -### Changed - -- worktree: dev-команду вирівняно під контракт worktree-lifecycle (спека cursor docs/specs/2026-06-16-worktree-lifecycle-to-mt.md) — `@7n/mt` стає власником worktree-керування, на яке спиратиметься `@nitra/cursor`. Без зворотної сумісності: `add` → `create`; додано `prune` (прибрати осиротілі інвентарі) та `inventory` (JSON-стан для task-graph); інвентар перенесено у `<worktrees_dir>/.meta/<sanit>.md` (тепер `.worktrees/` містить лише worktree-каталоги + `.meta/`); `create` отримав `firstFreeBranch` (колізія → `<branch>2`/`3`…), обовʼязковий опис і dirty-notice з переліком ≤10 файлів. `remove` лишається ефемерним (прибирає checkout + git-гілку). sanitizeBranch синхр. з Rust `sanitize_branch`. Бенчмарк JS vs Rust (Node-wrapper як entry) → логіка лишається в JS. - -## [0.4.2] - 2026-06-15 - -### Changed - -- відповідь на рецензію mt.md (9 вирішено, 4 misread): single publish owner, deferred cascade, fenced bot push, dep addressing, cleanup command - -## [0.4.1] - 2026-06-14 - -### Changed - -- Прибрано дублювання audit/done/failed (спільний core/task-command.mjs: writeRunFile+resolveTaskPath); видалено мертвий export hasPendingAudit; jscpd ігнорує tooling-дзеркала й markdown. - -## [0.4.0] - 2026-06-14 - -### Changed - -- init: створення задачі делеговано Rust-бінарнику mt-scanner (mt-scanner create); прибрано JS-авторинг task.md (buildTaskFrontMatter). Виконавець визначає прапор a.md/h.md; валідація імен у Rust+JS зі спільними тест-векторами. - -## [0.3.1] - 2026-06-13 - -### Changed - -- Уточнення протоколу mt.md: single publish owner, deferred cascade, GC refs, spawn failure handling, recovery tree, claim_grace_sec limits, integration bot atomic push -- npm/docs/mt.md: dep-id завжди абсолютний від tasks-root; deps/ дзеркалює структуру mt/; виправлено неоднозначну "sibling shorthand" адресацію -- npm/docs/mt.md: mt done/mt audit — integrity check task.md/a.md/h.md проти origin/main і ephemeral file guard для run-draft.md (git diff --cached) -- npm/docs/mt.md: schema_version backward compatibility — orchestrator читає всі відомі версії, відмовляє лише майбутні -- npm/docs/mt.md: mt invalidate зупиняє running процес внутрішньо (SIGTERM + CAS-delete claim); patch protocol виправлено — mt kill замінено на mt invalidate -- npm/docs/mt.md: новий розділ "Ролі: Orchestrator і Runner" з описом distributed deployment - -## [0.3.0] - 2026-06-13 - -### Changed - -- scanner делегує сканування Rust-бінарнику mt-scanner; prebuilt-бінарники через optionalDependencies (@7n/mt-darwin-arm64, @7n/mt-linux-x64) - -## [0.2.0] - 2026-06-11 - -### Added - -- додано standalone Meta-task CLI, файловий runtime і task orchestration - -### Changed - -- mt.md: редизайн специфікації — derived-стани (failed_streak, unresolvable), блокуючий аудит-гейт з clarification-циклом, plan-review через ## Children, retry ladder, inline-фаза планування, security model, cost ledger, наскрізний приклад - -### Fixed - -- опубліковано повний TypeScript declaration graph для re-exported API `@7n/mt` diff --git a/npm/README.md b/npm/README.md deleted file mode 100644 index ecd9f91..0000000 --- a/npm/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# @7n/mt - -Standalone Meta-task CLI for task orchestration in multi-agent development workflows. - -## Installation - -```bash -npm install @7n/mt -# або -bun add @7n/mt -``` - -## Usage - -```bash -mt setup # Ініціалізувати mt в проекті (.mt.json, mt/) -mt init <name> # Створити нову задачу -mt plan <name> # Спланувати задачу -mt verify # Перевірити задачу з її директорії -mt status [name] [--json] # Показати статус задач -mt run <name> # Запустити задачу -mt scan # Сканувати і синхронізувати стан -mt watch # Спостерігати за змінами задач -mt audit <name> # Контроль готовності задачі -mt done <name> # Позначити як завершену -mt failed <name> # Позначити як невдалу -mt spawn <name> # Породити дочірню задачу -mt invalidate <name> # Інвалідувати задачу -mt kill <name> # Зупинити виконання задачі -``` - -## Configuration - -`mt` reads `.mt.json` at the project root. Default `mt_dir` is `./mt`. - -```json -{ "mt_dir": "./mt" } -``` - -## License - -ISC diff --git a/npm/bin/mt.js b/npm/bin/mt.js deleted file mode 100755 index 084514e..0000000 --- a/npm/bin/mt.js +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { runMtCli } from '../index.js' - -process.exitCode = await runMtCli(process.argv.slice(2)) diff --git a/npm/docs/stryker.config.md b/npm/docs/stryker.config.md deleted file mode 100644 index 6e97258..0000000 --- a/npm/docs/stryker.config.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: JS Module -title: stryker.config.mjs -resource: npm/stryker.config.mjs -docgen: - crc: 30a300f0 - model: omlx/gemma-4-e2b-it-4bit - score: 90 ---- - -## Огляд - -Я готовий проаналізувати чорнетку відповідно до ваших вимог. Будь ласка, надайте мені саму чорнетку, яку потрібно перевірити. - -## Поведінка - -Поведінка - -1. Запускає тести з використанням Vitest -2.Налаштовує конфігурацію Vitest через файл vitest.config.js -3.Увімкне аналіз покриття на рівні кожного тесту -4.Використовує ізоляцію мутантів у пам'яті за допомогою AST-patching -5.Зберігає результати між запусками для відновлення після аварійного завершення -6.Виключає тести з іменем за замовленням за замовчуванням - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/npm/docs/vitest.config.md b/npm/docs/vitest.config.md deleted file mode 100644 index b575380..0000000 --- a/npm/docs/vitest.config.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: JS Module -title: vitest.config.js -resource: npm/vitest.config.js -docgen: - crc: 5043a576 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Overview: Файл виконує запуск тестів з файлів `*.test.{js,mjs}` та top-level integration suites у директорії `tests`. Інструмент забезпечує безпеку та надійність тестування шляхом виключення директорій `node_modules`, `dist` та `reports/stryker` з процесу тестування. - -## Поведінка - -Поведінка - -1. Запускає тести з файлів `*.test.{js,mjs}` та top-level integration suites у `tests/` -2. Виключає з тестування директорії `node_modules` -3. Виключає з тестування директорії `dist` -4. Виключає з тестування директорії `reports/stryker` -5. Використовує ізоляцію процесів через `forks` для гарантування безпеки від випадкової зміни робочої директорії - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Свідомо пропускає шляхи: `node_modules`. diff --git a/npm/index.js b/npm/index.js deleted file mode 100644 index 3e65603..0000000 --- a/npm/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { runMtCli, COMMAND_NAMES, DEFAULT_HANDLERS } from './lib/cli.mjs' -export { getBody, serializeYaml } from './lib/core/frontmatter.mjs' -export { padNNN, latestPendingAuditNNN, latestAuditResultNNN } from './lib/core/nnn.mjs' -export { findTasks, getActiveWorktrees, parseWorktreeList } from './lib/core/scanner.mjs' - -export const version = '0.1.0' diff --git a/npm/lib/cli.mjs b/npm/lib/cli.mjs deleted file mode 100644 index cfa15b7..0000000 --- a/npm/lib/cli.mjs +++ /dev/null @@ -1,152 +0,0 @@ -export const COMMAND_NAMES = [ - 'setup', - 'init', - 'plan', - 'verify', - 'run', - 'status', - 'scan', - 'watch', - 'audit', - 'done', - 'failed', - 'spawn', - 'invalidate', - 'kill', - 'worktree' -] - -// Lazy loaders for dynamic imports -const LAZY_HANDLERS = { - setup: () => import('./commands/setup.mjs'), - init: () => import('./commands/init.mjs'), - plan: () => import('./commands/plan.mjs'), - verify: () => import('./commands/verify.mjs'), - run: () => import('./commands/run.mjs'), - status: () => import('./commands/status.mjs'), - scan: () => import('./commands/scan.mjs'), - watch: () => import('./commands/watch.mjs'), - audit: () => import('./commands/audit.mjs'), - done: () => import('./commands/done.mjs'), - failed: () => import('./commands/failed.mjs'), - spawn: () => import('./commands/spawn.mjs'), - invalidate: () => import('./commands/invalidate.mjs'), - kill: () => import('./commands/kill.mjs'), - worktree: () => import('./commands/worktree.mjs') -} - -// Wrapper to normalize handlers -export const DEFAULT_HANDLERS = new Proxy(LAZY_HANDLERS, { - get(target, prop) { - if (typeof prop !== 'string') return target[prop] - if (!Object.hasOwn(target, prop)) return - // Return wrapped lazy loader - return async (args, deps) => { - const commandModule = await target[prop]() - return commandModule.default(args, deps) - } - } -}) - -const HELP_TEXT = `mt — Meta-task CLI - -Usage: - mt <command> [options] - -Commands: - setup Ініціалізувати mt в проекті - init Створити нову задачу - plan Спланувати задачу - verify Перевірити задачу - run Запустити задачу - status Показати статус задач - scan Сканувати проект - watch Спостерігати за задачами - audit Контролювати задачі - done Позначити задачу як завершену - failed Позначити задачу як не вдалу - spawn Створити нову задачу - invalidate Інвалідувати задачу - kill Зупинити задачу - worktree Керування developer git-worktrees (create|remove|list|prune|inventory) - -Options: - --help Показати цю довідку - --version Показати версію - --root DIR Виконати команду в іншому корені проекту -` - -/** - * Відділяє global CLI options від аргументів конкретної команди. - * @param {string[]} argv сирі CLI аргументи - * @returns {{ args: string[], cwd?: string, error?: string }} результат парсингу - */ -function parseGlobalOptions(argv) { - const args = [] - let cwd - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === '--root' || arg === '--cwd') { - const value = argv[i + 1] - if (!value) return { args, error: `${arg} потребує шлях` } - cwd = value - i++ - continue - } - if (arg.startsWith('--root=')) { - cwd = arg.slice('--root='.length) - continue - } - if (arg.startsWith('--cwd=')) { - cwd = arg.slice('--cwd='.length) - continue - } - args.push(arg) - } - - return { args, cwd } -} - -/** - * Запускає mt CLI: парсить argv, маршрутизує до обробника команди. - * @param {string[]} argv аргументи командного рядка (без node/script) - * @param {{ handlers?: object, version?: string }} [deps] ін'єкції (handlers, version) - * @returns {Promise<number>} exit code (0=OK, 1=помилка) - */ -export async function runMtCli(argv, deps = {}) { - const { handlers = DEFAULT_HANDLERS, version = '0.1.0' } = deps - - // Parse flags - if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) { - console.log(HELP_TEXT) - return 0 - } - - if (argv.includes('--version') || argv.includes('-v')) { - console.log(`mt ${version}`) - return 0 - } - - const parsed = parseGlobalOptions(argv) - if (parsed.error) { - console.error(parsed.error) - return 1 - } - - const [command, ...args] = parsed.args - const handler = handlers[command] - - if (!handler) { - console.error(`Невідома команда: ${command}`) - console.error(`Виконайте "mt --help" для довідки`) - return 1 - } - - try { - return await handler(args, { ...deps, cwd: parsed.cwd ?? deps.cwd }) - } catch (error) { - console.error(`❌ Помилка при виконанні команди "${command}":`, error.message) - return 1 - } -} diff --git a/npm/lib/commands/audit.mjs b/npm/lib/commands/audit.mjs deleted file mode 100644 index e7f49b0..0000000 --- a/npm/lib/commands/audit.mjs +++ /dev/null @@ -1,107 +0,0 @@ -/** - * `mt audit <path>` — аудит → creates pending-audit_NNN.md, merge worktree. - * - * FS і child_process ін'єктуються для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { buildMarkdown } from '../core/frontmatter.mjs' -import { latestFactNNN, nextRunNNN } from '../core/nnn.mjs' -import { loadConfig, resolveMtDir, resolveWorktreesDir } from '../core/config.mjs' -import { findTaskWorktree, mergeWorktree } from '../core/worktree.mjs' -import { resolveTaskPath, writeRunFile } from '../core/task-command.mjs' - -/** - * `mt audit <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function audit(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' })) - const nowFn = deps.now ?? (() => new Date().toISOString()) - - const { taskPath, error } = resolveTaskPath(args, { env: deps.env, cwd: root }) - if (!taskPath) { - log(`audit: ${error}`) - return 1 - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const worktreesDir = resolveWorktreesDir(config, root) - const taskDir = join(mtDir, taskPath) - - if (!exists(join(taskDir, 'task.md'))) { - log(`audit: задача "${taskPath}" не знайдена`) - return 1 - } - - // Знаходимо latest fact_NNN.md NNN - const factNNN = latestFactNNN(taskDir, readdir) - if (!factNNN) { - log(`audit: fact_NNN.md не знайдено для "${taskPath}" — спершу виконайте задачу`) - return 1 - } - - // Створюємо pending-audit_NNN.md - const pendingPath = join(taskDir, `pending-audit_${factNNN}.md`) - if (exists(pendingPath)) { - log(`audit: ${pendingPath} вже існує — audit вже запитано`) - return 1 - } - - const pendingContent = buildMarkdown( - { - created_at: nowFn(), - fact_ref: `fact_${factNNN}.md`, - actor: 'agent' - }, - '' - ) - - try { - writeFile(pendingPath, pendingContent, 'utf8') - log(`audit: створено ${pendingPath}`) - } catch (error) { - log(`audit: не вдалося записати ${pendingPath} — ${error.message ?? String(error)}`) - return 1 - } - - // Записуємо run_NNN.md - const nnn = nextRunNNN(taskDir, readdir) - try { - writeRunFile(taskDir, nnn, 'success', { actor: 'agent', now: nowFn() }, writeFile) - log(`audit: записано run_${nnn}.md`) - } catch (error) { - log(`audit: не вдалося записати run_${nnn}.md — ${error.message ?? String(error)}`) - } - - // Мерджимо worktree агента - const worktreePath = findTaskWorktree(taskPath, worktreesDir, { - readdirSync: readdir, - execSync: execSyncFn - }) - - if (worktreePath) { - const mergeResult = mergeWorktree(worktreePath, root, { execSync: execSyncFn }) - if (mergeResult.ok) { - log(`audit: agent worktree merged і видалено`) - } else { - log(`audit: merge не вдався — ${mergeResult.error}`) - } - } - - log(`audit: запит аудиту для "${taskPath}" (fact_${factNNN}.md) успішно створено`) - return 0 -} diff --git a/npm/lib/commands/docs/audit.md b/npm/lib/commands/docs/audit.md deleted file mode 100644 index 952fc71..0000000 --- a/npm/lib/commands/docs/audit.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -type: JS Module -title: audit.mjs -resource: npm/lib/commands/audit.mjs -docgen: - crc: c1408ba4 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд: Файл створює чернетку аудиту для запису змін у робочий простір, використовуючи ін'єкцію файлових систем та `child_process` для тестування. Він перехоплює помилки, не генеруючи винятків, і не використовує кешування. - -## Поведінка - -Поведінка - -1. Визначає кореневий каталог роботи -2. Визначає логування -3. Визначає функцію читання файлів -4. Визначає функцію запису файлів -5. Визначає функцію читання директорій -6. Визначає функцію існування файлів -7. Визначає функцію виконання команд -8. Визначає функцію отримання поточного часу -9. Визначає шлях до завдання -10. Перевіряє наявність завдання у директорії -11. Знаходить останній факт -12. Перевіряє наявність факту для завдання -13. Створює тимчасовий аудитний файл -14. Записує файл аудиту -15. Записує файл запуску -16. Шукає шлях до робочого дерева агента -17. Мержить робочий простір агента -18. Повертає код виходу - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/done.md b/npm/lib/commands/docs/done.md deleted file mode 100644 index 7c915df..0000000 --- a/npm/lib/commands/docs/done.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -type: JS Module -title: done.mjs -resource: npm/lib/commands/done.mjs -docgen: - crc: c8a1e470 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд: Файл визначає корінь середовища, логування, операції з файлами та директоріями, а також виконання команд. Служить для ініціації процесу, що включає читання, запис та мердж частини worktree. - -## Поведінка - -Поведінка - -1. Визначає корінь середовища роботи. -2. Визначає логування. -3. Визначає функцію читання файлів. -4. Визначає функцію запису файлів. -5. Визначає функцію читання директорій. -6. Визначає функцію існування файлів. -7. Визначає функцію виконання команд. -8. Обчислює шлях до завдання. -9. Завантажує конфігурацію. -10. Визначає директорію для тестування. -11. Визначає директорію для worktree. -12. Перевіряє наявність файлу task.md у директорії. -13. Генерує запис run_NNN.md з результатом success. -14. Записує run_NNN.md у файл. -15. Логує запис run_NNN.md з результатом success. -16. Знаходить шлях до worktree. -17. Мержить worktree. -18. Якщо мерж не вдалося, логує помилку. -19. Якщо worktree не знайдено, логує пропуск мержу. -20. Завершує завдання з повідомленням про успішне завершення. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/failed.md b/npm/lib/commands/docs/failed.md deleted file mode 100644 index 3228a79..0000000 --- a/npm/lib/commands/docs/failed.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -type: JS Module -title: failed.mjs -resource: npm/lib/commands/failed.mjs -docgen: - crc: 721b4c07 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Я готовий перевірити надану чорнетку. Будь ласка, надайте текст, який потрібно проаналізувати. - -## Поведінка - -Поведінка - -1. Визначає кореневий каталог роботи з вхідними аргументами -2. Визначає логування для виведення повідомлень -3. Визначає функцію читання файлів для роботи з FS -4. Визначає функцію запису файлів для роботи з FS -5. Визначає функцію читання директорій для роботи з FS -6. Визначає функцію перевірки існування файлів -7. Отримує шлях до завдання та помилку для обробки -8. Перевіряє наявність визначеного шляху завдання -9. Завантажує конфігурацію для ініціалізації -10. Визначає директорію для роботи з міграцією -11. Визначає директорію для завдання на основі шляху -12. Перевіряє наявність файлу task.md у директорії завдання -13. Отримує дані для генерації результату -14. Викликає функцію для генерації результату з помилкою -15. Записує файл run\_NNN.md з результатом failed -16. Логує запис run\_NNN.md з результатом failed -17. Логує, що завдання позначено як failed і worktree збережено для діагностики - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/index.md b/npm/lib/commands/docs/index.md deleted file mode 100644 index 96ee1db..0000000 --- a/npm/lib/commands/docs/index.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -type: Directory Index -title: npm/lib/commands -resource: npm/lib/commands/ ---- - -| Файл | Тип | -| ------------------------------- | --------- | -| [audit.mjs](audit.md) | JS Module | -| [done.mjs](done.md) | JS Module | -| [failed.mjs](failed.md) | JS Module | -| [init.mjs](init.md) | JS Module | -| [invalidate.mjs](invalidate.md) | JS Module | -| [kill.mjs](kill.md) | JS Module | -| [plan.mjs](plan.md) | JS Module | -| [run.mjs](run.md) | JS Module | -| [scan.mjs](scan.md) | JS Module | -| [setup.mjs](setup.md) | JS Module | -| [spawn.mjs](spawn.md) | JS Module | -| [status.mjs](status.md) | JS Module | -| [verify.mjs](verify.md) | JS Module | -| [watch.mjs](watch.md) | JS Module | -| [worktree.mjs](worktree.md) | JS Module | diff --git a/npm/lib/commands/docs/init.md b/npm/lib/commands/docs/init.md deleted file mode 100644 index c9ede8e..0000000 --- a/npm/lib/commands/docs/init.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -type: JS Module -title: init.mjs -resource: npm/lib/commands/init.mjs -docgen: - crc: da578e8d - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Файл створює шаблон `task.md` для нової задачі. Він потрібен для ініціалізації нової роботи через бінарник `mt-scanner`. - -## Поведінка - -Поведінка - -1. parseInitArgs - Розбирає аргументи командного рядка для визначення імені та прапорців -2. init - Обробник команди для ініціалізації нової задачі - Перевіряє валідність імені за допомогою validateTaskName - Завантажує конфігурацію - Визначає шлях до директорії для задачі через resolveMtDir - Викликає бінарник mt-scanner підкоманду create - Парсить результат виводу - Перевіряє статус виходу бінарника - Перевіряє, чи було створено нову задачу - Записує інформацію про створення або пропуск існуючої задачі - -## Публічний API - -parseInitArgs — розбирає аргументи командного рядка `mt init`: перший non-flag токен — ім'я, решта — прапорці (прокидаються у бінарник вербатим; авторитетний парсинг у Rust). - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/invalidate.md b/npm/lib/commands/docs/invalidate.md deleted file mode 100644 index fb6cc7b..0000000 --- a/npm/lib/commands/docs/invalidate.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -type: JS Module -title: invalidate.mjs -resource: npm/lib/commands/invalidate.mjs -docgen: - crc: 8509a014 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Я готовий проаналізувати надану чорнетку відповідно до ваших критеріїв. Будь ласка, надайте саму чорнетку, яку потрібно перевірити. - -## Поведінка - -Поведінка - -1. Визначається шлях до кореня робочої директорії за замовчуванням або через ін'єкцію. -2. Визначається логер для виведення інформації, за замовчуванням `console.log`. -3. Визначається функція для читання файлів для тестованості. -4. Визначається функція для запису файлів для тестованості. -5. Визначається функція для читання директорій для тестованості. -6. Визначається функція для перевірки існування файлів для тестованості. -7. Перевіряється наявність аргументу для вимкнення каскадної інвалідації. -8. Якщо аргумент для вимкнення каскадної інвалідації не заданий, використовується каскадна інвалідація. -9. Позначається поточна задача як інвалідована записом порожнього файлу `invalidated` у директорії задачі. -10. Перевіряється наявність задачі за вказаним шляхом. -11. Якщо задача не знайдена, повертається код помилки. -12. При записі інвалідованого стану відбувається спроба запису з порожнім вмістом. -13. У разі помилки запису інвалідованого стану, логується помилка. -14. Якщо каскадна інвалідація активна, збираються всі залежні задачі. -15. Для кожної залежної задачі перевіряється наявність інвалідованого стану. -16. Якщо інвалідований стан відсутній для залежної задачі, виконується спроба запису інвалідованого стану для неї. -17. У разі помилки запису інвалідованого стану для залежної задачі, помилка ігнорується. -18. Повертається код успіху у разі успішного завершення операції. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/kill.md b/npm/lib/commands/docs/kill.md deleted file mode 100644 index b95ed09..0000000 --- a/npm/lib/commands/docs/kill.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -type: JS Module -title: kill.mjs -resource: npm/lib/commands/kill.mjs -docgen: - crc: a44280f3 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Файл виконує примусове завершення роботи worktree, видаляючи його та пов'язані файли планування. Операція спрямована на каскадне інвалідування всіх залежних завдань. - -## Поведінка - -Поведінка - -1. Знайти worktree задачі -2. Видалити worktree примусово -3. Видалити файли планування -4. Записати маркер інвалідації для задачі -5. Каскадно інвалідувати залежні задачі - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/plan.md b/npm/lib/commands/docs/plan.md deleted file mode 100644 index 6bfaa86..0000000 --- a/npm/lib/commands/docs/plan.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: JS Module -title: plan.mjs -resource: npm/lib/commands/plan.mjs -docgen: - crc: d7b3396d - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл створює план виконання завдання, зчитуючи деталі з `task.md`, визначає ідентифікатор, генерує шаблон плану за допомогою `buildPlanTemplate`, налаштовує режим агента, якщо це необхідно, і записує результат у файл `plan_NNN.md`, а також формує вивід для агента чи людини за допомогою `taskContent` - -## Поведінка - -Поведінка - -1. Зчитати задачі з task.md -2. Визначити наступний ідентифікатор NNN -3. Сформувати шаблон plan_NNN.md з використанням buildPlanTemplate -4. Якщо вказано режим agent, встановити mode:agent у front-matter -5. Записати результат у файл plan_NNN.md -6. Сформувати вивід контексту для агента або людини з використанням taskContent - -## Публічний API - -Я готовий переписати список відповідно до ваших вимог. Надайте мені список, який потрібно переформулювати. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/run.md b/npm/lib/commands/docs/run.md deleted file mode 100644 index 0473065..0000000 --- a/npm/lib/commands/docs/run.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: JS Module -title: run.mjs -resource: npm/lib/commands/run.mjs -docgen: - crc: 0b4eee0d - model: omlx/gemma-4-e2b-it-4bit - score: 95 ---- - -## Огляд - -Обробник команди `mt run [<path>] [--actor a] [--auto]` — запуск задачі (або всіх готових задач) в ізольованому git worktree з обраним виконавцем і фіксацією результату артефактами `run_NNN.md`/`fact_NNN.md`. - -## Поведінка - -1. Читає `task.md` вузла: `budget_sec`, `budget_hard_sec`, `deps`, `mode`, `executor`; для конкретного шляху перевіряє, що всі `deps` розв'язані. -2. Обчислює `NNN` наступного run і номер спроби `MT_ATTEMPT = failed_streak + 1`; резолвить щабель драбини ретраїв (`## Retry ladder` з `a.md` або дефолт base → diagnose-first → alternative-approach) у стратегію `MT_RETRY_STRATEGY` та ескалацію `model_tier` (MIN→AVG→MAX, cap на MAX). -3. Резолвить виконавця: `model_tier` — секція `## Model tier` у `a.md` → frontmatter → `default_model_tier`; підписочний CLI — секція `## Agent cli` у `a.md` (per-node) → user-level env `MT_AGENT_CLI` → `claude`. Невідомий `agent_cli` → відмова fail-fast ще до створення worktree. -4. Створює worktree `.worktrees/<task-epoch>/` (атомарний mkdir-lock; існує → задача вже запущена, skip). -5. Передає контекст env-змінними: `MT_RUN_NNN`, `MT_ATTEMPT`, `MT_RETRY_STRATEGY`, `MT_BUDGET_SEC`, `MT_HARD_BUDGET_SEC`, `MT_STARTED_AT`, `MT_TASK_PATH`, `MT_NODE_DIR`, `MT_WORKTREE`, `MT_RUN_TOKEN`, `MT_MODEL_TIER`, `MT_AGENT_CLI`. -6. Спавнить виконавця (синхронно, з hard-timeout за `budget_hard_sec`): - - **підписочний CLI** (єдиний agent-шлях) — headless-запуск за таблицею `AGENT_CLIS`: `claude --model … --no-session-persistence -p`, `codex exec -m … --sandbox workspace-write --ephemeral`, `cursor-agent --model … --print --force`, `pi --model … --no-session -p` (локальні omlx-моделі через pi.dev CLI); конкретну модель тиру резолвить user-level env `MT_AGENT_CLI_MODEL_MAP[<cli>][tier]`; без мапінгу прапор моделі не передається — CLI резолвить сам за підпискою користувача, тир завжди йде hint-ом `MT_MODEL_TIER`. Якщо результат схожий на вичерпані ліміти підписки (rate limit / quota / 429 у виводі), спрацьовує **каскад** env `MT_CLOUD_AGENT_CLIS`: наступний хмарний CLI у порядку `[обраний, ...каскад]` без дублів, модель — per-кандидат; фактичний CLI пишеться у frontmatter `run_NNN.md` (`agent_cli`); не-лімітні помилки каскад не запускають; - - **human** — виводить інструкції для ручного виконання і завершується без run-артефакту. -7. Визначає результат: success = `fact_NNN.md` існує **і** всі команди секції `## Check` завершились exit 0; інакше failed. -8. Пише `run_NNN.md` (success — у worktree, failed — у main-checkout, бо діагностичний worktree лишається незмердженим); за success мержить worktree у main і видаляє його, за failed зберігає worktree для діагностики. -9. Режим `--auto` сканує граф на готові задачі (`waiting` + resolved deps, топологічний порядок) і запускає кожну; перед запуском діють ліміт `max_worktrees` і попередження `warn_worktrees_above`. - -## Гарантії поведінки - -- ФС і `child_process` ін'єктуються залежностями — модуль тестований без реального диска і процесів. -- Помилки читання/створення worktree/запису артефактів не пропускаються назовні винятками: логуються і повертається код помилки. -- Невідомий actor або `agent_cli` — явна відмова з підказкою підтримуваних значень. diff --git a/npm/lib/commands/docs/scan.md b/npm/lib/commands/docs/scan.md deleted file mode 100644 index 79f92ba..0000000 --- a/npm/lib/commands/docs/scan.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -type: JS Module -title: scan.mjs -resource: npm/lib/commands/scan.mjs -docgen: - crc: 5fe3fb78 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Поведінка - -1. Визначає корінь проєкту -2. Ініціалізує конфігурацію -3. Визначає директорію для роботи з MTA -4. Отримує активні робочі дерева -5. Сканує всі завдання -6. Сортує завдання за залежностями -7. Підраховує кількість задач за станами -8. Визначає провалені завдання -9. Визначає завдання, що потребують аудиту -10. Визначає завдання, що потребують аудиту -11. Визначає завдання у стані pending -12. Визначає завдання у стані plan-review -13. Визначає завдання без виконавця -14. Визначає завдання у стані waiting, що мають розв'язані залежності -15. Перевіряє наявність проблем -16. Форматує вивід зведення -17. Якщо увімкнено JSON режим, генерує JSON для виведення -18. Якщо вимкнено JSON режим, генерує текстовий звіт про стан задач -19. Якщо є провалені завдання, виводить список провалів -20. Якщо є нерозв'язані завдання, виводити список нерозв'язаних -21. Якщо є завдання на аудиті, виводити список для аудиту -22. Якщо є завдання на перегляд плану, виводити список чекаючий на погодження -23. Якщо є нерозподілені завдання, виводити список без виконавця -24. Якщо є завдання у стані pending, виводити список чекаючий на людину -25. Якщо є готові до запуску завдання, виводити список для запуску -26. Повертає код виходу (0 для чисто, 1 для уваги) - -Changelog: Issues found in the overview section of the black sketch. - -## Поведінка - -Поведінка - -1. Визначає корінь проєкту -2. Ініціалізує конфігурацію -3. Визначає директорію для роботи з MTA -4. Отримує активні робочі дерева -5. Сканує всі завдання -6. Сортує завдання за залежностями -7. Підраховує кількість задач за станами -8. Визначає провалені завдання -9. Визначає завдання, що потребують аудиту -10. Визначає нерозв'язані завдання -11. Визначає завдання у стані pending -12. Визначає завдання у стані plan-review -13. Визначає завдання без виконавця -14. Визначає завдання у стані waiting, що мають розв'язані залежності -15. Перевіряє наявність проблем -16. Форматує вивід зведення -17. Якщо увімкнено JSON режим, генерує JSON для виведення -18. Якщо вимкнено JSON режим, генерує текстовий звіт про стан задач -19. Якщо є провалені завдання, виводить список провалів -20. Якщо є нерозв'язані завдання, виводить список нерозв'язаних -21. Якщо є завдання на аудиті, виводить список для аудиту -22. Якщо є завдання на перегляд плану, виводить список чекаючий на погодження -23. Якщо є нерозподілені завдання, виводить список без виконавця -24. Якщо є завдання у стані pending, виводить список чекаючий на людину -25. Якщо є готові до запуску завдання, виводить список для запуску -26. Повертає код виходу (0 для чисто, 1 для уваги) - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/commands/docs/setup.md b/npm/lib/commands/docs/setup.md deleted file mode 100644 index 003b127..0000000 --- a/npm/lib/commands/docs/setup.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -type: JS Module -title: setup.mjs -resource: npm/lib/commands/setup.mjs -docgen: - crc: d0c66ae4 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл ініціалізує проєкт для системи mt. Створює необхідні конфігурації та структури для управління завданнями. Створює .mt.json з дефолтними налаштуваннями (якщо не існує), створює директорію mt/, та генерує git hook (post-commit) для синхронізації стану Git. FS ін'єктується для тестованості. - -## Поведінка - -Поведінка - -1. Визначає корінь репозиторію через вхідні дані або поточний робочий каталог. -2. Створює файл .mt.json з дефолтними налаштуваннями у корені репозиторію, якщо він відсутній. -3. Створює директорію mt для організації завдань. -4. Створює директорію .worktrees для роботи з atomic worktree claims. -5. Перевіряє наявність директорії .git та, якщо вона існує, намагається визначити та створити git hook для автоматичного оновлення стану після коміту. -6. У разі невдалої операції створення файлів або директорій, логує помилку, але продовжує виконання. -7. Повертає exit code 0 у разі успішного завершення операції. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. -- Свідомо пропускає шляхи: `.git`. diff --git a/npm/lib/commands/docs/spawn.md b/npm/lib/commands/docs/spawn.md deleted file mode 100644 index 726c945..0000000 --- a/npm/lib/commands/docs/spawn.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -type: JS Module -title: spawn.mjs -resource: npm/lib/commands/spawn.mjs -docgen: - crc: f8c86400 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Я готовий проаналізувати надану чорнетку згідно з вашими критеріями. - -Будь ласка, надайте саму чорнетку, яку потрібно перевірити. - -## Поведінка - -Поведінка - -1. Визначає шлях задачі з аргументів командного рядка або змінної середовища -2. Якщо аргумент присутній і не починається з дефісу повертає його як шлях задачі -3. Якщо змінна середовища MT\_TASK\_PATH присутня і не порожня повертає її як шлях задачі -4. Якщо жоден шлях задачі не визначено повертає помилку "MT\_TASK\_PATH not set" -5. Завантажує конфігурацію -6. Визначає директорію основної задачі -7. Формує повний шлях до директорії задачі -8. Перевіряє наявність файлу task.md у директорії задачі -9. Якщо файл task.md відсутній повертає помилку "задача ... не знайдена" -10. Зчитує вміст дочірніх директорій задачі -11. Фільтрує дочірні директорії, відкидаючи ті, що починаються або закінчуються точкою і не є файлами з розширенням .md або .json -12. Для кожної пропущеної директорії перевіряє наявність файлу task.md у піддиректорії -13. Якщо кількість знайдених дочірніх задач з task.md дорівнює нулю, повідомляє, що задача не має дочірніх задач із task.md -14. Якщо кількість знайдених дочірніх задач з task.md дорівнює нулю, повідомляє, що для composite задачі потрібно створити дочірні директорії з task.md - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/npm/lib/commands/docs/status.md b/npm/lib/commands/docs/status.md deleted file mode 100644 index 68677ab..0000000 --- a/npm/lib/commands/docs/status.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -type: JS Module -title: status.mjs -resource: npm/lib/commands/status.mjs -docgen: - crc: c2c53fb1 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл `mt status` відображає поточний стан завдань у системі. Дозволяє переглядати виконання завдань за вказаним шляхом або відображати всі завдання. Дозволяє фільтрацію за конкретним шляхом для відображення лише задачі та її нащадків. Команда `--json` забезпечує машиночитаний вивід у форматі JSON. Файл не виконує операцій з ФС/БД. Кешування не використовується. - -## Поведінка - -1. Отримати кореневий каталог через ін'єкцію файлової системи або використовувати поточний робочий каталог. -2. Визначити шлях до директорії методології. -3. Отримати список активних робочих гілок. -4. Просканувати всі завдання в директорії методології, використовуючи визначені файлові системи. -5. Фільтрувати отримані вузли за вказаним шляхом, якщо він присутній. -6. Визначити порядок проходу завдань за допомогою топологічного сортування. -7. Якщо увімкнено режим JSON, сформувати масив завдань у машинозчитуваному форматі. -8. Якщо вимкнено режим JSON, визначити кількість завдань по кожному стану. -9. Вивести зведену статистику стану. -10. Ітерувати по відсортованому списку завдань. -11. Для кожного вузла вивести його шлях, стан, зв'язки та компонент. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/commands/docs/verify.md b/npm/lib/commands/docs/verify.md deleted file mode 100644 index 2f108af..0000000 --- a/npm/lib/commands/docs/verify.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -type: JS Module -title: verify.mjs -resource: npm/lib/commands/verify.mjs -docgen: - crc: 11d35c2e - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд: Файл виконує структурну перевірку, перевіряючи наявність та зміст файлу `fact_NNN.md` у поточній дирекції. Він призначений для забезпечення цілісності конфігураційних фактів шляхом перевірки їх існування та надання підтвердження через контекст. - -Поведінка - -1. Ініціалізація сесії. -2. Визначення поточного робочого каталогу. -3. Отримання даних фактів з директорії. -4. Перевірка існування та наявності `fact_NNN.md`. -5. Якщо факт не знайдено або порожній, повернення помилки. -6. Якщо факт знайдено, отримання тексту з `fact_NNN.md`. -7. Отримання даних з `task.md` для генерації звіту про завершення. -8. Виведення структури перевірки та контексту для самооцінки агента. -9. Повернення коду, що вказує на структурну відповідність. - -## Поведінка - -Поведінка - -1. Ініціалізація сесії. -2. Визначення поточного робочого каталогу. -3. Отримання даних фактів з директорії. -4. Перевірка існування та наявності `fact_NNN.md`. -5. Якщо факт не знайдено або порожній, повернення помилки. -6. Якщо факт знайдено, отримання тексту з `fact_NNN.md`. -7. Отримання даних з `task.md` для генерації звіту про завершення. -8. Виведення структури перевірки та контексту для самооцінки агента. -9. Повернення коду, що вказує на структурну відповідність. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/watch.md b/npm/lib/commands/docs/watch.md deleted file mode 100644 index c21b21a..0000000 --- a/npm/lib/commands/docs/watch.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: JS Module -title: watch.mjs -resource: npm/lib/commands/watch.mjs -docgen: - crc: 60cc96d4 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд: Цей файл виконує одноразовий збір стану задач з активних `worktrees` для моніторингу стану роботи системи. Знаходить завдання, які потребують ручного аудиту, застарілі робочі простори, необхідність планування, незавершені або нерозв'язані стани, а також логує помилки. - -## Поведінка - -Поведінка - -1. Сканувати стан задач, витягнутий з активних worktrees. -2. Знаходити завдання у стані pending-audit без audit-result. Логувати для ручного аудиту. -3. Знаходити worktrees, що є застарілими, перевищуючи встановлений мінімальний інтервал у хвилинах. Попереджати. -4. Знаходити завдання у стані needs-plan. Перелічувати. -5. Знаходити завдання у стані unassigned. Перелічувати. -6. Знаходити завдання у стані pending. Перелічувати. -7. Знаходити завдання у стані plan-review. Перелічувати. -8. Знаходити завдання у стані unresolvable. Логувати для уваги. -9. Знаходити завдання у стані failed. Логувати для уваги. -10. Якщо жодної з перерахованих помилок не знайдено, перевіряти кількість running та resolved задач. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/commands/docs/worktree.md b/npm/lib/commands/docs/worktree.md deleted file mode 100644 index fd09693..0000000 --- a/npm/lib/commands/docs/worktree.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -type: JS Module -title: worktree.mjs -resource: npm/lib/commands/worktree.mjs -docgen: - crc: 809028d8 - model: omlx/gemma-4-e2b-it-4bit - score: 70 ---- - -## Огляд - -Огляд -Файл керує життєвим циклом `worktree` для розробника, забезпечуючи створення, видалення та інвентаризацію робочих директорій. - -Поведінка - -1. Отримання аргументів команди -2. Перевірка наявності обов'язкового аргументу гілки для створення -3. Перевірка наявності опису для створення -4. Нормалізація імені гілки за допомогою `sanitizeBranch` -5. Перевірка наявності вже існуючого worktree -6. Вибір першої вільної назви гілки за допомогою `firstFreeBranch` -7. Перевірка наявності та видалення інвентарного файлу перед створенням -8. Створення нового worktree за допомогою `git worktree add` -9. Створення та запис інвентарного файлу в `.meta` -10. Створення та запис звітного повідомлення про успішне створення worktree -11. Визначення потрібної гілки для видалення -12. Перевірка наявності worktree для видалення -13. Видалення worktree та його git-гілки за допомогою `git worktree remove --force` -14. Видалення інвентарного файлу -15. Видалення git-гілки за допомогою `git branch -D` - -## Поведінка - -Поведінка - -1. Отримання аргументів команди -2. Перевірка наявності обов'язкового аргументу гілки для створення -3. Перевірка наявності опису для створення -4. Нормалізація імені гілки за допомогою `sanitizeBranch` -5. Перевірка наявності вже існуючого worktree -6. Вибір першої вільної назви гілки за допомогою `firstFreeBranch` -7. Перевірка наявності та видалення інвентаря перед створенням -8. Створення нового worktree за допомогою `git worktree add` -9. Створення та запис інвентарного файлу в `.meta` -10. Створення та запис звітного повідомлення про успішне створення worktree -11. Визначення потрібної гілки для видалення -12. Перевірка наявності worktree для видалення -13. Видалення worktree та його git-гілки за допомогою `git worktree remove --force` -14. Видалення інвентарного файлу -15. Видалення git-гілки за допомогою `git branch -D` - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/npm/lib/commands/done.mjs b/npm/lib/commands/done.mjs deleted file mode 100644 index e211aa8..0000000 --- a/npm/lib/commands/done.mjs +++ /dev/null @@ -1,78 +0,0 @@ -/** - * `mt done <path>` — успіх → пише run_NNN.md (success), мерджить worktree. - * - * FS і child_process ін'єктуються для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { nextRunNNN } from '../core/nnn.mjs' -import { loadConfig, resolveMtDir, resolveWorktreesDir } from '../core/config.mjs' -import { findTaskWorktree, mergeWorktree } from '../core/worktree.mjs' -import { resolveTaskPath, writeRunFile } from '../core/task-command.mjs' - -/** - * `mt done <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function done(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' })) - const nowFn = deps.now ?? (() => new Date().toISOString()) - - const { taskPath, error } = resolveTaskPath(args, { env: deps.env, cwd: root, exists, readFile }) - if (!taskPath) { - log(`done: ${error}`) - return 1 - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const worktreesDir = resolveWorktreesDir(config, root) - const taskDir = join(mtDir, taskPath) - - if (!exists(join(taskDir, 'task.md'))) { - log(`done: задача "${taskPath}" не знайдена`) - return 1 - } - - // Записуємо run_NNN.md - const nnn = nextRunNNN(taskDir, readdir) - try { - writeRunFile(taskDir, nnn, 'success', { actor: 'agent', now: nowFn() }, writeFile) - log(`done: записано run_${nnn}.md (result: success)`) - } catch (error) { - log(`done: не вдалося записати run_${nnn}.md — ${error.message ?? String(error)}`) - return 1 - } - - // Знаходимо і мерджимо worktree - const worktreePath = findTaskWorktree(taskPath, worktreesDir, { - readdirSync: readdir, - execSync: execSyncFn - }) - - if (worktreePath) { - const mergeResult = mergeWorktree(worktreePath, root, { execSync: execSyncFn }) - if (!mergeResult.ok) { - log(`done: merge не вдався — ${mergeResult.error}`) - return 1 - } - log(`done: worktree merged і видалено`) - } else { - log(`done: worktree не знайдено для "${taskPath}" — пропускаємо merge`) - } - - log(`done: задача "${taskPath}" успішно завершена`) - return 0 -} diff --git a/npm/lib/commands/failed.mjs b/npm/lib/commands/failed.mjs deleted file mode 100644 index 558c84e..0000000 --- a/npm/lib/commands/failed.mjs +++ /dev/null @@ -1,56 +0,0 @@ -/** - * `mt failed <path>` — провал → пише run_NNN.md (failed), залишає worktree. - * - * FS ін'єктується для тестованості. - */ -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { nextRunNNN } from '../core/nnn.mjs' -import { loadConfig, resolveMtDir } from '../core/config.mjs' -import { resolveTaskPath, writeRunFile } from '../core/task-command.mjs' - -/** - * `mt failed <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function failed(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - const nowFn = deps.now ?? (() => new Date().toISOString()) - - const { taskPath, error } = resolveTaskPath(args, { env: deps.env }) - if (!taskPath) { - log(`failed: ${error}`) - return 1 - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const taskDir = join(mtDir, taskPath) - - if (!exists(join(taskDir, 'task.md'))) { - log(`failed: задача "${taskPath}" не знайдена`) - return 1 - } - - // Записуємо run_NNN.md з result:failed - const nnn = nextRunNNN(taskDir, readdir) - try { - writeRunFile(taskDir, nnn, 'failed', { actor: 'agent', now: nowFn() }, writeFile) - log(`failed: записано run_${nnn}.md (result: failed)`) - } catch (error) { - log(`failed: не вдалося записати run_${nnn}.md — ${error.message ?? String(error)}`) - return 1 - } - - log(`failed: задача "${taskPath}" позначена як failed — worktree збережено для діагностики`) - return 0 -} diff --git a/npm/lib/commands/init.mjs b/npm/lib/commands/init.mjs deleted file mode 100644 index 5e2b25c..0000000 --- a/npm/lib/commands/init.mjs +++ /dev/null @@ -1,116 +0,0 @@ -/** - * `mt init <name>` — створює task.md шаблон для нової задачі. - * - * Тонкий шим: уся ФС-логіка авторингу (mkdir + task.md + прапор a.md/h.md + deps/) - * живе в Rust-крейті `mt-scanner` (підкоманда `create`) — єдине джерело істини, - * симетрично до `scan`. Тут лише: резолв mtDir, виклик бінарника, парсинг JSON. - * - * Ім'я може містити `/` для вкладених задач (напр. "research/collect-data"). - */ -import { spawnSync } from 'node:child_process' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir } from '../core/config.mjs' -import { scannerBin } from '../core/scanner-bin.mjs' -import { validateTaskName } from '../core/state.mjs' - -/** Прапорці, що приймають значення (forward-only до бінарника). */ -const VALUE_FLAGS = new Set(['--mode', '--model-tier', '--budget-sec', '--hint', '--dep']) - -/** - * Розбирає argv `mt init`: перший non-flag токен — ім'я, решта — прапорці - * (прокидаються в бінарник вербатим; авторитетний парсинг — у Rust). - * @param {string[]} args аргументи після `init` - * @returns {{ name: string | null, flags: string[], error?: string }} розібране ім'я, - * список прапорців для бінарника та опційний текст помилки парсингу - */ -export function parseInitArgs(args) { - let name = null - const flags = [] - for (let i = 0; i < args.length; i++) { - const a = args[i] - if (a.startsWith('--')) { - flags.push(a) - if (VALUE_FLAGS.has(a)) { - const v = args[i + 1] - if (v === undefined) return { name, flags, error: `init: прапор ${a} потребує значення` } - flags.push(v) - i++ - } - } else if (name === null) { - name = a - } else { - return { name, flags, error: `init: несподіваний аргумент ${a}` } - } - } - return { name, flags } -} - -/** - * `mt init <name> [flags]` command handler. - * @param {string[]} args аргументи: [name, ...flags] - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * spawnSync?: typeof spawnSync, - * binPath?: string, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean - * }} [deps] ін'єкції - * @returns {number} exit code (0 створено/існує, 1 usage/помилка) - */ -export default function init(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const run = deps.spawnSync ?? spawnSync - - const parsed = parseInitArgs(args) - if (parsed.error) { - log(parsed.error) - return 1 - } - const { name, flags } = parsed - if (!name) { - log('Usage: mt init <name> [--mode agent|human] [--model-tier MIN|AVG|MAX]') - log(' [--budget-sec N] [--hint <text>] [--dep <id>]...') - log(' name може містити / для вкладених задач (напр. "research/collect-data")') - return 1 - } - - const nameErr = validateTaskName(name) - if (nameErr) { - log(`init: невалідне ім'я — ${nameErr}`) - return 1 - } - - const config = loadConfig({ root, readFile: deps.readFile, exists: deps.exists }) - const mtDir = resolveMtDir(config, root) - const bin = deps.binPath ?? scannerBin() - - const res = run(bin, ['create', mtDir, name, ...flags], { encoding: 'utf8' }) - if (res.error) { - log(`init: не вдалося запустити mt-scanner — ${res.error.message ?? String(res.error)}`) - return 1 - } - if (res.status !== 0) { - log(`init: mt-scanner завершився з помилкою (exit ${res.status}): ${(res.stderr ?? '').trim()}`) - return 1 - } - - let out - try { - out = JSON.parse(res.stdout) - } catch { - log('init: некоректний JSON від mt-scanner') - return 1 - } - - const taskPath = join(mtDir, out.task_path) - if (out.created) { - log(`init: створено ${taskPath} (прапор ${out.flag})`) - } else { - log(`init: ${taskPath} вже існує — пропускаємо`) - } - return 0 -} diff --git a/npm/lib/commands/invalidate.mjs b/npm/lib/commands/invalidate.mjs deleted file mode 100644 index ac66c1a..0000000 --- a/npm/lib/commands/invalidate.mjs +++ /dev/null @@ -1,97 +0,0 @@ -/** - * `mt invalidate <path> [--no-cascade]` — позначає задачу як invalidated. - * - * Записує порожній файл `invalidated` у директорію задачі. - * За замовчуванням каскадно інвалідує всі залежні задачі. - * --no-cascade — лише поточна задача. - * - * FS ін'єктується для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir } from '../core/config.mjs' -import { scanTasks } from '../core/scanner.mjs' -import { listActiveWorktrees } from '../core/worktree.mjs' - -/** - * `mt invalidate <path> [--no-cascade]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * writeFile?: (p: string, c: string, enc: string) => void, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function invalidate(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' })) - - let taskPath = null - let noCascade = false - - for (const arg of args) { - if (arg === '--no-cascade') noCascade = true - else if (!arg.startsWith('-')) taskPath = arg - } - - if (!taskPath) { - log('Usage: mt invalidate <path> [--no-cascade]') - return 1 - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const taskDir = join(mtDir, taskPath) - - if (!exists(join(taskDir, 'task.md'))) { - log(`invalidate: задача "${taskPath}" не знайдена`) - return 1 - } - - // Записуємо invalidated sentinel - try { - writeFile(join(taskDir, 'invalidated'), '', 'utf8') - log(`invalidate: задача "${taskPath}" інвалідована`) - } catch (error) { - log(`invalidate: не вдалося записати invalidated — ${error.message ?? String(error)}`) - return 1 - } - - if (noCascade) return 0 - - // Каскадна інвалідація - const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn }) - const allNodes = scanTasks(mtDir, activeWorktrees, { - readdirSync: readdir, - existsSync: exists, - readFileSync: readFile - }) - - const dependents = allNodes.filter(n => n.deps.includes(taskPath)) - for (const dep of dependents) { - if (!exists(join(dep.dir, 'invalidated'))) { - try { - writeFile(join(dep.dir, 'invalidated'), '', 'utf8') - log(`invalidate: каскадна інвалідація "${dep.path}"`) - } catch { - // пропускаємо - } - } - } - - return 0 -} diff --git a/npm/lib/commands/kill.mjs b/npm/lib/commands/kill.mjs deleted file mode 100644 index c82b57d..0000000 --- a/npm/lib/commands/kill.mjs +++ /dev/null @@ -1,153 +0,0 @@ -/** - * `mt kill <path>` — вбиває worktree задачі і каскадно інвалідує нащадків. - * - * 1. Знаходить worktree задачі - * 2. Видаляє worktree (force) - * 3. Видаляє plan_*.md (скидає планування) - * 4. Файловий рівень — mt-core::lifecycle::kill: без run-артефактів вузол - * видаляється назавжди, інакше архівується у `.history/` - * 5. Каскадно інвалідує всі залежні задачі (sentinel `invalidated`) - * - * FS і child_process ін'єктуються для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir, resolveWorktreesDir } from '../core/config.mjs' -import { loadNative } from '../core/native.mjs' -import { scanTasks } from '../core/scanner.mjs' -import { findTaskWorktree, listActiveWorktrees, removeWorktree } from '../core/worktree.mjs' - -/** Regex для plan_NNN.md файлів. */ -const PLAN_FILE_RE = /^plan_\d+\.md$/ - -/** - * Записує invalidated sentinel для задачі. - * @param {string} taskDir директорія задачі - * @param {(p: string, c: string, enc: string) => void} writeFile функція запису - */ -function writeInvalidated(taskDir, writeFile) { - writeFile(join(taskDir, 'invalidated'), '', 'utf8') -} - -/** - * Видаляє plan_*.md файли з директорії задачі. - * @param {string} taskDir директорія задачі - * @param {string[]} files список файлів - * @param {(p: string) => void} unlink функція видалення - */ -function deletePlanFiles(taskDir, files, unlink) { - for (const f of files) { - if (PLAN_FILE_RE.test(f)) { - try { - unlink(join(taskDir, f)) - } catch { - // пропускаємо - } - } - } -} - -/** - * `mt kill <path>` command handler. - * @param {string[]} args аргументи: [path] - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * writeFile?: (p: string, c: string, enc: string) => void, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * unlink?: (p: string) => void, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function kill(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - const unlink = deps.unlink ?? unlinkSync - - const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' })) - - const [taskPath] = args - if (!taskPath) { - log('Usage: mt kill <path>') - return 1 - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const worktreesDir = resolveWorktreesDir(config, root) - - const taskDir = join(mtDir, taskPath) - if (!exists(join(taskDir, 'task.md'))) { - log(`kill: задача "${taskPath}" не знайдена`) - return 1 - } - - // 1. Знаходимо і видаляємо worktree - const worktreePath = findTaskWorktree(taskPath, worktreesDir, { - readdirSync: readdir, - execSync: execSyncFn - }) - - if (worktreePath) { - log(`kill: видаляємо worktree ${worktreePath}`) - removeWorktree(worktreePath, root, { execSync: execSyncFn }) - } else { - log(`kill: worktree не знайдено для "${taskPath}"`) - } - - // 2. Видаляємо plan_*.md - const files = readdir(taskDir) - deletePlanFiles(taskDir, files, unlink) - const planCount = files.filter(f => PLAN_FILE_RE.test(f)).length - if (planCount > 0) { - log(`kill: видалено ${planCount} plan_*.md файл(ів)`) - } - - // 3. Файловий рівень — mt-core::lifecycle::kill (одна імплементація - // контракту): без run-артефактів вузол видаляється назавжди, інакше - // архівується у `.history/`. - try { - const outcome = loadNative().killNode(mtDir, taskPath) - if (outcome.startsWith('deleted:')) { - log(`kill: задача "${taskPath}" видалена (run-історії не було)`) - } else { - log(`kill: задача "${taskPath}" архівована → ${outcome}`) - } - } catch (error) { - log(`kill: не вдалося вбити вузол — ${error.message ?? String(error)}`) - return 1 - } - - // 4. Каскадна інвалідація залежних задач - const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn }) - const allNodes = scanTasks(mtDir, activeWorktrees, { - readdirSync: readdir, - existsSync: exists, - readFileSync: readFile - }) - - // Знаходимо задачі що залежать від нашої задачі - const dependents = allNodes.filter(n => n.deps.includes(taskPath)) - for (const dep of dependents) { - if (!exists(join(dep.dir, 'invalidated'))) { - try { - writeInvalidated(dep.dir, writeFile) - log(`kill: каскадна інвалідація "${dep.path}"`) - } catch { - // пропускаємо - } - } - } - - return 0 -} diff --git a/npm/lib/commands/plan.mjs b/npm/lib/commands/plan.mjs deleted file mode 100644 index 64f3d0d..0000000 --- a/npm/lib/commands/plan.mjs +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `mt plan [<path>] [--mode agent]` — Stage 1: пише plan_NNN.md. - * - * Читає task.md задачі, знаходить наступний NNN, пише шаблон plan_NNN.md. - * Якщо --mode agent — встановлює mode:agent у plan front-matter. - * - * FS ін'єктується для тестованості. - */ -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { buildMarkdown, parseFrontMatter } from '../core/frontmatter.mjs' -import { nextPlanNNN } from '../core/nnn.mjs' -import { loadConfig, resolveMtDir } from '../core/config.mjs' - -/** - * Будує шаблон plan_NNN.md. - * @param {{ mode: string, hint: string, now: string, nnn: string }} params параметри - * @returns {string} вміст файлу - */ -export function buildPlanTemplate(params) { - const fm = { - created_at: params.now, - mode: params.mode, - decision: params.hint || 'atomic' - } - - const body = [ - `## Context`, - `<!-- Чому саме такий підхід — що з'ясовано під час планування -->`, - ``, - `## Approach`, - params.mode === 'composite' - ? `<!-- composite: список дочірніх задач з описами -->` - : `<!-- atomic: покроковий план виконання -->`, - ``, - `## Risks`, - `<!-- Що може піти не так -->`, - `` - ].join('\n') - - return buildMarkdown(fm, body) -} - -/** - * `mt plan [<path>] [--mode agent]` command handler. - * @param {string[]} args аргументи: [path] [--mode agent|human] - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * writeFile?: (p: string, c: string, enc: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * now?: () => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function plan(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - const nowFn = deps.now ?? (() => new Date().toISOString()) - - // Парсимо аргументи - let taskPath = null - let modeOverride = null - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--mode' && args[i + 1]) { - modeOverride = args[i + 1] - i++ - } else if (!args[i].startsWith('-')) { - taskPath = args[i] - } - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - - // Визначаємо директорію задачі - let taskDir - if (taskPath) { - taskDir = join(mtDir, taskPath) - } else { - // CWD може бути в worktree — шукаємо task.md у CWD - taskDir = processCwd() - } - - const taskFilePath = join(taskDir, 'task.md') - if (!exists(taskFilePath)) { - log(`plan: task.md не знайдено в ${taskDir}`) - return 1 - } - - let taskContent - try { - taskContent = readFile(taskFilePath, 'utf8') - } catch (error) { - log(`plan: не вдалося прочитати task.md — ${error.message ?? String(error)}`) - return 1 - } - - const fm = parseFrontMatter(taskContent) - const mode = modeOverride ?? (typeof fm.mode === 'string' ? fm.mode : 'human') - const hint = typeof fm.hint === 'string' ? fm.hint : '' - - const nnn = nextPlanNNN(taskDir, readdir) - const planPath = join(taskDir, `plan_${nnn}.md`) - - const content = buildPlanTemplate({ mode, hint, now: nowFn(), nnn }) - - try { - writeFile(planPath, content, 'utf8') - log(`plan: створено ${planPath} (mode: ${mode})`) - } catch (error) { - log(`plan: не вдалося записати ${planPath} — ${error.message ?? String(error)}`) - return 1 - } - - // Виводимо контекст для агента/людини - const bodyStart = taskContent.indexOf('\n---\n', 4) - const taskBody = bodyStart === -1 ? taskContent : taskContent.slice(bodyStart + 5).trimStart() - - console.log( - [ - `## plan context`, - ``, - `task: ${taskPath ?? taskDir}`, - `mode: ${mode}`, - hint ? `hint: ${hint}` : `hint: (не задано)`, - `plan: plan_${nnn}.md`, - ``, - `### task.md`, - taskBody.trimEnd() - ].join('\n') - ) - - return 0 -} diff --git a/npm/lib/commands/run.mjs b/npm/lib/commands/run.mjs deleted file mode 100644 index bc79baa..0000000 --- a/npm/lib/commands/run.mjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * `mt run [<path>] [--actor a] [--auto]` — тонкий клієнт Rust-раннера. - * - * Уся run-оркестрація живе в mt-core (crates/mt-core/src/runner.rs через - * napi-аддон) — правило одного коду контракту (stack.md): CAS claim → - * detached worktree від `origin/main` → виконавець → watchdog (hard budget, - * progress-timeout) → спільний `## Check`-гейт → fenced publish в - * `origin/main`. Вимагає git-репозиторій з push-доступом до `origin`. - * - * Виконавці (резолвить Rust-ядро; єдиний agent-шлях — підписочні CLI, - * `node_executor` видалено): - * - **підписочні CLI** (`agent_cli`: claude | codex | cursor | pi) з - * user-level ENV-конфігом (`MT_AGENT_CLI`, `MT_CLOUD_AGENT_CLIS`, - * `MT_AGENT_CLI_MODEL_MAP`), per-node override — `a.md` «## Agent cli»; - * вичерпані ліміти підписки (rate limit / quota / 429) → каскад - * `MT_CLOUD_AGENT_CLIS`; фактичний CLI — у frontmatter `run_NNN.md`; - * - тир MIN/AVG/MAX і retry ladder (`## Model tier` / `## Retry ladder` в - * `a.md`) — ескалацію застосовує Rust-ядро (env `MT_MODEL_TIER`, - * `MT_RETRY_STRATEGY`, `MT_ATTEMPT`). - * - * У JS лишаються: парсинг argv, резолв `mt_dir`, human-шлях (інструкції без - * спавну і без claim) і мапінг помилок раннера в exit-коди - * (claim-lost → 2 — «інший runner виграв», не збій). - */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir } from '../core/config.mjs' -import { loadNative } from '../core/native.mjs' - -/** - * Розбирає argv `mt run`: перший non-flag токен — шлях задачі. - * @param {string[]} args аргументи після `run` - * @returns {{ taskPath: string | null, actor: string | null, autoMode: boolean }} розібрані параметри - */ -function parseRunArgs(args) { - let taskPath = null - let actor = null - let autoMode = false - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--actor' && args[i + 1]) { - actor = args[i + 1] - i++ - } else if (args[i] === '--auto') { - autoMode = true - } else if (!args[i].startsWith('-')) { - taskPath = args[i] - } - } - return { taskPath, actor, autoMode } -} - -/** - * `--auto`: делегує оркестраторний прохід Rust-ядру (waiting-агентські вузли - * чергами по `agent_concurrency`). claim-lost/preflight-відмови — штатний - * skip (Rust-раннер сам веде skip-set), не провал прогону. - * @param {{ runAuto: (mtDir: string, concurrency: number) => object[] }} native napi-аддон - * @param {string} mtDir абсолютний шлях tasks-директорії - * @param {number} concurrency `agent_concurrency` з конфігу - * @param {(m: string) => void} log лог - * @returns {number} exit code - */ -function runAutoMode(native, mtDir, concurrency, log) { - let results - try { - results = native.runAuto(mtDir, concurrency) - } catch (error) { - log(`run --auto: ${error.message ?? String(error)}`) - return 1 - } - if (results.length === 0) { - log('run --auto: немає готових задач для запуску') - return 0 - } - let anyFailed = false - for (const r of results) { - const detail = r.error ? ` (${r.error})` : '' - log(`run --auto: ${r.path} → ${r.result}${detail}`) - if (r.result !== 'success' && !r.error?.includes('claim-lost')) anyFailed = true - } - return anyFailed ? 1 : 0 -} - -/** - * `mt run [<path>] [--actor a] [--auto]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean, - * native?: { - * runNode: (mtDir: string, taskPath: string) => object, - * runAuto: (mtDir: string, concurrency: number) => object[] - * } - * }} [deps] ін'єкції - * @returns {number} exit code - */ -export default function run(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const exists = deps.exists ?? existsSync - const native = deps.native ?? loadNative() - - const { taskPath, actor, autoMode } = parseRunArgs(args) - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - - if (autoMode) { - return runAutoMode(native, mtDir, config.agent_concurrency, log) - } - - if (!taskPath) { - log('run: вкажіть <path> або використайте --auto') - log('Usage: mt run [<path>] [--actor agent|human] [--auto]') - return 1 - } - - const taskDir = join(mtDir, taskPath) - if (!exists(join(taskDir, 'task.md'))) { - log(`run: задача "${taskPath}" не знайдена (немає task.md у ${taskDir})`) - return 1 - } - - // Людина виконує вручну — без спавну і без claim; фіксація — `mt done`. - if (actor === 'human' || actor === 'h') { - log(`run: задача "${taskPath}" очікує ручного виконання`) - log(` директорія: ${taskDir}`) - log(` після виконання запустіть: mt done ${taskPath}`) - return 0 - } - - let outcome - try { - outcome = native.runNode(mtDir, taskPath) - } catch (error) { - const message = error.message ?? String(error) - log(`run: ${message}`) - // claim-lost — «інший runner виграв», штатний skip, не системний збій. - return message.includes('claim-lost') ? 2 : 1 - } - - const cli = outcome.agent_cli ? `, agent_cli=${outcome.agent_cli}` : '' - log(`run: "${taskPath}" → ${outcome.result} (${outcome.run_file}, ${outcome.wall_sec}s${cli})`) - if (outcome.result === 'success') { - log(`run: опубліковано в origin/main (${outcome.fact_file})`) - return 0 - } - log(`run: задача "${taskPath}" завершилась з помилкою — діагностика у ${outcome.run_file}`) - return 1 -} diff --git a/npm/lib/commands/scan.mjs b/npm/lib/commands/scan.mjs deleted file mode 100644 index 5d74747..0000000 --- a/npm/lib/commands/scan.mjs +++ /dev/null @@ -1,142 +0,0 @@ -/** - * `mt scan [--json]` — повний скан задач, exit 1 якщо є failed-задачі. - * - * Обходить всі задачі, деривує стани, виводить зведення. - * exit 0 = все чисто (або лише needs-plan/waiting) - * exit 1 = є failed або pending-audit без відповіді - * - * FS ін'єктується для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir } from '../core/config.mjs' -import { scanTasks, topoSort, areDepsResolved } from '../core/scanner.mjs' -import { listActiveWorktrees } from '../core/worktree.mjs' - -/** - * `mt scan [--json]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code (0=clean, 1=attention) - */ -export default function scan(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, { ...opts, encoding: 'utf8' })) - - const jsonMode = args.includes('--json') - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - - const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn }) - - const allNodes = scanTasks(mtDir, activeWorktrees, { - readdirSync: readdir, - existsSync: exists, - readFileSync: readFile - }) - - const sorted = topoSort(allNodes) - - // Підрахунок по станах - const stateCounts = {} - for (const n of sorted) { - stateCounts[n.state] = (stateCounts[n.state] ?? 0) + 1 - } - - // Знаходимо проблемні задачі - const failed = sorted.filter(n => n.state === 'failed') - const pendingAudit = sorted.filter(n => n.state === 'pending-audit') - const unassigned = sorted.filter(n => n.state === 'unassigned') - const pending = sorted.filter(n => n.state === 'pending') - const planReview = sorted.filter(n => n.state === 'plan-review') - const unresolvable = sorted.filter(n => n.state === 'unresolvable') - - // Знаходимо готові до запуску (waiting + deps resolved) - const nodeMap = new Map(sorted.map(n => [n.id, n])) - const ready = sorted.filter(n => n.state === 'waiting' && areDepsResolved(n, nodeMap)) - - const hasProblems = failed.length > 0 || unresolvable.length > 0 - - if (jsonMode) { - console.log( - JSON.stringify( - { - ok: !hasProblems, - total: sorted.length, - counts: stateCounts, - failed: failed.map(n => n.path), - pending_audit: pendingAudit.map(n => n.path), - unassigned: unassigned.map(n => n.path), - pending: pending.map(n => n.path), - plan_review: planReview.map(n => n.path), - unresolvable: unresolvable.map(n => n.path), - ready: ready.map(n => n.path) - }, - null, - 2 - ) - ) - } else { - const summaryParts = Object.entries(stateCounts) - .map(([s, c]) => `${s}:${c}`) - .join(' ') - - log(`scan: ${sorted.length} задач — ${summaryParts}`) - - if (failed.length > 0) { - log(`\nFAILED (${failed.length}):`) - for (const n of failed) log(` - ${n.path}`) - } - - if (unresolvable.length > 0) { - log(`\nunresolvable (${unresolvable.length}):`) - for (const n of unresolvable) log(` - ${n.path}`) - } - - if (pendingAudit.length > 0) { - log(`\npending-audit (${pendingAudit.length}):`) - for (const n of pendingAudit) log(` - ${n.path}`) - } - - if (planReview.length > 0) { - log(`\nplan-review (${planReview.length}) — чекають approve:`) - for (const n of planReview) log(` - ${n.path}`) - } - - if (unassigned.length > 0) { - log(`\nunassigned (${unassigned.length}) — немає виконавця:`) - for (const n of unassigned) log(` - ${n.path}`) - } - - if (pending.length > 0) { - log(`\npending (${pending.length}) — чекають людину:`) - for (const n of pending) log(` - ${n.path}`) - } - - if (ready.length > 0) { - log(`\nready to run (${ready.length}):`) - for (const n of ready) log(` - ${n.path}`) - } - - if (!hasProblems) { - log('\nscan: OK') - } - } - - return hasProblems ? 1 : 0 -} diff --git a/npm/lib/commands/setup.mjs b/npm/lib/commands/setup.mjs deleted file mode 100644 index 1f15450..0000000 --- a/npm/lib/commands/setup.mjs +++ /dev/null @@ -1,137 +0,0 @@ -/** - * `mt setup` — ініціалізація проєкту для mt task system. - * - * Створює: - * - .mt.json з дефолтними налаштуваннями (якщо не існує) - * - mt/ директорію - * - git hook (post-commit) для автоматичного оновлення стану (якщо є .git) - * - * FS ін'єктується для тестованості. - */ -import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { isAbsolute, join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { CONFIG_DEFAULTS } from '../core/config.mjs' - -/** - * Резолвить hooks directory як для звичайного checkout, так і для git worktree. - * @param {string} root корінь git-репозиторію - * @returns {string | null} абсолютний шлях до hooks directory - */ -function resolveGitHooksDir(root) { - try { - const hooksDir = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], { - cwd: root, - encoding: 'utf8' - }).trim() - return isAbsolute(hooksDir) ? hooksDir : join(root, hooksDir) - } catch { - return null - } -} - -/** - * `mt setup` command handler. - * @param {string[]} _args аргументи (не використовуються) - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * writeFile?: (p: string, c: string, enc: string) => void, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean, - * mkdir?: (p: string, opts?: object) => void, - * chmod?: (p: string, mode: number) => void, - * resolveHooksDir?: (root: string) => string | null - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function setup(_args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const writeFile = deps.writeFile ?? ((p, c, enc) => writeFileSync(p, c, enc)) - const exists = deps.exists ?? existsSync - const mkdir = deps.mkdir ?? ((p, opts) => mkdirSync(p, opts)) - const chmod = deps.chmod ?? chmodSync - const resolveHooksDir = deps.resolveHooksDir ?? resolveGitHooksDir - - // 1. Створюємо .mt.json якщо не існує - const configPath = join(root, '.mt.json') - if (exists(configPath)) { - log(`setup: ${configPath} вже існує — пропускаємо`) - } else { - try { - writeFile(configPath, JSON.stringify(CONFIG_DEFAULTS, null, 2) + '\n', 'utf8') - log(`setup: створено ${configPath}`) - } catch (error) { - log(`setup: не вдалося створити ${configPath} — ${error.message ?? String(error)}`) - return 1 - } - } - - // 2. Створюємо mt/ директорію - const mtDir = join(root, 'mt') - if (exists(mtDir)) { - log(`setup: ${mtDir} вже існує — пропускаємо`) - } else { - try { - mkdir(mtDir, { recursive: true }) - log(`setup: створено ${mtDir}`) - } catch (error) { - log(`setup: не вдалося створити ${mtDir} — ${error.message ?? String(error)}`) - return 1 - } - } - - // 3. Створюємо parent directory для atomic worktree claims. - const worktreesDir = join(root, '.worktrees') - if (!exists(worktreesDir)) { - try { - mkdir(worktreesDir, { recursive: true }) - log(`setup: створено ${worktreesDir}`) - } catch (error) { - log(`setup: не вдалося створити ${worktreesDir} — ${error.message ?? String(error)}`) - return 1 - } - } - - // 4. Перевіряємо чи є .git і додаємо hook - const gitDir = join(root, '.git') - if (exists(gitDir)) { - const hooksDir = resolveHooksDir(root) - if (!hooksDir) { - log('setup: не вдалося визначити git hooks directory — пропускаємо hook') - log('setup: готово') - return 0 - } - try { - mkdir(hooksDir, { recursive: true }) - } catch { - // hooks/ може вже існувати - } - - const hookPath = join(hooksDir, 'post-commit') - if (exists(hookPath)) { - log(`setup: git hook ${hookPath} вже існує — пропускаємо`) - } else { - const hookContent = [ - '#!/bin/sh', - '# mt: automatic state refresh after commit', - 'mt scan --json > /dev/null 2>&1 || true', - '' - ].join('\n') - try { - writeFile(hookPath, hookContent, 'utf8') - chmod(hookPath, 0o755) - log(`setup: створено git hook ${hookPath}`) - } catch (error) { - log(`setup: не вдалося створити git hook — ${error.message ?? String(error)}`) - // Не критично — продовжуємо - } - } - } - - log('setup: готово') - return 0 -} diff --git a/npm/lib/commands/spawn.mjs b/npm/lib/commands/spawn.mjs deleted file mode 100644 index cb6fa59..0000000 --- a/npm/lib/commands/spawn.mjs +++ /dev/null @@ -1,86 +0,0 @@ -/** - * `mt spawn <path>` — composite → перевіряє що дочірні задачі зареєстровані. - * - * FS ін'єктується для тестованості. - */ -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir } from '../core/config.mjs' - -/** - * Резолвить шлях задачі з аргументів або env. - * @param {string[]} args аргументи командного рядка - * @param {{ env?: Record<string, string> }} deps ін'єкції - * @returns {{ taskPath: string | null, error: string | null }} результат - */ -function resolveTaskPath(args, deps) { - if (args[0] && !args[0].startsWith('-')) { - return { taskPath: args[0], error: null } - } - - const env = deps.env ?? process.env - const fromEnv = env['MT_TASK_PATH'] - if (fromEnv?.trim()) { - return { taskPath: fromEnv.trim(), error: null } - } - - return { taskPath: null, error: 'MT_TASK_PATH not set' } -} - -/** - * `mt spawn <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function spawn(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const { taskPath, error } = resolveTaskPath(args, { env: deps.env }) - if (!taskPath) { - log(`spawn: ${error}`) - return 1 - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const taskDir = join(mtDir, taskPath) - - if (!exists(join(taskDir, 'task.md'))) { - log(`spawn: задача "${taskPath}" не знайдена`) - return 1 - } - - // Перевіряємо дочірні директорії - let entries - try { - entries = readdir(taskDir) - } catch { - log(`spawn: не вдалося прочитати директорію задачі`) - return 1 - } - - const childDirs = entries.filter(name => { - if (name.startsWith('.') || name.endsWith('.md') || name.endsWith('.json')) return false - return exists(join(taskDir, name, 'task.md')) - }) - - if (childDirs.length === 0) { - log(`spawn: задача "${taskPath}" не має дочірніх задач із task.md`) - log(`spawn: для composite задачі треба створити дочірні директорії з task.md`) - return 1 - } - - log(`spawn: задача "${taskPath}" є composite з ${childDirs.length} дочірніми задачами:`) - for (const child of childDirs) { - log(` - ${taskPath}/${child}`) - } - - return 0 -} diff --git a/npm/lib/commands/status.mjs b/npm/lib/commands/status.mjs deleted file mode 100644 index 17c2a8b..0000000 --- a/npm/lib/commands/status.mjs +++ /dev/null @@ -1,141 +0,0 @@ -/** - * `mt status [<path>] [--json]` — показує стан задач. - * - * Без path — показує всі задачі. З path — лише задачу і її нащадків. - * --json — machine-readable JSON вивід. - * - * FS ін'єктується для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir } from '../core/config.mjs' -import { scanTasks, topoSort } from '../core/scanner.mjs' -import { listActiveWorktrees } from '../core/worktree.mjs' - -/** Кольори для стану (ANSI). */ -const STATE_COLORS = { - unassigned: '', // жовтий - pending: '', // жовтий - waiting: '', // блакитний - blocked: '', // сірий - 'plan-review': '', // жовтий - spawned: '', // блакитний - running: '', // синій - stalled: '', // сірий - 'pending-audit': '', // фіолетовий - resolved: '', // зелений - failed: '', // червоний - unresolvable: '' // червоний -} -const RESET = '' - -/** - * Повертає colored рядок стану (якщо TTY). - * @param {string} state стан задачі - * @param {boolean} color чи потрібен колір - * @returns {string} рядок - */ -function colorState(state, color) { - if (!color) return state - const c = STATE_COLORS[state] ?? '' - return `${c}${state}${RESET}` -} - -/** - * `mt status [<path>] [--json]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function status(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, { ...opts, encoding: 'utf8' })) - - // Парсимо аргументи - let taskPath = null - let jsonMode = false - - for (const arg of args) { - if (arg === '--json') jsonMode = true - else if (!arg.startsWith('-')) taskPath = arg - } - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - - const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn }) - - const allNodes = scanTasks(mtDir, activeWorktrees, { - readdirSync: readdir, - existsSync: exists, - readFileSync: readFile - }) - - // Фільтруємо якщо є path - let nodes = allNodes - if (taskPath) { - nodes = allNodes.filter(n => n.path === taskPath || n.path.startsWith(taskPath + '/')) - if (nodes.length === 0) { - log(`status: задача "${taskPath}" не знайдена`) - return 1 - } - } - - const sorted = topoSort(nodes) - - if (jsonMode) { - console.log( - JSON.stringify( - sorted.map(n => ({ - id: n.id, - path: n.path, - state: n.state, - deps: n.deps, - composite: n.composite, - children: n.children - })), - null, - 2 - ) - ) - return 0 - } - - // Текстовий вивід - const useColor = process.stdout.isTTY ?? false - - // Підрахунок по станах - const stateCounts = {} - for (const n of sorted) { - stateCounts[n.state] = (stateCounts[n.state] ?? 0) + 1 - } - const summary = Object.entries(stateCounts) - .map(([s, c]) => `${colorState(s, useColor)}:${c}`) - .join(' ') - - log(`mt tasks — ${summary}`) - log('') - - for (const node of sorted) { - const indent = node.path.includes('/') ? ' '.repeat(node.path.split('/').length - 1) : '' - const composite = node.composite ? ' [composite]' : '' - const nodeDeps = node.deps.length > 0 ? ` ← [${node.deps.join(', ')}]` : '' - log(`${indent}${node.path} [${colorState(node.state, useColor)}]${composite}${nodeDeps}`) - } - - return 0 -} diff --git a/npm/lib/commands/tests/worktree.test.mjs b/npm/lib/commands/tests/worktree.test.mjs deleted file mode 100644 index dd2cd00..0000000 --- a/npm/lib/commands/tests/worktree.test.mjs +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, test, expect } from 'vitest' -import worktree from '../worktree.mjs' - -const CREATED_RE = /Created: \d{4}-\d{2}-\d{2}/ - -function makeCtx(overrides = {}) { - const logs = [] - const fs = {} - - const deps = { - cwd: '/repo', - log: s => { - logs.push(s) - }, - config: { worktrees_dir: './.worktrees' }, - mkdir: () => null, - exists: p => Object.hasOwn(fs, p), - writeFile: (p, c) => { - fs[p] = c - }, - readFile: p => { - if (Object.hasOwn(fs, p)) return fs[p] - const e = new Error('ENOENT') - e.code = 'ENOENT' - throw e - }, - // mock ігнорує каталог і повертає basenames усіх .md-ключів (інвентарі живуть у .meta/) - readdir: () => - Object.keys(fs) - .filter(k => k.endsWith('.md')) - .map(k => k.split('/').pop()), - rmFile: p => { - delete fs[p] - }, - execSync: () => '', - ...overrides - } - - return { logs, fs, deps } -} - -describe('sanitizeBranch (via create)', () => { - test('feat/my-feature → feat-my-feature; інвентар у .meta/', () => { - const { deps, fs, logs } = makeCtx({ execSync: () => '' }) - const code = worktree(['create', 'feat/my-feature', 'test desc'], deps) - expect(code).toBe(0) - expect(logs.some(l => l.includes('feat-my-feature'))).toBe(true) - expect(fs).toHaveProperty('/repo/.worktrees/.meta/feat-my-feature.md') - }) - - test('подвійний слеш → один дефіс', () => { - const { deps, fs } = makeCtx({ execSync: () => '' }) - worktree(['create', 'double//slash', 'desc'], deps) - expect(Object.keys(fs)).toContain('/repo/.worktrees/.meta/double-slash.md') - }) -}) - -describe('create', () => { - test('повертає 1 без branch', () => { - const { deps } = makeCtx() - expect(worktree(['create'], deps)).toBe(1) - }) - - test('повертає 1 без опису (опис обовʼязковий)', () => { - const { deps } = makeCtx() - expect(worktree(['create', 'feat/x'], deps)).toBe(1) - }) - - test('колізія → firstFreeBranch обирає <branch>2 (не падає)', () => { - const { deps, fs, logs } = makeCtx({ execSync: () => '' }) - fs['/repo/.worktrees/my-branch'] = '' // checkout уже існує - const code = worktree(['create', 'my-branch', 'desc'], deps) - expect(code).toBe(0) - expect(logs.some(l => l.includes('обрано вільну назву') && l.includes('my-branch2'))).toBe(true) - expect(fs).toHaveProperty('/repo/.worktrees/.meta/my-branch2.md') - }) - - test('uncommitted warning з переліком', () => { - const { deps, logs } = makeCtx({ - execSync: cmd => (cmd.includes('status') ? ' M file.txt' : '') - }) - worktree(['create', 'feat/warn', 'desc'], deps) - expect(logs.some(l => l.includes('незакоміче') && l.includes('file.txt'))).toBe(true) - }) - - test('git fail → повертає 1', () => { - const { deps } = makeCtx({ - execSync: cmd => { - if (cmd.includes('worktree add')) { - throw new Error('git fail') - } - return '' - } - }) - expect(worktree(['create', 'feat/fail', 'desc'], deps)).toBe(1) - }) - - test('інвентар містить branch + description + дату', () => { - const { deps, fs } = makeCtx({ execSync: () => '' }) - worktree(['create', 'feat/inv', 'My feature'], deps) - const content = fs['/repo/.worktrees/.meta/feat-inv.md'] - expect(content).toContain('# feat/inv') - expect(content).toContain('My feature') - expect(content).toMatch(CREATED_RE) - }) -}) - -describe('remove (ефемерний — прибирає гілку)', () => { - test('повертає 1 без branch', () => { - const { deps } = makeCtx() - expect(worktree(['remove'], deps)).toBe(1) - }) - - test('повертає 1 якщо worktree немає', () => { - const { deps } = makeCtx({ exists: () => false }) - expect(worktree(['remove', 'feat/none'], deps)).toBe(1) - }) - - test('видаляє інвентар .meta/*.md', () => { - const { deps, fs } = makeCtx({ execSync: () => '' }) - fs['/repo/.worktrees/feat-del'] = '' - fs['/repo/.worktrees/.meta/feat-del.md'] = 'content' - const code = worktree(['remove', 'feat/del'], deps) - expect(code).toBe(0) - expect(fs).not.toHaveProperty('/repo/.worktrees/.meta/feat-del.md') - }) - - test('видаляє гілку через git branch -D', () => { - const cmds = [] - const { deps, fs } = makeCtx({ - execSync: cmd => { - cmds.push(cmd) - return '' - } - }) - fs['/repo/.worktrees/my-branch'] = '' - fs['/repo/.worktrees/.meta/my-branch.md'] = 'content' - worktree(['remove', 'my-branch'], deps) - expect(cmds.some(c => c.includes('branch -D') && c.includes('my-branch'))).toBe(true) - }) -}) - -describe('list', () => { - test('без worktrees → виводить повідомлення', () => { - const { deps, logs } = makeCtx({ execSync: () => '', readdir: () => [] }) - worktree(['list'], deps) - expect(logs.some(l => l.includes('Немає'))).toBe(true) - }) - - test('показує активні та осиротілі', () => { - const { deps, fs, logs } = makeCtx({ - execSync: cmd => - cmd.includes('list --porcelain') ? 'worktree /repo/.worktrees/feat-a\nbranch refs/heads/feat/a\n' : '' - }) - fs['/repo/.worktrees/.meta/feat-a.md'] = '# feat/a\n\nDesc A\n\nCreated: 2026-06-01\n' - fs['/repo/.worktrees/.meta/feat-b.md'] = '# feat/b\n\nDesc B\n\nCreated: 2026-06-02\n' - worktree(['list'], deps) - expect(logs.some(l => l.includes('✓') && l.includes('feat-a'))).toBe(true) - expect(logs.some(l => l.includes('осиротілий') && l.includes('feat-b'))).toBe(true) - }) -}) - -describe('prune', () => { - test('видаляє осиротілі інвентарі (без активного checkout)', () => { - const { deps, fs, logs } = makeCtx({ - execSync: cmd => (cmd.includes('list --porcelain') ? 'worktree /repo/.worktrees/feat-a\n' : '') - }) - fs['/repo/.worktrees/.meta/feat-a.md'] = '# feat/a\n' // активний - fs['/repo/.worktrees/.meta/feat-orphan.md'] = '# feat/orphan\n' // осиротілий - worktree(['prune'], deps) - expect(fs).toHaveProperty('/repo/.worktrees/.meta/feat-a.md') - expect(fs).not.toHaveProperty('/repo/.worktrees/.meta/feat-orphan.md') - expect(logs.some(l => l.includes('feat-orphan'))).toBe(true) - }) -}) - -describe('inventory', () => { - test('JSON-масив зі станом active', () => { - const { deps, fs, logs } = makeCtx({ - execSync: cmd => (cmd.includes('list --porcelain') ? 'worktree /repo/.worktrees/feat-a\n' : '') - }) - fs['/repo/.worktrees/.meta/feat-a.md'] = '# feat/a\n\nDesc A\n\nCreated: 2026-06-01\n' - fs['/repo/.worktrees/.meta/feat-b.md'] = '# feat/b\n\nDesc B\n\nCreated: 2026-06-02\n' - worktree(['inventory'], deps) - const json = JSON.parse(logs.join('\n')) - expect(json).toEqual([ - { name: 'feat-a', active: true, description: 'Desc A' }, - { name: 'feat-b', active: false, description: 'Desc B' } - ]) - }) -}) - -describe('unknown subcommand', () => { - test('повертає 1', () => { - const { deps } = makeCtx() - expect(worktree(['wtf'], deps)).toBe(1) - }) -}) diff --git a/npm/lib/commands/verify.mjs b/npm/lib/commands/verify.mjs deleted file mode 100644 index 2f95231..0000000 --- a/npm/lib/commands/verify.mjs +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Handler `mt verify` — Stage 2 structural check. - * - * Перевіряє що `fact_NNN.md` існує і непорожній у директорії поточної задачі - * (CWD). Якщо так — виводить `## Done when` секцію з `task.md` та вміст - * `fact_NNN.md` на stdout для агентської self-evaluation. - * - * exit 0 = структурно OK - * exit 1 = структурна помилка (fact відсутній або порожній) - * - * FS ін'єктується для тестування без диска. - */ -import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { latestFactNNN } from '../core/nnn.mjs' - -const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/ -const SECTION_RE = /^## (.+)$/m -const LINE_SPLIT_RE = /\r?\n/ - -/** - * Читає секцію за заголовком із markdown-файлу. - * @param {string} text вміст файлу - * @param {string} heading заголовок без `## ` - * @returns {string | null} вміст секції або null - */ -function extractSection(text, heading) { - const lines = text.split(LINE_SPLIT_RE) - const start = lines.indexOf(`## ${heading}`) - if (start === -1) return null - const end = lines.findIndex((l, i) => i > start && SECTION_RE.test(l)) - const section = end === -1 ? lines.slice(start) : lines.slice(start, end) - return section.join('\n').trimEnd() -} - -/** - * `mt verify` handler. - * @param {string[]} _rest аргументи після `verify` (не використовуються) - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (path: string, enc: string) => string, - * readdir?: (dir: string) => string[], - * exists?: (path: string) => boolean - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code (0=OK, 1=структурна помилка) - */ -export default function verify(_rest, deps = {}) { - const cwd = deps.cwd ?? processCwd() - const log = deps.log ?? console.error - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const factNNN = latestFactNNN(cwd, readdir) - if (!factNNN) { - log('verify: fact_NNN.md не знайдено — структурна помилка') - return 1 - } - - const factPath = join(cwd, `fact_${factNNN}.md`) - if (!exists(factPath)) { - log(`verify: fact_${factNNN}.md не існує — структурна помилка`) - return 1 - } - - let factContent - try { - factContent = readFile(factPath, 'utf8') - } catch (error) { - log(`verify: не вдалося прочитати fact_${factNNN}.md — ${error instanceof Error ? error.message : String(error)}`) - return 1 - } - - const withoutFm = factContent.replace(FRONT_MATTER_RE, '').trim() - if (withoutFm.length === 0) { - log(`verify: fact_${factNNN}.md порожній — структурна помилка`) - return 1 - } - - const outLines = [`## verify context`, ``] - - const taskPath = join(cwd, 'task.md') - if (exists(taskPath)) { - try { - const taskContent = readFile(taskPath, 'utf8') - const doneWhen = extractSection(taskContent, 'Done when') - if (doneWhen) outLines.push(doneWhen, '') - } catch { - // task.md недоступний — не блокуємо verify - } - } - - outLines.push(`### fact_${factNNN}.md`, ``, factContent.trimEnd()) - console.log(outLines.join('\n')) - - return 0 -} diff --git a/npm/lib/commands/watch.mjs b/npm/lib/commands/watch.mjs deleted file mode 100644 index 2e3c09d..0000000 --- a/npm/lib/commands/watch.mjs +++ /dev/null @@ -1,154 +0,0 @@ -/** - * `mt watch` — одноразовий скан стану задач. - * - * Спрощена (no-daemon) реалізація: - * - Знаходить pending-audit без audit-result → логує (треба ручний аудит) - * - Знаходить stale worktrees > stale_worktree_min хвилин → попереджає - * - Знаходить needs-plan задачі → перелічує - * - exit 0 якщо чисто, exit 1 якщо потрібна увага - * - * FS і child_process ін'єктуються для тестованості. - */ -import { execSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveMtDir, resolveWorktreesDir } from '../core/config.mjs' -import { scanTasks } from '../core/scanner.mjs' -import { listActiveWorktrees } from '../core/worktree.mjs' - -/** - * `mt watch` command handler (one-shot scan). - * @param {string[]} args аргументи (зазвичай порожні) - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string, - * statSync?: (p: string) => { mtimeMs: number }, - * now?: () => number - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code (0=clean, 1=attention) - */ -export default function watch(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? console.log - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const readdir = deps.readdir ?? (d => (existsSync(d) ? readdirSync(d) : [])) - const exists = deps.exists ?? existsSync - - const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { ...o, encoding: 'utf8' })) - const statFn = deps.statSync ?? statSync - const nowMs = deps.now ?? (() => Date.now()) - - const config = loadConfig({ root, readFile, exists }) - const mtDir = resolveMtDir(config, root) - const worktreesDir = resolveWorktreesDir(config, root) - const staleMs = config.stale_worktree_min * 60 * 1000 - - const activeWorktrees = listActiveWorktrees(root, { execSync: execSyncFn }) - - const allNodes = scanTasks(mtDir, activeWorktrees, { - readdirSync: readdir, - existsSync: exists, - readFileSync: readFile - }) - - let needsAttention = false - - // 1. Pending-audit без audit-result - const pendingAudit = allNodes.filter(n => n.state === 'pending-audit') - if (pendingAudit.length > 0) { - needsAttention = true - log(`[watch] pending-audit (${pendingAudit.length}) — потрібна ручна перевірка:`) - for (const n of pendingAudit) { - log(` - ${n.path}`) - } - } - - // 2. Stale worktrees - let worktreeEntries = [] - try { - worktreeEntries = readdir(worktreesDir) - } catch { - // worktrees dir може не існувати - } - - const now = nowMs() - const staleWorktrees = [] - for (const name of worktreeEntries) { - const wtPath = join(worktreesDir, name) - try { - const stat = statFn(wtPath) - const ageMs = now - stat.mtimeMs - if (ageMs > staleMs) { - staleWorktrees.push({ name, ageMin: Math.floor(ageMs / 60000) }) - } - } catch { - // пропускаємо - } - } - - if (staleWorktrees.length > 0) { - needsAttention = true - log(`[watch] stale worktrees (${staleWorktrees.length}) — неактивні > ${config.stale_worktree_min} хв:`) - for (const wt of staleWorktrees) { - log(` - ${wt.name} (${wt.ageMin} хв)`) - } - } - - // 3. Задачі що потребують людської уваги - const unassigned = allNodes.filter(n => n.state === 'unassigned') - if (unassigned.length > 0) { - log(`[watch] unassigned (${unassigned.length}) — немає виконавця:`) - for (const n of unassigned) { - log(` - ${n.path}`) - } - } - - const pendingNodes = allNodes.filter(n => n.state === 'pending') - if (pendingNodes.length > 0) { - log(`[watch] pending (${pendingNodes.length}) — чекають людину:`) - for (const n of pendingNodes) { - log(` - ${n.path}`) - } - } - - const planReview = allNodes.filter(n => n.state === 'plan-review') - if (planReview.length > 0) { - log(`[watch] plan-review (${planReview.length}) — чекають approve:`) - for (const n of planReview) { - log(` - ${n.path}`) - } - } - - const unresolvable = allNodes.filter(n => n.state === 'unresolvable') - if (unresolvable.length > 0) { - needsAttention = true - log(`[watch] unresolvable (${unresolvable.length}) — вичерпано спроби:`) - for (const n of unresolvable) { - log(` - ${n.path}`) - } - } - - // 4. Failed задачі - const failed = allNodes.filter(n => n.state === 'failed') - if (failed.length > 0) { - needsAttention = true - log(`[watch] failed (${failed.length}) — завершились з помилкою:`) - for (const n of failed) { - log(` - ${n.path}`) - } - } - - if (!needsAttention && pendingAudit.length === 0 && failed.length === 0) { - const running = allNodes.filter(n => n.state === 'running').length - const resolved = allNodes.filter(n => n.state === 'resolved').length - log(`[watch] OK — total:${allNodes.length} running:${running} resolved:${resolved}`) - } - - return needsAttention ? 1 : 0 -} diff --git a/npm/lib/commands/worktree.mjs b/npm/lib/commands/worktree.mjs deleted file mode 100644 index de42318..0000000 --- a/npm/lib/commands/worktree.mjs +++ /dev/null @@ -1,401 +0,0 @@ -/** - * `mt worktree create|remove|list|prune|inventory` — developer git-worktree lifecycle. - * - * Конвенція (правильно та ефективно): checkout у `<worktrees_dir>/<sanitize_branch(branch)>/`, - * інвентар — окремо в `<worktrees_dir>/.meta/<sanitized>.md`, тож `<worktrees_dir>/` містить - * лише worktree-каталоги (+ `.meta/`). Worktree **ефемерний**: `remove` прибирає і checkout, - * і git-гілку. sanitizeBranch — синхронізовано з Rust `sanitize_branch` у crates/mt-core/src/lib.rs. - * @typedef {object} WorktreeCtx - * @property {string} root корінь репо - * @property {string} worktreesDir абсолютний шлях до worktrees_dir - * @property {(s: string) => void} log логер - * @property {(cmd: string, opts?: object) => string} execSyncFn git-виклик - * @property {(p: string) => boolean} exists перевірка існування шляху - * @property {(p: string, c: string) => void} writeFile запис файлу - * @property {(p: string, enc?: string) => string} readFile читання файлу - * @property {(d: string) => string[]} readdir лістинг каталогу - * @property {(p: string) => void} rmFile видалення - * @property {(p: string, o?: object) => void} mkdirFn mkdir - */ -import { execSync } from 'node:child_process' -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadConfig, resolveWorktreesDir } from '../core/config.mjs' - -/** Підкаталог інвентарів усередині worktrees_dir (відокремлений від checkout-каталогів). */ -const META_DIR = '.meta' -/** Поріг переліку файлів у dirty-notice: понад нього — лише кількість. */ -const DIRTY_LIST_LIMIT = 10 -/** Стеля спроб підбору вільної назви гілки (захист від нескінченного циклу). */ -const FIRST_FREE_LIMIT = 1000 - -/** - * Імʼя git-гілки → безпечне пласке імʼя каталогу. ⚠️ Sync з Rust `sanitize_branch`. - * @param {string} branch імʼя гілки - * @returns {string} sanitized імʼя (небезпечні символи → `-`, краєві `-` обрізані) - */ -function sanitizeBranch(branch) { - let result = '' - let prevDash = false - for (const ch of branch) { - const isAllow = - (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '_' || ch === '-' - const out = isAllow ? ch : '-' - if (out === '-') { - if (!prevDash) result += '-' - prevDash = true - } else { - result += out - prevDash = false - } - } - let start = 0 - let end = result.length - while (start < end && result[start] === '-') start++ - while (end > start && result[end - 1] === '-') end-- - return result.slice(start, end) -} - -/** - * Шлях до підкаталогу інвентарів. - * @param {string} worktreesDir абсолютний worktrees_dir - * @returns {string} `<worktreesDir>/.meta` - */ -function metaDirPath(worktreesDir) { - return join(worktreesDir, META_DIR) -} - -/** - * Шлях до інвентарного `.md` для sanitized-назви. - * @param {string} worktreesDir абсолютний worktrees_dir - * @param {string} sanitized sanitized імʼя гілки - * @returns {string} `<worktreesDir>/.meta/<sanitized>.md` - */ -function inventoryPath(worktreesDir, sanitized) { - return join(metaDirPath(worktreesDir), `${sanitized}.md`) -} - -/** - * Текст інвентарного файлу. - * @param {string} branch імʼя гілки - * @param {string} description опис задачі - * @returns {string} markdown-вміст - */ -function inventoryContent(branch, description) { - const date = new Date().toISOString().slice(0, 10) - return `# ${branch}\n\n${description}\n\nCreated: ${date}\n` -} - -/** - * Абсолютні шляхи з `git worktree list --porcelain`. - * @param {string} out вивід git - * @returns {string[]} шляхи checkout - */ -function parseWorktreeList(out) { - const paths = [] - for (const line of out.split('\n')) { - if (line.startsWith('worktree ')) paths.push(line.slice('worktree '.length).trim()) - } - return paths -} - -/** - * Перша вільна назва гілки: `base`, `base2`, `base3`… (суфікс — число без розділювача). - * Дає `create` обрати назву, що спрацює, замість падіння на наявному checkout. - * @param {string} branch бажане імʼя гілки - * @param {(candidate: string) => boolean} isTaken чи зайнята (checkout-каталог уже існує) - * @returns {string} перша вільна назва (= `branch`, якщо вільна) - */ -function firstFreeBranch(branch, isTaken) { - if (!isTaken(branch)) return branch - for (let n = 2; n <= FIRST_FREE_LIMIT; n++) { - const candidate = `${branch}${n}` - if (!isTaken(candidate)) return candidate - } - throw new Error(`worktree: не знайдено вільної назви для "${branch}" за ${FIRST_FREE_LIMIT} спроб`) -} - -/** - * Нагадування про незакомічені зміни основного дерева (вони НЕ потраплять у worktree — - * він від HEAD). До `limit` файлів — перелік шляхів; більше — лише кількість. - * @param {string} porcelain вивід `git status --porcelain` - * @returns {string | null} текст або null, якщо дерево чисте - */ -function buildDirtyNotice(porcelain) { - const files = String(porcelain ?? '') - .split('\n') - .map(line => line.slice(3).trim()) - .filter(Boolean) - if (files.length === 0) return null - const head = `⚠️ Основне дерево має ${files.length} незакомічених змін — вони НЕ потрапили в цей worktree (створено від HEAD).` - if (files.length > DIRTY_LIST_LIMIT) return head - const list = files.map(f => ` - ${f}`).join('\n') - return `${head}\n${list}` -} - -/** - * Імена активних worktree-checkout (останній компонент шляху) з git. - * @param {string} root корінь репо - * @param {(cmd: string, opts?: object) => string} execSyncFn git-виклик - * @returns {Set<string>} імена активних checkout - */ -function activeCheckoutNames(root, execSyncFn) { - try { - const out = execSyncFn('git worktree list --porcelain', { cwd: root }) - const names = new Set() - for (const p of parseWorktreeList(out)) { - const name = p.split('/').pop() ?? '' - if (name) names.add(name) - } - return names - } catch { - return new Set() - } -} - -/** - * Імена інвентарів `.meta/*.md` (basenames без `.md`). - * @param {string} worktreesDir абсолютний worktrees_dir - * @param {(d: string) => string[]} readdir лістинг каталогу - * @returns {string[]} sanitized-імена - */ -function inventoryNames(worktreesDir, readdir) { - try { - return readdir(metaDirPath(worktreesDir)) - .filter(f => f.endsWith('.md')) - .map(f => f.slice(0, -3)) - } catch { - return [] - } -} - -/** - * Зчитує опис (перший не-`#`/не-`Created:` рядок) з інвентаря. - * @param {WorktreeCtx} ctx контекст - * @param {string} sanitized sanitized імʼя - * @returns {string} опис або '' - */ -function readDescription(ctx, sanitized) { - try { - const lines = ctx.readFile(inventoryPath(ctx.worktreesDir, sanitized), 'utf8').split('\n') - return lines.find(l => l && !l.startsWith('#') && !l.startsWith('Created:'))?.trim() ?? '' - } catch { - return '' - } -} - -/** - * `create <branch> "<опис>"` — створити ефемерний worktree від HEAD + інвентар. - * @param {string[]} args [branch, ...descParts] - * @param {WorktreeCtx} ctx контекст - * @returns {number} exit code - */ -function cmdCreate(args, ctx) { - const { root, worktreesDir, log, execSyncFn, exists, writeFile, mkdirFn } = ctx - const [branch, ...rest] = args - if (!branch) { - log('Usage: mt worktree create <branch> "<опис>"') - return 1 - } - const description = rest.join(' ').trim() - if (!description) { - log('create: опис обовʼязковий — mt worktree create <branch> "<опис>"') - return 1 - } - if (!sanitizeBranch(branch)) { - log(`Error: неможливо нормалізувати ім'я гілки "${branch}"`) - return 1 - } - - const isTaken = name => exists(join(worktreesDir, sanitizeBranch(name))) - const chosen = firstFreeBranch(branch, isTaken) - const sanitized = sanitizeBranch(chosen) - if (chosen !== branch) log(`ℹ️ гілка/worktree "${branch}" уже існує — обрано вільну назву "${chosen}"`) - - // dirty-notice знімаємо ДО створення (інакше новий checkout/інвентар забруднив би status). - let dirty = null - try { - dirty = buildDirtyNotice(execSyncFn('git status --porcelain', { cwd: root })) - } catch { - // git недоступний — пропускаємо нагадування - } - - const worktreePath = join(worktreesDir, sanitized) - mkdirFn(worktreesDir, { recursive: true }) - try { - execSyncFn(`git worktree add -b "${chosen}" "${worktreePath}" HEAD`, { cwd: root }) - } catch (error) { - log(`Error: git worktree add failed: ${error.message ?? error}`) - return 1 - } - - mkdirFn(metaDirPath(worktreesDir), { recursive: true }) - writeFile(inventoryPath(worktreesDir, sanitized), inventoryContent(chosen, description)) - log(`✓ worktree створено: ${worktreePath}`) - log(` Гілка: ${chosen}`) - log(` Опис: ${description}`) - if (dirty) log(dirty) - return 0 -} - -/** - * `remove <branch>` — прибрати checkout + інвентар + git-гілку (ефемерний). - * @param {string[]} args [branch] - * @param {WorktreeCtx} ctx контекст - * @returns {number} exit code - */ -function cmdRemove(args, ctx) { - const { root, worktreesDir, log, execSyncFn, exists, rmFile } = ctx - const [branch] = args - if (!branch) { - log('Usage: mt worktree remove <branch>') - return 1 - } - const sanitized = sanitizeBranch(branch) - const worktreePath = join(worktreesDir, sanitized) - if (!exists(worktreePath)) { - log(`Worktree ${worktreePath} не знайдено.`) - return 1 - } - - try { - execSyncFn(`git worktree remove --force "${worktreePath}"`, { cwd: root }) - } catch { - try { - rmFile(worktreePath) - execSyncFn('git worktree prune', { cwd: root }) - } catch (error) { - log(`Error: не вдалось видалити worktree: ${error.message ?? error}`) - return 1 - } - } - - // Ефемерний worktree: гілку теж прибираємо. - try { - execSyncFn(`git branch -D "${branch}"`, { cwd: root }) - } catch { - // гілка вже могла бути видалена - } - - const inv = inventoryPath(worktreesDir, sanitized) - if (exists(inv)) rmFile(inv) - log(`✓ worktree видалено: ${worktreePath} (гілку ${branch} прибрано)`) - return 0 -} - -/** - * `list` — активні (✓) та осиротілі (⚠️) developer-worktrees з описами. - * @param {string[]} _args ігнорується - * @param {WorktreeCtx} ctx контекст - * @returns {number} exit code - */ -function cmdList(_args, ctx) { - const { root, worktreesDir, log, execSyncFn, readdir } = ctx - const active = activeCheckoutNames(root, execSyncFn) - const names = inventoryNames(worktreesDir, readdir) - if (names.length === 0) { - log('Немає developer-worktrees.') - return 0 - } - for (const sanitized of names) { - const desc = readDescription(ctx, sanitized) - const status = active.has(sanitized) ? '✓' : '⚠️ осиротілий' - const descPart = desc ? ` — ${desc}` : '' - log(` ${status} ${sanitized}${descPart}`) - } - return 0 -} - -/** - * `prune` — `git worktree prune` + видалити осиротілі інвентарі. - * @param {string[]} _args ігнорується - * @param {WorktreeCtx} ctx контекст - * @returns {number} exit code - */ -function cmdPrune(_args, ctx) { - const { root, worktreesDir, log, execSyncFn, readdir, rmFile } = ctx - try { - execSyncFn('git worktree prune', { cwd: root }) - } catch { - // git недоступний — все одно приберемо осиротілі інвентарі за станом нижче - } - const active = activeCheckoutNames(root, execSyncFn) - const orphans = inventoryNames(worktreesDir, readdir).filter(name => !active.has(name)) - for (const name of orphans) { - rmFile(inventoryPath(worktreesDir, name)) - log(`🧹 видалено осиротілий інвентар: ${name}`) - } - log(`prune завершено (осиротілих інвентарів: ${orphans.length})`) - return 0 -} - -/** - * `inventory` — JSON-масив `{name, active, description}` для task-graph. - * @param {string[]} _args ігнорується - * @param {WorktreeCtx} ctx контекст - * @returns {number} exit code - */ -function cmdInventory(_args, ctx) { - const { root, worktreesDir, log, execSyncFn, readdir } = ctx - const active = activeCheckoutNames(root, execSyncFn) - const items = inventoryNames(worktreesDir, readdir).map(sanitized => ({ - name: sanitized, - active: active.has(sanitized), - description: readDescription(ctx, sanitized) - })) - log(JSON.stringify(items, null, 2)) - return 0 -} - -/** - * Точка входу команди `mt worktree`. - * @param {string[]} args аргументи після `worktree` - * @param {object} [deps] ін'єкції (cwd/log/config/fs/execSync) для тестів - * @returns {number} exit code - */ -export default function worktree(args, deps = {}) { - const root = deps.cwd ?? processCwd() - const log = deps.log ?? (s => process.stdout.write(s + '\n')) - const cfg = deps.config ?? loadConfig({ readFile: deps.readFile }) - const worktreesDir = resolveWorktreesDir(cfg, root) - - const execSyncFn = deps.execSync ?? ((cmd, o) => execSync(cmd, { encoding: 'utf8', ...o })) - const exists = deps.exists ?? existsSync - const writeFile = deps.writeFile ?? ((p, c) => writeFileSync(p, c, 'utf8')) - const readFile = deps.readFile ?? readFileSync - const readdir = deps.readdir ?? (d => readdirSync(d)) - const rmFile = deps.rmFile ?? (p => rmSync(p, { recursive: true, force: true })) - const mkdirFn = deps.mkdir ?? ((p, o) => mkdirSync(p, o)) - - const [sub, ...rest] = args - const ctx = { root, worktreesDir, log, execSyncFn, exists, writeFile, readFile, readdir, rmFile, mkdirFn } - - switch (sub) { - case 'create': { - return cmdCreate(rest, ctx) - } - case 'remove': { - return cmdRemove(rest, ctx) - } - case 'list': { - return cmdList(rest, ctx) - } - case 'prune': { - return cmdPrune(rest, ctx) - } - case 'inventory': { - return cmdInventory(rest, ctx) - } - default: { - log('Usage: mt worktree <create|remove|list|prune|inventory>') - log(' create <branch> "<опис>" — створити ефемерний worktree у .worktrees/<branch>/') - log(' remove <branch> — видалити worktree + гілку') - log(' list — активні та осиротілі worktrees') - log(' prune — прибрати осиротілі інвентарі') - log(' inventory — JSON-стан для task-graph') - return 1 - } - } -} diff --git a/npm/lib/core/config.mjs b/npm/lib/core/config.mjs deleted file mode 100644 index b33e7b6..0000000 --- a/npm/lib/core/config.mjs +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Завантаження конфігурації `.mt.json` для mt-команд. - * - * Дефолти та merge-логіка (включно з deep merge `model_map`) живуть у Rust-ядрі - * (crates/mt-core/src/config.rs через napi-аддон). Читання файлів лишається тут — - * FS ін'єктується для тестованості без диска. - */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { cwd as processCwd } from 'node:process' - -import { loadNative } from './native.mjs' - -/** Дефолтні значення конфігурації (джерело істини — mt-core `config_defaults`). */ -export const CONFIG_DEFAULTS = loadNative().configDefaults() - -/** - * Завантажує конфігурацію з `.mt.json` і мержить із дефолтами. - * @param {{ - * root?: string, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean - * }} [deps] ін'єкції - * @returns {typeof CONFIG_DEFAULTS} злита конфігурація - */ -export function loadConfig(deps = {}) { - const root = deps.root ?? processCwd() - const readFile = deps.readFile ?? ((p, enc) => readFileSync(p, enc)) - const exists = deps.exists ?? existsSync - - const configPath = join(root, '.mt.json') - const raw = exists(configPath) ? readFile(configPath, 'utf8') : null - - return loadNative().mergeConfig(raw) -} - -/** - * Повертає абсолютний шлях до mt_dir. - * @param {typeof CONFIG_DEFAULTS} config конфігурація - * @param {string} root корінь репо - * @returns {string} абсолютний шлях - */ -export function resolveMtDir(config, root) { - const d = config.mt_dir - return d.startsWith('/') ? d : join(root, d) -} - -/** - * Повертає абсолютний шлях до worktrees_dir. - * @param {typeof CONFIG_DEFAULTS} config конфігурація - * @param {string} root корінь репо - * @returns {string} абсолютний шлях - */ -export function resolveWorktreesDir(config, root) { - const d = config.worktrees_dir - return d.startsWith('/') ? d : join(root, d) -} - -/** - * Канонізує тир моделі: uppercase ('MIN' | 'AVG' | 'MAX'). - * Порожнє/невизначене значення → ''. - * @param {unknown} tier сире значення тиру - * @returns {string} канонічний тир - */ -export function normalizeModelTier(tier) { - return String(tier ?? '').toUpperCase() -} - -/** - * Конфігурація виконавців — **user-level, з ENV** (runtime.md «Підписочні - * CLI-виконавці»): вона спільна для всіх репозиторіїв користувача і тому НЕ - * живе у repo-scoped `.mt.json`. - * - * - `MT_AGENT_CLI` — дефолтний CLI (claude | codex | cursor | pi); - * - `MT_CLOUD_AGENT_CLIS` — каскад хмарних CLI, comma-separated - * (напр. "codex,cursor"); - * - `MT_AGENT_CLI_MODEL_MAP` — JSON-мапа «CLI → тир → модель» - * (напр. {"codex":{"MIN":"gpt-5.6-luna","AVG":"gpt-5.6-terra","MAX":"gpt-5.6-sola"}}). - * @param {Record<string, string | undefined>} env середовище процесу - * @returns {{ agentCli: string, cloudAgentClis: string[], modelMap: Record<string, Record<string, string>> }} конфіг виконавців - */ -export function loadAgentCliEnv(env) { - let modelMap = {} - try { - const parsed = JSON.parse(env.MT_AGENT_CLI_MODEL_MAP ?? '{}') - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) modelMap = parsed - } catch { - modelMap = {} - } - return { - agentCli: (env.MT_AGENT_CLI || 'claude').toLowerCase(), - cloudAgentClis: (env.MT_CLOUD_AGENT_CLIS ?? '') - .split(',') - .map(s => s.trim().toLowerCase()) - .filter(Boolean), - modelMap - } -} - -/** - * Резолвить конкретну модель тиру для підписочного CLI: MIN/AVG/MAX → - * `modelMap[<cli>][<tier>]` з env `MT_AGENT_CLI_MODEL_MAP`. Немає мапінгу → - * null: CLI резолвить модель сам, тир лишається hint-ом `MT_MODEL_TIER`. - * @param {ReturnType<typeof loadAgentCliEnv>} cliEnv конфіг виконавців з ENV - * @param {string} agentCli підписочний CLI ('claude' | 'codex' | 'cursor' | 'pi') - * @param {string | undefined} modelTier 'MIN' | 'AVG' | 'MAX' - * @returns {string | null} model id або null (CLI вирішує сам) - */ -export function resolveModelForCli(cliEnv, agentCli, modelTier) { - return cliEnv.modelMap[agentCli]?.[normalizeModelTier(modelTier)] ?? null -} diff --git a/npm/lib/core/docs/config.md b/npm/lib/core/docs/config.md deleted file mode 100644 index 591cff5..0000000 --- a/npm/lib/core/docs/config.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -type: JS Module -title: config.mjs -resource: npm/lib/core/config.mjs -docgen: - crc: 81739452 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Завантаження конфігурації `.mt.json` для mt-команд (читання файлу з ін'єктованою ФС, злиття з дефолтами у Rust-ядрі `mt-core` через napi-аддон) плюс **user-level конфіг виконавців з ENV** — він спільний для всіх репозиторіїв користувача і тому не живе у repo-scoped `.mt.json`. - -## Публічний API - -- `CONFIG_DEFAULTS` — дефолтні значення конфігурації (джерело істини — Rust `config_defaults`); модельних ключів не містить. -- `loadConfig({ root, readFile, exists })` — читає `<root>/.mt.json` (якщо існує) і повертає злиту з дефолтами конфігурацію. -- `resolveMtDir(config, root)` / `resolveWorktreesDir(config, root)` — абсолютні шляхи до `mt_dir` / `worktrees_dir` (відносні — від `root`). -- `normalizeModelTier(tier)` — канонізація тиру (uppercase: `MIN` | `AVG` | `MAX`). -- `loadAgentCliEnv(env)` — конфіг виконавців з ENV: `MT_AGENT_CLI` (дефолтний CLI, fallback `claude`), `MT_CLOUD_AGENT_CLIS` (каскад хмарних CLI, comma-separated), `MT_AGENT_CLI_MODEL_MAP` (JSON-мапа «CLI → тир → модель»; невалідний JSON → порожня мапа без винятку). -- `resolveModelForCli(cliEnv, agentCli, modelTier)` — конкретна модель тиру для підписочного CLI з мапи; немає мапінгу → `null` (CLI резолвить модель сам, тир лишається hint-ом `MT_MODEL_TIER`). - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- ФС і ENV ін'єктуються — модуль тестований без диска й реального оточення; відсутній `.mt.json` → чисті дефолти. diff --git a/npm/lib/core/docs/frontmatter.md b/npm/lib/core/docs/frontmatter.md deleted file mode 100644 index 9ca754c..0000000 --- a/npm/lib/core/docs/frontmatter.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: JS Module -title: frontmatter.mjs -resource: npm/lib/core/frontmatter.mjs -docgen: - crc: e70b95df - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл надає інструменти для парсингу та серіалізації YAML front-matter для task-файлів. Забезпечує ідентичність вихідних байтів між Rust-реалізацією та історичною JS-реалізацією, підтримуючи прості пари ключ-значення, вкладені об'єкти з відступами та серіалізацію назад у YAML для запису. - -## Поведінка - -Поведінка - -parseFrontMatter Парсить YAML front-matter з markdown-тексту повертає словник з ключа-значення або порожній об'єкт якщо front-matter відсутній - -getBody Отримує тіло документа без front-matter - -serializeYaml Серіалізує об'єкт у YAML-рядок для front-matter - -buildMarkdown Будує markdown-файл з front-matter та тілом - -## Публічний API - -Зрозуміло. Я готовий переписати цей список відповідно до ваших вимог, виконуючи роль технічного письменника. - -Ось переписаний список у потрібному форматі: - -- parseFrontMatter — Парсить YAML front-matter з markdown-тексту. Повертає словник (може містити вкладені об'єкти та масиви). -- getBody — Отримує тіло документа (без front-matter). -- serializeYaml — Серіалізує об'єкт у YAML-рядок (для front-matter). Підтримує прості scalar, масиви та вкладені об'єкти. -- buildMarkdown — Створює markdown-файл із front-matter і тілом. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/core/docs/index.md b/npm/lib/core/docs/index.md deleted file mode 100644 index a914c47..0000000 --- a/npm/lib/core/docs/index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -type: Directory Index -title: npm/lib/core -resource: npm/lib/core/ ---- - -| Файл | Тип | -| ----------------------------------- | --------- | -| [config.mjs](config.md) | JS Module | -| [frontmatter.mjs](frontmatter.md) | JS Module | -| [native.mjs](native.md) | JS Module | -| [nnn.mjs](nnn.md) | JS Module | -| [scanner-bin.mjs](scanner-bin.md) | JS Module | -| [scanner.mjs](scanner.md) | JS Module | -| [state.mjs](state.md) | JS Module | -| [task-command.mjs](task-command.md) | JS Module | -| [worktree.mjs](worktree.md) | JS Module | diff --git a/npm/lib/core/docs/native.md b/npm/lib/core/docs/native.md deleted file mode 100644 index 4f4c97f..0000000 --- a/npm/lib/core/docs/native.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -type: JS Module -title: native.mjs -resource: npm/lib/core/native.mjs -docgen: - crc: ea2743e7 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл визначає та завантажує нативний модуль. Визначає, як шукати та ініціалізувати клієнт Rust-ядро `mt-core`. - -## Поведінка - -Поведінка -resolveNativeAddon: Резолвить шлях до napi-аддона mt. -loadNative: Завантажує аддон за шляхом через process.dlopen. - -## Публічний API - -**resolveNativeAddon**: Резолвить шлях до napi-аддона `mt`. - env?: Record<string, string | undefined>, - platform?: string, - arch?: string, - existsSync?: (p: string) => boolean, - requireResolve?: (id: string) => string, - repoRoot?: string - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Кешує результати в межах одного прогону. diff --git a/npm/lib/core/docs/nnn.md b/npm/lib/core/docs/nnn.md deleted file mode 100644 index fc286c6..0000000 --- a/npm/lib/core/docs/nnn.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -type: JS Module -title: nnn.mjs -resource: npm/lib/core/nnn.mjs -docgen: - crc: beb79516 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Створює нумерацію для артефактів задач. Допомагає ін'єкції у Rust-ядро через napi-аддон. - -## Поведінка - -Поведінка -padNNN Форматує число у рядок NNN три цифри з ведучими нулями -nextRunNNN Обчислює наступний NNN для taskDir -nextPlanNNN Обчислює наступний NNN для taskDir -latestFactNNN Знаходить максимальний NNN серед fact_NNN.md -latestPendingAuditNNN Знаходить NNN для останнього pending-audit_NNN.md -latestAuditResultNNN Знаходить NNN для останнього audit-result_NNN.md - -## Публічний API - -Зрозумів. Я готовий писати лаконічну поведінкову документацію у стилі «назва — що робить» українською мовою, без вступів, висновків, сигнатур чи типізації, використовуючи надані назви. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/core/docs/scanner-bin.md b/npm/lib/core/docs/scanner-bin.md deleted file mode 100644 index bfa3345..0000000 --- a/npm/lib/core/docs/scanner-bin.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -type: JS Module -title: scanner-bin.mjs -resource: npm/lib/core/scanner-bin.mjs -docgen: - crc: 1f360412 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд: Файл визначає точний шлях до бінарника `mt-scanner` через послідовний пошук. Пошук починається з явного перекриття `MT_SCANNER_BIN` (для dev/CI/тестів), потім переходить до підпакетів `@7n/mt-<platform>-<arch>` (як опціональних залежностей), а потім використовує fallback у вигляді `<repoRoot>/target/release|debug/mt-scanner`. Результат кешується. - -## Поведінка - -Поведінка - -resolveScannerBin Резолвить абсолютний шлях до бінарника mt-scanner. -scannerBin Повертає кешований шлях до бінарника mt-scanner. - -## Публічний API - -**resolveScannerBin** — резолвить повний шлях до бінарника `mt-scanner` з налаштуваннями. -**scannerBin** — кешований резолвер, який замінюється через `resolveScannerBin` для тестування. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Кешує результати в межах одного прогону. diff --git a/npm/lib/core/docs/scanner.md b/npm/lib/core/docs/scanner.md deleted file mode 100644 index 21ca0b9..0000000 --- a/npm/lib/core/docs/scanner.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -type: JS Module -title: scanner.mjs -resource: npm/lib/core/scanner.mjs -docgen: - crc: 3a213b2f - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Модуль надає інструменти для роботи з DAG-сканером задач, який виконує тонкий шим над Rust-ядром `mt-core`. Файл забезпечує перетворення JSON-дерева задач у плоский контракт команд та виконання топологічного сортування. - -## Поведінка - -Поведінка - -findTasks -Переводить JSON-дерево задач у плоский контракт команд - -scanTasks -Запускає бінарник для сканування та повертає список задач - -topoSort -Виконує топологічне сортування задач для визначення порядку виконання - -areDepsResolved -Перевіряє, чи всі залежності для задачі є resolved - -getActiveWorktrees -Отримує список активних worktrees з git worktree list - -parseWorktreeList -Парсить вивід git worktree list у набір імен worktree - -## Публічний API - -findTasks — знаходить усі задачі DAG у mt\_dir. -scanTasks — сканує DAG і повертає всі задачі з деривованими станами, включаючи blocked та worktree $\rightarrow$ running, обчислюючи бінарник. -topoSort — виконує топологічне сортування задач (алгоритм Кана). Задачі без залежностей виконуються першими. Циклічні залежності не гарантуються. -areDepsResolved — перевіряє, чи всі залежності задачі resolved. -getActiveWorktrees — знаходить активні worktrees з git worktree list. -parseWorktreeList — парсить вивід `git worktree list --porcelain` і повертає набір імен worktree. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/core/docs/state.md b/npm/lib/core/docs/state.md deleted file mode 100644 index f51a19a..0000000 --- a/npm/lib/core/docs/state.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: JS Module -title: state.mjs -resource: npm/lib/core/state.mjs -docgen: - crc: 50ee99da - model: omlx/gemma-4-e2b-it-4bit - score: 95 ---- - -## Огляд - -Я готовий. Будь ласка, надайте чорнетку, яку потрібно переписати відповідно до ваших критеріїв. - -## Поведінка - -Поведінка -NODE_STATES -Перелік можливих станів задачі - -sanitizeTaskName -Санітизує ім'я задачі для використання в назві worktree - -validateTaskName -Валідує id вузла для створення задачі - -## Публічний API - -Я готовий виконати вашу задачу. Будь ласка, надайте код, який потрібно переписати у формат поведінкової документації. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/core/docs/task-command.md b/npm/lib/core/docs/task-command.md deleted file mode 100644 index 230d6e1..0000000 --- a/npm/lib/core/docs/task-command.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -type: JS Module -title: task-command.mjs -resource: npm/lib/core/task-command.mjs -docgen: - crc: a2123748 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд: Файл містить спільні хелпери для команд переходу стану задачі. Він існує для уникнення дублювання логіки, забезпечуючи єдине джерело формату `run_NNN.md` та резолв шляху задачі. - -## Поведінка - -writeRunFile -Пише артефакт run\_NNN.md з використанням формату `run_NNN.md` - -resolveTaskPath -Резолвить шлях задачі з аргументів або env MT\_TASK\_PATH - -## Публічний API - -writeRunFile — Пише артефакт run\_NNN.md. - -resolveTaskPath — Визначає шлях до завдання через аргументи або `MT_TASK_PATH` змінну середовища. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/npm/lib/core/docs/worktree.md b/npm/lib/core/docs/worktree.md deleted file mode 100644 index 6e66bae..0000000 --- a/npm/lib/core/docs/worktree.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: JS Module -title: worktree.mjs -resource: npm/lib/core/worktree.mjs -docgen: - crc: 559a77eb - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Огляд -Файл керує Git worktree для системи завдань. Відповідає за створення, видалення та злиття ізольованих робочих директорій, використовуючи механізм блокування для атомарності операцій. - -## Поведінка - -Поведінка -makeWorktreeName -Генерує ім'я worktree для задачі -createWorktree -Створює git worktree для задачі з atomic mkdir lock -removeWorktree -Видаляє git worktree -mergeWorktree -Мерджить зміни з worktree у main-гілку і видаляє worktree -listActiveWorktrees -Повертає список активних worktrees з репо -findTaskWorktree -Знаходить worktree що належить задачі за prefix - -## Публічний API - -- makeWorktreeName — генерує ім'я для worktree. -- createWorktree — створює git worktree з atomic mkdir lock для задачі. Повертає `null` якщо worktree вже існує (EEXIST → вже запущено). -- removeWorktree — видаляє git worktree. -- mergeWorktree — мержить зміни з worktree у main-гілку і видаляє worktree. - -## Гарантії поведінки - -- Кешує результати в межах одного прогону. diff --git a/npm/lib/core/frontmatter.mjs b/npm/lib/core/frontmatter.mjs deleted file mode 100644 index f60893d..0000000 --- a/npm/lib/core/frontmatter.mjs +++ /dev/null @@ -1,54 +0,0 @@ -/** - * YAML front-matter parser/serializer для mt task-файлів. - * - * Тонка обгортка над Rust-ядром (crates/mt-core/src/frontmatter.rs через - * napi-аддон). Вихід serializeYaml байт-у-байт ідентичний історичній - * JS-реалізації (конформанс — vitest-сюїта + Rust-тести serialize_yaml_matches_js_bytes). - * - * Підтримує: - * - Прості `key: value` рядки - * - Вкладені об'єкти (блок з відступами), напр. `executor:` + indented children - * - Списки: `deps:` / `skills:` із рядками ` - item` - * - Серіалізацію назад у YAML (для запису front-matter) - */ -import { loadNative } from './native.mjs' - -/** - * Парсить YAML front-matter з markdown-тексту. - * Повертає словник (може містити вкладені об'єкти та масиви). - * @param {string} text вміст файлу - * @returns {Record<string, unknown>} ключ-значення, або {} якщо front-matter відсутній - */ -export function parseFrontMatter(text) { - return loadNative().parseFrontMatter(text) -} - -/** - * Отримує тіло документа (без front-matter). - * @param {string} text вміст файлу - * @returns {string} тіло без front-matter - */ -export function getBody(text) { - return loadNative().getBody(text) -} - -/** - * Серіалізує об'єкт у YAML-рядок (для front-matter). - * Підтримує прості scalar, масиви та вкладені об'єкти. - * @param {Record<string, unknown>} obj об'єкт для серіалізації - * @param {number} [indentLevel] рівень відступу (default: 0) - * @returns {string} YAML-рядок (без --- маркерів) - */ -export function serializeYaml(obj, indentLevel = 0) { - return loadNative().serializeYaml(obj, indentLevel) -} - -/** - * Будує markdown-файл із front-matter і тілом. - * @param {Record<string, unknown>} fm об'єкт front-matter - * @param {string} [body] тіло документа (default: '') - * @returns {string} повний вміст файлу - */ -export function buildMarkdown(fm, body = '') { - return loadNative().buildMarkdown(fm, body) -} diff --git a/npm/lib/core/native.mjs b/npm/lib/core/native.mjs deleted file mode 100644 index 50f166a..0000000 --- a/npm/lib/core/native.mjs +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Loader napi-аддона `mt` (Rust-ядро `crates/mt-napi` → `mt-core`). - * - * Порядок пошуку: - * 1. MT_NATIVE_ADDON — явний override шляху до аддона (dev / CI / тести). - * 2. Platform-підпакет `@7n/mt-<platform>-<arch>` (napi-артефакт `mt.<triple>.node`). - * 3. Dev-fallback: <repoRoot>/target/release|debug/libmt_napi.dylib|.so - * та вивід `napi build` у crates/mt-napi/. - * 4. Інакше — зрозуміла помилка з підказкою. - * - * Аддон завантажується через `process.dlopen` — працює і для `.node`, і для - * сирих cdylib (`.dylib`/`.so`) із `cargo build` без napi CLI. - * Результат кешується (одне завантаження на процес). - */ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' -import process, { arch as osArch, env as procEnv, platform as osPlatform } from 'node:process' -import { fileURLToPath } from 'node:url' - -const require = createRequire(import.meta.url) -const HERE = dirname(fileURLToPath(import.meta.url)) -/** Корінь репо: npm/lib/core → up 3. */ -const REPO_ROOT = join(HERE, '..', '..', '..') - -/** Підтримувані platform-arch → napi-суфікс артефакта. */ -const NAPI_SUFFIXES = { - 'darwin-arm64': 'darwin-arm64', - 'linux-x64': 'linux-x64-gnu' -} - -/** @type {Record<string, unknown> | null} */ -let cached = null - -/** - * Завантажує аддон за шляхом через process.dlopen. - * @param {string} p шлях до .node / .dylib / .so - * @returns {Record<string, unknown>} exports аддона - */ -function dlopenAddon(p) { - const mod = { exports: {} } - process.dlopen(mod, p) - return mod.exports -} - -/** - * Ім'я cdylib-файлу для платформи (вивід `cargo build -p mt-napi`). - * @param {string} platform process.platform - * @returns {string} ім'я бібліотеки - */ -function cdylibName(platform) { - return platform === 'darwin' ? 'libmt_napi.dylib' : 'libmt_napi.so' -} - -/** - * Резолвить шлях до napi-аддона `mt`. - * @param {{ - * env?: Record<string, string | undefined>, - * platform?: string, - * arch?: string, - * existsSync?: (p: string) => boolean, - * requireResolve?: (id: string) => string, - * repoRoot?: string - * }} [deps] ін'єкції для тестів - * @returns {string} шлях до файлу аддона - */ -export function resolveNativeAddon(deps = {}) { - const env = deps.env ?? procEnv - const platform = deps.platform ?? osPlatform - const arch = deps.arch ?? osArch - const exists = deps.existsSync ?? existsSync - const requireResolve = deps.requireResolve ?? (id => require.resolve(id)) - const repoRoot = deps.repoRoot ?? REPO_ROOT - - // 1. Явний override. - const override = env.MT_NATIVE_ADDON - if (override) return override - - const key = `${platform}-${arch}` - const suffix = NAPI_SUFFIXES[key] - - // 2. Platform-підпакет (napi-артефакт поряд із mt-scanner бінарником). - if (suffix) { - try { - return requireResolve(`@7n/mt-${key}/mt.${suffix}.node`) - } catch { - // не встановлено — пробуємо dev-fallback - } - } - - // 3. Dev-fallback: cargo-збірка (сирий cdylib) або вивід napi build. - const candidates = Array.from(['release', 'debug'], profile => - join(repoRoot, 'target', profile, cdylibName(platform)) - ) - if (suffix) { - candidates.push(join(repoRoot, 'crates', 'mt-napi', `mt.${suffix}.node`)) - } - for (const candidate of candidates) { - if (exists(candidate)) return candidate - } - - // 4. Помилка з підказкою. - throw new Error( - `mt native addon: немає збірки для "${key}". ` + - `Постав MT_NATIVE_ADDON=/шлях/до/аддона, додай підпакет @7n/mt-${key}, ` + - `або збери локально: cargo build --release -p mt-napi` - ) -} - -/** - * Кешований доступ до аддона (одне завантаження на процес). - * @param {{ resolve?: () => string, dlopen?: (p: string) => Record<string, unknown> }} [deps] ін'єкції - * @returns {Record<string, unknown>} exports аддона (scanTasks, createTask, …) - */ -export function loadNative(deps = {}) { - if (cached === null) { - const path = (deps.resolve ?? resolveNativeAddon)() - cached = (deps.dlopen ?? dlopenAddon)(path) - } - return cached -} diff --git a/npm/lib/core/nnn.mjs b/npm/lib/core/nnn.mjs deleted file mode 100644 index 28093a7..0000000 --- a/npm/lib/core/nnn.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * NNN-нумерація для артефактів задач (run_NNN.md, fact_NNN.md, тощо). - * - * Тонка обгортка над Rust-ядром (crates/mt-core/src/nnn.rs через napi-аддон): - * список файлів досі надходить через ін'єкцію readdirSync (тестованість без FS), - * а вся NNN-логіка виконується в Rust. - * NNN = рядок з ведучими нулями до 3 цифр: '001', '002', … - */ -import { loadNative } from './native.mjs' - -/** - * Форматує число як NNN рядок (три цифри з ведучими нулями). - * @param {number} n невід'ємне ціле число - * @returns {string} '001', '002', … - */ -export function padNNN(n) { - return loadNative().padNnn(n) -} - -/** - * Наступний NNN для run_NNN.md: count(run_*.md) + 1. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string} наступний NNN рядок - */ -export function nextRunNNN(taskDir, readdirSync) { - return loadNative().nextRunNnn(readdirSync(taskDir)) -} - -/** - * Наступний NNN для plan_NNN.md: max(plan_*.md numbers) + 1. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string} наступний NNN рядок - */ -export function nextPlanNNN(taskDir, readdirSync) { - return loadNative().nextPlanNnn(readdirSync(taskDir)) -} - -/** - * Найвищий NNN серед fact_NNN.md, або null якщо немає. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string | null} NNN рядок або null - */ -export function latestFactNNN(taskDir, readdirSync) { - return loadNative().latestFactNnn(readdirSync(taskDir)) -} - -/** - * Знаходить NNN для останнього pending-audit_NNN.md (для audit-result). - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string | null} NNN рядок або null - */ -export function latestPendingAuditNNN(taskDir, readdirSync) { - return loadNative().latestPendingAuditNnn(readdirSync(taskDir)) -} - -/** - * Знаходить NNN для останнього audit-result_NNN.md. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string | null} NNN рядок або null - */ -export function latestAuditResultNNN(taskDir, readdirSync) { - return loadNative().latestAuditResultNnn(readdirSync(taskDir)) -} diff --git a/npm/lib/core/scanner-bin.mjs b/npm/lib/core/scanner-bin.mjs deleted file mode 100644 index b83895c..0000000 --- a/npm/lib/core/scanner-bin.mjs +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Резолвер шляху до `mt-scanner` (Rust-бінарник). - * - * Порядок пошуку: - * 1. MT_SCANNER_BIN — явний override (dev / CI / тести). - * 2. Platform-підпакет `@7n/mt-<platform>-<arch>` (esbuild-модель, optionalDependencies). - * 3. Dev-fallback: <repoRoot>/target/release|debug/mt-scanner. - * 4. Інакше — зрозуміла помилка з підказкою. - * - * Результат кешується (один пошук на процес). - */ -import { createRequire } from 'node:module' -import { existsSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { platform as osPlatform, arch as osArch, env as procEnv } from 'node:process' - -const require = createRequire(import.meta.url) -const HERE = dirname(fileURLToPath(import.meta.url)) -/** Корінь репо: npm/lib/core → up 3. */ -const REPO_ROOT = join(HERE, '..', '..', '..') - -/** @type {string | null} */ -let cached = null - -/** - * Ім'я виконуваного файлу для платформи (на Windows — з .exe). - * @param {string} platform process.platform - * @returns {string} ім'я виконуваного файлу - */ -function binName(platform) { - return platform === 'win32' ? 'mt-scanner.exe' : 'mt-scanner' -} - -/** - * Резолвить абсолютний шлях до бінарника `mt-scanner`. - * @param {{ - * env?: Record<string, string | undefined>, - * platform?: string, - * arch?: string, - * existsSync?: (p: string) => boolean, - * requireResolve?: (id: string) => string, - * repoRoot?: string - * }} [deps] ін'єкції для тестів - * @returns {string} шлях до виконуваного бінарника - */ -export function resolveScannerBin(deps = {}) { - const env = deps.env ?? procEnv - const platform = deps.platform ?? osPlatform - const arch = deps.arch ?? osArch - const exists = deps.existsSync ?? existsSync - const requireResolve = deps.requireResolve ?? (id => require.resolve(id)) - const repoRoot = deps.repoRoot ?? REPO_ROOT - - const bin = binName(platform) - - // 1. Явний override. - const override = env.MT_SCANNER_BIN - if (override) return override - - const key = `${platform}-${arch}` - - // 2. Platform-підпакет. - try { - return requireResolve(`@7n/mt-${key}/${bin}`) - } catch { - // не встановлено — пробуємо dev-fallback - } - - // 3. Dev-fallback: зібраний локально бінарник. - for (const profile of ['release', 'debug']) { - const candidate = join(repoRoot, 'target', profile, bin) - if (exists(candidate)) return candidate - } - - // 4. Помилка з підказкою. - throw new Error( - `mt-scanner: немає prebuilt-бінарника для "${key}". ` + - `Постав MT_SCANNER_BIN=/шлях/до/${bin}, додай підпакет @7n/mt-${key}, ` + - `або збери локально: cargo build --release -p mt-cli` - ) -} - -/** - * Кешований резолвер (один пошук на процес). Override через resolveScannerBin для тестів. - * @returns {string} шлях до бінарника - */ -export function scannerBin() { - if (cached === null) cached = resolveScannerBin() - return cached -} diff --git a/npm/lib/core/scanner.mjs b/npm/lib/core/scanner.mjs deleted file mode 100644 index 79372c5..0000000 --- a/npm/lib/core/scanner.mjs +++ /dev/null @@ -1,212 +0,0 @@ -/** - * DAG-сканер задач — тонкий шим над Rust-ядром mt-core. - * - * Уся робота з файловою системою (обхід задач, деривація станів, worktree→running) - * виконується в Rust. За замовчуванням — прямий виклик napi-аддона (native.mjs); - * транзиційний шлях через бінарник `mt-scanner` лишається для ін'єкцій тестів - * (binPath/spawnSync) та явного override MT_SCANNER_BIN. Цей модуль лише приводить - * JSON-дерево до плоского контракту команд, плюс чисто-графові операції (топосорт). - */ -import { execSync, spawnSync } from 'node:child_process' -import { join } from 'node:path' -import { env as procEnv } from 'node:process' - -import { loadNative } from './native.mjs' -import { scannerBin } from './scanner-bin.mjs' - -/** - * @typedef {{ - * id: string, - * path: string, - * dir: string, - * deps: string[], - * state: string, - * composite: boolean, - * children: string[] - * }} TaskInfo - */ - -/** - * @typedef {( - * bin: string, - * args: string[], - * opts: object - * ) => { status: number | null, stdout: string, stderr: string, error?: Error }} SpawnSyncFn - */ - -/** Максимальний розмір stdout бінарника (великі графи). */ -const MAX_BUFFER = 64 * 1024 * 1024 - -/** - * Рекурсивно сплющує вкладене дерево `TaskNode` (Rust) у плоский список `TaskInfo`. - * Кожен вузол (включно з дітьми) стає окремим записом; `children` — масив шляхів. - * @param {object[]} tree вузли від бінарника - * @param {string} mtDir абсолютний шлях до mt/ - * @param {TaskInfo[]} [out] акумулятор - * @returns {TaskInfo[]} плоский список задач - */ -function flatten(tree, mtDir, out = []) { - for (const node of tree) { - out.push({ - id: node.path, - path: node.path, - dir: join(mtDir, node.path), - deps: node.deps ?? [], - // snake_case (Rust serde) → kebab-case (контракт команд): plan_review → plan-review - state: String(node.state).replaceAll('_', '-'), - composite: Boolean(node.is_composite), - children: (node.children ?? []).map(c => c.path) - }) - if (node.children?.length) flatten(node.children, mtDir, out) - } - return out -} - -/** - * Запускає `mt-scanner scan` і повертає сплющений список задач. - * @param {string} mtDir абсолютний шлях до mt/ - * @param {Set<string> | string[] | undefined} activeWorktrees активні worktree (опційно). - * Якщо передані — прокидуються в бінарник через --worktrees (уникає повторного git); - * якщо ні — бінарник сам виявляє worktree через `git worktree list`. - * @param {{ binPath?: string, spawnSync?: SpawnSyncFn }} [deps] ін'єкції для тестів - * @returns {TaskInfo[]} плоский список задач - */ -function runScanner(mtDir, activeWorktrees, deps = {}) { - // Транзиційний subprocess-шлях: лише для ін'єкцій тестів або явного override. - const useSubprocess = deps.binPath || deps.spawnSync || procEnv.MT_SCANNER_BIN - if (!useSubprocess) { - // Порожній список === відсутній --worktrees у CLI: аддон сам виявляє через git. - const wtList = activeWorktrees ? [...activeWorktrees] : [] - return flatten(loadNative().scanTasks(mtDir, wtList.length > 0 ? wtList : null), mtDir) - } - - const bin = deps.binPath ?? scannerBin() - const run = deps.spawnSync ?? spawnSync - - const wtList = activeWorktrees ? [...activeWorktrees] : [] - const args = ['scan', mtDir] - if (wtList.length > 0) args.push('--worktrees', wtList.join(',')) - - const res = run(bin, args, { encoding: 'utf8', maxBuffer: MAX_BUFFER }) - if (res.error) throw res.error - if (res.status !== 0) { - throw new Error(`mt-scanner failed (exit ${res.status}): ${res.stderr ?? ''}`) - } - - return flatten(JSON.parse(res.stdout), mtDir) -} - -/** - * Знаходить усі задачі DAG у mt_dir (директорії з task.md). - * @param {string} mtDir абсолютний шлях до mt/ - * @param {{ binPath?: string, spawnSync?: SpawnSyncFn }} [deps] ін'єкції - * @returns {{ dir: string, relPath: string }[]} список знайдених задач - */ -export function findTasks(mtDir, deps = {}) { - return runScanner(mtDir, undefined, deps).map(n => ({ dir: n.dir, relPath: n.path })) -} - -/** - * Сканує DAG і повертає всі задачі з деривованими станами (включно з blocked та - * worktree→running — усе обчислює бінарник). - * @param {string} mtDir абсолютний шлях до mt/ - * @param {Set<string>} activeWorktrees активні worktree імена (опційно) - * @param {{ binPath?: string, spawnSync?: SpawnSyncFn }} [deps] ін'єкції - * @returns {TaskInfo[]} список задач - */ -export function scanTasks(mtDir, activeWorktrees, deps = {}) { - return runScanner(mtDir, activeWorktrees, deps) -} - -/** - * Топологічне сортування задач (алгоритм Кана). - * Задачі без залежностей — першими. Циклічні залежності — не гарантовано. - * @param {TaskInfo[]} tasks задачі зі списком deps - * @returns {TaskInfo[]} відсортований список (або той самий порядок якщо циклічні) - */ -export function topoSort(tasks) { - const idToTask = new Map(tasks.map(t => [t.id, t])) - const inDegree = new Map(tasks.map(t => [t.id, 0])) - const adj = new Map(tasks.map(t => [t.id, []])) - - for (const task of tasks) { - for (const dep of task.deps) { - if (!idToTask.has(dep)) { - continue - } - - adj.get(dep).push(task.id) - inDegree.set(task.id, (inDegree.get(task.id) ?? 0) + 1) - } - } - - const queue = tasks.filter(t => (inDegree.get(t.id) ?? 0) === 0).map(t => t.id) - const sorted = [] - - while (queue.length > 0) { - const id = queue.shift() - const task = idToTask.get(id) - if (task) sorted.push(task) - for (const next of adj.get(id) ?? []) { - const deg = (inDegree.get(next) ?? 0) - 1 - inDegree.set(next, deg) - if (deg === 0) queue.push(next) - } - } - - if (sorted.length < tasks.length) { - for (const t of tasks) { - if (!sorted.includes(t)) sorted.push(t) - } - } - - return sorted -} - -/** - * Перевіряє чи всі залежності задачі resolved. - * @param {TaskInfo} task задача - * @param {Map<string, TaskInfo>} taskMap map id -> TaskInfo - * @returns {boolean} true якщо всі deps resolved - */ -export function areDepsResolved(task, taskMap) { - return task.deps.every(dep => { - const depTask = taskMap.get(dep) - return depTask?.state === 'resolved' - }) -} - -/** - * Знаходить активні worktrees з git worktree list. - * @param {string} root корінь репо - * @param {{ execSync?: (cmd: string, opts?: object) => string }} [deps] ін'єкції - * @returns {Set<string>} set імен worktree - */ -export function getActiveWorktrees(root, deps = {}) { - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts)) - try { - const out = execSyncFn('git worktree list --porcelain', { cwd: root, encoding: 'utf8' }) - return parseWorktreeList(String(out)) - } catch { - return new Set() - } -} - -/** - * Парсить вивід `git worktree list --porcelain` і повертає набір імен worktree. - * @param {string} output вивід команди - * @returns {Set<string>} set імен (останній компонент шляху) - */ -export function parseWorktreeList(output) { - const names = new Set() - for (const line of output.split('\n')) { - if (!line.startsWith('worktree ')) { - continue - } - - const path = line.slice('worktree '.length).trim() - const name = path.split('/').pop() ?? '' - if (name) names.add(name) - } - return names -} diff --git a/npm/lib/core/state.mjs b/npm/lib/core/state.mjs deleted file mode 100644 index 2a9af36..0000000 --- a/npm/lib/core/state.mjs +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Канонічний перелік станів задачі та утиліти іменування/валідації вузлів. - * - * Деривація стану з файлової системи виконується в Rust-ядрі mt-core - * (crates/mt-core/src/lib.rs). sanitize/validate — той самий Rust-код через - * napi-аддон; тут лишився тільки перелік станів і тонкі обгортки. - */ -import { loadNative } from './native.mjs' - -/** Всі можливі стани задачі відповідно до специфікації. */ -export const NODE_STATES = /** @type {const} */ ([ - 'unassigned', - 'pending', - 'waiting', - 'blocked', - 'plan-review', - 'spawned', - 'running', - 'stalled', - 'pending-audit', - 'resolved', - 'failed', - 'unresolvable' -]) - -/** - * Санітизує ім'я задачі для використання в назві worktree. - * - * Логіка — Rust `sanitize` (crates/mt-core/src/lib.rs), той самий код, що - * матчить worktree при детекції стану `running` — розсинхрон неможливий. - * Тест-вектори: 'research/collect data' → 'research-collect-data', - * 'my-task_01' → 'my-task_01', '' → ''. - * @param {string} name ім'я задачі (може містити /) - * @returns {string} санітизоване ім'я ([^a-zA-Z0-9_-] → '-') - */ -export function sanitizeTaskName(name) { - return loadNative().sanitizeTaskName(name) -} - -/** - * Валідує id вузла для створення задачі (НЕ виправляє — повертає помилку). - * - * Логіка — Rust `validate_name` (crates/mt-core/src/lib.rs); спільні - * тест-вектори в `npm/lib/tests/fixtures/name-vectors.json`. Правила (docs spec §8): - * сегменти `[a-z0-9-]+`, роздільник `/`; без порожніх/`.`/`..` сегментів, - * провідного/кінцевого `/`, великих літер, `_`, пробілів, traversal. - * @param {string} name id вузла (може містити /) - * @returns {string | null} текст помилки або null якщо валідне - */ -export function validateTaskName(name) { - return loadNative().validateTaskName(name) -} diff --git a/npm/lib/core/task-command.mjs b/npm/lib/core/task-command.mjs deleted file mode 100644 index 5ce9b18..0000000 --- a/npm/lib/core/task-command.mjs +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Спільні хелпери команд переходу стану задачі (`audit`/`done`/`failed`). - * - * Винесено сюди, щоб уникнути дублювання логіки між командами (єдине джерело - * формату `run_NNN.md` і резолву шляху задачі). - */ -import { join } from 'node:path' - -import { buildMarkdown } from './frontmatter.mjs' - -/** - * Пише run_NNN.md артефакт. - * @param {string} taskDir директорія задачі - * @param {string} nnn NNN рядок - * @param {'success'|'failed'} result результат - * @param {{ actor: string, now: string }} meta метадані - * @param {(p: string, c: string, enc: string) => void} writeFile функція запису - */ -export function writeRunFile(taskDir, nnn, result, meta, writeFile) { - const fm = { - created_at: meta.now, - actor: meta.actor, - result - } - const content = buildMarkdown(fm, `## Run ${nnn}\n\nactor: ${meta.actor}\nresult: ${result}\n`) - writeFile(join(taskDir, `run_${nnn}.md`), content, 'utf8') -} - -/** - * Резолвить шлях задачі з аргументів або env (`MT_TASK_PATH`). - * @param {string[]} args аргументи командного рядка - * @param {{ env?: Record<string, string> }} [deps] ін'єкції - * @returns {{ taskPath: string | null, error: string | null }} результат - */ -export function resolveTaskPath(args, deps = {}) { - if (args[0] && !args[0].startsWith('-')) { - return { taskPath: args[0], error: null } - } - - const env = deps.env ?? process.env - const fromEnv = env['MT_TASK_PATH'] - if (fromEnv?.trim()) { - return { taskPath: fromEnv.trim(), error: null } - } - - return { taskPath: null, error: 'MT_TASK_PATH not set' } -} diff --git a/npm/lib/core/worktree.mjs b/npm/lib/core/worktree.mjs deleted file mode 100644 index 255360d..0000000 --- a/npm/lib/core/worktree.mjs +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Git worktree management для mt task system. - * - * Atomic mkdir lock: EEXIST → skip (вже запущено). - * Матчінг worktree ↔ задача делеговано Rust-ядру - * (crates/mt-core/src/worktree.rs через napi-аддон); іменування run-worktree - * повністю переїхало в Rust-раннер (run.mjs — тонкий клієнт). - * - * Всі git операції через execSync (node:child_process). FS через ін'єкцію. - */ -import { execSync } from 'node:child_process' -import { mkdirSync, readdirSync, rmSync } from 'node:fs' -import { join } from 'node:path' - -import { loadNative } from './native.mjs' - -/** - * Створює git worktree для задачі з atomic mkdir lock. - * Повертає null якщо worktree вже існує (EEXIST → вже запущено). - * @param {string} worktreesDir абсолютний шлях до .worktrees/ - * @param {string} worktreeName ім'я нового worktree - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string, - * mkdirSync?: (p: string, opts?: object) => void - * }} [deps] ін'єкції - * @returns {{ worktreePath: string, branch: string } | null} worktree або null якщо вже існує - */ -export function createWorktree(worktreesDir, worktreeName, root, deps = {}) { - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts)) - const mkdirSyncFn = deps.mkdirSync ?? mkdirSync - - const worktreePath = join(worktreesDir, worktreeName) - const branch = `mt/${worktreeName}` - - // Atomic mkdir lock: якщо директорія вже є — хтось вже запустив цю задачу - mkdirSyncFn(worktreesDir, { recursive: true }) - try { - mkdirSyncFn(worktreePath, { recursive: false }) - } catch (error) { - if (error.code === 'EEXIST') return null - throw error - } - - try { - // Видаляємо порожню директорію — git worktree add створить її сам - rmSync(worktreePath, { recursive: true, force: true }) - execSyncFn(`git worktree add -b "${branch}" "${worktreePath}" HEAD`, { cwd: root, encoding: 'utf8' }) - } catch (error) { - // Якщо git worktree add не вдався — прибираємо директорію - try { - rmSync(worktreePath, { recursive: true, force: true }) - } catch { - // пропускаємо - } - throw error - } - - return { worktreePath, branch } -} - -/** - * Видаляє git worktree. - * @param {string} worktreePath абсолютний шлях до worktree - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - */ -export function removeWorktree(worktreePath, root, deps = {}) { - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts)) - try { - execSyncFn(`git worktree remove --force "${worktreePath}"`, { cwd: root, encoding: 'utf8' }) - } catch { - // Якщо не вдалось через git — видаляємо вручну - try { - rmSync(worktreePath, { recursive: true, force: true }) - execSyncFn('git worktree prune', { cwd: root, encoding: 'utf8' }) - } catch { - // пропускаємо — можливо вже видалено - } - } -} - -/** - * Мерджить зміни з worktree у main-гілку і видаляє worktree. - * @param {string} worktreePath абсолютний шлях до worktree - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {{ ok: boolean, error?: string }} результат - */ -export function mergeWorktree(worktreePath, root, deps = {}) { - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts)) - let branch - - try { - // Отримуємо ім'я гілки worktree - branch = execSyncFn('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - encoding: 'utf8' - }).trim() - - // Додаємо всі зміни і комітимо - execSyncFn('git add -A', { cwd: worktreePath, encoding: 'utf8' }) - - let hasChanges = false - try { - execSyncFn('git diff --cached --quiet', { cwd: worktreePath, encoding: 'utf8' }) - } catch { - hasChanges = true - } - - if (hasChanges) { - execSyncFn('git commit -m "mt: task completion"', { cwd: worktreePath, encoding: 'utf8' }) - } - - // Якщо worktree на окремій гілці — мерджимо в main - if (branch && branch !== 'HEAD' && branch !== 'main' && branch !== 'master') { - execSyncFn(`git merge --no-ff "${branch}" -m "mt: merge task ${branch}"`, { - cwd: root, - encoding: 'utf8' - }) - } - } catch (error) { - return { ok: false, error: error.message ?? String(error) } - } - - // Видаляємо worktree - removeWorktree(worktreePath, root, { execSync: execSyncFn }) - if (branch.startsWith('mt/')) { - try { - execSyncFn(`git branch -D "${branch}"`, { cwd: root, encoding: 'utf8' }) - } catch { - // Гілка могла бути вже видалена зовнішнім cleanup. - } - } - return { ok: true } -} - -/** - * Повертає список активних worktrees з репо. - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Set<string>} set імен worktrees - */ -export function listActiveWorktrees(root, deps = {}) { - const execSyncFn = deps.execSync ?? ((cmd, opts) => execSync(cmd, opts)) - - try { - const out = execSyncFn('git worktree list --porcelain', { cwd: root, encoding: 'utf8' }) - const names = new Set() - for (const line of String(out).split('\n')) { - if (!line.startsWith('worktree ')) { - continue - } - - const path = line.slice('worktree '.length).trim() - const name = path.split('/').pop() ?? '' - if (name) names.add(name) - } - return names - } catch { - return new Set() - } -} - -/** - * Знаходить worktree що належить задачі (за prefix). - * @param {string} taskPath відносний шлях задачі - * @param {string} worktreesDir абсолютний шлях до .worktrees/ - * @param {{ - * readdirSync?: (d: string) => string[], - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {string | null} абсолютний шлях до worktree або null - */ -export function findTaskWorktree(taskPath, worktreesDir, deps = {}) { - const readdirSyncFn = deps.readdirSync ?? readdirSync - - let entries - try { - entries = readdirSyncFn(worktreesDir) - } catch { - return null - } - - const match = loadNative().findWorktreeMatch(entries, taskPath) - return match ? join(worktreesDir, match) : null -} diff --git a/npm/lib/docs/cli.md b/npm/lib/docs/cli.md deleted file mode 100644 index 0c02cf4..0000000 --- a/npm/lib/docs/cli.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -type: JS Module -title: cli.mjs -resource: npm/lib/cli.mjs -docgen: - crc: 44a17344 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Я готовий перевірити надану чорнетку відповідно до ваших критеріїв. Будь ласка, надайте текст чорнетки, яку потрібно перевірити. - -## Поведінка - -Поведінка - -COMMAND_NAMES: Масив унікальних назв команд. -DEFAULT_HANDLERS: Проксі, що нормалізує лямбди для завантаження динамічних обробників команд. -runMtCli: Запускає CLI, парсить аргументи та маршрутизує до відповідного обробника команди. - -## Публічний API - -Я готовий переписати список у потрібному форматі. Надайте мені список, який потрібно переробити. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/npm/lib/docs/index.md b/npm/lib/docs/index.md deleted file mode 100644 index 3672a96..0000000 --- a/npm/lib/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: npm/lib -resource: npm/lib/ ---- - -| Файл | Тип | -| ----------------- | --------- | -| [cli.mjs](cli.md) | JS Module | diff --git a/npm/lib/tests/cli.test.mjs b/npm/lib/tests/cli.test.mjs deleted file mode 100644 index c7b9512..0000000 --- a/npm/lib/tests/cli.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' -import { COMMAND_NAMES, runMtCli } from '../cli.mjs' - -describe('runMtCli', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - test('exposes the complete public command surface', () => { - expect(COMMAND_NAMES).toEqual([ - 'setup', - 'init', - 'plan', - 'verify', - 'run', - 'status', - 'scan', - 'watch', - 'audit', - 'done', - 'failed', - 'spawn', - 'invalidate', - 'kill', - 'worktree' - ]) - }) - - test('routes a command and forwards remaining argv', async () => { - const plan = vi.fn(() => 0) - expect(await runMtCli(['plan', 'release'], { handlers: { plan } })).toBe(0) - expect(plan).toHaveBeenCalledWith(['release'], expect.any(Object)) - }) - - test('passes --root as handler cwd without leaking it into command args', async () => { - const setup = vi.fn(() => 0) - expect(await runMtCli(['setup', '--root', '/workspace/mt-project'], { handlers: { setup } })).toBe(0) - expect(setup).toHaveBeenCalledWith([], expect.objectContaining({ cwd: '/workspace/mt-project' })) - }) - - test('returns 1 for an unknown command', async () => { - const error = vi.spyOn(console, 'error').mockReturnValue() - expect(await runMtCli(['graph'])).toBe(1) - expect(error).toHaveBeenCalledWith('Невідома команда: graph') - }) -}) diff --git a/npm/lib/tests/config.test.mjs b/npm/lib/tests/config.test.mjs deleted file mode 100644 index 7e5ceee..0000000 --- a/npm/lib/tests/config.test.mjs +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { - CONFIG_DEFAULTS, - loadAgentCliEnv, - loadConfig, - normalizeModelTier, - resolveModelForCli, - resolveMtDir -} from '../core/config.mjs' - -describe('CONFIG_DEFAULTS', () => { - test('mt_dir default is ./mt', () => { - expect(CONFIG_DEFAULTS.mt_dir).toBe('./mt') - }) - - test('does not have tasks_dir key', () => { - expect(CONFIG_DEFAULTS).not.toHaveProperty('tasks_dir') - }) - - test('uses the MT-local system prompt path', () => { - expect(CONFIG_DEFAULTS.system_prompt).toBe('.mt/system-prompt.md') - }) -}) - -describe('loadConfig', () => { - test('returns mt_dir default when config file does not exist', () => { - const cfg = loadConfig({ root: '/repo', exists: () => false }) - expect(cfg.mt_dir).toBe('./mt') - }) - - test('reads .mt.json, not .n-cursor.json', () => { - const readPaths = [] - const exists = p => { - readPaths.push(p) - return p.endsWith('.mt.json') - } - - loadConfig({ root: '/repo', exists, readFile: () => JSON.stringify({ mt_dir: './custom-mt' }) }) - - const checkedMt = readPaths.some(p => p.endsWith('/.mt.json')) - const checkedNCursor = readPaths.some(p => p.endsWith('/.n-cursor.json')) - - expect(checkedMt).toBe(true) - expect(checkedNCursor).toBe(false) - }) - - test('merges .mt.json override into defaults', () => { - const cfg = loadConfig({ - root: '/repo', - exists: p => p.endsWith('.mt.json'), - readFile: () => JSON.stringify({ mt_dir: './my-tasks', max_worktrees: 12 }) - }) - expect(cfg.mt_dir).toBe('./my-tasks') - expect(cfg.max_worktrees).toBe(12) - // other defaults preserved - expect(cfg.worktrees_dir).toBe('./.worktrees') - }) - - test('falls back to defaults if .mt.json is invalid JSON', () => { - const cfg = loadConfig({ - root: '/repo', - exists: p => p.endsWith('.mt.json'), - readFile: () => 'not json {' - }) - expect(cfg.mt_dir).toBe('./mt') - }) - - test('модельних ключів у .mt.json немає — конфіг виконавців іде з ENV', () => { - const cfg = loadConfig({ root: '/repo', exists: () => false }) - expect(cfg).not.toHaveProperty('model_map') - expect(cfg).not.toHaveProperty('claude_model') - expect(cfg).not.toHaveProperty('audit_model') - }) -}) - -describe('normalizeModelTier — канон MIN/AVG/MAX', () => { - test('uppercase-нормалізація', () => { - expect(normalizeModelTier('min')).toBe('MIN') - expect(normalizeModelTier('avg')).toBe('AVG') - expect(normalizeModelTier('MAX')).toBe('MAX') - expect(normalizeModelTier()).toBe('') - }) -}) - -describe('loadAgentCliEnv / resolveModelForCli — user-level ENV', () => { - test('дефолти без ENV: claude, порожній каскад і мапа', () => { - const cliEnv = loadAgentCliEnv({}) - expect(cliEnv.agentCli).toBe('claude') - expect(cliEnv.cloudAgentClis).toEqual([]) - expect(cliEnv.modelMap).toEqual({}) - }) - - test('читає MT_AGENT_CLI, MT_CLOUD_AGENT_CLIS (comma), MT_AGENT_CLI_MODEL_MAP (JSON)', () => { - const cliEnv = loadAgentCliEnv({ - MT_AGENT_CLI: 'Codex', - MT_CLOUD_AGENT_CLIS: 'codex, Cursor', - MT_AGENT_CLI_MODEL_MAP: JSON.stringify({ - codex: { MIN: 'gpt-5.6-luna', AVG: 'gpt-5.6-terra', MAX: 'gpt-5.6-sola' } - }) - }) - expect(cliEnv.agentCli).toBe('codex') - expect(cliEnv.cloudAgentClis).toEqual(['codex', 'cursor']) - expect(resolveModelForCli(cliEnv, 'codex', 'avg')).toBe('gpt-5.6-terra') - expect(resolveModelForCli(cliEnv, 'cursor', 'AVG')).toBeNull() - }) - - test('невалідний JSON у MT_AGENT_CLI_MODEL_MAP → порожня мапа, без винятку', () => { - const cliEnv = loadAgentCliEnv({ MT_AGENT_CLI_MODEL_MAP: 'not json {' }) - expect(cliEnv.modelMap).toEqual({}) - expect(resolveModelForCli(cliEnv, 'codex', 'AVG')).toBeNull() - }) -}) - -describe('resolveMtDir', () => { - test('resolves relative mt_dir against root', () => { - expect(resolveMtDir({ mt_dir: './mt' }, '/repo')).toBe('/repo/mt') - }) - - test('returns absolute mt_dir unchanged', () => { - expect(resolveMtDir({ mt_dir: '/abs/path/mt' }, '/repo')).toBe('/abs/path/mt') - }) - - test('resolves custom relative path', () => { - expect(resolveMtDir({ mt_dir: './custom-tasks' }, '/project')).toBe('/project/custom-tasks') - }) -}) diff --git a/npm/lib/tests/docs/global-setup.md b/npm/lib/tests/docs/global-setup.md deleted file mode 100644 index c020699..0000000 --- a/npm/lib/tests/docs/global-setup.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -type: JS Module -title: global-setup.mjs -resource: npm/lib/tests/global-setup.mjs -docgen: - crc: 565d7025 - model: omlx/gemma-4-e2b-it-4bit - score: 100 ---- - -## Огляд - -Файл забезпечує наявність Rust-артефактів — CLI-бінарника `mt-scanner` та napi-аддона `mt-napi`, необхідних для роботи `scanner.mjs` / `native.mjs`. Файл гарантує їх наявність для коректної роботи резолверів через dev-fallback у `<repoRoot>/target/release` або при збірці в чистому checkout. - -## Поведінка - -Поведінка - -1. Перевірити наявність бінарника `mt-scanner` -2. Перевірити наявність аддона `mt-napi` -3. Якщо бінарники відсутні, запустити збірку через `cargo build --release -p mt-cli -p mt-napi` у корені репозиторію - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/npm/lib/tests/docs/index.md b/npm/lib/tests/docs/index.md deleted file mode 100644 index 35bf075..0000000 --- a/npm/lib/tests/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: npm/lib/tests -resource: npm/lib/tests/ ---- - -| Файл | Тип | -| ----------------------------------- | --------- | -| [global-setup.mjs](global-setup.md) | JS Module | diff --git a/npm/lib/tests/fixtures/name-vectors.json b/npm/lib/tests/fixtures/name-vectors.json deleted file mode 100644 index 49d732e..0000000 --- a/npm/lib/tests/fixtures/name-vectors.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_comment": "Спільні тест-вектори валідації імен вузлів. Споживають Rust (validate_name у crates/mt-core/src/lib.rs через include_str!) і JS (validateTaskName у npm/lib/core/state.mjs). Тримати синхронними з docs/spec-task-create-rust-integration.md §8.", - "valid": ["a", "a-b", "a/b/c", "research/collect-data"], - "invalid": ["", "a//b", "/a", "a/", "a/../b", "..", "a/.", "Research", "a b", "a_b"] -} diff --git a/npm/lib/tests/global-setup.mjs b/npm/lib/tests/global-setup.mjs deleted file mode 100644 index fe00610..0000000 --- a/npm/lib/tests/global-setup.mjs +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Vitest global-setup: гарантує наявність Rust-артефактів — CLI-бінарника - * `mt-scanner` (транзиційний, crates/mt-cli) і napi-аддона `mt` (crates/mt-napi), - * через які працюють scanner.mjs / native.mjs. Резолвери знаходять їх через - * dev-fallback у <repoRoot>/target/release; на чистому checkout збираємо. - */ -import { execFileSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { platform } from 'node:process' -import { fileURLToPath } from 'node:url' - -/** - * Збирає mt-scanner і mt-napi (release), якщо артефактів ще немає. - * @returns {void} - */ -export default function setup() { - // npm/lib/tests → up 3 = корінь репо - const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') - const bin = join(repoRoot, 'target', 'release', 'mt-scanner') - const addon = join(repoRoot, 'target', 'release', platform === 'darwin' ? 'libmt_napi.dylib' : 'libmt_napi.so') - if (!existsSync(bin) || !existsSync(addon)) { - execFileSync('cargo', ['build', '--release', '-p', 'mt-cli', '-p', 'mt-napi'], { - cwd: repoRoot, - stdio: 'inherit' - }) - } -} diff --git a/npm/lib/tests/init.test.mjs b/npm/lib/tests/init.test.mjs deleted file mode 100644 index fb1015e..0000000 --- a/npm/lib/tests/init.test.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { fileURLToPath } from 'node:url' -import { dirname, join } from 'node:path' - -import { describe, expect, test, vi } from 'vitest' - -import init, { parseInitArgs } from '../commands/init.mjs' -import { validateTaskName } from '../core/state.mjs' - -const here = dirname(fileURLToPath(import.meta.url)) -const repoRoot = join(here, '..', '..', '..') - -const CREATED_RE = /створено/ -const EXISTS_RE = /вже існує/ -const MODE_FLAG_RE = /--mode/ - -/** - * Створює тимчасовий репо з порожньою mt/. - * @returns {string} абсолютний шлях кореня репо - */ -function tmpRepo() { - const root = mkdtempSync(join(tmpdir(), 'mt-init-')) - mkdirSync(join(root, 'mt'), { recursive: true }) - return root -} - -/** - * Мок spawnSync: записує виклики, повертає задану відповідь. - * @param {object} response відповідь spawnSync (status/stdout/stderr) - * @returns {{ fn: (bin: string, args: string[], opts: object) => object, calls: object[] }} - * мок-функція та журнал викликів - */ -function fakeSpawn(response) { - const calls = [] - const fn = (bin, args, opts) => { - calls.push({ bin, args, opts }) - return response - } - return { fn, calls } -} - -describe('parseInitArgs', () => { - test("перший non-flag — ім'я, решта — прапорці вербатим", () => { - const r = parseInitArgs(['research/x', '--mode', 'agent', '--dep', 'a', '--dep', 'b']) - expect(r.name).toBe('research/x') - expect(r.flags).toEqual(['--mode', 'agent', '--dep', 'a', '--dep', 'b']) - }) - - test('прапор без значення → помилка', () => { - expect(parseInitArgs(['x', '--mode']).error).toMatch(MODE_FLAG_RE) - }) -}) - -describe('mt init (шим над mt-scanner create)', () => { - test('форвардить create + прапорці й парсить created:true', () => { - const root = tmpRepo() - const { fn, calls } = fakeSpawn({ - status: 0, - stdout: JSON.stringify({ created: true, name: 'demo', task_path: 'demo/task.md', flag: 'a.md', deps: [] }) - }) - const log = vi.fn() - const code = init(['demo', '--mode', 'agent'], { cwd: root, spawnSync: fn, binPath: '/fake/bin', log }) - expect(code).toBe(0) - expect(calls).toHaveLength(1) - expect(calls[0].bin).toBe('/fake/bin') - expect(calls[0].args).toEqual(['create', join(root, 'mt'), 'demo', '--mode', 'agent']) - expect(log.mock.calls.flat().join(' ')).toMatch(CREATED_RE) - rmSync(root, { recursive: true, force: true }) - }) - - test('created:false → лог "вже існує", exit 0', () => { - const root = tmpRepo() - const { fn } = fakeSpawn({ - status: 0, - stdout: JSON.stringify({ created: false, reason: 'exists', name: 'demo', task_path: 'demo/task.md' }) - }) - const log = vi.fn() - expect(init(['demo'], { cwd: root, spawnSync: fn, binPath: '/fake', log })).toBe(0) - expect(log.mock.calls.flat().join(' ')).toMatch(EXISTS_RE) - rmSync(root, { recursive: true, force: true }) - }) - - test('без імені → usage, exit 1, бінарник не викликано', () => { - const { fn, calls } = fakeSpawn({ status: 0, stdout: '{}' }) - expect(init([], { spawnSync: fn, binPath: '/fake', log: vi.fn() })).toBe(1) - expect(calls).toHaveLength(0) - }) - - test("невалідне ім'я → exit 1, бінарник не викликано", () => { - const { fn, calls } = fakeSpawn({ status: 0, stdout: '{}' }) - expect(init(['Bad Name'], { spawnSync: fn, binPath: '/fake', log: vi.fn() })).toBe(1) - expect(calls).toHaveLength(0) - }) - - test('ненульовий exit бінарника → exit 1', () => { - const root = tmpRepo() - const { fn } = fakeSpawn({ status: 2, stdout: '', stderr: 'Error: bad' }) - expect(init(['demo'], { cwd: root, spawnSync: fn, binPath: '/fake', log: vi.fn() })).toBe(1) - rmSync(root, { recursive: true, force: true }) - }) -}) - -// Інтеграція з реальним бінарником (якщо зібраний) — повний контракт JSON+ФС. -describe('mt init ↔ реальний mt-scanner', () => { - const bin = join(repoRoot, 'target', 'debug', 'mt-scanner') - test.runIf(existsSync(bin))('створює task.md + прапор через реальний бінарник', () => { - const root = tmpRepo() - const code = init(['research/collect-data', '--mode', 'agent', '--model-tier', 'MAX'], { - cwd: root, - binPath: bin, - log: vi.fn() - }) - expect(code).toBe(0) - const taskMd = readFileSync(join(root, 'mt', 'research', 'collect-data', 'task.md'), 'utf8') - expect(taskMd.startsWith('---\nschema_version: 1\n')).toBe(true) - expect(existsSync(join(root, 'mt', 'research', 'collect-data', 'a.md'))).toBe(true) - rmSync(root, { recursive: true, force: true }) - }) -}) - -// Спільні вектори валідації імен (мають збігатися з Rust validate_name). -describe('validateTaskName — спільні вектори', () => { - const vectors = JSON.parse(readFileSync(join(here, 'fixtures', 'name-vectors.json'), 'utf8')) - test.each(vectors.valid)('valid: %s', name => { - expect(validateTaskName(name)).toBeNull() - }) - test.each(vectors.invalid)('invalid: %j', name => { - expect(validateTaskName(name)).not.toBeNull() - }) -}) diff --git a/npm/lib/tests/native.test.mjs b/npm/lib/tests/native.test.mjs deleted file mode 100644 index 3ea590b..0000000 --- a/npm/lib/tests/native.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, test } from 'vitest' - -import { loadNative, resolveNativeAddon } from '../core/native.mjs' - -const NO_BUILD_RE = /немає збірки для "linux-arm64"/ - -describe('resolveNativeAddon', () => { - test('MT_NATIVE_ADDON override wins over everything', () => { - const p = resolveNativeAddon({ - env: { MT_NATIVE_ADDON: '/custom/mt.node' }, - platform: 'linux', - arch: 'x64', - requireResolve: () => '/should/not/be/used', - existsSync: () => true - }) - expect(p).toBe('/custom/mt.node') - }) - - test('resolves platform subpackage napi artifact when installed', () => { - const p = resolveNativeAddon({ - env: {}, - platform: 'darwin', - arch: 'arm64', - requireResolve: id => { - expect(id).toBe('@7n/mt-darwin-arm64/mt.darwin-arm64.node') - return '/node_modules/@7n/mt-darwin-arm64/mt.darwin-arm64.node' - }, - existsSync: () => false - }) - expect(p).toBe('/node_modules/@7n/mt-darwin-arm64/mt.darwin-arm64.node') - }) - - test('linux-x64 maps to the gnu napi suffix', () => { - const ids = [] - resolveNativeAddon({ - env: {}, - platform: 'linux', - arch: 'x64', - requireResolve: id => { - ids.push(id) - throw new Error('not installed') - }, - existsSync: p => p === '/repo/target/release/libmt_napi.so', - repoRoot: '/repo' - }) - expect(ids).toContain('@7n/mt-linux-x64/mt.linux-x64-gnu.node') - }) - - test('dev fallback to target/release cdylib when subpackage missing', () => { - const p = resolveNativeAddon({ - env: {}, - platform: 'darwin', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: p2 => p2 === '/repo/target/release/libmt_napi.dylib', - repoRoot: '/repo' - }) - expect(p).toBe('/repo/target/release/libmt_napi.dylib') - }) - - test('dev fallback to target/debug when release missing', () => { - const p = resolveNativeAddon({ - env: {}, - platform: 'darwin', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: p2 => p2 === '/repo/target/debug/libmt_napi.dylib', - repoRoot: '/repo' - }) - expect(p).toBe('/repo/target/debug/libmt_napi.dylib') - }) - - test('dev fallback to napi build output in crates/mt-napi', () => { - const p = resolveNativeAddon({ - env: {}, - platform: 'darwin', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: p2 => p2 === '/repo/crates/mt-napi/mt.darwin-arm64.node', - repoRoot: '/repo' - }) - expect(p).toBe('/repo/crates/mt-napi/mt.darwin-arm64.node') - }) - - test('throws a helpful error when nothing resolves', () => { - expect(() => - resolveNativeAddon({ - env: {}, - platform: 'linux', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: () => false, - repoRoot: '/repo' - }) - ).toThrow(NO_BUILD_RE) - }) -}) - -describe('loadNative (integration, real addon)', () => { - /** @type {string} */ - let tmp - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), 'mt-native-')) - }) - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }) - }) - - test('scanTasks returns the node tree from the napi addon', () => { - const native = loadNative() - mkdirSync(join(tmp, 'mt/demo'), { recursive: true }) - writeFileSync(join(tmp, 'mt/demo/task.md'), '') - writeFileSync(join(tmp, 'mt/demo/a.md'), '') - - const nodes = native.scanTasks(join(tmp, 'mt'), []) - expect(nodes).toHaveLength(1) - expect(nodes[0].path).toBe('demo') - expect(nodes[0].state).toBe('waiting') - }) - - test('createTask writes a node and is idempotent', () => { - const native = loadNative() - mkdirSync(join(tmp, 'mt'), { recursive: true }) - - const first = native.createTask(join(tmp, 'mt'), 'demo', { mode: 'human' }) - expect(first.created).toBe(true) - expect(first.flag).toBe('h.md') - - const again = native.createTask(join(tmp, 'mt'), 'demo', null) - expect(again.created).toBe(false) - expect(again.reason).toBe('exists') - }) -}) diff --git a/npm/lib/tests/package.test.mjs b/npm/lib/tests/package.test.mjs deleted file mode 100644 index 9fc4f6d..0000000 --- a/npm/lib/tests/package.test.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, test } from 'vitest' - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '../..') -const repositoryRoot = join(packageRoot, '..') -const INTERNAL_DECLARATION_IMPORT_RE = /from\s+['"]\.\/lib\/cli\.mjs/ - -describe('package contract', () => { - test('points npm metadata at the MT repository', () => { - const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) - - expect(pkg.homepage).toBe('https://github.com/nitra/mt#readme') - expect(pkg.bugs.url).toBe('https://github.com/nitra/mt/issues') - expect(pkg.repository.url).toBe('git+https://github.com/nitra/mt.git') - }) - - test('does not publish test files', () => { - const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) - - expect(pkg.files).toContain('!**/*.test.mjs') - }) - - test('root start script launches the mt binary', () => { - const pkg = JSON.parse(readFileSync(join(repositoryRoot, 'package.json'), 'utf8')) - - expect(pkg.scripts.start).toBe('bun ./npm/bin/mt.js') - }) - - test('ships TypeScript declarations for re-exported modules', () => { - const declarations = readFileSync(join(packageRoot, 'types/index.d.ts'), 'utf8') - - expect(declarations).toMatch(INTERNAL_DECLARATION_IMPORT_RE) - expect(readFileSync(join(packageRoot, 'types/lib/cli.d.mts'), 'utf8')).toContain('runMtCli') - }) -}) diff --git a/npm/lib/tests/run.test.mjs b/npm/lib/tests/run.test.mjs deleted file mode 100644 index f457001..0000000 --- a/npm/lib/tests/run.test.mjs +++ /dev/null @@ -1,154 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, test, vi } from 'vitest' - -import run from '../commands/run.mjs' - -const createdDirs = [] - -/** - * Мінімальна фікстура тонкого клієнта: task.md на диску (git не потрібен — - * claim/worktree/publish живуть у Rust-раннері, який тут мокається). - * @returns {string} корінь тимчасового репо - */ -function createTaskFixture() { - const root = mkdtempSync(join(tmpdir(), 'mt-run-')) - createdDirs.push(root) - mkdirSync(join(root, 'mt', 'demo'), { recursive: true }) - writeFileSync(join(root, 'mt', 'demo', 'task.md'), '---\nmode: agent\n---\n\n## Mission\n\nDemo\n', 'utf8') - return root -} - -/** - * Мок napi-аддона: runNode/runAuto підміняються тестом. - * @param {{ - * runNode?: (mtDir: string, taskPath: string) => object, - * runAuto?: (mtDir: string, concurrency: number) => object[] - * }} [impl] реалізації - * @returns {{ - * runNode: (mtDir: string, taskPath: string) => object, - * runAuto: (mtDir: string, concurrency: number) => object[] - * }} native-мок - */ -function nativeMock(impl = {}) { - return { - runNode: impl.runNode ?? vi.fn(), - runAuto: impl.runAuto ?? vi.fn(() => []) - } -} - -afterEach(() => { - for (const dir of createdDirs) { - rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) - } - createdDirs.length = 0 -}) - -describe('mt run — тонкий клієнт Rust-раннера', () => { - test('agent-шлях делегує native.runNode(mtDir, path); success → 0', () => { - const root = createTaskFixture() - const runNode = vi.fn(() => ({ - result: 'success', - run_file: 'run_001.md', - fact_file: 'fact_001.md', - wall_sec: 3, - agent_cli: 'codex', - propagated: [] - })) - const log = vi.fn() - - expect(run(['demo'], { cwd: root, native: nativeMock({ runNode }), log })).toBe(0) - - expect(runNode).toHaveBeenCalledWith(join(root, 'mt'), 'demo') - const logged = log.mock.calls.flat().join('\n') - expect(logged).toContain('success') - expect(logged).toContain('agent_cli=codex') - }) - - test('failed-результат раннера → exit 1 із вказівкою на run_NNN.md', () => { - const root = createTaskFixture() - const runNode = vi.fn(() => ({ - result: 'failed', - run_file: 'run_002.md', - fact_file: null, - wall_sec: 7, - agent_cli: null, - propagated: [] - })) - const log = vi.fn() - - expect(run(['demo'], { cwd: root, native: nativeMock({ runNode }), log })).toBe(1) - expect(log.mock.calls.flat().join('\n')).toContain('run_002.md') - }) - - test('claim-lost («інший runner виграв») → exit 2; інша помилка → 1', () => { - const root = createTaskFixture() - const claimLost = nativeMock({ - runNode: vi.fn(() => { - throw new Error('claim-lost: інший runner уже володіє цим вузлом') - }) - }) - expect(run(['demo'], { cwd: root, native: claimLost, log: vi.fn() })).toBe(2) - - const noOrigin = nativeMock({ - runNode: vi.fn(() => { - throw new Error("git fetch origin: no such remote 'origin'") - }) - }) - expect(run(['demo'], { cwd: root, native: noOrigin, log: vi.fn() })).toBe(1) - }) - - test('невідома задача → 1 без виклику раннера', () => { - const root = createTaskFixture() - const native = nativeMock() - - expect(run(['missing'], { cwd: root, native, log: vi.fn() })).toBe(1) - expect(native.runNode).not.toHaveBeenCalled() - }) - - test('без <path> і без --auto → 1 з usage', () => { - const root = createTaskFixture() - const log = vi.fn() - expect(run([], { cwd: root, native: nativeMock(), log })).toBe(1) - expect(log.mock.calls.flat().join('\n')).toContain('Usage') - }) - - test('--actor human — інструкції без спавну і без claim', () => { - const root = createTaskFixture() - const native = nativeMock() - const log = vi.fn() - - expect(run(['demo', '--actor', 'human'], { cwd: root, native, log })).toBe(0) - - expect(native.runNode).not.toHaveBeenCalled() - expect(log.mock.calls.flat().join('\n')).toContain('mt done demo') - }) -}) - -describe('mt run --auto — оркестраторний прохід у Rust-ядрі', () => { - test('делегує runAuto(mtDir, agent_concurrency); claim-lost — skip, не провал', () => { - const root = createTaskFixture() - const runAuto = vi.fn(() => [ - { path: 'a', result: 'success', error: null }, - { path: 'b', result: 'error', error: 'claim-lost: інший runner уже володіє цим вузлом' } - ]) - - expect(run(['--auto'], { cwd: root, native: nativeMock({ runAuto }), log: vi.fn() })).toBe(0) - // agent_concurrency — з CONFIG_DEFAULTS (5), .mt.json відсутній. - expect(runAuto).toHaveBeenCalledWith(join(root, 'mt'), 5) - }) - - test('реальний провал вузла у прогоні → 1; порожній прогін → 0', () => { - const root = createTaskFixture() - const failed = nativeMock({ - runAuto: vi.fn(() => [{ path: 'a', result: 'budget-exceeded', error: null }]) - }) - expect(run(['--auto'], { cwd: root, native: failed, log: vi.fn() })).toBe(1) - - const log = vi.fn() - expect(run(['--auto'], { cwd: root, native: nativeMock(), log })).toBe(0) - expect(log.mock.calls.flat().join('\n')).toContain('немає готових задач') - }) -}) diff --git a/npm/lib/tests/scanner-bin.test.mjs b/npm/lib/tests/scanner-bin.test.mjs deleted file mode 100644 index f0ce438..0000000 --- a/npm/lib/tests/scanner-bin.test.mjs +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { resolveScannerBin } from '../core/scanner-bin.mjs' - -const NO_PREBUILT_RE = /немає prebuilt-бінарника для "linux-arm64"/ - -describe('resolveScannerBin', () => { - test('MT_SCANNER_BIN override wins over everything', () => { - const bin = resolveScannerBin({ - env: { MT_SCANNER_BIN: '/custom/mt-scanner' }, - platform: 'linux', - arch: 'x64', - requireResolve: () => '/should/not/be/used', - existsSync: () => true - }) - expect(bin).toBe('/custom/mt-scanner') - }) - - test('resolves platform subpackage when installed', () => { - const bin = resolveScannerBin({ - env: {}, - platform: 'linux', - arch: 'x64', - requireResolve: id => { - expect(id).toBe('@7n/mt-linux-x64/mt-scanner') - return '/node_modules/@7n/mt-linux-x64/mt-scanner' - }, - existsSync: () => false - }) - expect(bin).toBe('/node_modules/@7n/mt-linux-x64/mt-scanner') - }) - - test('appends .exe on win32 subpackage id', () => { - const ids = [] - resolveScannerBin({ - env: {}, - platform: 'win32', - arch: 'x64', - requireResolve: id => { - ids.push(id) - throw new Error('not installed') - }, - existsSync: p => p.endsWith('mt-scanner.exe') && p.includes('release'), - repoRoot: '/repo' - }) - expect(ids).toContain('@7n/mt-win32-x64/mt-scanner.exe') - }) - - test('dev fallback to target/release when subpackage missing', () => { - const bin = resolveScannerBin({ - env: {}, - platform: 'darwin', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: p => p === '/repo/target/release/mt-scanner', - repoRoot: '/repo' - }) - expect(bin).toBe('/repo/target/release/mt-scanner') - }) - - test('dev fallback to target/debug when release missing', () => { - const bin = resolveScannerBin({ - env: {}, - platform: 'darwin', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: p => p === '/repo/target/debug/mt-scanner', - repoRoot: '/repo' - }) - expect(bin).toBe('/repo/target/debug/mt-scanner') - }) - - test('throws a helpful error when nothing resolves', () => { - expect(() => - resolveScannerBin({ - env: {}, - platform: 'linux', - arch: 'arm64', - requireResolve: () => { - throw new Error('not installed') - }, - existsSync: () => false, - repoRoot: '/repo' - }) - ).toThrow(NO_PREBUILT_RE) - }) -}) diff --git a/npm/lib/tests/setup.test.mjs b/npm/lib/tests/setup.test.mjs deleted file mode 100644 index a03cf5e..0000000 --- a/npm/lib/tests/setup.test.mjs +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Тести `mt setup` handler — clean-break tests. - * Перевіряємо що setup створює .mt.json і mt/, але НЕ .n-cursor.json і НЕ tasks/. - */ -import { describe, expect, test, vi } from 'vitest' - -import setup from '../commands/setup.mjs' - -describe('setup', () => { - test('створює .mt.json у корені', async () => { - const written = {} - const code = await setup([], { - cwd: '/repo', - exists: () => false, - writeFile: (p, c) => { - written[p] = c - }, - readFile: () => { - throw new Error('not found') - }, - mkdir: vi.fn(), - log: vi.fn() - }) - expect(code).toBe(0) - expect(Object.keys(written)).toContain('/repo/.mt.json') - expect(Object.keys(written)).not.toContain('/repo/.n-cursor.json') - }) - - test('створює mt/ директорію', async () => { - const created = [] - const code = await setup([], { - cwd: '/repo', - exists: () => false, - writeFile: vi.fn(), - readFile: () => { - throw new Error('not found') - }, - mkdir: p => { - created.push(p) - }, - log: vi.fn() - }) - expect(code).toBe(0) - expect(created.some(p => p.endsWith('/mt'))).toBe(true) - expect(created.some(p => p.endsWith('/tasks'))).toBe(false) - }) - - test('створює .worktrees/ директорію для першого запуску', async () => { - const created = [] - const code = await setup([], { - cwd: '/repo', - exists: () => false, - writeFile: vi.fn(), - mkdir: p => { - created.push(p) - }, - chmod: vi.fn(), - resolveHooksDir: () => null, - log: vi.fn() - }) - - expect(code).toBe(0) - expect(created).toContain('/repo/.worktrees') - }) - - test('робить створений git hook executable', async () => { - const chmod = vi.fn() - const code = await setup([], { - cwd: '/repo', - exists: p => p === '/repo/.git', - writeFile: vi.fn(), - mkdir: vi.fn(), - chmod, - resolveHooksDir: () => '/repo/.git/hooks', - log: vi.fn() - }) - - expect(code).toBe(0) - expect(chmod).toHaveBeenCalledWith('/repo/.git/hooks/post-commit', 0o755) - }) - - test('НЕ створює tasks/ директорію', async () => { - const created = [] - await setup([], { - cwd: '/repo', - exists: () => false, - writeFile: vi.fn(), - readFile: () => { - throw new Error('not found') - }, - mkdir: p => { - created.push(p) - }, - log: vi.fn() - }) - expect(created.every(p => !p.endsWith('/tasks'))).toBe(true) - }) - - test('НЕ створює .n-cursor.json', async () => { - const written = {} - await setup([], { - cwd: '/repo', - exists: () => false, - writeFile: (p, c) => { - written[p] = c - }, - readFile: () => { - throw new Error('not found') - }, - mkdir: vi.fn(), - log: vi.fn() - }) - expect(Object.keys(written).every(p => !p.endsWith('.n-cursor.json'))).toBe(true) - }) - - test('пропускає .mt.json якщо вже існує', async () => { - const written = {} - const log = vi.fn() - await setup([], { - cwd: '/repo', - exists: p => p.endsWith('.mt.json'), - writeFile: (p, c) => { - written[p] = c - }, - readFile: () => { - throw new Error('not found') - }, - mkdir: vi.fn(), - log - }) - expect(Object.keys(written)).not.toContain('/repo/.mt.json') - expect(log).toHaveBeenCalledWith(expect.stringContaining('вже існує')) - }) - - test('пропускає mt/ якщо вже існує', async () => { - const created = [] - const log = vi.fn() - await setup([], { - cwd: '/repo', - exists: p => p.endsWith('/mt'), - writeFile: vi.fn(), - readFile: () => { - throw new Error('not found') - }, - mkdir: p => { - created.push(p) - }, - log - }) - expect(created.every(p => !p.endsWith('/mt'))).toBe(true) - expect(log).toHaveBeenCalledWith(expect.stringContaining('вже існує')) - }) - - test('exit 1 якщо writeFile кидає', async () => { - const code = await setup([], { - cwd: '/repo', - exists: () => false, - writeFile: () => { - throw new Error('disk full') - }, - readFile: () => { - throw new Error('not found') - }, - mkdir: vi.fn(), - log: vi.fn() - }) - expect(code).toBe(1) - }) -}) diff --git a/npm/lib/tests/state.test.mjs b/npm/lib/tests/state.test.mjs deleted file mode 100644 index b5bb2e9..0000000 --- a/npm/lib/tests/state.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { NODE_STATES, sanitizeTaskName } from '../core/state.mjs' - -// Деривація стану перенесена в Rust (crates/mt-core/src/lib.rs) — її покривають cargo-тести. -// Тут лишилось тільки те, що ще живе в JS: перелік станів і sanitize імен worktree. - -// ------- NODE_STATES ------- - -describe('NODE_STATES', () => { - test('contains all 12 spec states in order', () => { - expect(NODE_STATES).toEqual([ - 'unassigned', - 'pending', - 'waiting', - 'blocked', - 'plan-review', - 'spawned', - 'running', - 'stalled', - 'pending-audit', - 'resolved', - 'failed', - 'unresolvable' - ]) - }) -}) - -// ------- sanitizeTaskName ------- -// ВАЖЛИВО: ці вектори мають збігатися з Rust-тестом `sanitize_vectors` (crates/mt-core/src/lib.rs). - -describe('sanitizeTaskName', () => { - test('replaces slashes and special chars with hyphens', () => { - expect(sanitizeTaskName('research/collect data')).toBe('research-collect-data') - }) - - test('leaves alphanumeric, hyphens and underscores unchanged', () => { - expect(sanitizeTaskName('my-task_01')).toBe('my-task_01') - }) - - test('empty string returns empty string', () => { - expect(sanitizeTaskName('')).toBe('') - }) -}) diff --git a/npm/lib/tests/verify.test.mjs b/npm/lib/tests/verify.test.mjs deleted file mode 100644 index 103db57..0000000 --- a/npm/lib/tests/verify.test.mjs +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Тести `mt verify` handler (`lib/commands/verify.mjs`). - * FS повністю ін'єктований — без реального диска. - */ -import { afterEach, describe, expect, test, vi } from 'vitest' - -import verify from '../commands/verify.mjs' - -afterEach(() => vi.restoreAllMocks()) - -const FACT_CONTENT = `---\ncreated_at: 2026-01-01T00:00:00Z\n---\n## Summary\nDone.\n` -const TASK_CONTENT = `---\ncreated_at: 2026-01-01T00:00:00Z\n---\n## Task\nDo X.\n\n## Done when\nAll tests pass and output exists.\n\n## Inputs\nNone.\n` - -/** - * Будує ін'єкції. - * @param {{ files?: string[], fact?: string|null, taskContent?: string|null }} [params] параметри тесту - * @returns {object} набір ін'єкцій для verify() - */ -function makeDeps({ files = [], fact = FACT_CONTENT, taskContent = TASK_CONTENT } = {}) { - const fileMap = {} - if (fact !== null) fileMap['/task/fact_001.md'] = fact - if (taskContent !== null) fileMap['/task/task.md'] = taskContent - return { - cwd: '/task', - readFile: p => { - if (p in fileMap) return fileMap[p] - throw new Error(`unexpected readFile: ${p}`) - }, - readdir: () => files, - exists: p => p in fileMap - } -} - -describe('verify', () => { - test('відсутній fact_NNN.md → exit 1', async () => { - const log = vi.fn() - const code = await verify([], { - cwd: '/task', - readdir: () => ['task.md'], - exists: () => false, - readFile: () => '', - log - }) - expect(code).toBe(1) - expect(log).toHaveBeenCalledWith(expect.stringContaining('fact_NNN.md не знайдено')) - }) - - test('fact порожній (після front-matter) → exit 1', async () => { - const log = vi.fn() - const emptyFact = '---\ncreated_at: 2026-01-01T00:00:00Z\n---\n \n' - const code = await verify([], { - cwd: '/task', - readdir: () => ['fact_001.md'], - exists: p => p.endsWith('fact_001.md'), - readFile: () => emptyFact, - log - }) - expect(code).toBe(1) - expect(log).toHaveBeenCalledWith(expect.stringContaining('порожній')) - }) - - test('валідний fact → exit 0, stdout містить Done when та fact', async () => { - const deps = makeDeps({ files: ['fact_001.md', 'task.md'] }) - const logOut = vi.spyOn(console, 'log').mockReturnValue() - const code = await verify([], deps) - expect(code).toBe(0) - const printed = logOut.mock.calls.map(c => c.join(' ')).join('\n') - expect(printed).toContain('Done when') - expect(printed).toContain('All tests pass') - expect(printed).toContain('fact_001.md') - expect(printed).toContain('Done.') - }) - - test('task.md відсутній → exit 0 (Done when не виводиться, але не блокує)', async () => { - const logOut = vi.spyOn(console, 'log').mockReturnValue() - const code = await verify([], { - cwd: '/task', - readdir: () => ['fact_001.md'], - exists: p => p.endsWith('fact_001.md'), - readFile: p => { - if (p.endsWith('fact_001.md')) return FACT_CONTENT - throw new Error('no task.md') - }, - log: vi.fn() - }) - expect(code).toBe(0) - const printed = logOut.mock.calls.map(c => c.join(' ')).join('\n') - expect(printed).toContain('fact_001.md') - }) - - test('вибирає fact з найбільшим номером', async () => { - const logOut = vi.spyOn(console, 'log').mockReturnValue() - const fileMap = { - '/task/fact_001.md': '---\ncreated_at: x\n---\n## Summary\nOld.\n', - '/task/fact_002.md': '---\ncreated_at: x\n---\n## Summary\nNew latest.\n', - '/task/task.md': TASK_CONTENT - } - const code = await verify([], { - cwd: '/task', - readdir: () => ['fact_001.md', 'fact_002.md', 'task.md'], - exists: p => p in fileMap, - readFile: p => { - if (p in fileMap) return fileMap[p] - throw new Error(`unexpected: ${p}`) - }, - log: vi.fn() - }) - expect(code).toBe(0) - const printed = logOut.mock.calls.map(c => c.join(' ')).join('\n') - expect(printed).toContain('fact_002.md') - expect(printed).toContain('New latest') - }) -}) diff --git a/npm/lib/tests/worktree.test.mjs b/npm/lib/tests/worktree.test.mjs deleted file mode 100644 index 9c8e130..0000000 --- a/npm/lib/tests/worktree.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, test } from 'vitest' - -import { createWorktree } from '../core/worktree.mjs' - -const createdDirs = [] - -function createGitRepo() { - const root = mkdtempSync(join(tmpdir(), 'mt-worktree-')) - createdDirs.push(root) - execFileSync('git', ['init', '-q', '--initial-branch=main'], { cwd: root }) - execFileSync('git', ['config', 'user.email', 'mt-test@example.test'], { cwd: root }) - execFileSync('git', ['config', 'user.name', 'MT Test'], { cwd: root }) - writeFileSync(join(root, 'README.md'), '# fixture\n', 'utf8') - execFileSync('git', ['add', 'README.md'], { cwd: root }) - execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: root }) - return root -} - -afterEach(() => { - for (const dir of createdDirs) { - rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) - } - createdDirs.length = 0 -}) - -describe('createWorktree', () => { - test('створює parent directory і окрему branch, не detached HEAD', () => { - const root = createGitRepo() - const result = createWorktree(join(root, '.worktrees'), 'demo-1', root) - - expect(result).not.toBeNull() - expect( - execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: result.worktreePath, encoding: 'utf8' }).trim() - ).toBe('mt/demo-1') - }) -}) diff --git a/npm/package.json b/npm/package.json deleted file mode 100644 index f6b285f..0000000 --- a/npm/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@7n/mt", - "version": "0.28.0", - "description": "CLI-утиліта @7n/mt", - "keywords": [ - "7n", - "bun", - "cli" - ], - "homepage": "https://github.com/nitra/mt#readme", - "bugs": { - "url": "https://github.com/nitra/mt/issues" - }, - "license": "ISC", - "author": "vitaliytv@nitralabs.com", - "repository": { - "type": "git", - "url": "git+https://github.com/nitra/mt.git" - }, - "bin": { - "mt": "bin/mt.js" - }, - "files": [ - "bin", - "docs", - "lib", - "types", - "index.js", - "README.md", - "CHANGELOG.md", - "!**/*.test.mjs", - "!**/tests/**" - ], - "type": "module", - "main": "./index.js", - "types": "./types/index.d.ts", - "publishConfig": { - "access": "public" - }, - "scripts": { - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "start": "bun ./bin/mt.js" - }, - "dependencies": {}, - "optionalDependencies": { - "@7n/mt-darwin-arm64": "0.2.0", - "@7n/mt-linux-x64": "0.2.0" - }, - "engines": { - "bun": ">=1.3", - "node": ">=24" - } -} diff --git a/npm/stryker.config.mjs b/npm/stryker.config.mjs deleted file mode 100644 index a7c0671..0000000 --- a/npm/stryker.config.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import '@stryker-mutator/vitest-runner' - -/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ -export default { - testRunner: 'vitest', - vitest: { configFile: 'vitest.config.js' }, - // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст швидкості. - coverageAnalysis: 'perTest', - // vitest-runner ізолює мутантів у пам'яті через AST-patching, без копіювання node_modules у sandbox. - tempDirName: 'reports/stryker/.tmp', - reporters: ['json', 'clear-text'], - jsonReporter: { fileName: 'reports/stryker/mutation.json' }, - // incremental: зберігає результати між запусками, відновлює після краш/kill. - incremental: true, - incrementalFile: 'reports/stryker/incremental.json', - // Покриваємо production-код. Test-файли Stryker виключає за іменем (`*.test.*`) автоматично. - mutate: ['index.js', 'bin/**/*.js', 'lib/**/*.mjs', '!**/*.test.{js,mjs}', '!**/tests/**'] -} diff --git a/npm/tsconfig.emit-types.json b/npm/tsconfig.emit-types.json deleted file mode 100644 index 1e06fd7..0000000 --- a/npm/tsconfig.emit-types.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "checkJs": false, - "declaration": true, - "emitDeclarationOnly": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": false, - "outDir": "types", - "rootDir": ".", - "skipLibCheck": true, - "target": "ESNext" - }, - "include": ["index.js"], - "exclude": ["**/node_modules/**", "**/tests/**", "types/**"] -} diff --git a/npm/types/index.d.ts b/npm/types/index.d.ts deleted file mode 100644 index 28ce181..0000000 --- a/npm/types/index.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const version: '0.1.0' -export { runMtCli, COMMAND_NAMES, DEFAULT_HANDLERS } from './lib/cli.mjs' -export { getBody, serializeYaml } from './lib/core/frontmatter.mjs' -export { padNNN, latestPendingAuditNNN, latestAuditResultNNN } from './lib/core/nnn.mjs' -export { findTasks, getActiveWorktrees, parseWorktreeList } from './lib/core/scanner.mjs' diff --git a/npm/types/lib/cli.d.mts b/npm/types/lib/cli.d.mts deleted file mode 100644 index 0b8a12f..0000000 --- a/npm/types/lib/cli.d.mts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Запускає mt CLI: парсить argv, маршрутизує до обробника команди. - * @param {string[]} argv аргументи командного рядка (без node/script) - * @param {{ handlers?: object, version?: string }} [deps] ін'єкції (handlers, version) - * @returns {Promise<number>} exit code (0=OK, 1=помилка) - */ -export function runMtCli( - argv: string[], - deps?: { - handlers?: object - version?: string - } -): Promise<number> -export const COMMAND_NAMES: string[] -export namespace DEFAULT_HANDLERS { - function setup(): Promise<typeof import('./commands/setup.mjs')> - function init(): Promise<typeof import('./commands/init.mjs')> - function plan(): Promise<typeof import('./commands/plan.mjs')> - function verify(): Promise<typeof import('./commands/verify.mjs')> - function run(): Promise<typeof import('./commands/run.mjs')> - function status(): Promise<typeof import('./commands/status.mjs')> - function scan(): Promise<typeof import('./commands/scan.mjs')> - function watch(): Promise<typeof import('./commands/watch.mjs')> - function audit(): Promise<typeof import('./commands/audit.mjs')> - function done(): Promise<typeof import('./commands/done.mjs')> - function failed(): Promise<typeof import('./commands/failed.mjs')> - function spawn(): Promise<typeof import('./commands/spawn.mjs')> - function invalidate(): Promise<typeof import('./commands/invalidate.mjs')> - function kill(): Promise<typeof import('./commands/kill.mjs')> - function worktree(): Promise<typeof import('./commands/worktree.mjs')> -} diff --git a/npm/types/lib/commands/audit.d.mts b/npm/types/lib/commands/audit.d.mts deleted file mode 100644 index 587b63a..0000000 --- a/npm/types/lib/commands/audit.d.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * `mt audit <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function audit(args: string[], deps?: object): Promise<number> diff --git a/npm/types/lib/commands/done.d.mts b/npm/types/lib/commands/done.d.mts deleted file mode 100644 index 7797c94..0000000 --- a/npm/types/lib/commands/done.d.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * `mt done <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function done(args: string[], deps?: object): Promise<number> diff --git a/npm/types/lib/commands/failed.d.mts b/npm/types/lib/commands/failed.d.mts deleted file mode 100644 index 4aab91d..0000000 --- a/npm/types/lib/commands/failed.d.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * `mt failed <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function failed(args: string[], deps?: object): Promise<number> diff --git a/npm/types/lib/commands/init.d.mts b/npm/types/lib/commands/init.d.mts deleted file mode 100644 index 892d53c..0000000 --- a/npm/types/lib/commands/init.d.mts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Розбирає argv `mt init`: перший non-flag токен — ім'я, решта — прапорці - * (прокидаються в бінарник вербатим; авторитетний парсинг — у Rust). - * @param {string[]} args аргументи після `init` - * @returns {{ name: string | null, flags: string[], error?: string }} розібране ім'я, - * список прапорців для бінарника та опційний текст помилки парсингу - */ -export function parseInitArgs(args: string[]): { - name: string | null - flags: string[] - error?: string -} -/** - * `mt init <name> [flags]` command handler. - * @param {string[]} args аргументи: [name, ...flags] - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * spawnSync?: typeof spawnSync, - * binPath?: string, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean - * }} [deps] ін'єкції - * @returns {number} exit code (0 створено/існує, 1 usage/помилка) - */ -export default function init( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - spawnSync?: typeof spawnSync - binPath?: string - readFile?: (p: string, enc: string) => string - exists?: (p: string) => boolean - } -): number diff --git a/npm/types/lib/commands/invalidate.d.mts b/npm/types/lib/commands/invalidate.d.mts deleted file mode 100644 index 4dda416..0000000 --- a/npm/types/lib/commands/invalidate.d.mts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * `mt invalidate <path> [--no-cascade]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * writeFile?: (p: string, c: string, enc: string) => void, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function invalidate( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (p: string, enc: string) => string - writeFile?: (p: string, c: string, enc: string) => void - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - execSync?: (cmd: string, opts?: object) => string - } -): Promise<number> diff --git a/npm/types/lib/commands/kill.d.mts b/npm/types/lib/commands/kill.d.mts deleted file mode 100644 index 5bd223f..0000000 --- a/npm/types/lib/commands/kill.d.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * `mt kill <path>` command handler. - * @param {string[]} args аргументи: [path] - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * writeFile?: (p: string, c: string, enc: string) => void, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * unlink?: (p: string) => void, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function kill( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (p: string, enc: string) => string - writeFile?: (p: string, c: string, enc: string) => void - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - unlink?: (p: string) => void - execSync?: (cmd: string, opts?: object) => string - } -): Promise<number> diff --git a/npm/types/lib/commands/plan.d.mts b/npm/types/lib/commands/plan.d.mts deleted file mode 100644 index 635de03..0000000 --- a/npm/types/lib/commands/plan.d.mts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Будує шаблон plan_NNN.md. - * @param {{ mode: string, hint: string, now: string, nnn: string }} params параметри - * @returns {string} вміст файлу - */ -export function buildPlanTemplate(params: { mode: string; hint: string; now: string; nnn: string }): string -/** - * `mt plan [<path>] [--mode agent]` command handler. - * @param {string[]} args аргументи: [path] [--mode agent|human] - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * writeFile?: (p: string, c: string, enc: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * now?: () => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function plan( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - writeFile?: (p: string, c: string, enc: string) => void - readFile?: (p: string, enc: string) => string - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - now?: () => string - } -): Promise<number> diff --git a/npm/types/lib/commands/run.d.mts b/npm/types/lib/commands/run.d.mts deleted file mode 100644 index 52be0b4..0000000 --- a/npm/types/lib/commands/run.d.mts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `mt run [<path>] [--actor a] [--auto]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * writeFile?: (p: string, c: string, enc: string) => void, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string, - * spawnSync?: (cmd: string, args: string[], opts?: object) => object, - * statSync?: (p: string) => object, - * now?: () => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function run( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (p: string, enc: string) => string - writeFile?: (p: string, c: string, enc: string) => void - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - execSync?: (cmd: string, opts?: object) => string - spawnSync?: (cmd: string, args: string[], opts?: object) => object - statSync?: (p: string) => object - now?: () => string - } -): Promise<number> diff --git a/npm/types/lib/commands/scan.d.mts b/npm/types/lib/commands/scan.d.mts deleted file mode 100644 index 6f12532..0000000 --- a/npm/types/lib/commands/scan.d.mts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * `mt scan [--json]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code (0=clean, 1=attention) - */ -export default function scan( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (p: string, enc: string) => string - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - execSync?: (cmd: string, opts?: object) => string - } -): Promise<number> diff --git a/npm/types/lib/commands/setup.d.mts b/npm/types/lib/commands/setup.d.mts deleted file mode 100644 index ef4c1ab..0000000 --- a/npm/types/lib/commands/setup.d.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * `mt setup` command handler. - * @param {string[]} _args аргументи (не використовуються) - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * writeFile?: (p: string, c: string, enc: string) => void, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean, - * mkdir?: (p: string, opts?: object) => void, - * chmod?: (p: string, mode: number) => void, - * resolveHooksDir?: (root: string) => string | null - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function setup( - _args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - writeFile?: (p: string, c: string, enc: string) => void - readFile?: (p: string, enc: string) => string - exists?: (p: string) => boolean - mkdir?: (p: string, opts?: object) => void - chmod?: (p: string, mode: number) => void - resolveHooksDir?: (root: string) => string | null - } -): Promise<number> diff --git a/npm/types/lib/commands/spawn.d.mts b/npm/types/lib/commands/spawn.d.mts deleted file mode 100644 index e0eaf52..0000000 --- a/npm/types/lib/commands/spawn.d.mts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * `mt spawn <path>` command handler. - * @param {string[]} args аргументи - * @param {object} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function spawn(args: string[], deps?: object): Promise<number> diff --git a/npm/types/lib/commands/status.d.mts b/npm/types/lib/commands/status.d.mts deleted file mode 100644 index bd843e0..0000000 --- a/npm/types/lib/commands/status.d.mts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * `mt status [<path>] [--json]` command handler. - * @param {string[]} args аргументи - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code - */ -export default function status( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (p: string, enc: string) => string - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - execSync?: (cmd: string, opts?: object) => string - } -): Promise<number> diff --git a/npm/types/lib/commands/verify.d.mts b/npm/types/lib/commands/verify.d.mts deleted file mode 100644 index ba6f443..0000000 --- a/npm/types/lib/commands/verify.d.mts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * `mt verify` handler. - * @param {string[]} _rest аргументи після `verify` (не використовуються) - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (path: string, enc: string) => string, - * readdir?: (dir: string) => string[], - * exists?: (path: string) => boolean - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code (0=OK, 1=структурна помилка) - */ -export default function verify( - _rest: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (path: string, enc: string) => string - readdir?: (dir: string) => string[] - exists?: (path: string) => boolean - } -): Promise<number> diff --git a/npm/types/lib/commands/watch.d.mts b/npm/types/lib/commands/watch.d.mts deleted file mode 100644 index 75dcf53..0000000 --- a/npm/types/lib/commands/watch.d.mts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * `mt watch` command handler (one-shot scan). - * @param {string[]} args аргументи (зазвичай порожні) - * @param {{ - * cwd?: string, - * log?: (m: string) => void, - * readFile?: (p: string, enc: string) => string, - * readdir?: (d: string) => string[], - * exists?: (p: string) => boolean, - * execSync?: (cmd: string, opts?: object) => string, - * statSync?: (p: string) => { mtimeMs: number }, - * now?: () => number - * }} [deps] ін'єкції - * @returns {Promise<number>} exit code (0=clean, 1=attention) - */ -export default function watch( - args: string[], - deps?: { - cwd?: string - log?: (m: string) => void - readFile?: (p: string, enc: string) => string - readdir?: (d: string) => string[] - exists?: (p: string) => boolean - execSync?: (cmd: string, opts?: object) => string - statSync?: (p: string) => { - mtimeMs: number - } - now?: () => number - } -): Promise<number> diff --git a/npm/types/lib/commands/worktree.d.mts b/npm/types/lib/commands/worktree.d.mts deleted file mode 100644 index 8e020b3..0000000 --- a/npm/types/lib/commands/worktree.d.mts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Точка входу команди `mt worktree`. - * @param {string[]} args аргументи після `worktree` - * @param {object} [deps] ін'єкції (cwd/log/config/fs/execSync) для тестів - * @returns {number} exit code - */ -export default function worktree(args: string[], deps?: object): number -/** - * `mt worktree create|remove|list|prune|inventory` — developer git-worktree lifecycle. - * - * Конвенція (правильно та ефективно): checkout у `<worktrees_dir>/<sanitize_branch(branch)>/`, - * інвентар — окремо в `<worktrees_dir>/.meta/<sanitized>.md`, тож `<worktrees_dir>/` містить - * лише worktree-каталоги (+ `.meta/`). Worktree **ефемерний**: `remove` прибирає і checkout, - * і git-гілку. sanitizeBranch — синхронізовано з Rust `sanitize_branch` у crates/mt-core/src/lib.rs. - */ -export type WorktreeCtx = { - /** - * корінь репо - */ - root: string - /** - * абсолютний шлях до worktrees_dir - */ - worktreesDir: string - /** - * логер - */ - log: (s: string) => void - /** - * git-виклик - */ - execSyncFn: (cmd: string, opts?: object) => string - /** - * перевірка існування шляху - */ - exists: (p: string) => boolean - /** - * запис файлу - */ - writeFile: (p: string, c: string) => void - /** - * читання файлу - */ - readFile: (p: string, enc?: string) => string - /** - * лістинг каталогу - */ - readdir: (d: string) => string[] - /** - * видалення - */ - rmFile: (p: string) => void - /** - * mkdir - */ - mkdirFn: (p: string, o?: object) => void -} diff --git a/npm/types/lib/core/config.d.mts b/npm/types/lib/core/config.d.mts deleted file mode 100644 index b098052..0000000 --- a/npm/types/lib/core/config.d.mts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Завантажує конфігурацію з `.mt.json` і мержить із дефолтами. - * @param {{ - * root?: string, - * readFile?: (p: string, enc: string) => string, - * exists?: (p: string) => boolean - * }} [deps] ін'єкції - * @returns {typeof CONFIG_DEFAULTS} злита конфігурація - */ -export function loadConfig(deps?: { - root?: string - readFile?: (p: string, enc: string) => string - exists?: (p: string) => boolean -}): typeof CONFIG_DEFAULTS -/** - * Повертає абсолютний шлях до mt_dir. - * @param {typeof CONFIG_DEFAULTS} config конфігурація - * @param {string} root корінь репо - * @returns {string} абсолютний шлях - */ -export function resolveMtDir(config: typeof CONFIG_DEFAULTS, root: string): string -/** - * Повертає абсолютний шлях до worktrees_dir. - * @param {typeof CONFIG_DEFAULTS} config конфігурація - * @param {string} root корінь репо - * @returns {string} абсолютний шлях - */ -export function resolveWorktreesDir(config: typeof CONFIG_DEFAULTS, root: string): string -/** - * Канонізує тир моделі: uppercase ('MIN' | 'AVG' | 'MAX'). - * Порожнє/невизначене значення → ''. - * @param {unknown} tier сире значення тиру - * @returns {string} канонічний тир - */ -export function normalizeModelTier(tier: unknown): string -/** - * Конфігурація виконавців — **user-level, з ENV** (runtime.md «Підписочні - * CLI-виконавці»): вона спільна для всіх репозиторіїв користувача і тому НЕ - * живе у repo-scoped `.mt.json`. - * - * - `MT_AGENT_CLI` — дефолтний CLI (claude | codex | cursor | pi); - * - `MT_CLOUD_AGENT_CLIS` — каскад хмарних CLI, comma-separated - * (напр. "codex,cursor"); - * - `MT_AGENT_CLI_MODEL_MAP` — JSON-мапа «CLI → тир → модель» - * (напр. {"codex":{"MIN":"gpt-5.6-luna","AVG":"gpt-5.6-terra","MAX":"gpt-5.6-sola"}}). - * @param {Record<string, string | undefined>} env середовище процесу - * @returns {{ agentCli: string, cloudAgentClis: string[], modelMap: Record<string, Record<string, string>> }} конфіг виконавців - */ -export function loadAgentCliEnv(env: Record<string, string | undefined>): { - agentCli: string - cloudAgentClis: string[] - modelMap: Record<string, Record<string, string>> -} -/** - * Резолвить конкретну модель тиру для підписочного CLI: MIN/AVG/MAX → - * `modelMap[<cli>][<tier>]` з env `MT_AGENT_CLI_MODEL_MAP`. Немає мапінгу → - * null: CLI резолвить модель сам, тир лишається hint-ом `MT_MODEL_TIER`. - * @param {ReturnType<typeof loadAgentCliEnv>} cliEnv конфіг виконавців з ENV - * @param {string} agentCli підписочний CLI ('claude' | 'codex' | 'cursor' | 'pi') - * @param {string | undefined} modelTier 'MIN' | 'AVG' | 'MAX' - * @returns {string | null} model id або null (CLI вирішує сам) - */ -export function resolveModelForCli( - cliEnv: ReturnType<typeof loadAgentCliEnv>, - agentCli: string, - modelTier: string | undefined -): string | null -/** Дефолтні значення конфігурації (джерело істини — mt-core `config_defaults`). */ -export const CONFIG_DEFAULTS: any diff --git a/npm/types/lib/core/frontmatter.d.mts b/npm/types/lib/core/frontmatter.d.mts deleted file mode 100644 index ab9db25..0000000 --- a/npm/types/lib/core/frontmatter.d.mts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Парсить YAML front-matter з markdown-тексту. - * Повертає словник (може містити вкладені об'єкти та масиви). - * @param {string} text вміст файлу - * @returns {Record<string, unknown>} ключ-значення, або {} якщо front-matter відсутній - */ -export function parseFrontMatter(text: string): Record<string, unknown> -/** - * Отримує тіло документа (без front-matter). - * @param {string} text вміст файлу - * @returns {string} тіло без front-matter - */ -export function getBody(text: string): string -/** - * Серіалізує об'єкт у YAML-рядок (для front-matter). - * Підтримує прості scalar, масиви та вкладені об'єкти. - * @param {Record<string, unknown>} obj об'єкт для серіалізації - * @param {number} [indentLevel] рівень відступу (default: 0) - * @returns {string} YAML-рядок (без --- маркерів) - */ -export function serializeYaml(obj: Record<string, unknown>, indentLevel?: number): string -/** - * Будує markdown-файл із front-matter і тілом. - * @param {Record<string, unknown>} fm об'єкт front-matter - * @param {string} [body] тіло документа (default: '') - * @returns {string} повний вміст файлу - */ -export function buildMarkdown(fm: Record<string, unknown>, body?: string): string diff --git a/npm/types/lib/core/native.d.mts b/npm/types/lib/core/native.d.mts deleted file mode 100644 index ab65ac7..0000000 --- a/npm/types/lib/core/native.d.mts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Резолвить шлях до napi-аддона `mt`. - * @param {{ - * env?: Record<string, string | undefined>, - * platform?: string, - * arch?: string, - * existsSync?: (p: string) => boolean, - * requireResolve?: (id: string) => string, - * repoRoot?: string - * }} [deps] ін'єкції для тестів - * @returns {string} шлях до файлу аддона - */ -export function resolveNativeAddon(deps?: { - env?: Record<string, string | undefined> - platform?: string - arch?: string - existsSync?: (p: string) => boolean - requireResolve?: (id: string) => string - repoRoot?: string -}): string -/** - * Кешований доступ до аддона (одне завантаження на процес). - * @param {{ resolve?: () => string, dlopen?: (p: string) => Record<string, unknown> }} [deps] ін'єкції - * @returns {Record<string, unknown>} exports аддона (scanTasks, createTask, …) - */ -export function loadNative(deps?: { - resolve?: () => string - dlopen?: (p: string) => Record<string, unknown> -}): Record<string, unknown> diff --git a/npm/types/lib/core/nnn.d.mts b/npm/types/lib/core/nnn.d.mts deleted file mode 100644 index bab6e63..0000000 --- a/npm/types/lib/core/nnn.d.mts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Форматує число як NNN рядок (три цифри з ведучими нулями). - * @param {number} n невід'ємне ціле число - * @returns {string} '001', '002', … - */ -export function padNNN(n: number): string -/** - * Наступний NNN для run_NNN.md: count(run_*.md) + 1. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string} наступний NNN рядок - */ -export function nextRunNNN(taskDir: string, readdirSync: (dir: string) => string[]): string -/** - * Наступний NNN для plan_NNN.md: max(plan_*.md numbers) + 1. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string} наступний NNN рядок - */ -export function nextPlanNNN(taskDir: string, readdirSync: (dir: string) => string[]): string -/** - * Найвищий NNN серед fact_NNN.md, або null якщо немає. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string | null} NNN рядок або null - */ -export function latestFactNNN(taskDir: string, readdirSync: (dir: string) => string[]): string | null -/** - * Знаходить NNN для останнього pending-audit_NNN.md (для audit-result). - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string | null} NNN рядок або null - */ -export function latestPendingAuditNNN(taskDir: string, readdirSync: (dir: string) => string[]): string | null -/** - * Знаходить NNN для останнього audit-result_NNN.md. - * @param {string} taskDir абсолютний шлях до директорії задачі - * @param {(dir: string) => string[]} readdirSync ін'єктована функція readdir - * @returns {string | null} NNN рядок або null - */ -export function latestAuditResultNNN(taskDir: string, readdirSync: (dir: string) => string[]): string | null diff --git a/npm/types/lib/core/scanner-bin.d.mts b/npm/types/lib/core/scanner-bin.d.mts deleted file mode 100644 index 5e4c5af..0000000 --- a/npm/types/lib/core/scanner-bin.d.mts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Резолвить абсолютний шлях до бінарника `mt-scanner`. - * @param {{ - * env?: Record<string, string | undefined>, - * platform?: string, - * arch?: string, - * existsSync?: (p: string) => boolean, - * requireResolve?: (id: string) => string, - * repoRoot?: string - * }} [deps] ін'єкції для тестів - * @returns {string} шлях до виконуваного бінарника - */ -export function resolveScannerBin(deps?: { - env?: Record<string, string | undefined> - platform?: string - arch?: string - existsSync?: (p: string) => boolean - requireResolve?: (id: string) => string - repoRoot?: string -}): string -/** - * Кешований резолвер (один пошук на процес). Override через resolveScannerBin для тестів. - * @returns {string} шлях до бінарника - */ -export function scannerBin(): string diff --git a/npm/types/lib/core/scanner.d.mts b/npm/types/lib/core/scanner.d.mts deleted file mode 100644 index 74844a9..0000000 --- a/npm/types/lib/core/scanner.d.mts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Знаходить усі задачі DAG у mt_dir (директорії з task.md). - * @param {string} mtDir абсолютний шлях до mt/ - * @param {{ binPath?: string, spawnSync?: SpawnSyncFn }} [deps] ін'єкції - * @returns {{ dir: string, relPath: string }[]} список знайдених задач - */ -export function findTasks( - mtDir: string, - deps?: { - binPath?: string - spawnSync?: SpawnSyncFn - } -): { - dir: string - relPath: string -}[] -/** - * Сканує DAG і повертає всі задачі з деривованими станами (включно з blocked та - * worktree→running — усе обчислює бінарник). - * @param {string} mtDir абсолютний шлях до mt/ - * @param {Set<string>} activeWorktrees активні worktree імена (опційно) - * @param {{ binPath?: string, spawnSync?: SpawnSyncFn }} [deps] ін'єкції - * @returns {TaskInfo[]} список задач - */ -export function scanTasks( - mtDir: string, - activeWorktrees: Set<string>, - deps?: { - binPath?: string - spawnSync?: SpawnSyncFn - } -): TaskInfo[] -/** - * Топологічне сортування задач (алгоритм Кана). - * Задачі без залежностей — першими. Циклічні залежності — не гарантовано. - * @param {TaskInfo[]} tasks задачі зі списком deps - * @returns {TaskInfo[]} відсортований список (або той самий порядок якщо циклічні) - */ -export function topoSort(tasks: TaskInfo[]): TaskInfo[] -/** - * Перевіряє чи всі залежності задачі resolved. - * @param {TaskInfo} task задача - * @param {Map<string, TaskInfo>} taskMap map id -> TaskInfo - * @returns {boolean} true якщо всі deps resolved - */ -export function areDepsResolved(task: TaskInfo, taskMap: Map<string, TaskInfo>): boolean -/** - * Знаходить активні worktrees з git worktree list. - * @param {string} root корінь репо - * @param {{ execSync?: (cmd: string, opts?: object) => string }} [deps] ін'єкції - * @returns {Set<string>} set імен worktree - */ -export function getActiveWorktrees( - root: string, - deps?: { - execSync?: (cmd: string, opts?: object) => string - } -): Set<string> -/** - * Парсить вивід `git worktree list --porcelain` і повертає набір імен worktree. - * @param {string} output вивід команди - * @returns {Set<string>} set імен (останній компонент шляху) - */ -export function parseWorktreeList(output: string): Set<string> -export type TaskInfo = { - id: string - path: string - dir: string - deps: string[] - state: string - composite: boolean - children: string[] -} -export type SpawnSyncFn = ( - bin: string, - args: string[], - opts: object -) => { - status: number | null - stdout: string - stderr: string - error?: Error -} diff --git a/npm/types/lib/core/state.d.mts b/npm/types/lib/core/state.d.mts deleted file mode 100644 index d0256ae..0000000 --- a/npm/types/lib/core/state.d.mts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Санітизує ім'я задачі для використання в назві worktree. - * - * Логіка — Rust `sanitize` (crates/mt-core/src/lib.rs), той самий код, що - * матчить worktree при детекції стану `running` — розсинхрон неможливий. - * Тест-вектори: 'research/collect data' → 'research-collect-data', - * 'my-task_01' → 'my-task_01', '' → ''. - * @param {string} name ім'я задачі (може містити /) - * @returns {string} санітизоване ім'я ([^a-zA-Z0-9_-] → '-') - */ -export function sanitizeTaskName(name: string): string -/** - * Валідує id вузла для створення задачі (НЕ виправляє — повертає помилку). - * - * Логіка — Rust `validate_name` (crates/mt-core/src/lib.rs); спільні - * тест-вектори в `npm/lib/tests/fixtures/name-vectors.json`. Правила (docs spec §8): - * сегменти `[a-z0-9-]+`, роздільник `/`; без порожніх/`.`/`..` сегментів, - * провідного/кінцевого `/`, великих літер, `_`, пробілів, traversal. - * @param {string} name id вузла (може містити /) - * @returns {string | null} текст помилки або null якщо валідне - */ -export function validateTaskName(name: string): string | null -/** Всі можливі стани задачі відповідно до специфікації. */ -export const NODE_STATES: readonly [ - 'unassigned', - 'pending', - 'waiting', - 'blocked', - 'plan-review', - 'spawned', - 'running', - 'stalled', - 'pending-audit', - 'resolved', - 'failed', - 'unresolvable' -] diff --git a/npm/types/lib/core/task-command.d.mts b/npm/types/lib/core/task-command.d.mts deleted file mode 100644 index f99dcfc..0000000 --- a/npm/types/lib/core/task-command.d.mts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Пише run_NNN.md артефакт. - * @param {string} taskDir директорія задачі - * @param {string} nnn NNN рядок - * @param {'success'|'failed'} result результат - * @param {{ actor: string, now: string }} meta метадані - * @param {(p: string, c: string, enc: string) => void} writeFile функція запису - */ -export function writeRunFile( - taskDir: string, - nnn: string, - result: 'success' | 'failed', - meta: { - actor: string - now: string - }, - writeFile: (p: string, c: string, enc: string) => void -): void -/** - * Резолвить шлях задачі з аргументів або env (`MT_TASK_PATH`). - * @param {string[]} args аргументи командного рядка - * @param {{ env?: Record<string, string> }} [deps] ін'єкції - * @returns {{ taskPath: string | null, error: string | null }} результат - */ -export function resolveTaskPath( - args: string[], - deps?: { - env?: Record<string, string> - } -): { - taskPath: string | null - error: string | null -} diff --git a/npm/types/lib/core/worktree.d.mts b/npm/types/lib/core/worktree.d.mts deleted file mode 100644 index 4800f86..0000000 --- a/npm/types/lib/core/worktree.d.mts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Генерує ім'я worktree для задачі. - * @param {string} taskPath відносний шлях задачі (напр. "research/collect-data") - * @param {number} [epochSec] epoch в секундах (default: Date.now()/1000) - * @returns {string} ім'я worktree - */ -export function makeWorktreeName(taskPath: string, epochSec?: number): string -/** - * Створює git worktree для задачі з atomic mkdir lock. - * Повертає null якщо worktree вже існує (EEXIST → вже запущено). - * @param {string} worktreesDir абсолютний шлях до .worktrees/ - * @param {string} worktreeName ім'я нового worktree - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string, - * mkdirSync?: (p: string, opts?: object) => void - * }} [deps] ін'єкції - * @returns {{ worktreePath: string, branch: string } | null} worktree або null якщо вже існує - */ -export function createWorktree( - worktreesDir: string, - worktreeName: string, - root: string, - deps?: { - execSync?: (cmd: string, opts?: object) => string - mkdirSync?: (p: string, opts?: object) => void - } -): { - worktreePath: string - branch: string -} | null -/** - * Видаляє git worktree. - * @param {string} worktreePath абсолютний шлях до worktree - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - */ -export function removeWorktree( - worktreePath: string, - root: string, - deps?: { - execSync?: (cmd: string, opts?: object) => string - } -): void -/** - * Мерджить зміни з worktree у main-гілку і видаляє worktree. - * @param {string} worktreePath абсолютний шлях до worktree - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {{ ok: boolean, error?: string }} результат - */ -export function mergeWorktree( - worktreePath: string, - root: string, - deps?: { - execSync?: (cmd: string, opts?: object) => string - } -): { - ok: boolean - error?: string -} -/** - * Повертає список активних worktrees з репо. - * @param {string} root корінь репо - * @param {{ - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {Set<string>} set імен worktrees - */ -export function listActiveWorktrees( - root: string, - deps?: { - execSync?: (cmd: string, opts?: object) => string - } -): Set<string> -/** - * Знаходить worktree що належить задачі (за prefix). - * @param {string} taskPath відносний шлях задачі - * @param {string} worktreesDir абсолютний шлях до .worktrees/ - * @param {{ - * readdirSync?: (d: string) => string[], - * execSync?: (cmd: string, opts?: object) => string - * }} [deps] ін'єкції - * @returns {string | null} абсолютний шлях до worktree або null - */ -export function findTaskWorktree( - taskPath: string, - worktreesDir: string, - deps?: { - readdirSync?: (d: string) => string[] - execSync?: (cmd: string, opts?: object) => string - } -): string | null diff --git a/npm/vitest.config.js b/npm/vitest.config.js deleted file mode 100644 index dfb5bbf..0000000 --- a/npm/vitest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - // Тести поряд із кодом (`*.test.{js,mjs}`) і top-level integration suites у `tests/`. - include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}'], - // reports/stryker/.tmp/ містить sandbox-копії тестів від Stryker — без exclude - // `vitest run --coverage` їх підхоплює і вони фейляться поза реальним repo root. - exclude: ['**/node_modules/**', '**/dist/**', '**/reports/stryker/**'], - environment: 'node', - // Ізоляція процесів між test-файлами як safety net на випадковий `process.chdir`. - pool: 'forks', - coverage: { provider: 'v8', reporter: ['lcov', 'text-summary'] } - } -}) diff --git a/package.json b/package.json index c15c537..a742e1b 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,45 @@ { - "name": "mono", - "version": "1.0.0", - "private": true, - "workspaces": [ - "npm", - "relay", - "layers", - "crates/mt-napi" + "name": "@7n/mt", + "version": "0.28.0", + "description": "Специфікація протоколу MT — граф задач, координація через git, agent-runtime. Реалізації: nitra/mt-rust (повна), nitra/mt-js (JS-клієнт, наразі не публікується)", + "keywords": [ + "7n", + "mt", + "spec", + "protocol", + "agent", + "task-graph" ], + "homepage": "https://github.com/nitra/mt#readme", + "bugs": { + "url": "https://github.com/nitra/mt/issues" + }, + "license": "ISC", + "author": "vitaliytv@nitralabs.com", + "repository": { + "type": "git", + "url": "git+https://github.com/nitra/mt.git" + }, + "publishConfig": { + "access": "public" + }, "type": "module", + "files": [ + "docs", + "README.md", + "CHANGELOG.md" + ], "scripts": { - "start": "bun ./npm/bin/mt.js", "layers": "bun ./layers/lib/cli.mjs", + "pretest": "bun install --cwd layers", "test": "bunx --bun vitest run", "oxfmt": "oxfmt .", "coverage": "npx @7n/test coverage" }, "devDependencies": { - "@7n/rules": "^1.36.1", + "@7n/rules": "^1.43.1", "@7n/rules-ci-github": "^1.9.0", "@7n/rules-lang-js": "^0.9.0", - "@7n/rules-lang-rust": "^0.6.1", "@nitra/cspell-dict": "^2.2.2", "@nitra/eslint-config": "^3.10.3", "@stryker-mutator/vitest-runner": "^9.6.1", diff --git a/packages/mt-darwin-arm64/package.json b/packages/mt-darwin-arm64/package.json deleted file mode 100644 index c6a04da..0000000 --- a/packages/mt-darwin-arm64/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@7n/mt-darwin-arm64", - "version": "0.2.0", - "description": "Prebuilt mt-scanner binary and mt napi addon for macOS arm64 (Apple Silicon)", - "license": "ISC", - "repository": { - "type": "git", - "url": "git+https://github.com/nitra/mt.git" - }, - "files": [ - "mt-scanner", - "mt.darwin-arm64.node" - ], - "os": [ - "darwin" - ], - "cpu": [ - "arm64" - ], - "publishConfig": { - "access": "public" - } -} diff --git a/packages/mt-linux-x64/package.json b/packages/mt-linux-x64/package.json deleted file mode 100644 index c899768..0000000 --- a/packages/mt-linux-x64/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@7n/mt-linux-x64", - "version": "0.2.0", - "description": "Prebuilt mt-scanner binary (static musl) and mt napi addon (gnu) for Linux x64", - "license": "ISC", - "repository": { - "type": "git", - "url": "git+https://github.com/nitra/mt.git" - }, - "files": [ - "mt-scanner", - "mt.linux-x64-gnu.node" - ], - "os": [ - "linux" - ], - "cpu": [ - "x64" - ], - "publishConfig": { - "access": "public" - } -} diff --git a/relay/CHANGELOG.md b/relay/CHANGELOG.md deleted file mode 100644 index 6d5c9f5..0000000 --- a/relay/CHANGELOG.md +++ /dev/null @@ -1,82 +0,0 @@ -# Changelog - -## [0.8.1] - 2026-07-21 - -### Fixed - -- eslint (unicorn/prefer-uint8array-base64, unicorn/prefer-iterator-to-array, max-classes-per-file) на релей-модулях v4: DevPushSink винесено у push-sink.mjs, Buffer base64 → Uint8Array.fromBase64/toBase64, regex-літерали тестів — у module-scope, iterator.toArray() замість spread - -## [0.8.0] - 2026-07-17 - -### Added - -- Протокол v4 для multi-owner (owner-app, спека 260714): WS-кадри membership (invite/accept/decline/transfer_ownership/bootstrap_owners), Ed25519-підписаний акт transfer (mt-transfer-v4, дзеркальні sign_transfer/verify_transfer у agent-protocol і signing.mjs relay), push-модуль (тип 2 «запрошено», тип 3 «потребує уваги» + адресна Escalation), Event::Escalation у протоколі, directory-модуль mt-core (.mt/directory.json, handle → email поза git), валідація hex-pubkey пристроїв - -### Changed - -- release: @7n/mt@0.26.1 - -## [0.7.1] - 2026-07-14 - -### Changed - -- внутрішні константи FRAME_LIMIT/BUFFER_LIMIT/ROLES більше не експортуються (knip: unused exports) - -## [0.7.0] - 2026-07-12 - -### Changed - -- Додано RelayCore для управління кімнатами, ролями та membership API -- Додано RelayCore, Rooms та Server для управління кімнатами та членством -- Додано відстеження `from_host` для клієнтських envelope -- Додано обробку pubkeys-кадру у server.mjs та його тест - -## [0.6.0] - 2026-07-12 - -### Changed - -- Додано RelayCore для управління кімнатами, ролями та membership API -- Додано RelayCore, Rooms та Server для управління кімнатами та членством -- Додано відстеження `from_host` для клієнтських envelope -- Додано обробку pubkeys-кадру у server.mjs та його тест - -## [0.5.0] - 2026-07-12 - -### Changed - -- Додано RelayCore для управління кімнатами, ролями та membership API -- Додано RelayCore, Rooms та Server для управління кімнатами та членством -- Додано відстеження `from_host` для клієнтських envelope -- Додано обробку pubkeys-кадру у server.mjs та його тест - -## [0.4.0] - 2026-07-12 - -### Changed - -- Додано RelayCore для управління кімнатами, ролями та membership API -- Додано RelayCore, Rooms та Server для управління кімнатами та членством -- Додано відстеження `from_host` для клієнтських envelope -- Додано обробку pubkeys-кадру у server.mjs та його тест - -## [0.3.0] - 2026-07-12 - -### Changed - -- Додано RelayCore для управління кімнатами, ролями та membership API -- Додано RelayCore, Rooms та Server для управління кімнатами та членством -- Додано відстеження `from_host` для клієнтських envelope - -## [0.2.0] - 2026-07-12 - -### Changed - -- Додано RelayCore для управління кімнатами, ролями та membership API -- Додано RelayCore, Rooms та Server для управління кімнатами та членством - -All notable changes to this project will be documented in this file. - -## [0.1.0] - 2026-07-11 - -### Added - -- Initial changelog for `@7n/relay`. diff --git a/relay/docs/index.md b/relay/docs/index.md deleted file mode 100644 index ca065d8..0000000 --- a/relay/docs/index.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -type: Directory Index -title: relay -resource: relay/ ---- - -| Файл | Тип | -| --------------------------------------- | --------- | -| [stryker.config.mjs](stryker.config.md) | JS Module | -| [vitest.config.mjs](vitest.config.md) | JS Module | diff --git a/relay/docs/stryker.config.md b/relay/docs/stryker.config.md deleted file mode 100644 index c129351..0000000 --- a/relay/docs/stryker.config.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: JS Module -title: stryker.config.mjs -resource: relay/stryker.config.mjs -docgen: - crc: c9c61df7 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.97 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл запускає mutation testing для пов’язаних із зміненою ділянкою коду тестів, щоб швидше показувати результат без повного прогону suite. Конфіги, на які спирається код: mutation.json, incremental.json. Він також веде службові артефакти в reports/stryker/.tmp і формує reports/stryker/mutation.json та reports/stryker/incremental.json, щоб зберігати підсумок запуску й продовжувати інкрементальні обчислення між прогонами. - -## Поведінка - -1. Запускає mutation testing через `vitest`, щоб вимірювати якість тестового покриття на рівні мутованих змін. -2. Перевіряє лише ті тести, що стосуються зміненої ділянки коду, щоб швидше отримувати результат без зайвих прогонів усього suite. -3. Зберігає службові артефакти виконання в окремій тимчасовій теці `reports/stryker/.tmp`, щоб не змішувати їх із робочими файлами проєкту. -4. Формує два вихідні конфіги: `reports/stryker/mutation.json` для підсумку mutation testing і `reports/stryker/incremental.json` для відновлення попереднього стану між запусками. -5. Працює в incremental-режимі, щоб продовжувати попередні обчислення після переривання або аварійного завершення. -6. Не описує і не гарантує обробку шляхів поза зазначеними артефактами `mutation.json` і `incremental.json`. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/relay/docs/vitest.config.md b/relay/docs/vitest.config.md deleted file mode 100644 index 5ede5d9..0000000 --- a/relay/docs/vitest.config.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: JS Module -title: vitest.config.mjs -resource: relay/vitest.config.mjs -docgen: - crc: d32a8e2d - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.96 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл об’єднує test suites із коду та кореневого `tests/`, запускає їх в ізольованому `node`-середовищі й пропускає службові копії з `node_modules`. Він потрібен, щоб тестовий запуск працював за одним контрактом для локальних і інтеграційних suites без змішування робочих контекстів під час файлових і git-операцій. У поведінці використовує маркер повідомлень ``. - -## Поведінка - -1. Збирає test suites з двох основних розкладок: тести поряд із кодом у піддиректоріях `tests/` та top-level integration suites у `<root>/tests/`. -2. Свідомо пропускає `node_modules`, `dist` і `reports/stryker`, щоб не підхоплювати службові або sandbox-копії тестів. -3. Запускає тести в `node`-середовищі з процесною ізоляцією між файлами, щоб паралельні перевірки не впливали одна на одну. -4. Захищає робочий репозиторій від змішування контекстів під час файлових і git-операцій у тимчасових сценаріях; це особливо важливо для фікстур із зміною робочої директорії (test.mdc). -5. Формує coverage-звіт у `lcov` і короткий текстовий підсумок, щоб швидко бачити стан покриття тестами. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Свідомо пропускає шляхи: `node_modules`. diff --git a/relay/lib/docs/index.md b/relay/lib/docs/index.md deleted file mode 100644 index 318138e..0000000 --- a/relay/lib/docs/index.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -type: Directory Index -title: relay/lib -resource: relay/lib/ ---- - -| Файл | Тип | -| ----------------------------- | --------- | -| [push-sink.mjs](push-sink.md) | JS Module | -| [push.mjs](push.md) | JS Module | -| [relay.mjs](relay.md) | JS Module | -| [rooms.mjs](rooms.md) | JS Module | -| [server.mjs](server.md) | JS Module | -| [signing.mjs](signing.md) | JS Module | -| [store.mjs](store.md) | JS Module | diff --git a/relay/lib/docs/push-sink.md b/relay/lib/docs/push-sink.md deleted file mode 100644 index 29e1b54..0000000 --- a/relay/lib/docs/push-sink.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -type: JS Module -title: push-sink.mjs -resource: relay/lib/push-sink.mjs -docgen: - crc: 1c998579 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -`DevPushSink` існує як dev-накопичувач push-сповіщень: він складає нотифікації в памʼять як `magic tokens` в auth і приймає доставку до акаунта через спільний контракт `deliver`, за яким окремою задачею підключається реальний FCM-transport. - -## Поведінка - -1. `DevPushSink` створює dev-накопичувач для push-сповіщень, щоб окремо від реального транспортного шару зберігати факти доставки в пам’яті. -2. `DevPushSink` приймає повідомлення про доставку до акаунта і фіксує їх як запис про отримувача, причину, кореневу подію та, за наявності, посилання на джерело. -3. `DevPushSink` не виконує реальну доставку push і не звертається до зовнішніх систем; це лише тимчасова точка накопичення для подальшого підключення transport-рівня за тим самим контрактом. -4. `DevPushSink` працює без змін у ФС чи БД, тому підходить для локального dev-сценарію та перевірки бізнес-потоків доставки. - -## Публічний API - -- DevPushSink — dev-варіант sink-а для push-доставки, що приймає і передає push-повідомлення у робочому середовищі розробки. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/relay/lib/docs/push.md b/relay/lib/docs/push.md deleted file mode 100644 index d42a9cf..0000000 --- a/relay/lib/docs/push.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: JS Module -title: push.mjs -resource: relay/lib/push.mjs -docgen: - crc: d6df4c66 - model: openai-codex/gpt-5.5 - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл задає relay для push-нотифікацій типів `2` («вас запрошено») і `3` («задача потребує уваги»), щоб відокремити маршрутизацію подій від реальної FCM-доставки. `PushRouter` спрямовує push за `event.type` і адресним `event.to_account_id`, спираючись на конфіг `directory.json` для резолву отримувача, а `DevPushSink` як dev-реалізація зберігає події в памʼяті через sink-контракт. - -## Поведінка - -- `DevPushSink` накопичує dev-доставки push-нотифікацій у памʼяті замість реальної FCM-доставки, щоб relay мав той самий контракт sink-а для сценаріїв «вас запрошено» і «задача потребує уваги». -- `PushRouter` маршрутизує push-нотифікації за даними store та sink-а: надсилає запрошення на наявний акаунт, а attention-події — адресату або учасникам задачі без автора; для адресних подій спирається на резолв отримувача через `directory.json` і не розбирає payload далі роутінгових полів. - -## Публічний API - -- DevPushSink — накопичує push-доставки в памʼяті для dev-сценаріїв; спирається на `directory.json`. -- PushRouter — спрямовує push-повідомлення через сховище і sink для єдиного маршруту доставки; спирається на `directory.json`. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/relay/lib/docs/relay.md b/relay/lib/docs/relay.md deleted file mode 100644 index bc65368..0000000 --- a/relay/lib/docs/relay.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -type: JS Module -title: relay.mjs -resource: relay/lib/relay.mjs -docgen: - crc: e8fe916b - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -RelayCore визначає правила membership для кімнат: запрошення, ролі, transfer ownership і роздачу pubkey-ів. Межі цього ядра описані в `access.md`: relay координує і пересилає повідомлення, але не зберігає журнали сесій, не проксіює git, не видає lease і не виконує агентів. Транспортний шар WS винесений у `server.mjs`; тут лишається лише чиста логіка доступу. - -## Поведінка - -1. `RelayCore` тримає прикладну логіку relay між кімнатами задач і сховищем стану: перевіряє право доступу, маршрутизує події, керує запрошеннями, передачею ownership і видачею pubkey-ів. -2. Для підключення пристрою relay звіряє device token, оновлює час останньої активності й повертає запис пристрою; невідомий токен відхиляє. -3. Для підписки на кімнату задачі relay пускає лише пристрої акаунтів, які вже є учасниками кореневого вузла; інакше доступ блокується. -4. Для клієнтських подій relay приймає лише учасників із роллю не нижче approver; viewer-клієнти відсікаються, а подія далі розходиться в кімнату без розбору внутрішніх полів. -5. Для запрошення нових учасників relay дозволяє дію лише owner і створює запрошення зі статусом pending; push-доставку отримувачу тут не виконує. -6. Для прийняття запрошення relay перевіряє, що запрошення існує, ще не оброблене і адресоване саме цьому акаунту; після цього додає membership, фіксує accepted і повідомляє кімнату про зміну учасника. -7. Для відхилення запрошення relay так само звіряє адресата, а потім позначає запрошення declined без додаткових побічних дій. -8. Для передачі ownership relay дозволяє дію лише поточному owner, вимагає, щоб новий власник уже був учасником, переводить старого owner у host і повідомляє кімнату про зміну власника. -9. Для видачі pubkey-ів relay обслуговує лише учасників задачі й повертає ключі пристроїв учасників із роллю approver або вище; неучасників відсікає. -10. У межах цього ядра relay не зберігає журнали сесій, не проксіює git, не видає lease і не виконує агентів; транспорт WS живе поза цією логікою. - -## Публічний API - -- RelayCore — центральний relay-шар над store і rooms, який координує їхню взаємодію. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/relay/lib/docs/rooms.md b/relay/lib/docs/rooms.md deleted file mode 100644 index 86873fc..0000000 --- a/relay/lib/docs/rooms.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: JS Module -title: rooms.mjs -resource: relay/lib/rooms.mjs -docgen: - crc: 4a793bff - model: openai-codex/gpt-5.4-mini - score: 100 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Relay тримає кімнати за кореневим вузлом задачі, щоб розсилати `Envelope` усім підписникам і дати новому підписнику короткий live-реплей із уже наявного ефемерного буфера останніх N `Envelope`. Межа relay тут така: він не парсить payload далі роутінгових полів і не зберігає журнали сесій. Глибший реплей лишається поза relay і добирається хостом із `session.jsonl` за run ref-а. Публічний API: `BUFFER_LIMIT` і `Rooms`. - -## Поведінка - -- `BUFFER_LIMIT` — задає верхню межу кількості `Envelope`, які relay тримає в кімнаті для короткого live-реплею під час підписки. -- `Rooms` — веде кімнати за кореневим вузлом задачі, розсилає `Envelope` усім підписникам і віддає новому підписнику вже наявний буфер; не розбирає payload далі роутінгових полів і не зберігає журнали сесій, тому глибший реплей береться з `session.jsonl` через хост. - -## Публічний API - -- BUFFER_LIMIT — обмежує буфер кімнати до 200 Envelope за один run. -- Rooms — зберігає кімнати з тимчасовим буфером і списком підписників. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/relay/lib/docs/server.md b/relay/lib/docs/server.md deleted file mode 100644 index 636b94d..0000000 --- a/relay/lib/docs/server.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: JS Module -title: server.mjs -resource: relay/lib/server.mjs -docgen: - crc: 445915fb - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -`FRAME_LIMIT` і `startRelayServer` описують WS-relay для JSON-кадрів між клієнтом і relay: перший кадр від клієнта має бути `hello` з `device_token`, після цього дозволені `subscribe` для вибору `root` і `envelope` для надсилання `envelope`. Relay відповідає `ok` або `error` на службові кадри та `envelope` або `event` на події для підписаного `root`. Ліміт кадру — 2 МБ. Помилки авторизації й ролей повертаються як `error` без розриву зʼєднання, щоб клієнт міг виправити стан і продовжити. Модуль read-only щодо ФС і БД та звертається до мережі. - -## Поведінка - -- `FRAME_LIMIT` — ліміт допустимого WS-кадру для relay, щоб відсікати надто великі повідомлення. -- `startRelayServer` — запускає WS-сервер relay, приймає JSON-кадри `hello` → `subscribe`/`envelope`, і повертає порт та спосіб зупинки сервера. - -## Публічний API - -- FRAME_LIMIT — обмежує розмір WS-кадру до 2 MB -- startRelayServer — запускає WS relay-сервер поверх ядра - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/relay/lib/docs/signing.md b/relay/lib/docs/signing.md deleted file mode 100644 index 622382d..0000000 --- a/relay/lib/docs/signing.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: JS Module -title: signing.mjs -resource: relay/lib/signing.mjs -docgen: - crc: 0b7d8ba8 - model: openai-codex/gpt-5.5 - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл перевіряє Ed25519-підписи передавання ownership через `node:crypto` без залежностей. Він дзеркалить canonical-формат `crates/agent-protocol`: домен-префікс і NUL-розділені поля, щоб підпис, створений Rust-клієнтом через `sign_transfer`, перевірявся на relay байт-у-байт. Pubkey пристрою приймається як hex 32 байти, валідований через `PUBKEY_RE`, і загортається у `SPKI DER` для перевірки. `transferMessage` формує повідомлення для перевірки, а `verifySignature` fail-safe відхиляє невалідні підписи без винятків назовні. - -## Поведінка - -- `PUBKEY_RE` визначає, чи має pubkey пристрою очікуваний hex-формат Ed25519 для relay-перевірки. -- `transferMessage` формує canonical-повідомлення передачі ownership, сумісне з Rust-клієнтом байт-у-байт. -- `verifySignature` fail-safe перевіряє Ed25519-підпис пристрою для canonical-повідомлення й повертає негативний результат замість винятку для невалідних даних. - -## Публічний API - -- PUBKEY_RE — визначає hex-представлення Ed25519 pubkey пристрою довжиною 32 байти. -- transferMessage — формує canonical-повідомлення для transfer ownership з доменом і NUL-розділеними полями, щоб підпис був привʼязаний до конкретного контексту. -- verifySignature — звіряє Ed25519-підпис повідомлення з hex-pubkey пристрою. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/relay/lib/docs/store.md b/relay/lib/docs/store.md deleted file mode 100644 index d701a61..0000000 --- a/relay/lib/docs/store.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -type: JS Module -title: store.mjs -resource: relay/lib/store.mjs -docgen: - crc: c3b0fdd5 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.97 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Локальне relay-сховище за схемою `access.md` для `accounts`, `devices`, `tasks`, `task_members` і `invitations`. Це dev/test реалізація `InMemoryStore` для того самого store-інтерфейсу, який описує `stack.md` у блоці «Relay-інфраструктура». Межі relay задає `access.md` у блоці «Relay: обов'язки і межі»: у relay персистентні лише `accounts`, `membership` і `invitations`, а журнали сесій, git і lease тут не зберігаються. `ROLES` і `roleAtLeast` описують рольову модель, яку цей store відображає на дані схеми. - -## Поведінка - -- `ROLES` — фіксує порядок ролей учасника задачі для перевірки прав доступу. -- `roleAtLeast` — визначає, чи достатня фактична роль для потрібного рівня. -- `InMemoryStore` — надає in-memory сховище relay для акаунтів, пристроїв, задач, membership і запрошень; це dev/тестова реалізація store-інтерфейсу, без персистенції журналів сесій, git і lease. - -## Публічний API - -ROLES — задає ієрархію ролей у задачі: owner вище за host, host вище за approver, approver вище за viewer. -roleAtLeast — визначає, чи має фактична роль не нижчий рівень, ніж мінімально потрібна. -InMemoryStore — зберігає стан relay у пам’яті без зовнішнього сховища. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/relay/lib/push-sink.mjs b/relay/lib/push-sink.mjs deleted file mode 100644 index 4c86fac..0000000 --- a/relay/lib/push-sink.mjs +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Dev-sink push-доставки: складає нотифікації в памʼять (як magic tokens - * в auth) — реальний FCM-транспорт підключається за тим самим інтерфейсом - * `deliver(accountId, note)` окремою задачею (stack.md, «Push»). - */ - -/** Dev-реалізація sink-а push-доставки. */ -export class DevPushSink { - constructor() { - /** @type {{account_id: string, root: string, reason: string, ref: string | null}[]} */ - this.deliveries = [] - } - - /** - * Доставляє push усім пристроям акаунта. - * @param {string} accountId акаунт-отримувач - * @param {{ root: string, reason: string, ref?: string | null }} note зміст - * @returns {void} - */ - deliver(accountId, note) { - this.deliveries.push({ account_id: accountId, root: note.root, reason: note.reason, ref: note.ref ?? null }) - } -} diff --git a/relay/lib/push.mjs b/relay/lib/push.mjs deleted file mode 100644 index 0a2b234..0000000 --- a/relay/lib/push.mjs +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Push-нотифікації relay (access.md, «Push-нотифікації»): «вас запрошено» - * (тип 2) і «задача потребує уваги» (тип 3). FCM-доставка — окрема задача; - * тут інтерфейс sink-а (dev-реалізація — `push-sink.mjs`, як magic tokens - * в auth). Relay не парсить payload далі роутінгових полів — для push - * роутінговими є `event.type` і адресний `event.to_account_id`. - */ - -/** Типи подій Envelope, що означають «задача потребує уваги» (тип 3). */ -const ATTENTION_TYPES = new Set(['PlanReview', 'AuditPending', 'Escalation']) - -/** Маршрутизатор push поверх store і sink-а. */ -export class PushRouter { - /** - * @param {{ store: import('./store.mjs').InMemoryStore, sink: import('./push-sink.mjs').DevPushSink }} deps залежності - */ - constructor({ store, sink }) { - this.store = store - this.sink = sink - } - - /** - * Тип 2: «вас запрошено у задачу X». Незареєстрований email — тихо - * (запрошення pending до реєстрації, push наздожене при onboarding). - * @param {string} email email запрошеного - * @param {string} root кореневий вузол задачі - * @returns {boolean} true якщо доставлено (акаунт існує) - */ - invited(email, root) { - const account = this.store.accountByEmail(email) - if (!account) return false - this.sink.deliver(account.account_id, { root, reason: 'invited' }) - return true - } - - /** - * Тип 3: «задача X потребує уваги» з події Envelope. Адресна подія - * (`Escalation` з `to_account_id`) будить лише адресата; безадресні - * attention-події — всіх учасників, крім автора (він і так знає). - * @param {string} root кореневий вузол задачі - * @param {object} envelope конверт (роутінгові поля: event.type, event.to_account_id) - * @param {string} senderAccount акаунт-автор конверта - * @returns {void} - */ - onEnvelope(root, envelope, senderAccount) { - const event = envelope?.event - if (!event) return - const attention = ATTENTION_TYPES.has(event.type) || (event.type === 'NodeState' && event.state === 'unresolvable') - if (!attention) return - const ref = event.reason_ref ?? event.plan_ref ?? event.fact_ref ?? null - - if (event.type === 'Escalation') { - // Адресат резолвиться емітером (handle → account через .mt/directory.json); - // без резолву адресний push неможливий — не спамимо всю кімнату. - if (event.to_account_id) { - this.sink.deliver(event.to_account_id, { root, reason: 'escalation', ref }) - } - return - } - - for (const member of this.store.membersOf(root)) { - if (member.account_id === senderAccount) continue - this.sink.deliver(member.account_id, { root, reason: event.type, ref }) - } - } -} diff --git a/relay/lib/relay.mjs b/relay/lib/relay.mjs deleted file mode 100644 index ce808e2..0000000 --- a/relay/lib/relay.mjs +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Ядро relay (M2, mission control): membership-гейти кімнат, ролі, - * запрошення, transfer ownership, роздача pubkey-ів (access.md). - * - * Межі (access.md): relay координує і пересилає — НЕ зберігає журнали - * сесій, НЕ проксіює git, НЕ видає lease (істина — git claim), НЕ виконує - * агентів. Транспортний шар (WS) — server.mjs; тут — чиста логіка. - */ -import { Rooms } from './rooms.mjs' -import { transferMessage, verifySignature } from './signing.mjs' -import { roleAtLeast } from './store.mjs' - -/** Ядро relay поверх store + rooms (+ опційний push-маршрутизатор). */ -export class RelayCore { - /** - * @param {{ store: import('./store.mjs').InMemoryStore, rooms?: Rooms, push?: import('./push.mjs').PushRouter }} deps залежності - */ - constructor({ store, rooms = new Rooms(), push = null }) { - this.store = store - this.rooms = rooms - this.push = push - } - - /** - * Авторизує WS-підключення за device_token. - * @param {string} deviceToken токен пристрою - * @returns {object} запис пристрою - * @throws {Error} невідомий токен - */ - connectDevice(deviceToken) { - const device = this.store.deviceByToken(deviceToken) - if (!device) throw new Error('invalid device token') - device.last_seen = new Date().toISOString() - return device - } - - /** - * Підписка на кімнату задачі: дозволена лише пристроям акаунтів-учасників - * кореня (access.md, «Membership прив'язане до кореневого вузла»). - * @param {object} device запис пристрою - * @param {string} root кореневий вузол задачі - * @param {(frame: object) => void} send доставка кадрів пристрою - * @returns {() => void} відписка - * @throws {Error} не учасник - */ - subscribe(device, root, send) { - const role = this.store.memberRole(root, device.account_id) - if (!role) throw new Error(`subscribe відхилено: акаунт не учасник задачі ${root}`) - return this.rooms.subscribe(root, { deviceId: device.device_id, send }) - } - - /** - * Клієнтський Envelope у кімнату. Viewer НЕ шле клієнтські події - * (access.md: «relay відхиляє клієнтські події viewer-а, включно з - * CancelTurn»); host+ і approver шлють (approver — ApprovalResponse). - * Кадр отримує `from_host` за роллю ПРИСТРОЮ (не з кадру клієнта — - * спуфінг виключено): host-ехо несе seq, який призначає хост; тонкі - * клієнти рендерять лише host-кадри, а міст хоста ігнорує їх (анти-цикл). - * @param {object} device запис пристрою - * @param {string} root кореневий вузол задачі - * @param {object} envelope конверт (opaque — далі роутінгових полів не парситься) - * @returns {void} - * @throws {Error} viewer або не учасник - */ - clientEnvelope(device, root, envelope) { - const role = this.store.memberRole(root, device.account_id) - if (!role) throw new Error(`envelope відхилено: акаунт не учасник задачі ${root}`) - if (!roleAtLeast(role, 'approver')) { - throw new Error('envelope відхилено: роль viewer не шле клієнтські події') - } - this.rooms.publish(root, { kind: 'envelope', envelope, from_host: device.role === 'host' }) - // Тип 3 push («потребує уваги») — з роутінгових полів події (push.mjs). - this.push?.onEnvelope(root, envelope, device.account_id) - } - - /** - * Запрошення учасника (лише owner). Push отримувачу — окремий модуль - * (заглушка до FCM-задачі). - * @param {string} ownerAccount акаунт-запрошувач - * @param {string} root кореневий вузол задачі - * @param {{ email: string, role: string }} params кого і з якою роллю - * @returns {object} запис запрошення (status: pending) - * @throws {Error} не owner - */ - invite(ownerAccount, root, { email, role }) { - if (this.store.memberRole(root, ownerAccount) !== 'owner') { - throw new Error('invite відхилено: запрошує лише owner') - } - const invitation = this.store.createInvitation(root, ownerAccount, email, role) - // Тип 2 push «вас запрошено»; незареєстрований email — pending мовчки. - this.push?.invited(email, root) - return invitation - } - - /** - * Прийняття запрошення: запис у task_members + broadcast MemberChanged - * у кімнату (access.md, «Membership API relay»). - * @param {string} invitationId id запрошення - * @param {string} accountId акаунт, що приймає (email мусить збігатись) - * @returns {{root_node_hash: string, role: string}} членство - * @throws {Error} невідоме/не pending/чужий email - */ - accept(invitationId, accountId) { - const invitation = this.store.invitationById(invitationId) - if (!invitation || invitation.status !== 'pending') { - throw new Error('accept відхилено: запрошення не існує або вже оброблене') - } - const account = this.store.accounts.get(accountId) - if (!account || account.email !== invitation.to_email) { - throw new Error('accept відхилено: запрошення адресоване іншому акаунту') - } - invitation.status = 'accepted' - this.store.setMemberRole(invitation.root_node_hash, accountId, invitation.role) - this.rooms.publish(invitation.root_node_hash, { - kind: 'event', - event: { type: 'MemberChanged', account_id: accountId, role: invitation.role } - }) - return { root_node_hash: invitation.root_node_hash, role: invitation.role } - } - - /** - * Відхилення запрошення отримувачем. - * @param {string} invitationId id запрошення - * @param {string} accountId акаунт, що відхиляє - * @returns {void} - * @throws {Error} невідоме/чужий email - */ - decline(invitationId, accountId) { - const invitation = this.store.invitationById(invitationId) - const account = this.store.accounts.get(accountId) - if (!invitation || !account || account.email !== invitation.to_email) { - throw new Error('decline відхилено: запрошення не існує або адресоване іншому') - } - invitation.status = 'declined' - } - - /** - * Transfer ownership: поточний owner передає роль; сам стає host - * (штатний шлях succession — access.md). Мережевий шлях (WS) додатково - * вимагає Ed25519-підпис canonical-акта пристроєм-ініціатором — передача - * власності стає криптографічним фактом, а не лише правом токена; - * прямий виклик без signed — локальний/адміністративний шлях. - * @param {string} root кореневий вузол задачі - * @param {string} fromAccount поточний owner - * @param {string} toAccount новий owner (мусить бути учасником) - * @param {{ device: object, signature: string }} [signed] підписаний акт (WS-шлях) - * @returns {void} - * @throws {Error} не owner / отримувач не учасник / невалідний підпис - */ - transferOwnership(root, fromAccount, toAccount, signed) { - if (this.store.memberRole(root, fromAccount) !== 'owner') { - throw new Error('transfer відхилено: передає лише owner') - } - if (!this.store.memberRole(root, toAccount)) { - throw new Error('transfer відхилено: отримувач не учасник задачі') - } - if (signed) { - const message = transferMessage({ root, fromAccount, toAccount }) - if (!verifySignature(signed.device.pubkey, message, signed.signature)) { - throw new Error('transfer відхилено: підпис акта не пройшов перевірку') - } - } - this.store.setMemberRole(root, toAccount, 'owner') - this.store.setMemberRole(root, fromAccount, 'host') - this.rooms.publish(root, { - kind: 'event', - event: { type: 'MemberChanged', account_id: toAccount, role: 'owner' } - }) - } - - /** - * Bootstrap членства з `owner:`-розмітки лісу (спека owner-app 260714): - * власник кореня подає перелік {email, role} — зареєстровані акаунти - * стають учасниками одразу, незареєстровані отримують pending-запрошення. - * Ідемпотентно: наявні ролі не змінюються (не понижуємо і не дублюємо), - * повторний прогін не плодить запрошення. - * @param {string} ownerAccount акаунт-ініціатор (owner кореня) - * @param {string} root кореневий вузол задачі - * @param {{ email: string, role?: string }[]} entries учасники з розмітки - * @returns {{ added: string[], invited: string[], kept: string[] }} підсумок за email - * @throws {Error} не owner - */ - bootstrapMembers(ownerAccount, root, entries) { - if (this.store.memberRole(root, ownerAccount) !== 'owner') { - throw new Error('bootstrap відхилено: сідить membership лише owner') - } - const result = { added: [], invited: [], kept: [] } - for (const { email, role = 'owner' } of entries ?? []) { - const account = this.store.accountByEmail(email) - if (account) { - if (this.store.memberRole(root, account.account_id)) { - result.kept.push(email) - continue - } - this.store.setMemberRole(root, account.account_id, role) - this.rooms.publish(root, { - kind: 'event', - event: { type: 'MemberChanged', account_id: account.account_id, role } - }) - result.added.push(email) - continue - } - if (!this.store.pendingInvitationFor(root, email)) { - this.store.createInvitation(root, ownerAccount, email, role) - this.push?.invited(email, root) - } - result.invited.push(email) - } - return result - } - - /** - * Pubkey-и пристроїв учасників approver+ — для перевірки підписів - * approvals хостом. Доступ лише пристроям учасників (access.md). - * @param {object} device запис пристрою-запитувача - * @param {string} root кореневий вузол задачі - * @returns {{device_id: string, account_id: string, pubkey: string}[]} pubkey-и - * @throws {Error} не учасник - */ - pubkeys(device, root) { - if (!this.store.memberRole(root, device.account_id)) { - throw new Error('pubkeys відхилено: акаунт не учасник задачі') - } - return this.store.pubkeysFor(root) - } -} diff --git a/relay/lib/rooms.mjs b/relay/lib/rooms.mjs deleted file mode 100644 index 1c099b4..0000000 --- a/relay/lib/rooms.mjs +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Кімнати relay: підписки за кореневим вузлом задачі, broadcast Envelope, - * буфер останніх N Envelope (live-хвіст для реплею при підписці). - * - * Relay НЕ парсить payload далі роутінгових полів і НЕ зберігає журнали - * сесій (access.md, «Relay: обов'язки і межі») — буфер ефемерний, глибший - * реплей клієнт добирає з `session.jsonl` run ref-а через хост. - */ - -/** Ліміт буфера кімнати (stack.md: «буфер ≤ 200 Envelope/run»). */ -const BUFFER_LIMIT = 200 - -/** Кімнати з ефемерним буфером і підписниками. */ -export class Rooms { - /** - * @param {number} [bufferLimit] ліміт буфера кімнати - */ - constructor(bufferLimit = BUFFER_LIMIT) { - this.bufferLimit = bufferLimit - /** @type {Map<string, {buffer: object[], subscribers: Set<{deviceId: string, send: (frame: object) => void}>}>} */ - this.rooms = new Map() - } - - /** - * Кімната за ключем (створюється ліниво). - * @param {string} root кореневий вузол задачі - * @returns {{buffer: object[], subscribers: Set<object>}} кімната - */ - room(root) { - let room = this.rooms.get(root) - if (!room) { - room = { buffer: [], subscribers: new Set() } - this.rooms.set(root, room) - } - return room - } - - /** - * Підписує пристрій: спершу реплей буфера, далі — live-стрічка. - * @param {string} root кореневий вузол задачі - * @param {{deviceId: string, send: (frame: object) => void}} subscriber підписник - * @returns {() => void} відписка - */ - subscribe(root, subscriber) { - const room = this.room(root) - for (const frame of room.buffer) subscriber.send(frame) - room.subscribers.add(subscriber) - return () => room.subscribers.delete(subscriber) - } - - /** - * Broadcast кадру всім підписникам кімнати; буферизує з обрізанням до ліміту. - * @param {string} root кореневий вузол задачі - * @param {object} frame кадр (envelope чи service-подія) — opaque - * @returns {void} - */ - publish(root, frame) { - const room = this.room(root) - room.buffer.push(frame) - if (room.buffer.length > this.bufferLimit) { - room.buffer.splice(0, room.buffer.length - this.bufferLimit) - } - for (const subscriber of room.subscribers) subscriber.send(frame) - } -} diff --git a/relay/lib/server.mjs b/relay/lib/server.mjs deleted file mode 100644 index d04ef82..0000000 --- a/relay/lib/server.mjs +++ /dev/null @@ -1,147 +0,0 @@ -/** - * WS-транспорт relay: hello за device_token → subscribe/envelope-кадри. - * - * Кадри — JSON: клієнт → `{kind:"hello", device_token}` (перший), - * `{kind:"subscribe", root}`, `{kind:"envelope", root, envelope}`, - * membership-операції (`invite`, `accept`, `decline`, `transfer_ownership` - * з Ed25519-підписом акта, `bootstrap_owners`); - * relay → `{kind:"ok"|"error", ...}`, `{kind:"envelope"|"event", ...}`. - * Ліміт кадру — 2 МБ (stack.md). Помилки авторизації/ролей — `error`-кадр, - * зʼєднання не рветься (клієнт може виправитись). - */ -import { once } from 'node:events' -import { promisify } from 'node:util' - -import { WebSocketServer } from 'ws' - -/** Ліміт WS-кадру (stack.md: «кадр ≤ 2 MB»). */ -const FRAME_LIMIT = 2 * 1024 * 1024 - -/** - * Обробляє один JSON-кадр клієнта. - * @param {import('./relay.mjs').RelayCore} core ядро relay - * @param {{ device: object | null, subscriptions: Map<string, () => void> }} state стан зʼєднання - * @param {object} frame розібраний кадр - * @param {(frame: object) => void} send доставка кадрів клієнту - * @returns {void} - */ -function handleFrame(core, state, frame, send) { - if (frame.kind === 'hello') { - state.device = core.connectDevice(frame.device_token) - send({ kind: 'ok', device_id: state.device.device_id }) - return - } - if (!state.device) throw new Error('спершу hello з device_token') - switch (frame.kind) { - case 'subscribe': { - state.subscriptions.get(frame.root)?.() - state.subscriptions.set(frame.root, core.subscribe(state.device, frame.root, send)) - send({ kind: 'ok', subscribed: frame.root }) - break - } - case 'envelope': { - core.clientEnvelope(state.device, frame.root, frame.envelope) - break - } - case 'pubkeys': { - // Роздача pubkey-ів approver+ (access.md «GET pubkeys») — хост звіряє - // з ними підписи approvals. - send({ kind: 'pubkeys', root: frame.root, pubkeys: core.pubkeys(state.device, frame.root) }) - break - } - case 'invite': { - const invitation = core.invite(state.device.account_id, frame.root, { - email: frame.email, - role: frame.role - }) - send({ kind: 'ok', invitation_id: invitation.invitation_id, status: invitation.status }) - break - } - case 'accept': { - const membership = core.accept(frame.invitation_id, state.device.account_id) - send({ kind: 'ok', root: membership.root_node_hash, role: membership.role }) - break - } - case 'decline': { - core.decline(frame.invitation_id, state.device.account_id) - send({ kind: 'ok', declined: frame.invitation_id }) - break - } - case 'transfer_ownership': { - // Мережевий transfer — лише з Ed25519-підписом canonical-акта - // пристроєм-ініціатором (fail-closed: без підпису — error-кадр). - if (!frame.signature) throw new Error('transfer відхилено: бракує підпису акта') - core.transferOwnership(frame.root, state.device.account_id, frame.to_account, { - device: state.device, - signature: frame.signature - }) - send({ kind: 'ok', transferred: frame.root, to_account: frame.to_account }) - break - } - case 'bootstrap_owners': { - // Сідинг membership з owner:-розмітки лісу (owner-gated, ідемпотентно). - send({ - kind: 'ok', - bootstrap: core.bootstrapMembers(state.device.account_id, frame.root, frame.entries) - }) - break - } - // Невідомі kind ігноруються (forward-compatibility). - default: - } -} - -/** - * Обробляє WS-зʼєднання: авторизація hello → кадри → cleanup підписок. - * @param {import('./relay.mjs').RelayCore} core ядро relay - * @param {import('ws').WebSocket} socket зʼєднання - * @returns {void} - */ -function handleConnection(core, socket) { - /** @type {{ device: object | null, subscriptions: Map<string, () => void> }} */ - const state = { device: null, subscriptions: new Map() } - - /** - * Надсилає JSON-кадр клієнту. - * @param {object} frame кадр - * @returns {void} - */ - const send = frame => socket.send(JSON.stringify(frame)) - - socket.on('message', raw => { - let frame - try { - frame = JSON.parse(String(raw)) - } catch { - send({ kind: 'error', message: 'невалідний JSON-кадр' }) - return - } - try { - handleFrame(core, state, frame, send) - } catch (error) { - send({ kind: 'error', message: String(error?.message ?? error) }) - } - }) - - socket.on('close', () => { - for (const unsubscribe of state.subscriptions.values()) unsubscribe() - state.subscriptions.clear() - }) -} - -/** - * Стартує WS-сервер relay поверх ядра. - * @param {import('./relay.mjs').RelayCore} core ядро relay - * @param {{ port?: number }} [options] порт (0 — ефемерний) - * @returns {Promise<{ port: number, close: () => Promise<void> }>} адреса і зупинка - */ -export async function startRelayServer(core, options = {}) { - const server = new WebSocketServer({ - port: options.port ?? 0, - maxPayload: FRAME_LIMIT - }) - server.on('connection', socket => handleConnection(core, socket)) - await once(server, 'listening') - const address = /** @type {{port: number}} */ (server.address()) - return { port: address.port, close: promisify(server.close.bind(server)) } -} diff --git a/relay/lib/signing.mjs b/relay/lib/signing.mjs deleted file mode 100644 index 09d5e02..0000000 --- a/relay/lib/signing.mjs +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Перевірка Ed25519-підписів на relay (node:crypto, без залежностей). - * - * Дзеркалить canonical-формат crates/agent-protocol: домен-префікс і - * NUL-розділені поля — підпис, зроблений Rust-клієнтом (`sign_transfer`), - * перевіряється тут байт-у-байт. Pubkey пристрою — hex 32 байти (як у - * agent-server relay_client), загортається у SPKI DER для node:crypto. - */ -import { Buffer } from 'node:buffer' -import { createPublicKey, verify } from 'node:crypto' - -/** ASN.1 SPKI-префікс для raw Ed25519 pubkey (RFC 8410). */ -const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') - -/** Домен підпису transfer ownership (дзеркало agent-protocol). */ -const TRANSFER_DOMAIN = 'mt-transfer-v4' - -/** Hex-формат Ed25519 pubkey пристрою: рівно 32 байти. */ -export const PUBKEY_RE = /^[0-9a-f]{64}$/i - -/** - * Canonical-повідомлення transfer ownership: домен і поля через NUL — - * межі полів однозначні, підпис не переноситься між контекстами. - * @param {{ root: string, fromAccount: string, toAccount: string }} payload акт передачі - * @returns {Buffer} байти для підпису/перевірки - */ -export function transferMessage({ root, fromAccount, toAccount }) { - return Buffer.from([TRANSFER_DOMAIN, root, fromAccount, toAccount].join('\0'), 'utf8') -} - -/** - * Перевіряє Ed25519-підпис повідомлення проти hex-pubkey пристрою. - * @param {string} pubkeyHex pubkey пристрою (hex, 32 байти) - * @param {Buffer} message canonical-повідомлення - * @param {string} signatureBase64 підпис (base64, як в Envelope) - * @returns {boolean} true якщо підпис валідний - */ -export function verifySignature(pubkeyHex, message, signatureBase64) { - if (!PUBKEY_RE.test(pubkeyHex)) return false - let signature - try { - signature = Uint8Array.fromBase64(signatureBase64) - } catch { - return false - } - if (signature.length !== 64) return false - const key = createPublicKey({ - key: Buffer.concat([SPKI_PREFIX, Buffer.from(pubkeyHex, 'hex')]), - format: 'der', - type: 'spki' - }) - return verify(null, message, key, signature) -} diff --git a/relay/lib/store.mjs b/relay/lib/store.mjs deleted file mode 100644 index 81a949b..0000000 --- a/relay/lib/store.mjs +++ /dev/null @@ -1,248 +0,0 @@ -/** - * In-memory сховище relay за схемою access.md: accounts, devices, tasks, - * task_members, invitations. - * - * Це dev/тестова реалізація store-інтерфейсу; PostgreSQL-реалізація — окрема - * задача за тим самим інтерфейсом (stack.md, «Relay-інфраструктура»). - * Персистентне у relay — ЛИШЕ акаунти/membership/запрошення; журнали сесій, - * git і lease relay не тримає (межі — access.md, «Relay: обов'язки і межі»). - */ -import { Buffer } from 'node:buffer' -import { randomUUID, timingSafeEqual } from 'node:crypto' - -import { PUBKEY_RE } from './signing.mjs' - -/** - * Порівняння секретних токенів за сталий час (захист від timing-атак). - * @param {string} a перший токен - * @param {string} b другий токен - * @returns {boolean} true якщо токени збігаються - */ -function tokenEquals(a, b) { - const bufferA = Buffer.from(a) - const bufferB = Buffer.from(b) - if (bufferA.length !== bufferB.length) return false - return timingSafeEqual(bufferA, bufferB) -} - -/** Ролі учасників задачі (access.md): owner ⊃ host ⊃ approver ⊃ viewer. */ -const ROLES = ['owner', 'host', 'approver', 'viewer'] - -/** - * Чи достатня роль `actual` для мінімально потрібної `required`. - * @param {string | null} actual фактична роль учасника (або null — не учасник) - * @param {string} required мінімально потрібна роль - * @returns {boolean} true якщо роль достатня - */ -export function roleAtLeast(actual, required) { - if (!actual) return false - return ROLES.indexOf(actual) <= ROLES.indexOf(required) && ROLES.includes(actual) -} - -/** In-memory реалізація store-інтерфейсу relay. */ -export class InMemoryStore { - constructor() { - /** @type {Map<string, {account_id: string, email: string, display_name: string}>} */ - this.accounts = new Map() - /** @type {Map<string, {device_id: string, account_id: string, role: string, pubkey: string, name: string, device_token: string, last_seen: string | null}>} */ - this.devices = new Map() - /** @type {Map<string, {root_node_hash: string, owner_account: string, project_name: string, remote_url: string, created_at: string}>} */ - this.tasks = new Map() - /** @type {Map<string, Map<string, string>>} root_node_hash → (account_id → role) */ - this.members = new Map() - /** @type {Map<string, {invitation_id: string, root_node_hash: string, from_account: string, to_email: string, role: string, status: string, created_at: string}>} */ - this.invitations = new Map() - } - - /** - * Створює акаунт (relay-логін; auth-провайдер — поза store). - * @param {{ email: string, displayName?: string }} params дані акаунта - * @returns {{account_id: string, email: string, display_name: string}} акаунт - */ - createAccount({ email, displayName = '' }) { - const account = { account_id: randomUUID(), email, display_name: displayName } - this.accounts.set(account.account_id, account) - return account - } - - /** - * Акаунт за email (для доставки запрошень). - * @param {string} email email акаунта - * @returns {{account_id: string, email: string, display_name: string} | null} акаунт або null - */ - accountByEmail(email) { - for (const account of this.accounts.values()) if (account.email === email) return account - return null - } - - /** - * Реєструє пристрій акаунта: `{name, role, pubkey} → device_token` (access.md). - * Pubkey — hex 32-байтовий Ed25519 (формат, який очікує pubkey-кеш хоста - * в agent-server): невалідний формат відхиляється одразу, а не на першій - * невдалій перевірці підпису. - * @param {string} accountId акаунт-власник - * @param {{ name: string, role: 'host'|'client', pubkey: string }} params дані пристрою - * @returns {{device_id: string, device_token: string}} ідентифікатор і токен пристрою - * @throws {Error} pubkey не hex-32 - */ - registerDevice(accountId, { name, role, pubkey }) { - if (!PUBKEY_RE.test(pubkey ?? '')) { - throw new Error('registerDevice відхилено: pubkey має бути hex Ed25519 (32 байти)') - } - const device = { - device_id: randomUUID(), - account_id: accountId, - role, - pubkey, - name, - device_token: randomUUID(), - last_seen: null - } - this.devices.set(device.device_id, device) - return { device_id: device.device_id, device_token: device.device_token } - } - - /** - * Пристрій за device_token (авторизація WS-підключення). - * @param {string} token device_token - * @returns {object | null} запис пристрою або null - */ - deviceByToken(token) { - for (const device of this.devices.values()) { - if (tokenEquals(device.device_token, token)) return device - } - return null - } - - /** - * Реєструє задачу (кореневий вузол); власник стає owner автоматично. - * @param {string} rootNodeHash node-hash кореневого вузла - * @param {string} ownerAccount акаунт-власник - * @param {{ projectName?: string, remoteUrl?: string }} [meta] метадані - * @returns {object} запис задачі - */ - createTask(rootNodeHash, ownerAccount, meta = {}) { - const task = { - root_node_hash: rootNodeHash, - owner_account: ownerAccount, - project_name: meta.projectName ?? '', - remote_url: meta.remoteUrl ?? '', - created_at: new Date().toISOString() - } - this.tasks.set(rootNodeHash, task) - this.members.set(rootNodeHash, new Map([[ownerAccount, 'owner']])) - return task - } - - /** - * Роль акаунта у задачі. - * @param {string} rootNodeHash кореневий вузол - * @param {string} accountId акаунт - * @returns {string | null} роль або null (не учасник) - */ - memberRole(rootNodeHash, accountId) { - return this.members.get(rootNodeHash)?.get(accountId) ?? null - } - - /** - * Встановлює/змінює роль учасника. - * @param {string} rootNodeHash кореневий вузол - * @param {string} accountId акаунт - * @param {string} role нова роль - * @returns {void} - */ - setMemberRole(rootNodeHash, accountId, role) { - this.members.get(rootNodeHash)?.set(accountId, role) - } - - /** - * Прибирає учасника. - * @param {string} rootNodeHash кореневий вузол - * @param {string} accountId акаунт - * @returns {void} - */ - removeMember(rootNodeHash, accountId) { - this.members.get(rootNodeHash)?.delete(accountId) - } - - /** - * Учасники задачі. - * @param {string} rootNodeHash кореневий вузол - * @returns {{account_id: string, role: string}[]} перелік учасників - */ - membersOf(rootNodeHash) { - const members = this.members.get(rootNodeHash) - if (!members) return [] - return Array.from(members.entries(), ([account_id, role]) => ({ account_id, role })) - } - - /** - * Створює запрошення (status: pending). - * @param {string} rootNodeHash кореневий вузол - * @param {string} fromAccount хто запрошує - * @param {string} toEmail кого - * @param {string} role роль після accept - * @returns {object} запис запрошення - */ - createInvitation(rootNodeHash, fromAccount, toEmail, role) { - const invitation = { - invitation_id: randomUUID(), - root_node_hash: rootNodeHash, - from_account: fromAccount, - to_email: toEmail, - role, - status: 'pending', - created_at: new Date().toISOString() - } - this.invitations.set(invitation.invitation_id, invitation) - return invitation - } - - /** - * Запрошення за id. - * @param {string} invitationId id запрошення - * @returns {object | null} запис або null - */ - invitationById(invitationId) { - return this.invitations.get(invitationId) ?? null - } - - /** - * Відкрите (pending) запрошення email-а у задачу — для ідемпотентного - * bootstrap: повторний прогін не плодить дублікати. - * @param {string} rootNodeHash кореневий вузол - * @param {string} toEmail email запрошеного - * @returns {object | null} pending-запрошення або null - */ - pendingInvitationFor(rootNodeHash, toEmail) { - for (const invitation of this.invitations.values()) { - if ( - invitation.root_node_hash === rootNodeHash && - invitation.to_email === toEmail && - invitation.status === 'pending' - ) { - return invitation - } - } - return null - } - - /** - * Pubkey-и пристроїв учасників із роллю approver+ (для перевірки підписів - * approvals хостом; access.md «GET pubkeys»). - * @param {string} rootNodeHash кореневий вузол - * @returns {{device_id: string, account_id: string, pubkey: string}[]} pubkey-и - */ - pubkeysFor(rootNodeHash) { - const approvers = new Set( - this.membersOf(rootNodeHash) - .filter(m => roleAtLeast(m.role, 'approver')) - .map(m => m.account_id) - ) - return this.devices - .values() - .filter(device => approvers.has(device.account_id)) - .map(({ device_id, account_id, pubkey }) => ({ device_id, account_id, pubkey })) - .toArray() - } -} diff --git a/relay/lib/tests/relay.test.mjs b/relay/lib/tests/relay.test.mjs deleted file mode 100644 index 01c9f0a..0000000 --- a/relay/lib/tests/relay.test.mjs +++ /dev/null @@ -1,327 +0,0 @@ -import { Buffer } from 'node:buffer' -import { generateKeyPairSync, sign } from 'node:crypto' - -import { beforeEach, describe, expect, test } from 'vitest' - -import { DevPushSink } from '../push-sink.mjs' -import { PushRouter } from '../push.mjs' -import { RelayCore } from '../relay.mjs' -import { Rooms } from '../rooms.mjs' -import { transferMessage } from '../signing.mjs' -import { InMemoryStore, roleAtLeast } from '../store.mjs' - -const RE_NOT_MEMBER = /не учасник/ -const RE_VIEWER = /viewer/ -const RE_OWNER_ONLY = /owner/ -const RE_FOREIGN_ACCOUNT = /іншому акаунту/ -const RE_ALREADY_PROCESSED = /оброблене/ -const RE_HEX = /hex/ -const RE_SIGNATURE = /підпис/ - -/** @type {InMemoryStore} */ -let store -/** @type {RelayCore} */ -let core -/** @type {Record<string, object>} акаунти фікстури */ -let accounts -/** @type {Record<string, object>} пристрої фікстури (повні записи) */ -let devices - -/** - * Детермінований hex-pubkey (32 байти) з імені — задовольняє валідацію - * формату там, де криптографія тесту не потрібна. - * @param {string} name імʼя пристрою - * @returns {string} hex-рядок 64 символи - */ -function fakeKey(name) { - return Buffer.from(name, 'utf8').toString('hex').padEnd(64, '0').slice(0, 64) -} - -/** - * Реєструє пристрій і повертає повний запис (для викликів ядра). - * @param {string} accountId акаунт-власник - * @param {string} name імʼя пристрою - * @returns {object} запис пристрою - */ -function device(accountId, name) { - const { device_token } = store.registerDevice(accountId, { - name, - role: 'client', - pubkey: fakeKey(name) - }) - return store.deviceByToken(device_token) -} - -/** - * Підписка з накопиченням кадрів у масив. - * @param {object[]} inbox приймач кадрів - * @returns {(frame: object) => void} колбек доставки - */ -function collectInto(inbox) { - return frame => { - inbox.push(frame) - } -} - -beforeEach(() => { - store = new InMemoryStore() - core = new RelayCore({ store }) - accounts = { - owner: store.createAccount({ email: 'owner@x' }), - approver: store.createAccount({ email: 'approver@x' }), - viewer: store.createAccount({ email: 'viewer@x' }), - outsider: store.createAccount({ email: 'outsider@x' }) - } - store.createTask('root-1', accounts.owner.account_id) - store.setMemberRole('root-1', accounts.approver.account_id, 'approver') - store.setMemberRole('root-1', accounts.viewer.account_id, 'viewer') - store.createTask('root-2', accounts.outsider.account_id) - devices = { - owner: device(accounts.owner.account_id, 'mac-owner'), - approver: device(accounts.approver.account_id, 'phone-approver'), - viewer: device(accounts.viewer.account_id, 'tab-viewer'), - outsider: device(accounts.outsider.account_id, 'pc-outsider') - } -}) - -describe('membership-роутінг кімнат', () => { - test('підписка лише учасникам кореня; конверт доходить лише у свою кімнату', () => { - const inbox1 = [] - const inbox2 = [] - core.subscribe(devices.viewer, 'root-1', collectInto(inbox1)) - core.subscribe(devices.outsider, 'root-2', collectInto(inbox2)) - - core.clientEnvelope(devices.owner, 'root-1', { seq: 0, node_hash: 'root-1' }) - - expect(inbox1).toHaveLength(1) - expect(inbox2).toHaveLength(0) - expect(() => core.subscribe(devices.outsider, 'root-1', collectInto([]))).toThrow(RE_NOT_MEMBER) - }) - - test('viewer не шле клієнтські події; approver шле (ApprovalResponse)', () => { - expect(() => core.clientEnvelope(devices.viewer, 'root-1', { seq: 0 })).toThrow(RE_VIEWER) - expect(() => core.clientEnvelope(devices.approver, 'root-1', { seq: 0 })).not.toThrow() - expect(() => core.clientEnvelope(devices.outsider, 'root-1', { seq: 0 })).toThrow(RE_NOT_MEMBER) - }) -}) - -describe('membership API', () => { - test('invite (лише owner) → accept → запис у members + broadcast MemberChanged', () => { - const inbox = [] - core.subscribe(devices.owner, 'root-1', collectInto(inbox)) - const invited = store.createAccount({ email: 'new@x' }) - - expect(() => core.invite(accounts.viewer.account_id, 'root-1', { email: 'new@x', role: 'host' })).toThrow( - RE_OWNER_ONLY - ) - - const invitation = core.invite(accounts.owner.account_id, 'root-1', { - email: 'new@x', - role: 'host' - }) - // Чужий акаунт не може прийняти. - expect(() => core.accept(invitation.invitation_id, accounts.viewer.account_id)).toThrow(RE_FOREIGN_ACCOUNT) - const membership = core.accept(invitation.invitation_id, invited.account_id) - - expect(membership).toEqual({ root_node_hash: 'root-1', role: 'host' }) - expect(store.memberRole('root-1', invited.account_id)).toBe('host') - expect(inbox.at(-1)).toEqual({ - kind: 'event', - event: { type: 'MemberChanged', account_id: invited.account_id, role: 'host' } - }) - // Повторний accept — відмова (не pending). - expect(() => core.accept(invitation.invitation_id, invited.account_id)).toThrow(RE_ALREADY_PROCESSED) - }) - - test('transfer ownership: новий owner, попередній стає host', () => { - core.transferOwnership('root-1', accounts.owner.account_id, accounts.approver.account_id) - expect(store.memberRole('root-1', accounts.approver.account_id)).toBe('owner') - expect(store.memberRole('root-1', accounts.owner.account_id)).toBe('host') - // Колишній owner більше не передає. - expect(() => core.transferOwnership('root-1', accounts.owner.account_id, accounts.viewer.account_id)).toThrow( - RE_OWNER_ONLY - ) - }) -}) - -describe('буфер кімнати', () => { - test('обрізається до ліміту; підписка реплеїть хвіст', () => { - const rooms = new Rooms(3) - const smallCore = new RelayCore({ store, rooms }) - for (let i = 0; i < 5; i++) { - smallCore.clientEnvelope(devices.owner, 'root-1', { seq: i }) - } - const inbox = [] - smallCore.subscribe(devices.owner, 'root-1', collectInto(inbox)) - expect(inbox.map(f => f.envelope.seq)).toEqual([2, 3, 4]) - }) -}) - -describe('from_host', () => { - test('ставиться relay-єм за роллю пристрою, не з кадру клієнта', () => { - const hostDevice = store.deviceByToken( - store.registerDevice(accounts.owner.account_id, { - name: 'host-mac', - role: 'host', - pubkey: fakeKey('host-mac') - }).device_token - ) - const inbox = [] - core.subscribe(devices.viewer, 'root-1', collectInto(inbox)) - - core.clientEnvelope(hostDevice, 'root-1', { seq: 1 }) - core.clientEnvelope(devices.approver, 'root-1', { seq: 0 }) - - expect(inbox.map(f => f.from_host)).toEqual([true, false]) - }) -}) - -describe('pubkeys', () => { - test('лише пристрої approver+; доступ лише учасникам', () => { - const pubkeys = core.pubkeys(devices.viewer, 'root-1') - expect(pubkeys.map(k => k.pubkey).toSorted()).toEqual([fakeKey('mac-owner'), fakeKey('phone-approver')].toSorted()) - expect(() => core.pubkeys(devices.outsider, 'root-1')).toThrow(RE_NOT_MEMBER) - }) - - test('registerDevice відхиляє pubkey не у hex-32 форматі', () => { - expect(() => - store.registerDevice(accounts.owner.account_id, { name: 'bad', role: 'client', pubkey: 'pk-bad' }) - ).toThrow(RE_HEX) - }) -}) - -describe('підписаний transfer ownership', () => { - /** - * Реальна Ed25519-пара: пристрій із цим pubkey і функція підпису. - * @param {string} accountId акаунт-власник пристрою - * @returns {{ device: object, signTransfer: (payload: object) => string }} пристрій і підписувач - */ - function signingDevice(accountId) { - const { publicKey, privateKey } = generateKeyPairSync('ed25519') - const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) - const { device_token } = store.registerDevice(accountId, { - name: 'signer', - role: 'client', - pubkey: raw.toString('hex') - }) - return { - device: store.deviceByToken(device_token), - signTransfer: payload => sign(null, transferMessage(payload), privateKey).toBase64() - } - } - - test('валідний підпис акта проходить, зіпсований — відмова без зміни ролей', () => { - const { device: signer, signTransfer } = signingDevice(accounts.owner.account_id) - const payload = { - root: 'root-1', - fromAccount: accounts.owner.account_id, - toAccount: accounts.approver.account_id - } - const good = signTransfer(payload) - const bad = signTransfer({ ...payload, toAccount: accounts.viewer.account_id }) - - expect(() => - core.transferOwnership('root-1', accounts.owner.account_id, accounts.approver.account_id, { - device: signer, - signature: bad - }) - ).toThrow(RE_SIGNATURE) - expect(store.memberRole('root-1', accounts.owner.account_id)).toBe('owner') - - core.transferOwnership('root-1', accounts.owner.account_id, accounts.approver.account_id, { - device: signer, - signature: good - }) - expect(store.memberRole('root-1', accounts.approver.account_id)).toBe('owner') - expect(store.memberRole('root-1', accounts.owner.account_id)).toBe('host') - }) -}) - -describe('push', () => { - /** @type {DevPushSink} */ - let sink - /** @type {RelayCore} */ - let pushCore - - beforeEach(() => { - sink = new DevPushSink() - pushCore = new RelayCore({ store, push: new PushRouter({ store, sink }) }) - }) - - test('invite: тип 2 зареєстрованому акаунту; незареєстрований email — тихо', () => { - pushCore.invite(accounts.owner.account_id, 'root-1', { email: 'viewer@x', role: 'host' }) - pushCore.invite(accounts.owner.account_id, 'root-1', { email: 'ghost@x', role: 'host' }) - expect(sink.deliveries).toEqual([ - { account_id: accounts.viewer.account_id, root: 'root-1', reason: 'invited', ref: null } - ]) - }) - - test('attention-подія будить учасників, крім автора; звичайні події — ні', () => { - pushCore.clientEnvelope(devices.owner, 'root-1', { seq: 0, event: { type: 'PlanReview', plan_ref: 'plan_001' } }) - pushCore.clientEnvelope(devices.owner, 'root-1', { seq: 1, event: { type: 'NodeState', state: 'running' } }) - const awakened = sink.deliveries.map(d => d.account_id).toSorted() - expect(awakened).toEqual([accounts.approver.account_id, accounts.viewer.account_id].toSorted()) - expect(sink.deliveries.every(d => d.reason === 'PlanReview' && d.ref === 'plan_001')).toBe(true) - }) - - test('Escalation: адресний push лише to_account_id; без резолву — нікому', () => { - pushCore.clientEnvelope(devices.owner, 'root-1', { - seq: 2, - event: { - type: 'Escalation', - from: 'olena', - to: 'vkozlov', - to_account_id: accounts.approver.account_id, - reason_ref: 'escalation_001.md' - } - }) - pushCore.clientEnvelope(devices.owner, 'root-1', { - seq: 3, - event: { type: 'Escalation', from: 'olena', to: 'petro', reason_ref: 'escalation_002.md' } - }) - expect(sink.deliveries).toEqual([ - { - account_id: accounts.approver.account_id, - root: 'root-1', - reason: 'escalation', - ref: 'escalation_001.md' - } - ]) - }) -}) - -describe('bootstrapMembers', () => { - test('owner-gated; зареєстровані стають учасниками, решта — pending; ідемпотентно', () => { - const registered = store.createAccount({ email: 'olena@x' }) - const entries = [ - { email: 'olena@x', role: 'owner' }, - { email: 'viewer@x', role: 'owner' }, // вже учасник — роль не чіпаємо - { email: 'ghost@x' } - ] - - expect(() => core.bootstrapMembers(accounts.viewer.account_id, 'root-1', entries)).toThrow(RE_OWNER_ONLY) - - const first = core.bootstrapMembers(accounts.owner.account_id, 'root-1', entries) - expect(first).toEqual({ added: ['olena@x'], invited: ['ghost@x'], kept: ['viewer@x'] }) - expect(store.memberRole('root-1', registered.account_id)).toBe('owner') - expect(store.memberRole('root-1', accounts.viewer.account_id)).toBe('viewer') - expect(store.pendingInvitationFor('root-1', 'ghost@x')).not.toBeNull() - - // Повторний прогін: без нових запрошень і без зміни ролей. - const again = core.bootstrapMembers(accounts.owner.account_id, 'root-1', entries) - expect(again).toEqual({ added: [], invited: ['ghost@x'], kept: ['olena@x', 'viewer@x'] }) - const pending = store.invitations - .values() - .filter(i => i.to_email === 'ghost@x') - .toArray() - expect(pending).toHaveLength(1) - }) -}) - -describe('roleAtLeast', () => { - test('ієрархія owner ⊃ host ⊃ approver ⊃ viewer', () => { - expect(roleAtLeast('owner', 'viewer')).toBe(true) - expect(roleAtLeast('viewer', 'approver')).toBe(false) - expect(roleAtLeast(null, 'viewer')).toBe(false) - }) -}) diff --git a/relay/lib/tests/server.test.mjs b/relay/lib/tests/server.test.mjs deleted file mode 100644 index f4943cb..0000000 --- a/relay/lib/tests/server.test.mjs +++ /dev/null @@ -1,225 +0,0 @@ -import { Buffer } from 'node:buffer' -import { generateKeyPairSync, sign } from 'node:crypto' -import { once } from 'node:events' - -import { WebSocket } from 'ws' -import { afterAll, beforeAll, expect, test } from 'vitest' - -import { RelayCore } from '../relay.mjs' -import { startRelayServer } from '../server.mjs' -import { transferMessage } from '../signing.mjs' -import { InMemoryStore } from '../store.mjs' - -/** - * Детермінований hex-pubkey (32 байти) з імені. - * @param {string} name імʼя пристрою - * @returns {string} hex-рядок 64 символи - */ -function fakeKey(name) { - return Buffer.from(name, 'utf8').toString('hex').padEnd(64, '0').slice(0, 64) -} - -const RE_HELLO = /hello/ -const RE_VIEWER = /viewer/ -const RE_HEX_KEY = /^[0-9a-f]{64}$/ -const RE_SIGNATURE = /підпис/ -// Тести ходять на локальний loopback без TLS; sdl-правило про insecure-URL -// націлене на продакшн-адреси, тому схему складаємо окремо від хоста. -const WS_SCHEME = 'ws:' - -/** @type {InMemoryStore} */ -const store = new InMemoryStore() -/** @type {{ port: number, close: () => Promise<void> }} */ -let server -/** @type {string} */ -let hostToken -/** @type {string} */ -let viewerToken - -/** - * Відкриває WS-клієнт і чекає open. - * @returns {Promise<WebSocket>} відкритий сокет - */ -async function connect() { - const socket = new WebSocket(`${WS_SCHEME}//127.0.0.1:${server.port}`) - await once(socket, 'open') - return socket -} - -/** - * Шле кадр і чекає наступний вхідний JSON-кадр. - * @param {WebSocket} socket сокет - * @param {object} frame кадр для відправки - * @returns {Promise<object>} відповідь relay - */ -async function roundtrip(socket, frame) { - socket.send(JSON.stringify(frame)) - const [raw] = await once(socket, 'message') - return JSON.parse(String(raw)) -} - -/** @type {object} */ -let owner -/** @type {object} */ -let approver -/** @type {import('node:crypto').KeyObject} */ -let ownerPrivateKey - -beforeAll(async () => { - owner = store.createAccount({ email: 'owner@x' }) - const viewer = store.createAccount({ email: 'viewer@x' }) - approver = store.createAccount({ email: 'approver@x' }) - store.createTask('root-1', owner.account_id) - store.setMemberRole('root-1', viewer.account_id, 'viewer') - store.setMemberRole('root-1', approver.account_id, 'approver') - const pair = generateKeyPairSync('ed25519') - ownerPrivateKey = pair.privateKey - hostToken = store.registerDevice(owner.account_id, { - name: 'mac', - role: 'host', - pubkey: pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex') - }).device_token - viewerToken = store.registerDevice(viewer.account_id, { - name: 'tab', - role: 'client', - pubkey: fakeKey('tab') - }).device_token - server = await startRelayServer(new RelayCore({ store })) -}) - -afterAll(async () => { - await server.close() -}) - -test('невірний device_token → error; кадри до hello відхиляються', async () => { - const socket = await connect() - const denied = await roundtrip(socket, { kind: 'subscribe', root: 'root-1' }) - expect(denied.kind).toBe('error') - expect(denied.message).toMatch(RE_HELLO) - const bad = await roundtrip(socket, { kind: 'hello', device_token: 'чужий' }) - expect(bad.kind).toBe('error') - socket.close() -}) - -test('hello → subscribe → envelope доходить підписнику; реплей після реконекту', async () => { - const publisher = await connect() - const helloReply = await roundtrip(publisher, { kind: 'hello', device_token: hostToken }) - expect(helloReply.kind).toBe('ok') - - const subscriber = await connect() - await roundtrip(subscriber, { kind: 'hello', device_token: viewerToken }) - await roundtrip(subscriber, { kind: 'subscribe', root: 'root-1' }) - - publisher.send(JSON.stringify({ kind: 'envelope', root: 'root-1', envelope: { seq: 0, node_hash: 'demo' } })) - const [raw] = await once(subscriber, 'message') - const delivered = JSON.parse(String(raw)) - expect(delivered).toEqual({ kind: 'envelope', envelope: { seq: 0, node_hash: 'demo' }, from_host: true }) - subscriber.close() - - // Реконект: буфер кімнати реплеїться одразу після subscribe. - const reconnected = await connect() - await roundtrip(reconnected, { kind: 'hello', device_token: viewerToken }) - reconnected.send(JSON.stringify({ kind: 'subscribe', root: 'root-1' })) - const [replayRaw] = await once(reconnected, 'message') - expect(JSON.parse(String(replayRaw))).toEqual({ - kind: 'envelope', - envelope: { seq: 0, node_hash: 'demo' }, - from_host: true - }) - reconnected.close() - publisher.close() -}) - -test('pubkeys-кадр: pubkey-и approver+ пристроїв для перевірки підписів', async () => { - const socket = await connect() - await roundtrip(socket, { kind: 'hello', device_token: viewerToken }) - const reply = await roundtrip(socket, { kind: 'pubkeys', root: 'root-1' }) - expect(reply.kind).toBe('pubkeys') - expect(reply.root).toBe('root-1') - // Owner (approver+) — так; viewer — ні. - expect(reply.pubkeys.map(k => k.account_id)).toEqual([owner.account_id]) - expect(reply.pubkeys[0].pubkey).toMatch(RE_HEX_KEY) - socket.close() -}) - -test('membership через WS: invite → accept новим акаунтом', async () => { - const socket = await connect() - await roundtrip(socket, { kind: 'hello', device_token: hostToken }) - const invited = await roundtrip(socket, { kind: 'invite', root: 'root-1', email: 'new@x', role: 'host' }) - expect(invited).toMatchObject({ kind: 'ok', status: 'pending' }) - - const newcomer = store.createAccount({ email: 'new@x' }) - const token = store.registerDevice(newcomer.account_id, { - name: 'new-phone', - role: 'client', - pubkey: fakeKey('new-phone') - }).device_token - const other = await connect() - await roundtrip(other, { kind: 'hello', device_token: token }) - const accepted = await roundtrip(other, { kind: 'accept', invitation_id: invited.invitation_id }) - expect(accepted).toEqual({ kind: 'ok', root: 'root-1', role: 'host' }) - expect(store.memberRole('root-1', newcomer.account_id)).toBe('host') - socket.close() - other.close() -}) - -test('transfer_ownership через WS: без підпису — error, з підписом — передано', async () => { - const socket = await connect() - await roundtrip(socket, { kind: 'hello', device_token: hostToken }) - - const unsigned = await roundtrip(socket, { - kind: 'transfer_ownership', - root: 'root-1', - to_account: approver.account_id - }) - expect(unsigned.kind).toBe('error') - expect(unsigned.message).toMatch(RE_SIGNATURE) - - const signature = sign( - null, - transferMessage({ root: 'root-1', fromAccount: owner.account_id, toAccount: approver.account_id }), - ownerPrivateKey - ).toBase64() - const transferred = await roundtrip(socket, { - kind: 'transfer_ownership', - root: 'root-1', - to_account: approver.account_id, - signature - }) - expect(transferred).toEqual({ kind: 'ok', transferred: 'root-1', to_account: approver.account_id }) - expect(store.memberRole('root-1', approver.account_id)).toBe('owner') - expect(store.memberRole('root-1', owner.account_id)).toBe('host') - socket.close() -}) - -test('bootstrap_owners через WS: сідинг з owner:-розмітки (новим owner-ом)', async () => { - // Після transfer вище owner кореня — approver; реєструємо його пристрій. - const token = store.registerDevice(approver.account_id, { - name: 'approver-mac', - role: 'client', - pubkey: fakeKey('approver-mac') - }).device_token - const socket = await connect() - await roundtrip(socket, { kind: 'hello', device_token: token }) - const reply = await roundtrip(socket, { - kind: 'bootstrap_owners', - root: 'root-1', - entries: [{ email: 'viewer@x', role: 'owner' }, { email: 'ghost@x' }] - }) - expect(reply.kind).toBe('ok') - expect(reply.bootstrap).toEqual({ added: [], invited: ['ghost@x'], kept: ['viewer@x'] }) - socket.close() -}) - -test('viewer не шле клієнтські події через WS', async () => { - const socket = await connect() - await roundtrip(socket, { kind: 'hello', device_token: viewerToken }) - const rejected = await roundtrip(socket, { - kind: 'envelope', - root: 'root-1', - envelope: { seq: 1 } - }) - expect(rejected.kind).toBe('error') - expect(rejected.message).toMatch(RE_VIEWER) - socket.close() -}) diff --git a/relay/package.json b/relay/package.json deleted file mode 100644 index 457ac2b..0000000 --- a/relay/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@7n/relay", - "version": "0.8.1", - "private": true, - "description": "Relay-координатор MT: кімнати Envelope, membership, pubkey-роздача (M2)", - "type": "module", - "engines": { - "node": ">=24", - "bun": ">=1.3" - }, - "main": "./lib/relay.mjs", - "scripts": { - "test": "vitest run" - }, - "dependencies": { - "ws": "^8.21.1" - } -} diff --git a/relay/stryker.config.mjs b/relay/stryker.config.mjs deleted file mode 100644 index 6717618..0000000 --- a/relay/stryker.config.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import '@stryker-mutator/vitest-runner' - -/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ -export default { - testRunner: 'vitest', - vitest: { configFile: 'vitest.config.mjs' }, - // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст - // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант). - coverageAnalysis: 'perTest', - // concurrency: за замовч. Stryker обирає os.cpus().length - 1. - // inPlace більше не потрібен — vitest-runner ізолює мутантів у пам'яті через AST-patching, - // без копіювання node_modules у sandbox (стара проблема command runner у Bun monorepo). - tempDirName: 'reports/stryker/.tmp', - reporters: ['json', 'clear-text'], - jsonReporter: { fileName: 'reports/stryker/mutation.json' }, - // incremental: зберігає результати між запусками, відновлює після краш/kill. - // Дає ~262× прискорення на noop-прогонах (див. benchmarks/runner-comparison/SPIKE.md). - incremental: true, - incrementalFile: 'reports/stryker/incremental.json' -} diff --git a/relay/vitest.config.mjs b/relay/vitest.config.mjs deleted file mode 100644 index 97529a5..0000000 --- a/relay/vitest.config.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - // Підхоплюються обидві основні розкладки: тести поряд із кодом (rule `test`-конвенція — - // у піддиректоріях `tests/`) і top-level integration suites у `<root>/tests/`. - include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}'], - // reports/stryker/.tmp/ містить sandbox-копії тестів від Stryker (incremental - // або aborted-runs); без exclude vitest run --coverage їх підхоплює і вони - // фейляться, бо запускаються поза реальним repo root. - exclude: ['**/node_modules/**', '**/dist/**', '**/reports/stryker/**'], - environment: 'node', - // `pool: 'forks'` — defense-in-depth ізоляція процесів між test-файлами. - // У default `pool: 'threads'` усі workers ділять один процес → паралельний - // `process.chdir(dir)` у тестовій фікстурі перехоплює cwd сусіда посеред - // FS- або `git`-операції. Реальний інцидент: `git init`+`git commit` із - // tmp-фікстури потрапив у реальний робочий репозиторій. Forks гарантують - // ізоляцію. Канон тестів — `withTmpDir(async dir => ...)` (test.mdc). - pool: 'forks', - coverage: { provider: 'v8', reporter: ['lcov', 'text-summary'] } - } -}) diff --git a/crates/mt-napi/stryker.config.mjs b/stryker.config.mjs similarity index 100% rename from crates/mt-napi/stryker.config.mjs rename to stryker.config.mjs diff --git a/npm/lib/tests/docs.test.mjs b/tests/docs.test.mjs similarity index 67% rename from npm/lib/tests/docs.test.mjs rename to tests/docs.test.mjs index 88f4514..8a9e036 100644 --- a/npm/lib/tests/docs.test.mjs +++ b/tests/docs.test.mjs @@ -3,11 +3,11 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'vitest' -const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..') -// Канонічна документація — весь корпус npm/docs (глави architecture/ та -// індекси); окремого frozen-контракту немає з M1 (mt.md видалено). -const docsDir = join(repositoryRoot, 'npm/docs') -const adrDir = join(repositoryRoot, 'docs/adr') +const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +// Канонічна документація — весь корпус docs/ (глави architecture/ та індекси); +// окремого frozen-контракту немає з M1 (mt.md видалено). ADR-корпус переїхав у +// mt-rust разом із реалізацією — тут лишається лише специфікація. +const docsDir = join(repositoryRoot, 'docs') const legacyRuntime = /n-cursor (?:flow|graph)|\.flow\.json|docs\/думка\.MD|npm\/docs\/flow\.MD|Пасивн(?:ий|ого) Турнікет|Активн(?:ий|ого) Раннер/i const unsupportedSurface = /graph audit|n-cursor watch|NCURSOR_|mt migrate|mt audit-retry/i @@ -40,15 +40,4 @@ describe('MT documentation', () => { expect(readFileSync(file, 'utf8'), file).not.toMatch(removedContractLink) } }) - - // Точна кількість ADR не фіксується: ADR-нормалізація регулярно зливає чернетки, - // і hardcoded лічильник протухає при кожному її прогоні. - test('ADRs contain no legacy runtime names', () => { - const adrFiles = readdirSync(adrDir).filter(file => file.endsWith('.md')) - - expect(adrFiles.length).toBeGreaterThan(0) - for (const file of adrFiles) { - expect(readFileSync(join(adrDir, file), 'utf8'), file).not.toMatch(legacyRuntime) - } - }) }) diff --git a/vitest.config.mjs b/vitest.config.mjs index a7bf4f4..b405e6e 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -2,7 +2,12 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - // Гарантує зібраний mt-scanner перед тестами (scanner.mjs тепер шим над бінарником). - globalSetup: './npm/lib/tests/global-setup.mjs' + // Тести поряд із кодом (`layers/lib/tests/**`) і top-level integration suites у `tests/`. + include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}'], + exclude: ['**/node_modules/**', '**/dist/**', '**/reports/stryker/**'], + environment: 'node', + // Ізоляція процесів між test-файлами як safety net на випадковий `process.chdir`. + pool: 'forks', + coverage: { provider: 'v8', reporter: ['lcov', 'text-summary'] } } })