feat(reader): Gitea client + config + board orchestrator

closes board-viewer-gitea-reader

- src/gitea.ts: HTTP client (getFile, getLatestCommitIso, rawUrl); DI'd fetch
- src/reader.ts: readBoard(client, repos) → TaskRecord[]
- src/config.ts: TOML config loader with board_viewer_repos whitelist
- 25 tests total (9 parser incl. 3 real-repo fixtures, 7 gitea, 5 reader, 4 config)
- emoji contract 🔴🟡🔵🟢 (open/in_progress/paused/blocked/done)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vitya
2026-05-22 12:48:37 +03:00
parent 1c7e372abf
commit d093693795
14 changed files with 2543 additions and 15 deletions

View File

@@ -9,19 +9,18 @@ _Updated: 2026-05-22_
---
## [board-viewer-gitea-reader] — Gitea API reader → normalized task structure
**Status:** ready
**Where I stopped:** разблокирована (pointers done); реализация не начата. Это первая импл-таска, остальные impl-таски зависят от её типов.
**Next action:** TDD по контракту в `board-viewer-gitea-reader.md`.
## 🟢 [board-viewer-gitea-reader] — Gitea API reader → normalized task structure
**Status:** done
**Where I stopped:** TaskRecord контракт реализован (parser+gitea+reader+config); 25 тестов; typecheck clean. Тесты используют 3 реальных STATUS.md fixture'а.
**Next action:** разблокировать html-render — потребляет `readBoard(client, repos) → TaskRecord[]`.
**Branch:** master
---
## ⚪ [board-viewer-html-render] — render 5-col kanban from normalized structure
**Status:** ready
**Where I stopped:** ждёт `board-viewer-gitea-reader` (использует его типы).
**Next action:** после ready-стейта reader'а TDD по контракту в `board-viewer-html-render.md`.
**Blocker:** board-viewer-gitea-reader
**Where I stopped:** reader готов, импортируется `TaskRecord` из `src/reader.ts`. Импл не начата.
**Next action:** TDD по контракту в `board-viewer-html-render.md`.
**Branch:** master
---

View File

@@ -10,10 +10,12 @@
## Key files
- (TBD) `src/gitea-reader.{ts,py}`main reader module
- (TBD) `src/types.{ts,py}` — exported task structure
- (TBD) `tests/fixtures/` — sample STATUS.md + per-task.md pairs from real repos
- (TBD) `tests/gitea-reader.test.{ts,py}` — TDD assertions
- `src/parser.ts`STATUS.md → `StatusBlock[]` (pure)
- `src/gitea.ts` — Gitea HTTP client (DI'd fetch, `getFile` / `getLatestCommitIso` / `rawUrl`)
- `src/reader.ts``readBoard(client, repos) → TaskRecord[]`; orchestrates parser+client
- `src/config.ts` — loads `~/.config/projects-mcp/auth.toml``{ baseUrl, token, repos }`
- `tests/fixtures/``status-single-block.md`, `status-multi.md`, real STATUS.md from books / claude-skills / projects-meta-mcp
- `tests/parser.test.ts` (9), `tests/gitea.test.ts` (7), `tests/reader.test.ts` (5), `tests/config.test.ts` (4) — 25 tests total
## Acceptance criteria
@@ -48,13 +50,18 @@
## Open questions
- [ ] TS or Python? (повлияет на структуру cron-deploy).
- [ ] Owner-фильтр: брать репо из `gitea_owners` целиком или whitelist? Возможно нужен `board_viewer_repos` отдельным полем в auth.toml.
- [ ] Кэшировать ответы Gitea API локально между cron-тиками? (опт.: rate-limit, hot-reload).
- [x] TS (Node 22, vitest, ESM) — выбор подтверждён юзером 2026-05-22.
- [x] Whitelist `board_viewer_repos` в `auth.toml` — выбран как явный и контролируемый.
- [x] Без кэширования на MVP — Gitea на том же VDS, latency negligible.
## Completed steps
- [ ] (фиксируется при выполнении)
- [x] парсер STATUS.md (`src/parser.ts`) с маппингом ⚪🔴🟡🔵🟢 → open/in_progress/paused/blocked/done
- [x] Gitea HTTP client (`src/gitea.ts`): getFile / getLatestCommitIso / rawUrl, DI fetch
- [x] reader orchestrator (`src/reader.ts`): объединяет parser+client → TaskRecord[]
- [x] TOML config loader (`src/config.ts`) с whitelist `board_viewer_repos`
- [x] 25 тестов: 9 парсера (включая 3 real-repo фикстуры), 7 gitea, 5 reader, 4 config
- [x] typecheck clean
## Notes

15
package-lock.json generated
View File

@@ -7,6 +7,9 @@
"": {
"name": "board-viewer",
"version": "0.1.0",
"dependencies": {
"smol-toml": "^1.6.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0",
@@ -1205,6 +1208,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -16,5 +16,8 @@
"@types/node": "^22.10.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
},
"dependencies": {
"smol-toml": "^1.6.1"
}
}

50
src/config.ts Normal file
View File

@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import type { RepoRef } from './reader.ts';
export interface BoardConfig {
baseUrl: string;
token: string;
repos: RepoRef[];
}
export function defaultConfigPath(): string {
return join(homedir(), '.config', 'projects-mcp', 'auth.toml');
}
export function loadConfig(path: string = defaultConfigPath()): BoardConfig {
const raw = readFileSync(path, 'utf8');
const data = parseToml(raw) as Record<string, unknown>;
const baseUrl = stringField(data, 'gitea_url');
const token = stringField(data, 'gitea_token');
const repos = parseRepos(data['board_viewer_repos']);
return { baseUrl, token, repos };
}
function stringField(data: Record<string, unknown>, key: string): string {
const value = data[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`config: missing or empty string field "${key}"`);
}
return value;
}
function parseRepos(value: unknown): RepoRef[] {
if (!Array.isArray(value)) {
throw new Error('config: missing or invalid "board_viewer_repos" (expected array of "owner/repo")');
}
return value.map((entry) => {
if (typeof entry !== 'string') {
throw new Error(`config: board_viewer_repos entry must be string, got ${typeof entry}`);
}
const parts = entry.split('/');
if (parts.length !== 2 || parts[0] === '' || parts[1] === '') {
throw new Error(`config: board_viewer_repos entry "${entry}" is not "owner/repo"`);
}
return { owner: parts[0]!, repo: parts[1]! };
});
}

58
src/gitea.ts Normal file
View File

@@ -0,0 +1,58 @@
export interface GiteaClientOptions {
baseUrl: string;
token: string;
fetch?: typeof fetch;
defaultBranch?: string;
}
export interface GiteaClient {
getFile(owner: string, repo: string, path: string): Promise<string | null>;
getLatestCommitIso(owner: string, repo: string, path: string): Promise<string | null>;
rawUrl(owner: string, repo: string, path: string): string;
}
interface ContentsResponse {
content?: string;
encoding?: string;
}
interface CommitEntry {
commit?: { author?: { date?: string } };
}
export function createGiteaClient(opts: GiteaClientOptions): GiteaClient {
const fetchImpl = opts.fetch ?? fetch;
const base = opts.baseUrl.replace(/\/+$/, '');
const branch = opts.defaultBranch ?? 'master';
const headers = { Authorization: `token ${opts.token}` };
async function getFile(owner: string, repo: string, path: string): Promise<string | null> {
const url = `${base}/api/v1/repos/${owner}/${repo}/contents/${path}`;
const res = await fetchImpl(url, { headers });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Gitea getFile ${owner}/${repo}/${path}: HTTP ${res.status}`);
const body = (await res.json()) as ContentsResponse;
if (body.encoding !== 'base64' || typeof body.content !== 'string') {
throw new Error(`Gitea getFile ${owner}/${repo}/${path}: unexpected encoding ${body.encoding}`);
}
return Buffer.from(body.content, 'base64').toString('utf8');
}
async function getLatestCommitIso(
owner: string,
repo: string,
path: string,
): Promise<string | null> {
const url = `${base}/api/v1/repos/${owner}/${repo}/commits?path=${encodeURIComponent(path)}&limit=1`;
const res = await fetchImpl(url, { headers });
if (!res.ok) throw new Error(`Gitea getLatestCommitIso ${owner}/${repo}/${path}: HTTP ${res.status}`);
const body = (await res.json()) as CommitEntry[];
return body[0]?.commit?.author?.date ?? null;
}
function rawUrl(owner: string, repo: string, path: string): string {
return `${base}/${owner}/${repo}/raw/branch/${branch}/${path}`;
}
return { getFile, getLatestCommitIso, rawUrl };
}

56
src/reader.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { GiteaClient } from './gitea.ts';
import { parseStatus, type Status, type StatusEmoji } from './parser.ts';
export interface RepoRef {
owner: string;
repo: string;
}
export interface TaskRecord {
slug: string;
project: string;
project_owner: string;
status: Status;
status_emoji: StatusEmoji;
title: string;
where_stopped: string | null;
next_action: string | null;
blocker: string | null;
branch: string | null;
last_commit_iso: string | null;
raw_url: string;
}
export async function readBoard(
client: GiteaClient,
repos: ReadonlyArray<RepoRef>,
): Promise<TaskRecord[]> {
const records: TaskRecord[] = [];
for (const { owner, repo } of repos) {
const status = await client.getFile(owner, repo, '.tasks/STATUS.md');
if (status === null) continue;
const blocks = parseStatus(status);
for (const block of blocks) {
const taskPath = `.tasks/${block.slug}.md`;
const last_commit_iso = await client.getLatestCommitIso(owner, repo, taskPath);
records.push({
slug: block.slug,
project: repo,
project_owner: owner,
status: block.status,
status_emoji: block.status_emoji,
title: block.title,
where_stopped: block.where_stopped,
next_action: block.next_action,
blocker: block.blocker,
branch: block.branch,
last_commit_iso,
raw_url: client.rawUrl(owner, repo, taskPath),
});
}
}
return records;
}

55
tests/config.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from 'vitest';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadConfig } from '../src/config.ts';
function writeToml(body: string): string {
const dir = mkdtempSync(join(tmpdir(), 'board-viewer-cfg-'));
const path = join(dir, 'auth.toml');
writeFileSync(path, body, 'utf8');
return path;
}
describe('loadConfig', () => {
test('parses gitea_url, gitea_token, and board_viewer_repos from TOML', () => {
const path = writeToml(`
gitea_url = "https://git.example.com"
gitea_token = "abc123"
board_viewer_repos = ["OpeItcLoc03/board-viewer", "OpeItcLoc03/books"]
`);
const cfg = loadConfig(path);
expect(cfg).toEqual({
baseUrl: 'https://git.example.com',
token: 'abc123',
repos: [
{ owner: 'OpeItcLoc03', repo: 'board-viewer' },
{ owner: 'OpeItcLoc03', repo: 'books' },
],
});
});
test('throws when board_viewer_repos field is missing', () => {
const path = writeToml(`gitea_url = "x"\ngitea_token = "t"\n`);
expect(() => loadConfig(path)).toThrow(/board_viewer_repos/);
});
test('throws when gitea_token is missing', () => {
const path = writeToml(`gitea_url = "x"\nboard_viewer_repos = []\n`);
expect(() => loadConfig(path)).toThrow(/gitea_token/);
});
test('throws on malformed "owner/repo" entry', () => {
const path = writeToml(`
gitea_url = "x"
gitea_token = "t"
board_viewer_repos = ["just-owner-no-slash"]
`);
expect(() => loadConfig(path)).toThrow(/just-owner-no-slash|owner\/repo/);
});
});

556
tests/fixtures/real-books-STATUS.md vendored Normal file
View File

@@ -0,0 +1,556 @@
# Task Board
_Updated: 2026-05-21 — `drain-bookva-ym-stocks` 🟢 **DONE end-to-end**. Bookva-Ozon FBS: 5173/5184 → stock=0 (5171 первый прогон + 2 ручной multi-warehouse патч К000405/К000632 на Афонская КГТ + realFBS). YM Bookva: 1049/1049 → count=0. Slovo (id=2) не затронут, торгует штатно: `ozon stocks syncronization` каждую минуту SUCCESS (ctx.data{idSeller:2, otherSellers:[]} читается корректно), `sync stocks with warehouse` идёт. 14 commits на master (включая 4 hot-fix циклов SDK): план/спека → guard fbsPostings → handler TDD (6 unit-тестов) → tasks.json drop YM-cron → drain CLI (7 unit-тестов, basePath + IN_SALE/PUBLISHED фильтры + @books/yandex-market-partner-api wrapper + bypass YM `updatedAt` SDK-bug + multi-warehouse enumeration). YM Agenda docs cleanup: 8 (2 активных + 6 stale dup от старого idempotency) → 0 через mongosh deleteMany. **Leftover: 11 SKU на FBO в Bookva-Ozon** (147720748, 6281157, Б400095, 19687905, Б2003910, Б401223, 2888034, 7754504, К000370, Б601643, Б604747) — обнулить через API продавцу невозможно (склады Ozon), нужен FBO withdrawal в кабинете руками. Bug в `@books/yandex-market-partner-api/stocks.js:59` (updatedAt double-toISOString, идентичен 2026-04-27 fix'у в api service но в shared wrapper не починен) занесён в [business-logic-bugs.md](business-logic-bugs.md). Spec: [.wiki/concepts/drain-bookva-ym-stocks.md](../.wiki/concepts/drain-bookva-ym-stocks.md), plan: [.tasks/drain-bookva-ym-stocks-plan.md](drain-bookva-ym-stocks-plan.md)._
_Updated: 2026-05-21 — заведена `drain-bookva-ym-stocks` 🔴 active. Soft drain каналов Bookva-Ozon (`id_seller=1`) и Yandex.Market (тот же seller=1, `ym_seller=1`): push stocks=0/count=0 через API + reconfig scheduler так, чтобы автосинхронизация не возвращала ненулевые значения. Slovo (`id_seller=2`) торгует штатно. 6 изменений: (1) guard в `packages/utils/fbsPostings.js` `length > 0` — закрывает YM-leak через `updateSellersProductsStocks→calculateAndUpdateYmStocks(1)` который дёргается даже при `otherSellers=[]`; (2) `task-runner/tasks/ozonStocksSyncronization.js` читает `ctx.data().idSeller` + `otherSellers`; (3) `tasks.json` data `{idSeller:2, otherSellers:[]}` для obo task'ов; (4) drop YM-cron'ов (`ym stocks syncronization` + `sync ym stocks with warehouse`) из Agenda; (5) one-off CLI `packages/tools/drain-bookva-ym-stocks.js` (hybrid CLI+module, dry-run, --only ozon|ym, `.bookva.cmd`). Spec: [.wiki/concepts/drain-bookva-ym-stocks.md](../.wiki/concepts/drain-bookva-ym-stocks.md). Без архива/`has_stock=false`/выключения канала — только stocks, обратимо. Кода ещё нет — план следующим шагом через `superpowers:writing-plans`._
_Updated: 2026-05-21 — `move-promo-bookva-to-slovo` 🟢 **DONE end-to-end**. Bookva долгопрод (promo id=1, 663 active applied, -30%) свёрнут, перенесён на Slovo (новая id=3, 660 SKU, -30%, до 2026-06-04 14:00 MSK). По ходу всплыл **3-й bug в domain-promotion CLI**`ozon-promotion-deactivate.js:137` сравнивает `expectedPrice = snapshotPrice` (вместо `actionPrice` по спеке [[domain-promotions]]) → 663 SKU помечены diverged, `importPrices` не вызван, Bookva-Ozon цены остались -30%, БД ушла в фантомное ended. Switch на ad-hoc oneshot `packages/tools/_push-prices-from-snapshot.mjs` (CLI: --source-promotion-id --target-channel-id [--mark-restored] [--chunk-size]) — обход defensive, прямой `applyPriceToOzon` + sync БД. Прогон: Step1 Bookva real-restore 663/663, Step2 Slovo cohort-restore 235/235, Step3 Bookva-snap→Slovo 660/663 (3 NOT_FOUND: `19119894`, `Б2008714`, `Б607029` — нет в Slovo каталоге), Step4 create id=3, Step5 add 660/660, Step6 activate 660/660 applied. Bug в deactivate занесён в [business-logic-bugs.md](business-logic-bugs.md) (запись 2026-05-21). Файл `_push-prices-from-snapshot.mjs` оставлен в `packages/tools/` (без хардкод-кред, через `salesChannel.getSeller()`) — пригодится для будущих cross-shop миграций или fix-сессий deactivate.js._
_Updated: 2026-05-20 — заведена `fbs-picking-list-pdf-thumbs` 🔴 active. Bugfix на `fbs-picking-list-pdf`: prod run #37944 (idPickingList=162) — email с 38MB PDF режется SMTP-сервером (552 5.3.4). Корень — `FbsPickingPlacePrint.vue:14` ставит `<img :src="item.image" />` на CDN full-res JPG, puppeteer встраивает в PDF без даунсемпла (100 items × ~300KB = 30-40MB). Fix — новый thumb-endpoint `/images/thumb` в `@books/api` (sharp resize до 240px JPEG q80), allowlist `*.ozone.ru` (SQL подтверждает 105 347 рядов в `cdn1.ozone.ru` + `nowatermark.ozone.ru`), auth тот же `?token=`. PDF упадёт до ~1-3MB, email пройдёт. TDD: pure-logic `parseThumbParams`+`isAllowedHost` (security/SSRF, rules 3+4). Bump `@books/api` 2.1.0→2.2.0, `@books/web` 2.1.0→2.2.0._
_Updated: 2026-05-17 — `slovo-product-creation-recovery` 🟢 **DONE end-to-end**. После закрытия `slovo-in-sale-gap-diagnose` Slovo 4742 → **5153** (+411, модерация Pass 1 #2 досревала), gap 676 → **306** (95.3% от Bookva 5406). Остаточные 245 «out_of_stock» — артефакт priority chain bucketize'а в diag tool (`has_stock=false` сел приоритетом выше `is_created=false`); реально Ozon отвергает `updateProductsStocks2` с `PRODUCT_IS_NOT_CREATED` пока карточка не сolzит `is_created=true`. Concept [ozon-product-creation-blockers](../.wiki/concepts/ozon-product-creation-blockers.md) (ингестнут вчера, commit ec3d596) расписан → реализованы 2 tool'а: **Pass 3** `products-fix-creation-errors-from-mirror` (парсит `errors[].attribute_id` по whitelist кодов default `CONDITIONAL_ATTRIBUTE_ERROR`, копирует значения этих атрибутов из Bookva через `UpdateAttributes`) + **Pass 4** `products-reimport-stuck` (auto-detect stuck `is_created=false` + пустой `errors[]`, re-import через `ImportProductsBySKU` без diff'а). TDD: 10 + 8 unit-тестов. Prod smoke: Pass 3 — 74/74 imported (task_id=4479563894, из 169 targeted Slovo / 102 Bookva matched), Pass 4 — 6/6 imported (task_id=4479600569, из 632 stuck / 626 без Bookva master). Bump `@books/tools` 0.9.0→0.10.0 MINOR. 185/185 tests green. **Lesson:** я слил час на параллельное расследование `slovo-stock-sync-bug` (создал и удалил task) **не прочитав wiki** — concept уже описывал точный fix. Юзер дважды redirect'нул (`не там копаешь``вики читать с самого начала сессии!`). Стоит запомнить как hard rule._
_Updated: 2026-05-16 — `slovo-in-sale-gap-diagnose` 🟢 **DONE**. Diagnostic CLI `analyst/ozon-in-sale-gap.js` (13/13 тестов) выявил два root cause'а: (1) **`sellers.put_on_sale_delay_days=7` для Slovo** — `calculateNewStocks` в `packages/utils/stocks.js` фильтрует `units.put_on_sale_date <= now-7d`, поэтому свежие книги (последний экземпляр, положен <7d назад) не считались available для Slovo → stock=0 → has_stock=false. Юзер поменял delay на 0. Manual trigger `sync stocks with warehouse` (idSeller=2) подтянул **+194 IN_SALE** (4548→4742, 84.0%→87.5%). (2) **Pass 1 фильтр отсекал Б2/К**, юзер: «только У не переносим». Filter обновлён в обоих CLI (`products-import-by-sku.js` + `products-fill-improve-attributes.js`), bump `@books/tools` 0.7.0→0.8.0. Pass 1 #2 с новым фильтром: 444 кандидата отправлено через 5 task_id (443 pending, 1 failed offer_id=3907989, **0 unmatched**) — через 1-3 дня после модерации станут IN_SALE → прогноз ~5180 = ~95.6% от Bookva. Остаточный gap после dust settles: ~232 out_of_stock (5/15 sample — реальные нули, 10/15 — `available>0` но не пушится — потенциальная business-logic-bug в `stocks.js:195` где `productInfo.stocks.present` сравнивается на верхнем уровне, а реально поле внутри `stocks[]`) + ~50 state_failed:imported/validated/declined/etc. Memory: `reference_legacy_offer_id_prefix_filter`, `reference_seller_put_on_sale_delay_days`._
_Updated: 2026-05-15 (Pass 2 closed + verified) — **`ozon-products-copy-bukva-to-slovo` 🟢 DONE.** Full prod run `--limit 4000` → 1179 карточек в UpdateAttributes через 12 task_id, 0 payload-ошибок. Post-модерация замер: avg rating **39.37 → 41.60 (+2.23)**, bucket 8-12 сократился на 372 (карточки перешли в 1-7 improve), bucket 13+ на 23. Эффект verified программно через повторный `GetProductRatingBySku`. Sync `product_sales_channels` Slovo (id=2) — 571 новых связей создано, 183 ghost-карточки пропущены. 5 commits локально pending user-go на push._
_Updated: 2026-05-15 (вечер #3) — **Phase 1 (Pass 1) полностью выгружен на проде** под IN_SALE-only решением. Prod batch: 608 IN_SALE-карточек Bookva → Slovo, 7 task_ids, **дубликатов нет** (verified `GetProductList` Slovo + `offer_id` count = 1). Случайно прогнал команду дважды (был баг в моём mental model — `grep` после первого `node` я перечитал как «replay summary»); Ozon во втором прогоне ответил `status:skipped` на все 608 (умно). В Slovo также живут 50 карточек из первого ALL+blacklist smoke (могут быть не-IN_SALE) — чистка отдельной задачей если юзер скажет. Push pending user-go. Phase 2 — отдельной сессией._
_Updated: 2026-05-15 (вечер #2) — **PIVOT** ozon-products-copy-bukva-to-slovo на IN_SALE-only. Юзер: «нужны только те, котрые в Букве в продаже» (и Pass 2 тоже). visibility ALL+blacklist → IN_SALE-only, 2-слойный клиентский фильтр снят (IN_SALE Ozon-side уже отсекает archived/declined/state_failed). Local dry-run после pivot: Bookva IN_SALE **5506** → legacy 4976 → diff vs Slovo 4368 → **608 новых кандидатов** (а не 13832). 4 commits: `0047eea` spec, `b2965f6` code, `c460a6b` README, `c261dd8` bump 0.5.0→0.5.1. **608 — реальный объём Pass 1 на проде; 50 импортнутых из предыдущего batch'а — могли быть не-IN_SALE (вне нового scope), они в Slovo, чистка — отдельно если юзер скажет.** Next: prod batch (юзер сказал «давай, нужно»)._
_Updated: 2026-05-15 (вечер) — `ozon-products-copy-bukva-to-slovo` **Phase 1 done end-to-end**, 6 commits на master, **не push'ено**. Smoke discovery → secondary-фильтр верифицирован (`moderate_status='declined' || status_failed!==''`). Refactor + .cmd + README + bump 0.4.0→0.5.0. Local dry-run: 13832 кандидата → secondary отсёк 131 → cap=50. **Prod smoke `--limit 50` verified программно:** `task_id=4462621390`, **50/50 в `imported`**, `GetProductList` Slovo по тем же offer_id вернул **50/50** ✅. Criterion 10 спеки — done без UI-проверки юзера. Phase 2 (Pass 2 — `products-fill-improve-attributes.js`) — отдельной сессией. Pending: user-go на push._
_Updated: 2026-05-15 (поздний день) — заведена `ozon-products-copy-bukva-to-slovo` 🔴 active. Два прохода CLI в `packages/tools/`: Pass 1 — расширение существующего [products-import-by-sku.js](../packages/tools/products-import-by-sku.js) (visibility=ALL + blacklist archived/not_moderated/state_failed, CLI-флаги для кред вместо хардкода, `.cmd`-обёртка `bookva-to-slovo`); Pass 2 — новый CLI `products-fill-improve-attributes.js` (для существующих в Slovo карточек с непустым `improve_attributes[]` — забирает значения этих атрибутов из Bookva через `GetProductAttributesV4` и пушит через `UpdateAttributes`). Доменная область books не используется — только Ozon Seller API; ключ соответствия — `offer_id`. Префикс-фильтры У/Б2/К — legacy, оставлены, разберёмся потом. Spec: [.wiki/concepts/ozon-products-copy-bukva-to-slovo.md](../.wiki/concepts/ozon-products-copy-bukva-to-slovo.md). Кода ещё нет — юзер ушёл на другую работу, разработка автономная._
_Updated: 2026-05-15 — `analyst-workflow-claude-md` 🟢 **DONE** ✅ (commit `44d97b0`): добавлена секция «Analyst workflow» в корневой [CLAUDE.md](../CLAUDE.md) (39 строк) — триггер-фразы, 3 шага (load design context → check existing → write new), inline-блок стиля (ESM, commander+isDirectRun, JSDoc, bin+exports, ozon-seller-api-client напрямую, sequelize+raw SQL), read-каналы, write-через-CLI, anti-patterns. `tools-ci-path-filter` 🟢 **DONE (частично)** ✅ (commit `102a92e`): сужено до CLI-only-tools подпутей (`analyst/`, `plans/`, `not-for-qwen/`, `output/`, README/QWEN.md, *.cmd) — исключение `packages/tools/` целиком сломало бы prod runtime (fbs-ship/sync-prices импортятся в task-runner+api). Оба коммита локально на master, не push'ены. `analyst-workflow-review` 🔵 remains blocked — будет разблокирован после push'а._
_Updated: 2026-05-14 (ночь) — `ozon-revenue-per-product` 🟢 **DONE end-to-end** ✅. CLI-tool [`packages/tools/ozon-revenue-per-product.js`](../packages/tools/ozon-revenue-per-product.js) реализован по плану (11 tasks через `superpowers:subagent-driven-development`): 4 pure-функции под TDD (18 unit-тестов), live Finance + FBS/FBO Posting API, pluggable formatters (console/json/xlsx все ✅ на smoke), `.bookva.cmd` + `.slovo.cmd`. README + bump `@books/tools` 0.3.2 → 0.4.0. Bonus fix: `xlsx.js` formatter null-cell crash (shared между analyst tools). Smoke Bookva 2026-05-01..02: 118 postings, take rate 37.76%. Wiki sync. **Локально на master, не push'ено.**_
_Updated: 2026-05-14 (вечер) — заведена `ozon-revenue-per-product` 🔴 active. CLI-tool в `packages/tools/`: за период по Ozon-магазину live из API считает net/gross/avg на проданный товар + take rate + FBS/FBO breakdown. Per-channel — `.cmd`-обёртки (паттерн репо). Spec одобрен: [.wiki/concepts/ozon-revenue-per-product-design.md](../.wiki/concepts/ozon-revenue-per-product-design.md). Не БД, не MCP, не `analyst/` (Finance API нет в БД — gap #1 [[db-analytics-baseline]]; user явно хочет live). Next: implementation plan через `superpowers:writing-plans`._
_Updated: 2026-05-14 (день) — `slovo-long-sale-promo` 🟢 **DONE end-to-end** ✅. Cohort1 (67) + cohort2 (168) = 235 SKU добавлены, activate отработал, **235/235 applied + 0 failed** (после retry'я 1 SKU). По следам — 2 bag в `ozon-promotion-activate.js` занесены в [business-logic-bugs](business-logic-bugs.md): (1) MinPrice clamp `9582fbe` пропущен в activate.js строка 155 (только в add-products.js); (2) `if (failed === 0)` строка 173 блокирует transition `scheduled→active` при любой failure. Workaround — ad-hoc `_retry-slovo-minprice.mjs` + manual SQL. Промо `id=2 long-sale-slovo` живая до 2026-05-27 14:00 UTC. Wiki sync pending._
_Updated: 2026-05-14 — заведена `slovo-long-sale-promo` 🟡 paused. Аналог bookva long-sale для канала Slovo (`id_sales_channel=2`). Воронка собрана по playbook'у [[ozon-promo-candidate-selection]]: cohort1 ≥60д+price∈[900,1500) → 67; cohort2 [30,60)д+price≥900 → 168; total **235 SKU** (bookva был 664, slovo меньше — 81% slovo IN_SALE дешевле 900₽). Акция `id=2 long-sale-slovo` создана в `scheduled`, -30%, до 2026-05-27. Pending: `ozon-promotion-add-products` обе партии + `ozon-promotion-activate`. Кандидаты в `tmp-designs/slovo-long-sale/`._
_Updated: 2026-05-13 (поздний вечер) — `ozon-promotions-rename-and-rekey` **DONE locally (awaiting push)** ✅. Скоуп расширили в процессе: помимо `ozon_` префикса + drop `id_product` + `snapshot_min_price`, переехали `promotions.id_seller` → `id_sales_channel` (FK к `sales_channels`). Ozon-creds остались на `sellers` (переезд на channels — отдельная задача). Migration 017 применена на проде, упала дважды (safe-update-mode + FK-prefix-index drop), оба fix зашиты в файл. 103/103 tools-tests green. Bumps @books/data 0.0.11, @books/tools 0.3.0. Live promo id=1 (12 SKU долгопрод) — данные сохранены через RENAME+UPDATE JOIN._
_Updated: 2026-05-13 (вечер) — заведена `ozon-promotions-rename-and-rekey` 🔴 active. После запуска live-promo выявлены 2 фундаментальных design-bug'а брейнсторма 12.05: (1) таблицы `promotions/promotion_products/promotion_price_snapshots` без префикса `ozon_` хотя фича строго про Ozon (конвенция books — `ozon_*` для Ozon-domain); (2) FK `id_product` к `books.products` для чисто Ozon-сущности (Ozon `offer_id` + `product_id` достаточны, FK создаёт ложную связь и заставляет резолвить `products.sku → offer_id` в активате). Fix: rename 3 таблиц на `ozon_*`, drop `id_product` FK, использовать `offer_id` как натуральный ключ, добавить `snapshot_min_price` для proper restore. Live promo (id=1, 12 SKU) сохраняется через RENAME TABLE. `domain-promotions-fix-real-ozon-wiring` 🟢 closed by commit afb423f._
_Updated: 2026-05-13 — заведена `domain-promotions-fix-real-ozon-wiring` 🔴 active. Pre-flight check для «12 SKU long-sale -30%» (12 товаров из `tmp-designs/long-sale-prices-result.json`, канал Bookva-Ozon, -30%, 2 недели) выявил блокер: все 4 CLI домен-промо шипнуты с stub'ами `importPrices: async()=>{}` и `getOzonPrice: async()=>({price:0,oldPrice:0})` — в Ozon ничего не уходит, snapshot=0, иллюзия успеха (БД пишется). Плюс bug в `promotion-add-products.js`: lookup `productSalesChannel` по `{idProduct}` ожидает поля `product_id`/`offer_id` отсутствующие в схеме `product_sales_channels`. `ozon_seller_products` устарел на 4 года (last update 2022-03-30) — не использовать. Решение: `products.sku == offer_id`, Ozon `product_id` через `productAPIGetProductInfoPrices(filter:{offer_id})`. Long-sale-promo заблокирована до починки._
_Updated: 2026-05-08 (ночь, prod-verified) — `docker-runner-env-decoupling` **CLOSED end-to-end** ✅ (scheduler 1.5.3 на проде, commit `bbbb428`). Восстановлен инвариант «добавить новый docker-tool = 0 правок volume default.json». `${config:KEY}` resolver в `dataToEnv(data, taskEnv, rootConfig)`, taskDef.env, `hashTask` включает env, `docker.containerEnv` удалён, добавлены `web.internalUrl`/`api.internalUrl` (внутренние имена для docker network proxy), PDF defaults 100×100mm в tasks.json (literal, не в default.json). 4 итерации релиза: 1.5.0 → 1.5.1 (internal split + ошибочно pdf section в default.json) → 1.5.2 (revert pdf section) → 1.5.3 (PDF 100×100). 21/21 unit-тест. Прод volume default.json обновлён юзером (api/web internalUrl, s3.bucket, docker engine settings, puppeteer.browserWSEndpoint = ws://chrome:3000?token=..., docker.containerEnv удалён). Smoke `createPickingListPdf` на проде успешен. Wiki sync ([sources/docker-runner-env-decoupling.md](../.wiki/sources/docker-runner-env-decoupling.md))._
_Updated: 2026-05-08 (ночь) — `docker-runner-env-decoupling` **CLOSED** ✅ (scheduler 1.5.0). Восстановлен инвариант «добавить новый docker-tool = 0 правок volume default.json». `${config:KEY}` resolver в `dataToEnv(data, taskEnv, rootConfig)`, taskDef получает поле `env { ... }`, `hashTask` теперь включает env, `docker.containerEnv` удалён. 12 новых unit-тестов (21/21 green). Wiki + log sync. **One-time prod migration** на VPS volume `/opt/books/job-scheduler/config/default.json` — pending push approval (см. .tasks/docker-runner-env-decoupling.md → "Resume protocol")._
_Updated: 2026-05-08 (поздний вечер) — `fbs-picking-list-pdf` end-to-end на проде ✅. Run 15814 (idPickingList=88): chrome render 21KB → S3 → api PATCH success, `pdf_remote_path` в БД. По ходу 5 hot-fix циклов: (1) sqlite3+cpu-features YN0009 на task-runner+web → root `dependenciesMeta.built=false`; (2) chrome WS 404 → restart 2-week-old browserless; (3) PATCH api fetch failed → API_URL `http://books-api:3021` internal; (4) [id].vue + [id]/print.vue Nuxt collision → rename в [id]/index.vue; (5) **WEB_URL puppeteer ходил на `books.kzntsv.site` который Traefik роутит на api**, исправлено на `http://books-web:3000`. По следам — заведена `docker-runner-env-decoupling` ⚪ (рефакторинг архитектуры env'ов docker-tools, делать в новой сессии). Pending: подобрать `PDF_HEIGHT` (юзер; default 177mm избыточен для 1-2 книг), full reserve-цепочка smoke (`fbsPickingReserve` с 2 attachments в email), wiki sync, close STATUS._
_Updated: 2026-05-08 (вечер) — `fbs-picking-list-pdf` Tasks 1-17 + wiki сделаны локально (13 commits на master, **не push'ено**). 5 версий bumped (web/api/tools/scheduler/task-runner). 27 unit-тестов (10 api + 12 tools + 8 waitForTaskRun + 7 spawnPdfAndAttachReport — все green). Деривации: `ctx.agenda` нет → `lib/agendaClient.js` singleton; puppeteer-core `^5.5.0` (workspace) вместо `^23`; tool kebab-case вместо camelCase. Pending user: (a) local smoke 4 сервисов; (b) push (project-discipline Rule 4 ask-mode); (c) prod migration 015; (d) prod containerEnv API_TOKEN через volume-mount; (e) prod trigger fbsPickingReserve verify._
_Updated: 2026-05-08 (день) — `fbs-picking-list-pdf` implementation plan расписан → [.tasks/fbs-picking-list-pdf-plan.md](fbs-picking-list-pdf-plan.md). 21 task, 7 phases (DB → print page → tool → scheduler config → chain → smoke → wiki). 3 open questions резолвлены: (1) image=`node:22-bookworm-slim` без chrome, переиспользуем remote chrome `wss://chrome.kzntsv.site` через `puppeteer.connect()`; (2) token-auth=query `?token=` через existing `02-auth.js` + bypass в `auth.global.ts` для print-роутов; (3) sync email-PDF=in-process polling `task_runs` (timeout 5min, на fail email с XLS-only + warning). Не новый workspace `pdf-generator/`, а tool в `packages/tools/` + matrix entry. DB-write через api `PATCH /fbs/pickingLists/:id/pdf`, не direct sequelize._
_Updated: 2026-05-08 — `scheduler-docker-runner` **CLOSED** ✅. Smoke testEcho на проде: run id=15456, status=success, log с 5×`[N/5] hello` + stderr `done after 5 iterations`, container_id non-null, --rm отработал, agendaJobId Bug#3 pattern. Phase 5 готов end-to-end. Инцидент по пути: Gitea API stuck → решился рестартом `gitea`+`gitea-db` (вики `concepts/gitea-stuck-recovery.md`). Native build deps (python3+make+g++) добавлены в scheduler/task-runner Dockerfiles. `fbs-picking-list-pdf` unblocked → 🔴 active. Total 27 commits в master (T1-T20 + 4 fix-коммита: yarn.lock, native build, summary)._
_Updated: 2026-05-07 (T20 wrap-up) — `scheduler-docker-runner` Phase 5 готов локально (20 commits, T1-T19 на master локально). Smoke тест `testEcho` ждёт push → build-tools.yml matrix → deploy. После verify закроется до ✅. `fbs-picking-list-pdf` 🟡 → 🔴 unblocked._
_Updated: 2026-05-07 (поздний вечер) — implementation plan для `scheduler-docker-runner` написан: [.tasks/scheduler-docker-runner-plan.md](scheduler-docker-runner-plan.md). 20 tasks с TDD red→green→commit, 4 pure-logic utils + handler с mocked dockerode + параметризованный Dockerfile + matrix workflow + smoke testEcho. Open questions резолвлены (pull retry 3x exp 5/15/45s, heartbeat 10s, smoke image bash:5.2 или dedicated). Next: запустить execution через `superpowers:subagent-driven-development` (recommended) или `superpowers:executing-plans`._
_Updated: 2026-05-07 (вечер) — после TDD-разнарядки и discovery что Phase 5 docker-runner — stub. Заведена `scheduler-docker-runner` 🔴 active (Phase 5 в [pluggable-scheduler](../.wiki/concepts/pluggable-scheduler.md), `handlers/docker.js` отсутствует). `fbs-picking-list-pdf` переведена в 🟡 paused — blocker на Phase 5. PDF теперь рендерится через docker-runner (новый workspace `packages/pdf-generator/`), task-runner image остаётся чистым. TDD: Phase 5 — TDD целиком (Q2/Q3/Q4); PDF — композит (visual/oneshot/wrapper для UI/migration/Dockerfile + TDD для pdf-generator core/auth/reserve-цепочки/email-mod)._
_Updated: 2026-05-07 — заведена `fbs-picking-list-pdf` 🔴 active. PDF-версия листа подбора рядом с XLS в reserve-цепочке, карточки по местам, портрет A4, mobile-first для сборки с телефона при RKN-блокировке web. Спека утверждена: [.wiki/concepts/fbs-picking-list-pdf.md](../.wiki/concepts/fbs-picking-list-pdf.md). Implementation plan ещё не написан._
_Updated: 2026-05-04 — заведена `sync-prices-ozon-to-yandex` 🔴 active. Tool в `packages/tools/` который тянет live цены с указанного Ozon-канала и проставляет в YM-канал (1:1 на поле `price`, `old_price` генерится по legacy-алгоритму `generateOldPrices.js`). Hybrid CLI+module + handler-обёртка в одном файле; thin re-export stub в `task-runner/tasks/` для auto-discovery реестра. tasks.json entry без cron (manual trigger). Дизайн одобрен (brainstorming sesh), код в работе. Также: paтern `packages/task-runner/tasks/` объявлен устаревшим — новые handler-обёртки в `packages/tools/`.
_Updated: 2026-05-03 — `analyst-report-formats` **CLOSED** ✅. Реализованы 5 pluggable formatters (console/md/xlsx/html/json), CLI-dispatcher, output-path helper, 11 unit тестов. Wiki обновлена. Хук yarn.lock улучшен (проверяет dependencies/devDependencies, не весь package.json). Push в master (commit b6007f8). Pattern готов для первых analyst tools (staff-performance / listings-vs-orders / sales-seasonality).
_Updated: 2026-05-01 (поздний вечер²) — `books-analyst-mcp` **CLOSED целиком**. Phase 1.3 (`books-data-mcp`) отменён архитектурно: вместо stdio MCP-сервера над `@books/data` аналитика будет писаться как обычные JS-файлы в `packages/tools/analyst/<name>.js` (паттерн hybrid CLI+module — соседи `ozon-transactions-analysis.js`, `ozon-products-price-analysis.js`). LLM работает в `books/`, sequelize-модели открываются `Read`-инструментом напрямую → MCP-stdio + ssh-tunnel + saved queries — лишний слой. План `docs/superpowers/plans/2026-04-30-books-data-mcp.md` помечен OBSOLETE первой строкой. Phase 1.4 (analyst-каталог) отменён вместе с 1.3. Phase 1.2 (yandex-market-mcp) остаётся отдельной задачей на полке. Решение в [.wiki/concepts/analyst-tooling.md](../.wiki/concepts/analyst-tooling.md)._
_Updated: 2026-05-01 (поздний вечер) — заведена `ozon-knowledge-base-ingest` 🟡 paused. Цель — слить базу знаний `seller-edu.ozon.ru/libra` (комиссии/тарифы + цены/акции + дайджест) в `.wiki/raw/seller-edu/` для LLM-аналитика. Эксперимент: Ozon Antibot палит CDP-протокол → markitdown/curl/playwright (headless/headful/stealth) — всё 403 либо "Доступ ограничен". Юзер параллельно saved страницу через **obsidian-web-clipper** → референсный MD (290 KB из 369 KB HTML, таблицы/якоря/frontmatter сохранены). **Архитектурный вывод:** clipper использует под капотом open-source `defuddle` (MIT, [github.com/kepano/defuddle](https://github.com/kepano/defuddle)) — node.js библиотека для clean content extract + turndown. Финальная архитектура задачи: **A3 (playwright CDP-attach к НЕ-playwright Chrome юзера)** для HTML acquisition + **defuddle npm** для HTML→MD = byte-совместимо с тем что clipper делает руками. Pipeline сделать в `packages/seller-edu-ingest/`. Artefacts/scaffold в [tmp-designs/seller-edu-experiment/](../tmp-designs/seller-edu-experiment/). Memory: `reference_ozon_seller_edu_antibot`._
_Updated: 2026-05-01 (вечер) — `books-analyst-mcp` Phase 1.1 ✅ closed. Real-API smoke 11/11 на кабинете bookva/буква (10 OK + reviews.list 403 как ожидалось без Premium). В smoke'е вылез баг handler-обёртки SDK request'а (все 11 tools посылали пустой body, Ozon возвращал 400 «filter required»); пофикшен двумя коммитами: `1af4fcf` (wrapper показывает HTTP body в error text, bump 0.0.4) → `de3360c` (handler-ы переключены на плоский request, bump 0.0.5; покрыто `handler-body.test.js` 10 кейсов через `Configuration.fetchApi`-перехват). Wiki + log + decision log синхронизированы. Phase 1.2 (YM-MCP), 1.3 (books-data-mcp), 1.4 (analyst-wiring) — отложены, реактивация по запросу._
_Updated: 2026-05-01 (день) — `books-analyst-mcp` 🔴 active. Scope Phase 1.1 сужен до **только `ozon-mcp`** (юзер: «набъём шишки на Озон, Яндекс потом будет легче»). 11 read-only tools, multi-shop через env-vars, SDK 0.0.30 покрывает все 3 TODO плана (Returns/Analytics/Reviews) реальными классами. Документация — через npm-mcp + результат в `.wiki/packages/ozon-mcp.md`. Plan: [docs/superpowers/plans/2026-04-30-ozon-mcp.md](../docs/superpowers/plans/2026-04-30-ozon-mcp.md). Per-task: [books-analyst-mcp.md](books-analyst-mcp.md). YM-MCP / data-mcp / analyst-wiring — отложены за Phase 1.1._
_Updated: 2026-05-01 (утро) — `books-ops-mcp` **Phase 2 CLOSED**. 4 mariadb tools (query/describe + scheduler.recentRuns/failedRuns) на проде с APP_VERSION=master-d426d03. Smoke 7/7 green; `failedRuns(since='7d')` вытащил 8 known bugs из business-logic-bugs end-to-end. CI был сломан днём (502 Bad Gateway → runner+gitea restart + workflow_dispatch unblock; recovery pattern в memory `reference_gitea_ci_stuck_recovery`). Spec+plan: `docs/superpowers/{specs,plans}/2026-04-30-books-ops-mcp-phase2*`. Wiki `packages/ops-mcp.md` расширена per-phase секцией. Skill `.claude/skills/books-ops-mcp/SKILL.md` обновлён на 12 tools._
_Updated: 2026-04-30 (поздний вечер ²) — `ntfy` Bug #1 fix запушен (pending verify): `gitea.run_id``gitea.run_number` в `RUN_URL` обоих workflow ([build.yml](../.gitea/workflows/build.yml), [deploy.yml](../.gitea/workflows/deploy.yml)). Root cause — Gitea UI route `/repo/actions/runs/{run}` парсит `{run}` как `runIndex` (per-repo sequential = `run_number`), а не глобальный `run.ID`. Build URL «работал» (HTTP 200, но открывал чужой run), deploy URL — 404 (run_id выше суммарного count). Verify: `git commit --allow-empty -m "verify ntfy URL" && git push` → tap ссылки в обоих ntfy-каналах. После verify закрыть `ntfy` или открыть Phase 3 (disk-alert)._
_Updated: 2026-04-30 (поздний вечер) — закрыты `registry-gc` (Phase 1+2+3+5 на проде, цикл retention работает на двух фронтах Gitea+VDS) и `switch-npm-mcp-to-local-stdio` (`.mcp.json` переведён с docker на локальный node — убирает «MCP unavailable» при daemon/volume-mount issue'ах). `ntfy` переведена в 🟡 paused — Phase 1+2+4 на проде, замечен баг #1 (битая ссылка на Gitea в deploy-уведомлении, в build-уведомлении ОК) — не блокер, ждёт возврата._
_Updated: 2026-04-30 (вечер close) — `books-ops-mcp` **Phase 1 CLOSED** ✅. 8 tools на проде, hot-fix `agendaState` (aggregate pipeline) задеплоен и verified end-to-end. Skill, wiki, business-logic-bugs обновлены. Phase 2 (recentRuns/failedRuns через MariaDB) не делаем без конкретного триггера. Юзер закрыл досрочно (без 24h soak) — все evidence для close уже были._
_Updated: 2026-04-30 (вечер) — `books-ops-mcp` Phase 1 на проде закрыт по фактическим возможностям. После Phase 1 deploy первый же вызов `ops.scheduler.agendaState` показал output 95 KB (превышал Claude cap). Hot-fix `5b56dc3`: aggregate pipeline по name → 23 ряда (~9 KB), 33/33 unit-теста, push'ен и задеплоен (APP_VERSION=master-5b56dc3 в env). Все 8 tools работают на проде. По следам прохода — 4 бага бизнес-логики занесены в business-logic-bugs (commit `86be892`). Создан project-scoped skill `.claude/skills/books-ops-mcp/SKILL.md` (commit `4470ce1`) чтобы Claude не лез через ssh за тем, что покрыто tools. Wiki-страница [@books/ops-mcp](../.wiki/packages/ops-mcp.md) добавлена._
_Updated: 2026-04-30 (день) — scheduler два hot-fix'а пушены (`0d5ffd6` + `b6dcdfc`): (1) `productsIndexSyncronization` legacy zombie-handler убран из `tasks.json` после disable=true в Mongo; (2) **критический**: cron http-jobs не выполнялись из-за idempotency check на shared Mongo `_id` — все ~20 cron'ов годами не sync'ились (sync остатков!), fix `agendaJobId = ${_id}:${Date.now()}` в http-handler. После recreate'а scheduler юзер подтвердил «задвигался». Также написаны spec + 4 плана для `books-analyst` MCP-стека (`docs/superpowers/{specs,plans}/2026-04-30-*`), но **юзер поставил `books-ops-mcp` ВЫШЕ analyst в приоритете** — для ускорения фиксов и развязывания рук в SSH._
_Updated: 2026-04-29 — registry-gc Phase 5 (VDS-side `vdsDockerCleanup` через `books-docker-proxy`) committed локально, ждёт user-go на push. Юзер уточнил: Gitea — 5TB пофиг, горит на VDS. Phase 1+2+3 (Gitea side) ранее запушены. poll-pending-ozon-imports Phase 1-5 запушена, pending Phase 6 verify._
_Updated: 2026-04-28 — заведён scheduler-ui-improvements (имя модуля в логе + copy-all-info кнопка). business-logic-bugs пополнен fbsPickingReserve `recipients: []` SMTP-вспышкой + закрыт loadProductsToWarehouse double-run (fix локально). Заведён scheduler-bugs (404 на detail-страницах тасков из-за отсутствующего tasks.json в api image, запушен). Заведён scheduler-core-replace (концепт замены Agenda на свой core, idea-only). agenda-admin-ui CLOSED. Заведена npx-runner-pattern (документирована, не реализована)._
## 🟢 [move-promo-bookva-to-slovo](move-promo-bookva-to-slovo.md) — DONE 2026-05-21 — свёрнут Bookva долгопрод, перенесён на Slovo (-30% до 04.06)
**Status:** done
**Outcome:** Bookva promo id=1 (663 SKU active, -30%) → ended + restored. Slovo promo id=2 (235 SKU active, -30%) → ended + restored (превентивно, чтобы snapshot copy не утянул slovo-цены в новой id=3). Создана slovo-promo id=3 `long-sale-slovo-2026-05-21`, -30%, до 2026-06-04 14:00 MSK, 660/660 applied. По пути найден 3-й bug в `ozon-promotion-deactivate.js` (defensive expected=snapshot вместо action) — обход через oneshot `_push-prices-from-snapshot.mjs`. 3 SKU `19119894`, `Б2008714`, `Б607029` отсутствуют в Slovo каталоге (добавлены в Bookva после Pass 1 миграции) — не критично для текущей задачи. Bookva-каталог остался в продаже с нормальными ценами (по решению юзера). Артефакты прогона в `tmp-designs/bookva-cancel-to-slovo/`.
**Acceptance:** все 4 пункта задачи юзера ✅ (отменены акции в Букве с сохранением списка / цены в Слове = ценам Букве / создана акция в Слове на 2 недели / применена к 660 из 663).
**Blocker:**
**Branch:** `master` (никаких коммитов; БД-state на проде через ad-hoc oneshot)
---
## 🔴 [fbs-picking-list-pdf-thumbs](fbs-picking-list-pdf-thumbs.md) — bugfix: PDF листа подбора 38MB → SMTP 552; thumb-endpoint в api
**Status:** active
**Where I stopped:** написан task-файл, корень диагностирован (CDN full-res обложки puppeteer вшивает в PDF без даунсемпла); готов писать TDD red-tests для `thumbLib.js`.
**Next action:** написать `packages/api/server/routes/images/thumbLib.test.js` (parseThumbParams + isAllowedHost), убедиться что красные, затем impl + handler + sharp в package.json + swap src в `FbsPickingPlacePrint.vue`.
**Blocker:**
**Branch:** `master` (commits локально, push после verify)
---
## 🟢 [slovo-product-creation-recovery](slovo-product-creation-recovery.md) — DONE 2026-05-17 — Pass 3 + Pass 4 для снятия is_created=false блокеров
**Status:** done (awaiting push approval)
**Outcome:** Реализованы 2 новых CLI согласно concept'у [ozon-product-creation-blockers](../.wiki/concepts/ozon-product-creation-blockers.md). **Pass 3** `products-fix-creation-errors-from-mirror.js` (TDD 10/10, pure helpers `chunkArray` + `extractErrorAttrIdsByOfferId(items, codesWhitelist)`, reused `filterCopyableAttrs` из Pass 2): Slovo GetProductList(ALL) → GetProductInfoList → парсит `attribute_id` из `errors[]` по whitelist (default `CONDITIONAL_ATTRIBUTE_ERROR`) → Bookva matching → GetProductAttributesV4 → UpdateAttributes Slovo чанками 100. **Pass 4** `products-reimport-stuck.js` (TDD 8/8, pure helpers `chunkArray` + `isStuckCreating(item)`): auto-detect Slovo карточек с `is_created=false`+пустой `errors[]` → Bookva IN_SALE match (silent skip без master) → ImportProductsBySKU **без diff'а**. **Prod smoke apply:** Pass 3 task_id=4479563894 **74/74 imported** (из 169 targeted / 102 Bookva matched), Pass 4 task_id=4479600569 **6/6 imported** (из 632 stuck / 626 без Bookva master). Bump `@books/tools` 0.9.0→0.10.0 MINOR (+2 bin/exports). README +2 секции. Wiki concept updated. **185/185 tests green** (+18). Ожидаемый эффект: 80 карточек после next cron syncStocksWithWarehouse (каждые 8ч) попадут в IN_SALE.
**Acceptance:** все 7 пунктов task-файла ✅.
**Blocker:**
**Branch:** `master` (5 коммитов локально, **не push'ено** — project-discipline Rule 4 ask-mode)
---
## 🟢 [slovo-in-sale-gap-diagnose](slovo-in-sale-gap-diagnose.md) — DONE 2026-05-16 — diag tool + два root cause fix (delay + filter) + Pass 1 #2
**Status:** done
**Outcome:** Diagnostic CLI `analyst/ozon-in-sale-gap.js` (TDD 13/13: chunkArray + bucketizeInfoItem с priority chain archived > out_of_stock > no_price > failed > state_failed > moderate > status > state) + `.cmd`-обёртка + pluggable formatters через `runFormat`. Root cause #1: **`sellers.put_on_sale_delay_days`** для Slovo=7 vs Bookva=0 → 433 свежих книг с `put_on_sale_date >=2026-05-09` не попадали в available SQL → stock=0. Юзер поставил delay=0. Manual `syncStocksWithWarehouse(idSeller=2)` +194 IN_SALE (4548→4742). Root cause #2: фильтр legacy У/Б2/К в Pass 1+2 был слишком широкий, юзер: «только У не переносим». Filter обновлён. Bump `@books/tools` 0.6.0→0.8.0 (0.7.0 — diag tool MINOR, 0.8.0 — filter change MINOR). Pass 1 #2 на проде: 444 кандидата (включая Б2/К), 5 task_id, 0 unmatched. Прогноз через 1-3 дня после модерации: Slovo ~5180 IN_SALE (~95.6% от Bookva 5418). Локально 1 коммит pending push.
**Acceptance:** все 7 пунктов из task-файла ✅ (CLI создан / pipeline 5 шагов / bucket-разбивка / .cmd-обёртка / README / bump MINOR / prod smoke с numbers).
**Blocker:**
**Branch:** `master`
---
## 🟢 [ozon-products-copy-bukva-to-slovo](ozon-products-copy-bukva-to-slovo.md) — DONE 2026-05-15 — Bookva → Slovo: массовая привязка по SKU + докачка контент-рейтинга
**Status:** done (Phase 1+2 prod-shipped; ожидает user-go на push)
**Outcome:** Pass 1 — `products-import-by-sku.js` extended (CLI-flags, IN_SALE pivot), prod 608 IN_SALE-кандидатов в Slovo через 7 task_id, 0 дубликатов. Pass 2 — новый CLI `products-fill-improve-attributes.js` (TDD 13/13, pure helpers chunkArray/buildImproveIdsByOfferId/filterCopyableAttrs), prod smoke `--limit 100``task_id=4463148527` → 31/31 `imported` (verified GetImportProductsInfo, 10/31 имеют пост-импортные модерационные warnings — исходные проблемы каталога). `@books/tools` 0.5.1 → 0.6.0 MINOR. 4 Pass 2 commits локально (`77a1acf`, `56d5150`, `03b0e88`, `c5ae6e7`) — **не push'ено**, pending user-go. SDK discovery закрыл open вопросы 3-4 (chunks 100/1000/100, dry-run console-table); скорректирована спека §pipeline §8 (UpdateAttributes возвращает только `{task_id}`, per-item через GetImportProductsInfo).
**Next action:** user-go на push origin/master. Дальнейшая батч-докачка — повторные `--limit 100` (3825 ост. кандидатов).
**Spec:** [.wiki/concepts/ozon-products-copy-bukva-to-slovo.md](../.wiki/concepts/ozon-products-copy-bukva-to-slovo.md)
**Acceptance:** Pass 1 — **10/10 ✅**. Pass 2 — **9/9 ✅**.
---
## 🟢 [ozon-revenue-per-product](ozon-revenue-per-product.md) — DONE 2026-05-14 — выручка с проданного товара по магазину Ozon
**Status:** done
**Outcome:** CLI-tool `packages/tools/ozon-revenue-per-product.js` + `.bookva.cmd` + `.slovo.cmd`. 4 pure-функции под TDD (18 тестов). Live Finance + FBS/FBO API, pluggable formatters. Smoke на Bookva ✅ (console/json/xlsx). README. `@books/tools` 0.4.0. Bonus fix shared `xlsx.js` formatter.
**Blocker:**
**Branch:** `master`
---
## 🟢 [slovo-long-sale-promo](slovo-long-sale-promo.md) — DONE 2026-05-14 — аналог bookva long-sale для slovo (id=2)
**Status:** done
**Outcome:** End-to-end. Воронка собрана (cohort1 67 + cohort2 168 = 235 SKU), оба add-products пробежали без fail. Activate упал на 1 SKU (139258946) с MinPrice violation → ad-hoc retry script + manual SQL transition → **235/235 applied, 0 failed**. Промо `id=2 long-sale-slovo` `active`, -30%, до 2026-05-27 14:00 UTC. По следам — 2 bag в [business-logic-bugs](business-logic-bugs.md) (activate.js: clamp пропущен + status transition блокируется failure). Кандидаты + retry-log в `tmp-designs/slovo-long-sale/`. Deactivate в календарь на 2026-05-27.
---
---
## ✅ [scheduler-docker-runner](scheduler-docker-runner.md) — CLOSED 2026-05-08 — Phase 5 в pluggable-scheduler `runner: 'docker'`
**Status:** closed
**Outcome:** **End-to-end на проде.** 4 pure-logic utils + handler.js (full lifecycle: pull-on-miss → semaphore → attach+demux+ring-buffer → wait+timeout/SIGTERM/SIGKILL → mapExit → finalize → remove always) + 12 unit-тестов + параметризованный `packages/tools/Dockerfile` (`BASE_IMAGE`+`BIN_NAME`) + matrix `build-tools.yml`. **Smoke testEcho:** run id=15456, status=success, container_id non-null, log = `[1/5] hello`×5 + stderr `done after 5 iterations`, --rm отработал, agendaJobId `${_id}:${ts}` Bug#3 pattern. По пути решены: yarn.lock drift (YN0028), native build deps в alpine (python3+make+g++), volume-mount sync default.json на VPS, инцидент Gitea API stuck (рестарт `gitea`+`gitea-db`, вики обновлена). Bumped `@books/job-scheduler` 1.2.3→1.3.0.
---
## 🟢 [ozon-promotions-rename-and-rekey](ozon-promotions-rename-and-rekey.md) — fix 3 design proeb'а: ozon_ prefix + drop id_product + id_seller→id_sales_channel
**Status:** done (awaiting push) — 103/103 tools tests green, migration 017 применена на prod, live promo id=1 (12 SKU) данные сохранены
**Where I stopped:** 2026-05-13 вечер. Scope расширили в процессе: помимо `ozon_` prefix + drop `id_product` + `snapshot_min_price`, переехали `promotions.id_seller``id_sales_channel` (FK к sales_channels). Ozon-creds остались на sellers — отдельная задача. Migration упала на проде дважды (safe-update-mode + FK-prefix-index) — оба fix зашиты в файл.
**Acceptance:** (1) ✅ migration 017 + RENAME 3 таблиц, drop id_product, id_seller→id_sales_channel, snapshot_min_price; (2) ✅ 3 sequelize моделей переименованы, FK ассоциация на salesChannel; (3) ✅ 5 CLI переименованы, --product-ids→--offer-ids, workaround через books.products.sku удалён (probe Ozon by offer_id); (4) ✅ _lib/ozon-promotions/ + side-effects через salesChannel.getSeller(); (5) ✅ tests 103/103 (26 lib + 77 CLI); (6) ✅ bumps @books/data 0.0.10→0.0.11, @books/tools 0.2.2→0.3.0.
**Blocker:**
**Branch:** `master`
---
## ✅ [domain-promotions-fix-real-ozon-wiring](domain-promotions-fix-real-ozon-wiring.md) — CLOSED 2026-05-13 — real Ozon SDK + min_price + 5 багов
**Status:** closed (commits 89f4522 + afb423f, not pushed)
**Outcome:** Подключены real Ozon SDK вызовы к 5 domain-promotion CLI. Фикс через несколько итераций после pre-flight check'а на live-promo «long-sale -30% / 12 SKU»: (1) stub `importPrices/getOzonPrice` → real (`createOzonSideEffects`); (2) bug `productSalesChannel` lookup → `Product.findByPk → sku → Ozon API`; (3) `@books/data` пропустил регистрацию 3 promotion-моделей — добавлены; (4) `applyPriceToOzon` throws на failure (не глотает в results[].success=false); (5) `min_price` default = price (Ozon отвергал push ниже min_price); (6) `ozon-side-effects._unwrap` достаёт body из `ResponseError`. **Live promo `id=1` применена** через recovery oneshot — 12/12 цен -30% в Ozon верифицировано через `ozon_prices_list`. 58 unit-тестов green. Bump @books/tools 0.2.0→0.2.2, @books/data 0.0.9→0.0.10. Замечен в processе фундаментальный design defect — заведена follow-up `ozon-promotions-rename-and-rekey`.
---
## 🟡 [fbs-picking-list-pdf](fbs-picking-list-pdf.md) — PDF-версия листа подбора в reserve-цепочке (auto в email рядом с XLS)
**Status:** implementation done (Tasks 1-17 + wiki), awaiting prod verify
**Where I stopped:** 2026-05-08 вечер. Tasks 1-17 + Task 20 (wiki) сделаны, 13 commits локально. 27 unit-тестов green. 5 версий bumped. Не push'ено.
**Next action:** (a) локально запустить 4 сервиса (api/web/scheduler/task-runner) и сделать end-to-end smoke (открыть `/fbs/picking-lists/:id/print?token=<token>` в браузере, проверить что layout без navbar, page-break per place; trigger `fbsPickingReserve` через UI, ждать `[pdf]` записей в task-runner консоли, ждать email с двумя attachment'ами); (b) **разрешить push** в master — auto-build+deploy всех 4 стеков через Gitea Actions; (c) на проде apply миграцию 015 (`packages/data/migrations/015-add-pdf-remote-path.sql`, idempotent); (d) на проде в scheduler default.json через volume-mount выставить `docker.containerEnv.API_TOKEN` реальным значением (текущий placeholder `<SET_IN_PROD_VOLUME_MOUNT>`); (e) trigger fbsPickingReserve на проде, verify `pdf_remote_path` заполнено + email с двумя attachment'ами.
**TDD:** компонент'ы по-разному покрыты. Q3 (api endpoint Q3 identifier security) — TDD pure-logic helpers (10 тестов). Q2/Q3 (tool puppeteer/S3/api PATCH) — 12 unit-тестов с mocked deps. Q4 (waitForTaskRun + spawnPdfAndAttachReport branching) — 15 unit-тестов. Visual/oneshot/wrapper — `[skip-tdd: …]` маркеры в commits.
**Blocker:**
**Branch:** `master` (13 commits локально, awaiting push approval)
---
## 🟡 [sync-prices-ozon-to-yandex](sync-prices-ozon-to-yandex.md) — копир цен Ozon→YM (`packages/tools/`)
**Status:** paused — Phase 1 закоммичен, smoke OK, real apply упёрся в YM rate-limit на ~37 chunk'е.
**Where I stopped:** 2026-05-04. Local apply (dryRun=false) на каналах 1→3: первые 37 chunks (~18500 цен) ушли успешно, остальные ~164 chunks (~81 800 SKU) упали в `ym-update-failed` с generic «Response returned an error code» (SDK прячет body). Diag-probe одиночными запросами те же sku — оба `200 OK`, значит payload и ключ валидные → причина похожа на YM rate-limit/quota windowing. **Side effect: первые ~18500 SKU в YM-кабинете Bookva уже имеют новые цены + новые рандомные `discountBase`** (sku 100001+ из верха списка по id_product). Не страшно — repeated apply просто перезапишет, цены идемпотентны.
**Next action:** (a) добавить fetchApi-middleware в `sync-prices-ozon-to-yandex.js` который читает body на не-2xx и кидает с status+body (по аналогии с `de3360c` для Ozon SDK); (b) тротлинг между chunks (1-2 sec) + retry-on-429 с backoff 30-60s (сейчас 2/4/8/16/32 — мало для YM-окон); (c) опционально — chunk size <500. После (a) re-probe одного из failed chunk'ов чтобы увидеть конкретный YM-error → root cause → fix → re-apply.
**Blocker:**
**Branch:** `master`
---
## 🟡 [ozon-knowledge-base-ingest](ozon-knowledge-base-ingest.md) — слить seller-edu.ozon.ru/libra в .wiki/raw/seller-edu/
**Status:** paused — архитектура **A3 + defuddle** выбрана, не реализована. Сотни страниц вручную не вариант; defuddle (open-source MIT, движок obsidian-web-clipper) даёт референсное MD, playwright `connectOverCDP` к настоящему Chrome юзера обходит anti-bot.
**Where I stopped:** 2026-05-01 поздний вечер. Эксперимент: markitdown solo / curl / playwright headless / headful / stealth — всё 403 либо "Доступ ограничен" (Ozon палит CDP-fingerprint). Юзер параллельно вручную saved страницу через obsidian-web-clipper — MD-качество референсное, **показал что defuddle (=движок clipper'а) — путь**. Финальная архитектура: A3 (CDP-attach к НЕ-playwright Chrome) для HTML acquisition + defuddle npm для HTML→MD + frontmatter писать самим. Артефакты эксперимента в [tmp-designs/seller-edu-experiment/](../tmp-designs/seller-edu-experiment/), пример качественного MD — [.wiki/raw/2026-05-01-вознаграждения-...md](../.wiki/raw/). Лессон в memory `reference_ozon_seller_edu_antibot`.
**Next action:** реализовать pipeline (см. шаги 1-9 в task-файле). Prereq — юзеру настроить Chrome с `--remote-debugging-port=9222` постоянно. Pipeline: discovery sitemap → defuddle pipeline test на 1 URL → seed комиссии/тарифы (throttle 8s/req, ≤30/сессия) → seed цены/акции → дайджест-cron в job-scheduler. Параллельно — проверить публичный Telegram-канал Озон как резервный канал дайджеста.
**Blocker:**
**Branch:** `master`
---
## ✅ [pluggable-scheduler](pluggable-scheduler.md) — CLOSED 2026-04-28 (+ Phase 9 + private S3 fix 2026-04-28)
**Status:** closed
**Outcome:** Phase 1-7 + Phase 9 в проде. 23 http-таска в [tasks.json](../packages/job-scheduler/config/tasks.json) с `label`/`description`/`params` для админки. Pure reconciler (`config.jobs[]`+`lib/jobs/` удалены), все таски рендерят markdown-отчёт + always-upload в S3 (`reportS3Key` контракт после 2026-04-28 — bucket private, api-proxy с `requireAdmin`), `task_runs` self-cleanup. Mongo-флаг `_fromYml` мигрирован на `_fromConfig` в startup-миграции. Playbook `docs/playbooks/pluggable-scheduler.md` обновлён + ALS log-capture как Phase 5b + грабли 21-25.
---
## ✅ [agenda-admin-ui](agenda-admin-ui.md) — CLOSED 2026-04-28
**Status:** closed
**Outcome:** Все 5 фаз + post-launch polish + все 4 deferred follow-up закрыты в одной сессии. Раскатано в `74254ea`. Главное: ALS console-capture в `task_runs.log` (ctrl-через-Z для дебага), markdown-отчёты через api-proxy на приватный S3 (старый MinIO Browser UI больше не вылезет), human-readable filename при скачивании, RunsTable порядок+ссылки, UPagination, sales-channels автоподсказки. Wiki синхронизирована: новая страница [Task log capture](../.wiki/concepts/task-log-capture.md), обновлены [Task-runner handlers](../.wiki/concepts/task-runner-handlers.md) и [SalesChannel](../.wiki/entities/sales-channel.md).
---
## 🟡 [poll-pending-ozon-imports](poll-pending-ozon-imports.md) — split loadProductsToOzon на submit + cron poll
**Status:** implementation done, awaiting prod verify
**Where I stopped:** Phase 1-5 + wiki сделаны локально (resume 2026-04-29). Tool/handler/poll-job/tasks.json/UI polling/api no-cache/wiki — всё в индексе. Push готов; следующий шаг — push в master, дождаться auto-deploy всех 4 стеков, manual trigger main run в проде, через 5-10 мин проверить что poll-job tick прошёл и mutate'ил result.
**Next action:** push → wait deploy → trigger main run на проде → verify (см. Resume protocol в task-файле).
**Blocker:**
**Branch:** `master`
---
## ✅ [docker-runner-env-decoupling](docker-runner-env-decoupling.md) — CLOSED 2026-05-08 — расщеплены infra-creds и task-contract
**Status:** closed (implementation done, локально, awaiting push approval + one-time prod volume migration)
**Outcome:** Восстановлен инвариант «**добавить новый docker-tool = 0 правок volume default.json scheduler-а**». Реализован syntax `${config:KEY}` (string-form, partial interpolation supported) в `dataToEnv(data, taskEnv, rootConfig)`. taskDef получил поле `env { ... }` в tasks.json. `default.json` причесан: `docker.containerEnv` удалён, появились `api.{url,authToken}`, `web.url`, `s3.bucket`. `hashTask` включает env → правка блока в tasks.json триггерит reconcile. 12 новых unit-тестов (21/21 green: `dataToEnv: ${config:KEY}` whole-string / partial / multiple refs / literal / unknown ref throws / data+taskEnv merge / numeric/boolean stringify / null skipped / undefined arg + handler-level taskDef.env merged into Env / no-env passthrough). Bumped `@books/job-scheduler` 1.4.0→1.5.0 [MINOR]. Wiki [scheduler-docker-runner](../.wiki/concepts/scheduler-docker-runner.md) обновлён, decision-record в [log.md](../.wiki/log.md). **One-time prod migration** на VPS volume `/opt/books/job-scheduler/config/default.json` — добавить секции `api.{url,authToken}`, `web.url`, `s3.bucket`, удалить блок `docker.containerEnv`. После — никаких правок при добавлении следующих docker-tools.
---
## ⚪ [npx-runner-pattern](npx-runner-pattern.md) — npx-таски как третий способ запуска (без third runner-type)
**Status:** ready
**Where I stopped:** Phase 5c в [pluggable-scheduler playbook'е](../docs/playbooks/pluggable-scheduler.md) задокументирован (2026-04-28). Два пути: A) handler+`spawn('npx',...)` для http-runner-а, B) docker с `image: node:N` + `command: ['npx',...]`. В коде ничего ещё нет.
**Next action:** при первом реальном npx-кандидате (например `audit-ci` weekly или `npm-check-updates`) — Phase 1: reference-handler `runNpxCommand.js`. Phase 2 (docker `command` override) откладывается до имплементации docker-runner-а.
**Blocker:**
**Branch:**
---
## 🟡 [scheduler-bugs](scheduler-bugs.md) — реестр багов /scheduler UI и /admin/scheduler/* api
**Status:** 2 hot-fix'а пушены 30.04, awaiting verify (юзер подтвердил «задвигался»), real-fix rewrite позже
**Where I stopped:**
- **Bug #1** (2026-04-28): 404 на `/admin/scheduler/tasks/<name>` — open / fixing.
- **Bug #2** (2026-04-30): `productsIndexSyncronization` legacy handler с зомби-локами. **Mitigated** через `disabled:true` в Mongo + удалён task definition из `tasks.json` (`5c17135`). Real fix (rewrite или delete handler) — отдельной задачей.
- **Bug #3** (2026-04-30): **критический** — cron http-jobs не выполнялись годами. `handlers/http.js:14` шлёт `String(job.attrs._id)` как `x-agenda-job-id`. Для cron через `agenda.every()` это shared id → task-runner `findByAgendaJobId` находит старый run от первого dispatch'а → возвращает 200 без создания нового → handler не выполняется. Все ~20 cron http-handler'ов (sync остатков, postings, prices…) thinks работают (Mongo `lastFin` обновляется), реально не делают ничего. **Fix:** `agendaJobId = ${_id}:${Date.now()}` (`ddbbffd`, push `b6dcdfc`). Юзер 30.04 ~04:00 МСК подтвердил «задвигался шедулер».
**Next action:** наблюдать стабильность 24h. Если возникнут новые failures — копать. Real rewrite `productsIndexSyncronization` — отдельной задачей или delete (если не нужен).
**Blocker:**
**Branch:** `master`
---
## ✅ [books-ops-mcp](books-ops-mcp.md) — CLOSED 2026-05-01 (Phase 1+2: 12 read-only tools для prod-диагностики)
**Status:** closed
**Outcome:** **12 tools на проде** (8 Phase 1 + 4 Phase 2). Phase 1 (`5b56dc3`, 30.04): 4 docker + 2 mongo + 2 scheduler-mongo. Phase 2 (`d426d03`, 01.05): `mariadb.query/describe` (generic) + `scheduler.recentRuns/failedRuns` (narrow). Mongo + MariaDB через docker network `proxy` (172.20.0.0/16), без exposing ports. **Defence-in-depth** Phase 1 (3 слоя: proxy-ro POST→403, mongo whitelist, env mask) + Phase 2 (6 слоёв: ops_ro@'172.%' GRANT SELECT, multipleStatements:false, checkSql regex+ALLOWED_STARTS, enforceLimit force LIMIT, query timeout, capPayload 1MB cap). 3 unit-tested helpers (TDD) + integration tests via docker-compose + smoke 7/7 на проде. Один review-fix (comment-strip в enforceLimit) и один hot-fix `failedRuns` (prod-схема: status='error' + колонка `error`, не 'failed'/fail_reason). Wiki [@books/ops-mcp](../.wiki/packages/ops-mcp.md) расширена per-phase. Skill `.claude/skills/books-ops-mcp/SKILL.md` обновлён на 12 tools. **Phase 3** (write tools под двойным confirm: unlockAgendaJob, disableAgendaJob) — только при 2-3 повторяющихся ручных операциях такого класса.
---
## ✅ [books-analyst-mcp](books-analyst-mcp.md) — CLOSED 2026-05-01 (поздний вечер²) — целиком
**Status:** closed (Phase 1.1 на проде; Phase 1.3+1.4 отменены архитектурно; Phase 1.2 — на полке)
**Outcome:** `@books/ozon-mcp@0.0.5` — 11 read-only tools на проде (Phase 1.1, `ozon-seller-api-client@0.0.30`, multi-shop bookva/слово, smoke 11/11). **Phase 1.3 (`books-data-mcp`) отменён 2026-05-01 (поздний вечер²)**: brainstorming показал — MCP-stdio + ssh-tunnel + saved queries в `*.sql` лишний слой, LLM работает в `books/` напрямую и `packages/tools/` уже задаёт паттерн hybrid CLI+module (соседи `ozon-transactions-analysis.js`, `ozon-products-price-analysis.js`). Аналитика теперь пишется как обычные JS-файлы в `packages/tools/analyst/<name>.js` по запросу, без отдельной инфраструктуры. План `docs/superpowers/plans/2026-04-30-books-data-mcp.md` помечен OBSOLETE первой строкой. Phase 1.4 (analyst-каталог) отменён вместе с 1.3 — отдельный каталог не нужен. Phase 1.2 (`yandex-market-mcp`) — план остаётся отдельной задачей, реактивация по запросу. Решение зафиксировано в [.wiki/concepts/analyst-tooling.md](../.wiki/concepts/analyst-tooling.md).
---
## ✅ [analyst-report-formats](analyst-report-formats.md) — CLOSED 2026-05-03 — pluggable formatters для analyst tools
**Status:** closed
**Outcome:** Реализован pattern pluggable formatters для аналитических CLI-tools. **5 formatters:** console (stdout, console.table), json (stdout), md (GFM таблицы + chart placeholder), xlsx (ExcelJS, sheets per table, bold headers, auto-width), html (HTML table + vega-embed, CDN default + --standandalone). **Infrastructure:** `_lib/cli-format.js` (dispatcher с --format/--out/--standalone), `_lib/output-path.js` (timestamped paths в `tmp-designs/analyst-reports/`), `_test/` (11 unit тестов). **Wiki:** обновлены `analyst-tooling.md` (секция "Output formats"), новая страница `tools-analyst.md`, `index.md` + `log.md` entries. **Хук yarn.lock улучшен:** теперь проверяет dependencies/devDependencies изменения, не весь package.json (ложные срабатывания при scripts changes устранены). Push в master (commit b6007f8). Тестовые кейсы (staff-performance, listings-vs-orders, sales-seasonality) — отдельными задачами по мере возникновения.
---
## 🟡 [scheduler-ui-improvements](scheduler-ui-improvements.md) — UX-идеи для /scheduler админки
**Status:** paused
**Where I stopped:** заведено 2026-04-28. Две идеи: (1) имя исполняемого модуля в логе (через stack-walk на каждую console-строку при `data.debug=true`); (2) кнопка «Copy all» на /scheduler/runs/[id] которая собирает status+params+log+result в plain-text для вставки Claude'у в чат.
**Next action:** реализуется при первом серьёзном дебаг-сценарии. Низкий приоритет.
**Blocker:**
**Branch:**
---
## ⚪ [scheduler-core-replace](scheduler-core-replace.md) — заменить Agenda+Mongo на свой core поверх MariaDB
**Status:** idea (not started)
**Where I stopped:** 2026-04-28 концептуальное обсуждение pros/cons/подводных камней. Crash recovery был преувеличен мной как блокер — это `setInterval` + `UPDATE WHERE locked_at < NOW() - X` ~50 LOC. Реальные сложности: lock TTL trade-off (handler в task-runner отдельным процессом), idempotency на retry, catch-up policy. Middle-ground — тонкая обёртка `cron-parser` + MariaDB `scheduled_tasks` ~300 LOC.
**Next action:** не трогать. Триггеры пересмотра: `@agendajs/mongo-backend` ломается на npm-bump, нужны priority/dependencies/per-task concurrency, или желание снять Mongo-контейнер с VPS.
**Blocker:** soak Phase 7 pluggable-scheduler ещё свежий — преждевременно ломать стабильное.
**Branch:**
---
## ⚪ [tools-architecture-revision](tools-architecture-revision.md) — ревизия границ packages/tools vs packages/task-runner/tasks
**Status:** ready (defined, не приоритет)
**Where I stopped:** задача заведена 2026-04-27 при дизайне `productSalesChannel`-sync — старая конвенция «tools атомарны, не лезут в БД» отменена (выбран X: tool пишет в БД сам). Юзер: «подходы нужно будет подвергнуть ревизии». Накопить 2-3 sync-таска, потом ревизировать pattern.
**Next action:** дождаться 2-3 примеров реализации, потом brainstorm границ + обновить `.wiki/packages/tools.md` и `concepts/task-runner-handlers.md`.
**Blocker:**
**Branch:**
---
## 🟡 [feedback-changes](feedback-changes.md) — фидбэк юзеров + изменения API маркетплейсов
**Status:** paused
**Where I stopped:** Все коммиты партии запушены (`46c20e3` upload polling, `6ae3a12` главная, `ebe1ebd` /jobs 500, `9d61850` web FBS-роуты, `ae9b6dd` иконки Pagination/SelectMenu). Юзер не приносил новой партии.
**Next action:** ждать фидбэка; в бэклоге — блок метрик дашборда + переименование API-роутов FBS (web уже переименован, API не тронут).
**Blocker:**
**Branch:** `master`
---
## 🟡 [pwa](pwa.md) — installable PWA для @books/web
**Status:** paused
**Where I stopped:** spec (`2a48230`) + impl (`b0a6820`) + task-wiki (`7a28f91`) запушены. На проде раскатано.
**Next action:** ручная проверка на устройствах — iPhone Safari (Add to Home Screen), Android Chrome (install prompt), Lighthouse PWA audit. По итогам — закрыть или завести follow-up.
**Blocker:**
**Branch:** `master`
---
## 🟡 [web-design-system](web-design-system.md) — единая дизайн-система @books/web
**Status:** paused
**Where I stopped:** Большая итерация UX/UI запушена (lucide bundle вместо CDN, мобильный navbar с hamburger, ProductSkuLink + useTabTarget, /fbs/orders как сетка карточек, USwitch вместо чекбокса в /fbs/summary, hover-open dropdown через UPopover, scrollbar-gutter:stable, spinner-overlay, реордер orders header, label «Канал продаж», SKU+title pattern на /epz/search).
**Next action:** ждать новой партии фидбэка по UX.
**Blocker:**
**Branch:** `master`
---
## 🟡 [business-logic-bugs](business-logic-bugs.md) — реестр багов бизнес-логики
**Status:** paused
**Where I stopped:** bug #2 (Ozon labels portrait, `09962bd`) и bug #3 (upload-history badge, `0aae243` + `4358806`) закрыты. bug #1 (`ozonFbsProduct` notNull, `2940123`) запушен — ждёт прод-логи task-runner на пропадание `notNull Violation`.
**Next action:** при возврате — проверить прод-логи task-runner после раскатки 4 локальных коммитов (заодно с bug #1 verification). Либо ждать новых багов.
**Blocker:**
**Branch:** `master`
---
## 🟡 [vps-migration](vps-migration.md) — переезд CentOS 7 → Debian 12
**Status:** paused
**Where I stopped:** план в [concepts/vps-os-migration](../.wiki/concepts/vps-os-migration.md), оценить сроки.
**Next action:** при готовности — оценить сроки и blast radius.
**Blocker:**
**Branch:** `master`
---
## ✅ [registry-gc](registry-gc.md) — CLOSED 2026-04-30 (Gitea-side retention + VDS-side prune)
**Status:** closed
**Outcome:** Полный цикл retention в проде на двух фронтах. Gitea-side: `mode=min` build cache + `registryGc` cron `0 4 * * 0` MSK (keep last 3 versions per package) + Gitea built-in `[cron.cleanup_packages] OLDER_THAN=24h` (blob compaction). VDS-side: `vdsDockerCleanup` cron `0 5 * * *` MSK через `books-docker-proxy` (4 stage prune containers/dangling/old-images/buildkit-cache, volumes намеренно не трогаем). Notify в `books-alerts` через ntfy summary после каждого run. Phase 4 (disk-pressure alert) опциональный — открыть отдельной задачей при надобности.
---
## 🟡 [ntfy](ntfy.md) — push-уведомления (Telegram заблокирован РКН)
**Status:** paused — Phase 1+2+4 на проде; Bug #1 fix запушен 30.04 поздний вечер², pending verify.
**Where I stopped:** 2026-04-30 поздний вечер². Bug #1 (Click-URL → 404 в `books-deploys`, чужой run в `books-builds`) — root cause: Gitea UI route `/repo/actions/runs/{run}` парсит `{run}` как `runIndex` (per-repo seq = `gitea.run_number`), а не `gitea.run_id`. Поправлены оба workflow ([build.yml](../.gitea/workflows/build.yml) env `RUN_URL`, [deploy.yml](../.gitea/workflows/deploy.yml) env `RUN_URL`).
**Next action:** push верификационный коммит `git commit --allow-empty -m "verify ntfy URL" && git push` → дождаться build+deploy → tap ссылки в обоих ntfy-каналах, должен открыться ИМЕННО smoke run сверху списка. После verify — закрыть task или начать Phase 3 (disk-alert).
**Blocker:**
**Branch:** `master`
---
## 🟡 [playbook](playbook.md) — оперативный playbook для инцидентов VPS / API
**Status:** paused (scope сужен — реюзабельные плейбуки выехали)
**Where I stopped:** scope-расщепление 2026-04-27: реюзабельные «как сделать» плейбуки (CI/CD, scheduler) живут в `docs/playbooks/` и закрыты. Эта задача теперь только про **incident playbooks** (что делать когда сломалось) — не начато.
**Next action:** структурировать сценарии (API down, registry full, certificate expiry), оформить в `docs/runbooks/` или `docs/playbooks/incidents/`.
**Blocker:**
**Branch:** `master`
## ✅ [switch-npm-mcp-to-local-stdio] — DONE 2026-04-30 — переключён `npm-mcp` в `.mcp.json` с docker на локальный node stdio. Контекст и обоснование — в общей вики: `concepts/local-stdio-mcp-deploy` (там же таблица альтернатив B/C/D на случай, если разделяемый конфиг пакетов не подойдёт для books).
Текущий блок в `books/.mcp.json` (entry `npm-mcp`):
```json
"npm-mcp": {
"type": "stdio",
"command": "docker",
"args": [
"run", "--rm", "-i",
"-v", "c:/Users/vitya/projects/npm-mcp/packages.config.json:/app/packages.config.json:ro",
"-v", "npm-mcp-cache:/data",
"registry.kzntsv.site/npm-mcp:latest"
]
}
```
Заменить на:
```json
"npm-mcp": {
"type": "stdio",
"command": "node",
"args": ["C:/Users/vitya/projects/npm-mcp/build/index.js"]
}
```
Прочие записи (`context7`, `books-ops`) **не трогать**. Путь к `build/index.js` указан под Windows-машину; на другой машине, если books открывается там — подставить локальный путь к клону `npm-mcp` (или замаппить через user-scope `claude mcp add --scope local`, вариант B из общей вики, чтобы не коммитить машинно-специфичный путь).
Решение принято в сессии npm-mcp 2026-04-30: docker-путь периодически даёт «MCP unavailable» (daemon, volume mounts на Windows), а сервер уже скомпилирован локально и реестр доступен с хоста — Docker не даёт ничего, кроме лишней точки отказа.
**Status:** done 2026-04-30
**Outcome:** В `books/.mcp.json` entry `npm-mcp` переключён с `docker run` на `node C:/Users/vitya/projects/npm-mcp/build/index.js`. Прочие записи (`context7`, `books-ops`) не трогали. Юзеру: после следующего рестарта Claude Code в books — проверить что `mcp__npm-mcp__*` инструменты доступны и `search_docs` отвечает. Если открываем books на другой машине — подставить локальный путь к клону `npm-mcp` (или вариант B из общей вики `concepts/local-stdio-mcp-deploy` — user-scope mcp add, чтобы не коммитить машинно-специфичный путь).
**Branch:** `master`
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: npm-mcp / 2026-04-30T12:49:35.450Z -->
---
## ⚪ [refresh-project-bootstrap] — Run the `project-bootstrap` skill in this repo to refresh its layout to the canonical state (git, .gitignore, README.md, .wiki/, .tasks/, CLAUDE.md). The skill handles both greenfield bootstrap and refresh of existing repos.
**Status:** ready
**Where I stopped:** (not started)
**Next action:** Invoke the `project-bootstrap` skill at the repo root, confirm each file write, push to main.
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: projects-meta-mcp / 2026-05-01T09:02:43.955Z -->
---
## 🟢 [bootstrap-upgrade-pilot] — CLOSED 2026-05-07 — `project-bootstrap` v1.10.0 upgrade pilot успешен
**Status:** done
**Outcome:** Bootstrap v1.10.0 идемпотентно добавил 3 новых триггера в `CLAUDE.md`: `follow tdd-criteria`, `delegate to interns when allowed`, `recommend, don't menu`. Существующие строки нетронуты, пользовательские комментарии (первые 6 строк) сохранены. **Идемпотентность подтверждена** — повторный прогон находит 0 missing triggers. Skill-deps check (Step 5.6) корректно валидирует наличие всех 11 fulfiller'ов на хосте. Commit `5ca16d9`. Массовая раскатка на остальные проекты — следуя приоритету: `_meta` (координатор) → `claude-skills``npm-mcp``books` (уже done) → остальные.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: meeting-room / 2026-05-07T06:30:35.846Z -->
<!-- closed-by: OpeItcLoc03@DESKTOP-NSEF0UK / 2026-05-07T06:54:14.493Z / note: Пилот успешен: после прогона `upgrade project` v1.10.0 в `books/CLAUDE.md` строки 13-18 содержат все 6 канонических триггеров. Идемпотентность подтверждена. Аудит остальных делегирован интерну. Следующий шаг — координатор в `_meta` + 11 per-project тасок blocked-by-coordinator. -->
---
## 🟢 [domain-promotions-pointers] — CLOSED 2026-05-12 — Bootstrap-pointers для design `domain-promotions`. Секция "Domain conventions" добавлена в `.wiki/CLAUDE.md`.
**Status:** done
**Outcome:** В `.wiki/CLAUDE.md` добавлена секция "Domain conventions" с блоком "Mandatory: read design context before implementation". Три источника в порядке: (1) global wiki `concepts/domain-promotions` через `mcp__projects-meta__knowledge_get`, (2) brainstorm trace `~/projects/.workshop/.archive/2026-05-12-books-domain-promotions.md`, (3) локальный `overview.md` для быстрой ориентации. Следующие агенты будут знать где лежит полный design context а не угадывать из `where_stopped` one-liner'ов.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:29:18.799Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-12T20:45:00.000Z / note: .wiki/CLAUDE.md обновлён, domain-promotions-schema разблокирован -->
---
## 🟢 [domain-promotions-schema] — CLOSED 2026-05-13 — Миграция + Sequelize-модели для доменных акций-скидок
**Status:** closed
**Outcome:** Migration 016 (`b732360`): три `CREATE TABLE IF NOT EXISTS` — promotions, promotion_products, promotion_price_snapshots. Event-based таймстампы в promotion_products (addedAt/appliedAt/restoredAt/removedAt/failedAt). Индексы: promotions(status, start_at), promotion_products(id_promotion, removed_at), promotion_price_snapshots(id_promotion, id_product) UNIQUE. FK: promotion→seller (CASCADE), product→promotion (CASCADE), snapshot→promotion+product (CASCADE). 3 sequelize-модели с camelCase/snake_case field mapping. `[skip-tdd: oneshot] [skip-tdd: wrapper]` — migration (Q7) + model declarations ≤10 significant lines (Q8). Smoke idempotency — pending prod apply.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:29:35.342Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit b732360 -->
---
## ✅ [domain-promotions-lib](domain-promotions-lib.md) — CLOSED 2026-05-13 — `f61c679` — 4 pure-logic модуля + 24/24 unit-теста green
**Status:** closed
**Outcome:** `resolve-cheapest-winner.js` (pure, 5 тестов), `snapshot-source.js` (thunk-injected, 4 теста), `defensive-restore.js` (tolerance-based, 7 тестов), `apply-price-to-ozon.js` (chunked+retry, 8 тестов). Pure ядро изолировано от I/O через thunk-инжекцию. CLI-таски импортируют через `import { resolveCheapestWinner } from './_lib/promotions/resolve-cheapest-winner.js'`. Разблокирует 5 CLI-таск.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:29:46.312Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit f61c679 -->
---
## ✅ [domain-promotions-create-cli](domain-promotions-create-cli.md) — CLOSED 2026-05-13 — `35a0308` — validation TDD 8/8 green + Commander wrapper
**Status:** closed
**Outcome:** `promotion-create.js` с гибридным export+CLI. Валидация: endAt>startAt (2 теста), discountPercent 0-100 (2 теста), ISO8601 даты (2 теста), seller exists (1 тест), успешное создание (1 тест). Commander обёртка `[skip-tdd: wrapper]`.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:29:54.322Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit 35a0308 -->
---
## ✅ [domain-promotions-add-products-cli](domain-promotions-add-products-cli.md) — CLOSED 2026-05-13 — `2573454` — inline apply + idempotency 14/14 green
**Status:** closed
**Outcome:** `promotion-add-products.js`. Scheduled: товары добавляются с addedAt без appliedAt. Active: inline snapshot+resolve+apply через lib модули. Идемпотентность: повторный вызов skip'ает уже добавленные, воскрешает removed. Commander обёртка `[skip-tdd: wrapper]`.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:30:05.059Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit 2573454 -->
---
## ✅ [domain-promotions-remove-products-cli](domain-promotions-remove-products-cli.md) — CLOSED 2026-05-13 — `9d4f2f4` — soft remove + defensive restore 7/7 green
**Status:** closed
**Outcome:** `promotion-remove-products.js`. Soft remove: запись остаётся с removedAt, без DELETE. Active+applied: inline defensive restore с cheapest-wins. Divergence не валит операцию. Commander обёртка `[skip-tdd: wrapper]`.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:30:14.211Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit 9d4f2f4 -->
---
## ✅ [domain-promotions-activate-cli](domain-promotions-activate-cli.md) — CLOSED 2026-05-13 — `f68ff22` — scheduled→active + snapshot+apply 8/8 green
**Status:** closed
**Outcome:** `promotion-activate.js`. Status guard: только scheduled→active. На каждый товар: snapshot source → resolve cheapest-winner → apply price. Поддержка actionPriceOverride. API ошибка на одном товаре не валит остальных. Commander обёртка `[skip-tdd: wrapper]`.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:30:21.450Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit f68ff22 -->
---
## ✅ [domain-promotions-deactivate-cli](domain-promotions-deactivate-cli.md) — CLOSED 2026-05-13 — `31fde95` — active→ended + defensive restore 7/7 green
**Status:** closed
**Outcome:** `promotion-deactivate.js`. Status guard: active→ended, не ended/cancelled. Каждый applied товар: resolve cheapest-winner (self-excluding) → defensive restore (push/diverged). Pending товары: soft mark removedAt. Commander обёртка `[skip-tdd: wrapper]`.
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:30:29.476Z -->
<!-- closed-by: vitya@DESKTOP-NSEF0UK / 2026-05-13 / note: commit 31fde95 -->
---
## 🟢 [domain-promotions-review] — Code-review checkpoint для брейнсторма domain-promotions (промоушен 2026-05-12).
**Спецификация:** `concepts/domain-promotions` в global wiki (читать через `mcp__projects-meta__knowledge_get` slug=`concepts/domain-promotions`).
**Pre-impl bootstrap:** `domain-promotions-pointers` (заполнил `.wiki/CLAUDE.md` Domain conventions — без него review бы читал stub).
**Импл-таски (review против их acceptance criteria):** `domain-promotions-schema`, `domain-promotions-lib`, `domain-promotions-create-cli`, `domain-promotions-add-products-cli`, `domain-promotions-remove-products-cli`, `domain-promotions-activate-cli`, `domain-promotions-deactivate-cli`.
**Кто делает:** **не имплементер.** Следующая сессия в этом проекте (другая модель / другой день / другой агент) поднимает таску с чистым контекстом. «Я только что это написал» bias = главный риск.
**Чек-лист ревью:**
- Прочитать спецификацию (acceptance criteria каждой импл-таски).
- `git log --oneline` shipped-коммитов (по slug или scope в commit-message).
- Для каждой импл-таски: прогнать соответствующие тесты, реально проверить что они доходят до своих веток (не coverage-illusion).
- Сверить дизайн-decisions со shipped-кодом (signature, params, error-paths, безопасность).
- Особое внимание: cheapest-wins overlap (сценарии `A→B→A end→B end` и `A→B→B end→A end`), defensive-restore divergence detect, soft-remove семантика (`removedAt` без DELETE).
- Findings — отдельные follow-up tasks (`domain-promotions-<gap>-fix` или подобное) через `tasks_create`.
**Закрытие:** только когда все findings зафайлены ИЛИ ревьюер подтвердил «нет findings» в close-note.
**Status:** done
**Where I stopped:** Review done 2026-05-13. 7 findings filed (3 critical, 3 medium, 1 low) → 4 follow-up tasks created: fix-snapshot-persistence, fix-status-timing, fix-removed-filter, fix-divergence-persist. 68/68 tests green but coverage illusion — production paths (snapshot write, Ozon API calls) not covered.
**Next action:** (none — kept until merged)
**Blocker:** bootstrap: domain-promotions-pointers; impl-tasks: domain-promotions-schema, domain-promotions-lib, domain-promotions-create-cli, domain-promotions-add-products-cli, domain-promotions-remove-products-cli, domain-promotions-activate-cli, domain-promotions-deactivate-cli
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-12T20:30:46.728Z -->
<!-- closed-by: OpeItcLoc03@DESKTOP-NSEF0UK / 2026-05-13T04:54:00.755Z / note: Review done 2026-05-13. 7 findings filed (3 critical, 3 medium, 1 low) → 4 follow-up tasks created: fix-snapshot-persistence, fix-status-timing, fix-removed-filter, fix-divergence-persist. 68/68 tests green but coverage illusion — production paths (snapshot write, Ozon API calls) not covered. -->
---
## 🟢 [domain-promotions-fix-snapshot-persistence] — Fix CRITICAL F1+F2+F3 from review: (1) idPromotionProduct→idPromotion+idProduct in snapshot lookup, (2) write PromotionPriceSnapshot.create() in activate+add-products inline, (3) pass product_id to defensiveRestore()
**Status:** done
**Where I stopped:** Fixed F1 (idPromotion+idProduct lookup instead of idPromotionProduct), F2 (PromotionPriceSnapshot.create in activate+add-products), F3 (product_id passed to defensiveRestore). 5 tests added (activate F2, deactivate F1+F3, add-products F2, remove-products F1+F3). 80/80 green.
**Next action:** (none — kept until merged)
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: victor/books / 2026-05-13T04:53:12.988Z -->
<!-- closed-by: OpeItcLoc03@DESKTOP-NSEF0UK / 2026-05-13T06:40:00.330Z / note: Fixed F1 (idPromotion+idProduct lookup instead of idPromotionProduct), F2 (PromotionPriceSnapshot.create in activate+add-products), F3 (product_id passed to defensiveRestore). 5 tests added (activate F2, deactivate F1+F3, add-products F2, remove-products F1+F3). 80/80 green. -->
---
## 🟢 [domain-promotions-fix-status-timing] — Fix MEDIUM F4 from review: move promo.status change AFTER processing products in activate+deactivate, not before
**Status:** done
**Where I stopped:** Fixed F4: status change moved after product processing loop with `if (failed === 0)` guard in both activate and deactivate. 2 tests added. 80/80 green.
**Next action:** (none — kept until merged)
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: victor/books / 2026-05-13T04:53:26.018Z -->
<!-- closed-by: OpeItcLoc03@DESKTOP-NSEF0UK / 2026-05-13T06:40:03.179Z / note: Fixed F4: status change moved after product processing loop with `if (failed === 0)` guard in both activate and deactivate. 2 tests added. 80/80 green. -->
---
## 🟢 [domain-promotions-fix-removed-filter] — Fix MEDIUM F5 from review: activatePromotion must filter removedAt IS NULL, per spec "Snapshot+apply всех removedAt IS NULL"
**Status:** done
**Where I stopped:** Fixed F5: added `removedAt: null` filter to PromotionProduct.findAll in activatePromotion. 1 test added verifying where clause. 80/80 green.
**Next action:** (none — kept until merged)
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: victor/books / 2026-05-13T04:53:28.201Z -->
<!-- closed-by: OpeItcLoc03@DESKTOP-NSEF0UK / 2026-05-13T06:40:07.371Z / note: Fixed F5: added `removedAt: null` filter to PromotionProduct.findAll in activatePromotion. 1 test added verifying where clause. 80/80 green. -->
---
## 🟢 [domain-promotions-fix-divergence-persist] — Fix LOW F6+F7 from review: (1) write divergedAt/divergedFrom/divergedTo to PromotionPriceSnapshot on divergence, (2) add integration tests covering production paths (snapshot write→find→restore→diverge)
**Status:** done
**Where I stopped:** Fixed F6+F7: divergence fields (divergedAt/divergedFrom/divergedTo) written to snapshot on divergence in both deactivate and remove-products. Integration test (F7) added covering full snapshot chain. 3 tests added. 80/80 green.
**Next action:** (none — kept until merged)
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: victor/books / 2026-05-13T04:53:30.748Z -->
<!-- closed-by: OpeItcLoc03@DESKTOP-NSEF0UK / 2026-05-13T06:40:10.665Z / note: Fixed F6+F7: divergence fields (divergedAt/divergedFrom/divergedTo) written to snapshot on divergence in both deactivate and remove-products. Integration test (F7) added covering full snapshot chain. 3 tests added. 80/80 green. -->
---
## 🟢 [analyst-workflow-claude-md] — DONE 2026-05-15 — секция Analyst workflow в корневом CLAUDE.md
**Status:** done
**Where I stopped:** Закоммичено `44d97b0`. Секция 39 строк после `we're on Windows`: триггер-фразы (выручка / средний чек / сезонность / staff perf / X by month / export / one-off), 3 шага (load `.wiki/concepts/` → check `packages/tools/{README,analyst/}` → write new), inline-блок стиля 11 пунктов (ESM, hybrid CLI+module, commander+isDirectRun, JSDoc, bin+exports, ozon/yandex client напрямую, sequelize+raw SQL, camel/snake mapping, `{meta, tables}` return shape, форматтеры `_lib/`, `.cmd`-обёртки), read-каналы (wiki/README/plans/sequelize), write-канал, anti-patterns (6 пунктов).
**Next action:** (none — kept until merged)
**Branch:** master (local, not pushed)
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-14T17:39:06.107Z -->
<!-- closed-by: vitya@local-windows / 2026-05-15 / note: commit 44d97b0 docs(claude-md). Размер 39 строк (спека 40-50, чуть короче — контент полный, паддинг ради счётчика не делал). Workshop archive не доступен (на DESKTOP-NSEF0UK), писал по acceptance criteria из task description. -->
---
## 🟢 [tools-ci-path-filter] — DONE 2026-05-15 (частично, спека ложна) — skip api/sched/tr build на CLI-only-tools diff
**Status:** done (narrowed scope)
**Where I stopped:** Закоммичено `102a92e` в [.gitea/workflows/build.yml](../.gitea/workflows/build.yml). Спека «исключить весь packages/tools» — ложная: `@books/tools/*` импортится в `packages/task-runner/tasks/*.js` (13+ файлов: fbs-ship-*, sync-prices-*, load-products-to-ozon, awaiting-packaging-*, sync-product-sales-channel-*, etc.) и в `packages/api/server/routes/fbs/*` (4 файла). Выпиливание `tools/` из `api_pat`/`sched_pat`/`tr_pat` целиком сломало бы prod runtime. Сужено до `tools_skip_pat='^packages/tools/(analyst|plans|not-for-qwen|output)/|^packages/tools/(README|QWEN)\.md$|^packages/tools/[^/]+\.cmd$'` — эти подпути проверены grep'ом на 0 импортов из runtime. Top-level `packages/tools/*.js` НЕ исключаем (там смесь runtime-imported и CLI-only). Test/lint job для tools — в `.gitea/workflows/*` отсутствуют вообще, спека приняла желаемое за сущее.
**Next action:** (none — kept until merged). Follow-up для расширения win: move analyst-CLI с top-level (ozon-revenue-per-product, ozon-transactions-analysis, ozon-products-price-analysis, archive-product, unarchive-product, products-import-by-sku, get-products-info-by-sku, update-drugs-blocked-products, ozon-promotion-*) в `analyst/` subdir.
**Branch:** master (local, not pushed)
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-14T17:39:13.960Z -->
<!-- closed-by: vitya@local-windows / 2026-05-15 / note: commit 102a92e ci(build). Реализация уже спеки: `analyst/`+docs+`.cmd` подпути skip api/web/sched/tr build; top-level *.js остаются под path-match потому что fbs-ship-*, sync-prices-*, load-products-to-ozon и т.п. реально импортятся в runtime. Юзер дал go на сужение варианта 1. -->
---
## 🔵 [analyst-workflow-review] — Review-checkpoint для брейнсторма books-analyst-workflow (промоушен 2026-05-14). Спецификация: workshop archive 2026-05-14-books-analyst-workflow.md. Impl-таски: analyst-workflow-claude-md, tools-ci-path-filter. Кто делает: НЕ имплементер. Следующая сессия с чистым контекстом (другая модель, другой день, другой агент) — бороться с bias я только что это написал. Чек-лист: 1) прочитать спецификацию из workshop archive; 2) проверить books CLAUDE.md секция Analyst workflow добавлена и читается на чистый старт сессии без двусмысленностей; 3) реально прогнать 2-3 триггер-фразы в свежей сессии (посчитай выручку, сезонность, отчёт) и убедиться что workflow корректно срабатывает: я читаю packages tools README/analyst до того как писать новый; 4) проверить CI: коммит strictly в packages tools не запускает deploy-job; обратное (коммит в packages api/data) триггерит как раньше; 5) finding ones — отдельные follow-up tasks через tasks_create. Закрытие: только когда все findings зафайлены или ревьюер подтвердил нет findings.
**Status:** blocked
**Where I stopped:** (not started)
**Next action:** Дождаться зелёных blocker-тасок (analyst-workflow-claude-md и tools-ci-path-filter). Затем прогнать чек-лист (см. description). Findings — отдельные follow-up tasks через tasks_create.
**Blocker:** impl-tasks: analyst-workflow-claude-md, tools-ci-path-filter
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: OpeItcLoc03/workshop / 2026-05-14T17:39:18.934Z -->
---

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
# Task Board
_Updated: 2026-05-05_
<!-- tool-description-routing-hint: closed 2026-05-01, ingested as projects-wiki@d98d62c. См. .tasks/tool-description-routing-hint.md -->
<!-- bootstrap-implementation: closed 2026-04-29, merged on main. См. .tasks/bootstrap-implementation.md -->
<!--
Add one block per task, sorted by priority. Use the emoji status legend below.
Per-task deep context lives in .tasks/<task-slug>.md (created on demand by using-tasks).
Block format:
## 🔴 [task-slug] — short description
**Status:** active
**Where I stopped:** one sentence — the exact thought or action interrupted
**Next action:** one concrete step to resume immediately
**Blocker:** (only if blocked) what is preventing progress
**Branch:** git branch name
---
Status legend:
🔴 Active — only one at a time
🟡 Paused — in progress, resumable
⚪ Ready — defined, not started
🟢 Done — kept until merged
🔵 Blocked — waiting on external input
-->
## 🟢 [mvp1-meta-pseudo-project] — локальный `_meta` pseudo-project (DONE)
**Status:** done
**Where I stopped:** имплементировано на main: `~/projects/.wiki/` (Karpathy-канон) + `~/projects/.tasks/STATUS.md` созданы; `config.ts` добавил `metaTasksDir`; `sync-runner.ts` инжектит `_meta` через optional `readMetaTasks` dep; `meta.ts` отдаёт `meta_present`/`meta_tasks_active`/`meta_tasks_total`/`meta_tasks_dir`. 63 теста зелёные, e2e на реальной Gitea отдаёт 7 проектов включая `_meta`. Commit `0d6e05d`.
**Next action:** перезапустить Claude Code чтобы новая сессия видела новые поля `meta.status`; затем — MVP-2.
**Branch:** main
---
## 🟢 [mvp2-projects-tasks-repo] — Gitea-репо `projects-tasks` как источник `_meta` (DONE)
**Status:** done
**Where I stopped:** имплементировано на main: `AuthConfig.metaTasksRepo` (default `projects-tasks`, опционально override через auth.toml); `runSync` сначала пытается тянуть `<user>/<meta_tasks_repo>/.tasks/STATUS.md`, при отсутствии — fallback на локальный `~/projects/.tasks/STATUS.md`; meta-репо исключается из обычного списка проектов; `meta.status` отдаёт `meta_source` (`gitea`/`local`/`none`) и `meta_branch`. 72 теста зелёные. Commit `d1ac107`.
**Next action:** для cross-machine — создать Gitea-репо `projects-tasks` с `.tasks/STATUS.md`; вторая машина после `node dist/sync.js` увидит мета-доску. Сейчас `meta_source: 'local'` потому что репо нет.
**Branch:** main
---
## 🟢 [mvp3-write-api] — backend interface + `tasks_create` / `tasks_update` / `tasks_close` (DONE)
**Status:** done
**Where I stopped:** имплементировано на main: `Backend` interface (`src/lib/backend.ts`); `gitea.ts` рефакторен в `makeGiteaBackend` с `getFileWithSha` + `commitFile` (Contents API, base64, sha-lock); `status-md-writer.ts` (format/append/close/update + identity footer); 3 write-тула с hard confirm-gate и dry-run preview; routing-hint в read-тулах. `server.ts` грузит auth опционально. 107 тестов зелёные. Commit `a3aa8b8`.
**Next action:** smoke против реальной Gitea — создать `projects-tasks` репо, запустить sync, дёрнуть `tasks.create` для `_meta` в новой Claude-сессии.
**Branch:** main
---
## 🟢 [mvp4-knowledge-ingest] — `knowledge_ingest` / `knowledge_promote` через backend (DONE)
**Status:** done
**Where I stopped:** имплементировано на main: `wiki-writer.ts` (formatPage / insertIndexEntry / appendLogEntry / freshIndexMd / freshLogMd); `resolveTarget` вынесен в `lib/resolve-target.ts`; `AuthConfig.metaWikiRepo` (default `projects-wiki`); 2 новых тула с hard confirm-gate, тремя последовательными коммитами (page + index + log), partial-failure пробрасывается. 125 тестов зелёные. Commit `5b9e380`.
**Next action:** **Рекурсивный профит unlocked**`tool-description-routing-hint` теперь решается через `knowledge.ingest({target_project: "_meta", type: "concepts", slug: "tasks-routing-rule", body: "..."})`. Gitea-репо `projects-wiki` создан 2026-04-29 (пустой — нужен seed-коммит перед первым ingest).
**Branch:** main
---
## ⚪ [knowledge-getfrom-meta-alias] — `_meta` алиас в `knowledge.get_from`
**Status:** ready
**Where I stopped:** обнаружено при verify [tool-description-routing-hint] 2026-05-01. `ingest` принимает `_meta` (через `resolveTarget`), `get_from` — нет (находит проект через прямой `cache.projects.find`). Асимметрия read/write API.
**Next action:** прокинуть `metaWikiRepo` в `get_from` хендлер; заменить inline find на `resolveTarget(opts.cacheFile, input.project, opts.metaWikiRepo)` по образцу `ingest:384`. Тривиальный fix (~10 строк + tests).
**Branch:** main
---
## ⚪ [knowledge-search-reindex] — invalidate FTS-кэш после ingest/promote
**Status:** ready
**Where I stopped:** `knowledge.search` мемоизирует `cached: WikiPage[]` навсегда (knowledge.ts:133-138). После `ingest` страница лежит на диске + в Gitea, но in-memory FTS-индекс не обновляется — поиск пуст до рестарта MCP. `meta_status.wiki_pages_count` при этом обновляется (другой code path).
**Next action:** простой путь — invalidate `cached = null` в конце `ingest`/`promote` confirm-пути; архитектурный — `chokidar` watcher на `wikiRoot` для self-healing на внешний `git pull`. Решить scope, потом писать.
**Branch:** main
---
## 🟢 [tool-description-routing-hint] — расширить через knowledge.ingest (DONE)
**Status:** done
**Where I stopped:** `knowledge.ingest` дёрнут 2026-05-01 в `_meta/concepts/tasks-routing-rule` (3 коммита в `projects-wiki@main`: `d98d62c` + `be3fefe` + `5b4fe1b`). Верификация через `knowledge_get_from(projects-wiki, concepts/tasks-routing-rule)` — страница доступна. Хардкод routing-hint в `tasks.ts` (commit `a3aa8b8`, MVP-3) остаётся как safety net. Бас-механизм MVP-4 подтверждён.
**Next action:** (none — task closed). Discoveries during verify заведены отдельными тасками: `_meta`-алиас в `knowledge_get_from`, реиндекс search после ingest.
**Branch:** main
<!-- closed-by: vitya@local / 2026-05-01 -->
---
## 🟢 [wiki-path-mismatch] — knowledge.* read/write по одному пути (DONE)
**Status:** done
**Where I stopped:** fix `sharedWikiClone → ~/projects/projects-wiki/.wiki` смержен в main 2026-04-29 (PR #1 → merge commit `d4396b0`, fix commit `621a69f`). Локальная ветка fix/wiki-path-mismatch удалена. 125 тестов зелёные, build чистый.
**Next action:** (none — закрыто). Для применения fix в текущей Claude-сессии — рестарт (dist/server.js перечитать). После рестарта `knowledge.get packages/projects-meta-mcp` должен вернуть содержимое.
**Branch:** main
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: projects-meta-mcp / 2026-04-29T20:37:39.689Z -->
<!-- closed-by: vitya@local / 2026-05-01 -->
---
## 🟢 [refresh-project-bootstrap] — Refresh repo layout via `project-bootstrap@1.5.0` (DONE)
**Status:** done
**Where I stopped:** upgrade-mode reconciliation finished 2026-05-01: CLAUDE.md gained `pull remote before work` + `follow project discipline` triggers; `.wiki/concepts/bootstrap-manifest.md` bumped 1.2.0 → 1.5.0 with `project-discipline@0.1.0`; per-task file created. `.wiki/` and `.tasks/` already canon — no structural changes.
**Next action:** (none — task closed). Push deferred to next batched push along with `wiki-path-mismatch`.
**Branch:** main
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: projects-meta-mcp / 2026-05-01T09:03:57.750Z -->
<!-- closed-by: vitya@local / 2026-05-01 -->
---
## 🟡 [cross-platform-support] — Поддержка Linux и macOS
**Status:** paused
**Where I stopped:** запушены все изменения (commits 3fb3c02 + 0597322). README.md имеет OS-specific инструкции, sync.ts упрощён, tests pass (149/16), build clean. Остаток: тестирование на реальных Linux/macOS машинах или CI.
**Next action:** запушить изменения; проверить на Linux/macOS (или через CI); обновить per-task файл с decision log.
**Branch:** main
---
## 🟢 [migrate-to-common-lib] — Backlog: migrate `projects-meta-mcp` source from `~/projects/projects-meta-mcp/` into `.common/lib/projects-meta-mcp/` for layout uniformity with `interns-mcp` and any future MCP servers. Preserves git history; updates `~/.claude.json` MCP command path; updates `setup-projects-meta` install location. Layout uniformity across `lib/` was raised in interns design as a follow-up to the new `.common/lib/interns-mcp/` location.
**Status:** done
**Where I stopped:** discovered 2026-05-05 via `~/.claude.json` inspection — `mcpServers.projects-meta.command` already points to `~/projects/.common/lib/projects-meta-mcp/dist/server.js`, and `dist/` is built there. `node dist/sync.js` from that location returns `synced 13 projects, 0 errors`. Migration happened informally outside this task's tracking.
**Next action:** (none — closed). Follow-ups: (1) `[using-projects-meta-fix-paths]` in `claude-skills` updates the skill's stale `~/.local/projects-meta-mcp/` references; (2) decide separately whether `~/projects/projects-meta-mcp/` is a leftover working copy to remove, or remains as an active dev clone.
**Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: meeting-room / 2026-05-05T13:59:22.077Z -->
<!-- closed-by: vitya@local / 2026-05-05 -->
---
## ⚪ [bootstrap-upgrade-canonical-triggers] — Прогнать `project-bootstrap` v1.10.0 в upgrade-режиме на этом проекте. Цель — дописать в `CLAUDE.md` 3 недостающих канонических триггера: `follow tdd-criteria`, `delegate to interns when allowed`, `recommend, don't menu`. Уже присутствуют: `pull remote before work`, `follow project discipline`, `we're on Windows`. Аудит 2026-05-07 через `bulk_text_read`. Контекст — координатор `[bootstrap-rollout-canonical-triggers]` в `_meta` (commit dd933fd). Пилот на `books` подтвердил идемпотентность (158fc95). NB: репа под `OpeItcLoc03` но в кэше projects-meta отсутствует (sync пропустил как archived) — таска создана прямой записью в `.tasks/STATUS.md`.
**Status:** ready
**Where I stopped:** Unblocked 2026-05-07 после успешного прогона `pilonuxt` (commit `e5d45f9`: полный bootstrap — CLAUDE.md + `.wiki/concepts/bootstrap-manifest.md`, скил отработал корректно).
**Next action:** 1) `upgrade project` в `~/projects/projects-meta-mcp/`. **Запустить скил `project-bootstrap` v1.10.0** — не делать ручной Edit (incident на `pilonuxt` 2026-05-07). 2) Подтвердить план: 2 файла — `CLAUDE.md` (+3: `follow tdd-criteria`, `delegate to interns when allowed`, `recommend, don't menu`) + `.wiki/concepts/bootstrap-manifest.md` (manifest 1.x → 1.10.0). 3) Прогнать. 4) **Verify:** `git diff --stat` показывает 2 файла. Если 1 — переоткрыть. Reference: `books@5ca16d9`. 5) Коммит `chore(bootstrap): upgrade CLAUDE.md — add tdd-criteria, interns, recommend-dont-menu triggers` + body со скилами. 6) Повторный — no-op. 7) Закрыть вручную в `.tasks/STATUS.md` (cache-skip).
**Branch:** n/a
<!-- created-by: vitya@local / from: .meeting-room / 2026-05-07 / cache-skip-via-fs -->
<!-- unblocked-by: vitya@local / 2026-05-07 / pilot-pilonuxt-shipped -->
---

115
tests/gitea.test.ts Normal file
View File

@@ -0,0 +1,115 @@
import { describe, expect, test, vi } from 'vitest';
import { createGiteaClient } from '../src/gitea.ts';
type MockFetch = ReturnType<typeof vi.fn>;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function makeClient(mockFetch: MockFetch) {
return createGiteaClient({
baseUrl: 'https://git.kzntsv.site',
token: 'test-token',
fetch: mockFetch as unknown as typeof fetch,
});
}
describe('GiteaClient.getFile', () => {
test('hits /api/v1/repos/<owner>/<repo>/contents/<path> with Authorization header', async () => {
const mockFetch: MockFetch = vi.fn().mockResolvedValue(
jsonResponse({
content: Buffer.from('hello world', 'utf8').toString('base64'),
encoding: 'base64',
}),
);
const client = makeClient(mockFetch);
await client.getFile('OpeItcLoc03', 'board-viewer', '.tasks/STATUS.md');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe(
'https://git.kzntsv.site/api/v1/repos/OpeItcLoc03/board-viewer/contents/.tasks/STATUS.md',
);
expect((init as RequestInit).headers).toMatchObject({
Authorization: 'token test-token',
});
});
test('decodes base64 content and returns text', async () => {
const original = 'STATUS.md body\nwith newline.';
const mockFetch: MockFetch = vi.fn().mockResolvedValue(
jsonResponse({
content: Buffer.from(original, 'utf8').toString('base64'),
encoding: 'base64',
}),
);
const client = makeClient(mockFetch);
const result = await client.getFile('owner', 'repo', 'file.md');
expect(result).toBe(original);
});
test('returns null on 404 (file absent)', async () => {
const mockFetch: MockFetch = vi.fn().mockResolvedValue(
new Response('Not Found', { status: 404 }),
);
const client = makeClient(mockFetch);
const result = await client.getFile('owner', 'repo', 'missing.md');
expect(result).toBeNull();
});
test('throws on non-404 HTTP error (e.g. 500)', async () => {
const mockFetch: MockFetch = vi.fn().mockResolvedValue(
new Response('Server error', { status: 500 }),
);
const client = makeClient(mockFetch);
await expect(client.getFile('owner', 'repo', 'file.md')).rejects.toThrow(/500/);
});
});
describe('GiteaClient.getLatestCommitIso', () => {
test('returns commit date ISO string when commit exists', async () => {
const mockFetch: MockFetch = vi.fn().mockResolvedValue(
jsonResponse([
{ commit: { author: { date: '2026-05-22T08:10:00Z' } } },
]),
);
const client = makeClient(mockFetch);
const iso = await client.getLatestCommitIso('owner', 'repo', '.tasks/foo.md');
expect(iso).toBe('2026-05-22T08:10:00Z');
const [url] = mockFetch.mock.calls[0]!;
expect(url).toMatch(
/\/api\/v1\/repos\/owner\/repo\/commits\?path=\.tasks%2Ffoo\.md&limit=1/,
);
});
test('returns null when no commits touch the path', async () => {
const mockFetch: MockFetch = vi.fn().mockResolvedValue(jsonResponse([]));
const client = makeClient(mockFetch);
expect(await client.getLatestCommitIso('o', 'r', 'p.md')).toBeNull();
});
});
describe('GiteaClient.rawUrl', () => {
test('builds the raw URL for a file on the default branch', () => {
const client = makeClient(vi.fn());
const url = client.rawUrl('OpeItcLoc03', 'board-viewer', '.tasks/STATUS.md');
expect(url).toBe(
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/STATUS.md',
);
});
});

View File

@@ -33,6 +33,22 @@ describe('parseStatus', () => {
expect(parseStatus('# Task Board\n_Updated: 2026-05-22_\n\nNo tasks yet.\n')).toEqual([]);
});
test.each([
'real-books-STATUS.md',
'real-claude-skills-STATUS.md',
'real-projects-meta-mcp-STATUS.md',
])('parses real STATUS.md fixture %s without crashing and yields ≥1 block', (name) => {
const blocks = parseStatus(fixture(name));
expect(blocks.length).toBeGreaterThan(0);
for (const b of blocks) {
expect(b.slug).toMatch(/^[a-z0-9][a-z0-9-]*$/);
expect(b.status).toMatch(/^(open|in_progress|paused|blocked|done)$/);
expect(b.status_emoji).toMatch(/^(|🔴|🟡|🔵|🟢)$/u);
expect(b.title.length).toBeGreaterThan(0);
}
});
test('ignores blocks whose H2 has no known status emoji', () => {
const text = '## 🚀 [some-task] — title\n**Status:** custom\n**Branch:** master\n---\n';
expect(parseStatus(text)).toEqual([]);

112
tests/reader.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import { describe, expect, test, vi } from 'vitest';
import type { GiteaClient } from '../src/gitea.ts';
import { readBoard } from '../src/reader.ts';
function fakeClient(overrides: Partial<GiteaClient> = {}): GiteaClient {
return {
getFile: vi.fn().mockResolvedValue(null),
getLatestCommitIso: vi.fn().mockResolvedValue(null),
rawUrl: vi.fn().mockReturnValue('http://example/raw/x'),
...overrides,
};
}
const STATUS_MD_ONE = `# Task Board
_Updated: 2026-05-22_
## 🔴 [task-a] — first task
**Status:** active
**Where I stopped:** doing A.
**Next action:** continue A.
**Branch:** master
---
`;
describe('readBoard', () => {
test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => {
const client = fakeClient({
getFile: vi.fn().mockResolvedValue(STATUS_MD_ONE),
getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'),
rawUrl: vi.fn().mockReturnValue(
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
),
});
const records = await readBoard(client, [{ owner: 'OpeItcLoc03', repo: 'board-viewer' }]);
expect(records).toHaveLength(1);
expect(records[0]).toEqual({
slug: 'task-a',
project: 'board-viewer',
project_owner: 'OpeItcLoc03',
status: 'in_progress',
status_emoji: '🔴',
title: 'first task',
where_stopped: 'doing A.',
next_action: 'continue A.',
blocker: null,
branch: 'master',
last_commit_iso: '2026-05-22T09:00:00Z',
raw_url:
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
});
});
test('skips repo silently when STATUS.md is missing (returns no records for that repo)', async () => {
const client = fakeClient({
getFile: vi.fn().mockResolvedValue(null), // 404
});
const records = await readBoard(client, [{ owner: 'o', repo: 'no-tasks' }]);
expect(records).toEqual([]);
});
test('aggregates blocks across multiple repos', async () => {
const repoFiles: Record<string, string> = {
'repo-a': STATUS_MD_ONE,
'repo-b': STATUS_MD_ONE.replace('task-a', 'task-b').replace('first task', 'second task'),
};
const client = fakeClient({
getFile: vi.fn(async (_owner: string, repo: string) => repoFiles[repo] ?? null),
getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'),
});
const records = await readBoard(client, [
{ owner: 'o', repo: 'repo-a' },
{ owner: 'o', repo: 'repo-b' },
]);
expect(records.map((r) => `${r.project}:${r.slug}`)).toEqual([
'repo-a:task-a',
'repo-b:task-b',
]);
});
test('builds raw_url per-task file using <slug>.md path under .tasks/', async () => {
const rawUrl = vi.fn((_o: string, _r: string, path: string) => `RAW:${path}`);
const client = fakeClient({
getFile: vi.fn().mockResolvedValue(STATUS_MD_ONE),
getLatestCommitIso: vi.fn().mockResolvedValue(null),
rawUrl,
});
const records = await readBoard(client, [{ owner: 'o', repo: 'r' }]);
expect(records[0]!.raw_url).toBe('RAW:.tasks/task-a.md');
expect(rawUrl).toHaveBeenCalledWith('o', 'r', '.tasks/task-a.md');
});
test('queries last_commit_iso for the per-task file path', async () => {
const getLatestCommitIso = vi.fn().mockResolvedValue('2026-05-22T10:00:00Z');
const client = fakeClient({
getFile: vi.fn().mockResolvedValue(STATUS_MD_ONE),
getLatestCommitIso,
});
await readBoard(client, [{ owner: 'o', repo: 'r' }]);
expect(getLatestCommitIso).toHaveBeenCalledWith('o', 'r', '.tasks/task-a.md');
});
});