From c8e7a3c51a7494a28811ec0ed0d9850a3bcc26d4 Mon Sep 17 00:00:00 2001 From: Its-My-Work Date: Tue, 22 Sep 2026 09:35:50 +0000 Subject: [PATCH] feat(plan): lint and import a plan/ package onto the Kanban board, with an approval card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A planner bot writes plan/tasks/*.md but never a Kanban card (the model that writes a plan is not the one that gets to decide it is correct, see docs/room-guardrails.md). This is the bridge: a linted preview the user approves, then the server — not an LLM — writes the cards. - plan-lib.js (pure, no fs): parseTaskFile (front matter + body), lintPlan (errors: duplicate id, unknown/missing bot, a depends_on cycle or dangling reference, missing title; warnings: no acceptance/verification section, no ТЗ coverage), buildImportPlan (create/update/skip, never a dangling depends_on), guessTzFile (name-based, for the coverage check). - plan-import.js: reads plan/tasks/*.md + 00-overview.md + the guessed ТЗ file (fsImpl injectable) and builds the review object; never touches the DB. - server.js: tasks.plan_task_id column (re-approving a plan UPDATES its cards instead of duplicating them, and never resets a status a human already moved past 'backlog'); POST /api/plans/review (dry run) and /api/plans/import (writes, re-lints fresh, cards land as 'backlog' — approving a plan never starts unattended work); the room posts a plan_review card automatically the moment its closing step changes plan/tasks/*.md (the disk diff it already computes, no extra model call); WS approve_plan applies the card's selection. - public/index.html: the approval card (checkboxes, deselecting a task also deselects its dependents, errors/warnings, an "imported" done state), restored identically from the DB on reload, wired into the same places agent_plan already is (Done-footer skip, markdown export skip, the message-ingestion loop). Tests: plan-lib (54), plan-import (21, fake fs), plans-api (19, a real server + real files: review/import/re-import/idempotency/status-preserved/subset-selection/dangling-dependency- skip), render/plan-review.test.mjs (card HTML + wiring). Confirmed live: a scripted room run (real runConversationRoom, a fake CLI that writes real plan files) posts the card the moment the closing step writes plan/tasks/*.md, with correct coverage; the card itself checked in real headless Chrome (render, checkbox/dependent interaction, the imported state) against the branch's own server.js. Whole npm test suite green. Not verified: a live run with a real model as the closing writer end to end through the actual card (the scripted-CLI run above covers the server-side wiring; PR #12's live run already showed the planner writing real plan/ files). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y2ZSHUdJHqVsvSNQ5ZeQ3f --- docs/plan-kanban-import.md | 52 ++++++++ package.json | 2 +- plan-import.js | 91 ++++++++++++++ plan-lib.js | 197 +++++++++++++++++++++++++++++++ public/index.html | 172 +++++++++++++++++++++++++-- server.js | 171 +++++++++++++++++++++++++++ test/plan-import.test.js | 142 ++++++++++++++++++++++ test/plan-lib.test.js | 147 +++++++++++++++++++++++ test/plans-api.test.js | 153 ++++++++++++++++++++++++ test/render/plan-review.test.mjs | 98 +++++++++++++++ 10 files changed, 1217 insertions(+), 8 deletions(-) create mode 100644 docs/plan-kanban-import.md create mode 100644 plan-import.js create mode 100644 plan-lib.js create mode 100644 test/plan-import.test.js create mode 100644 test/plan-lib.test.js create mode 100644 test/plans-api.test.js create mode 100644 test/render/plan-review.test.mjs diff --git a/docs/plan-kanban-import.md b/docs/plan-kanban-import.md new file mode 100644 index 0000000..cb3e07e --- /dev/null +++ b/docs/plan-kanban-import.md @@ -0,0 +1,52 @@ +# Plan → Kanban import + +A planner bot writes a plan/ package to disk; it never creates a Kanban card (see the `planner` bot's own prompt). This is the bridge: a linted preview the user approves, then the server — not an LLM — writes the cards. + +## Format + +`plan/tasks/T-001-slug.md`: +``` +--- +id: T-001 +title: short title +bot: kolya-prohramist +depends_on: [] +model: sonnet +max_turns: 30 +covers: [ТЗ §3.2, ТЗ §5] +status: backlog +--- +body (first ~2000 chars become the card's description; the rest stays in its `context`) +## Критерии приёмки +... +## Проверка +... +``` +`plan/00-overview.md` is free text (goals, phase order, open questions) — read for the card's subtitle, not otherwise parsed. + +## The check (`plan-lib.js`, pure, no fs) + +- **Errors** (block approval): duplicate id, unknown/missing bot, `depends_on` pointing at a missing id or itself, a dependency cycle, missing title. +- **Warnings** (shown, do not block): no `## Критерии приёмки` / `## Проверка` section, no `covers`, a ТЗ section that no task's `covers` mentions. +- **Coverage**: the ТЗ file is *guessed* from the project root — any `.md` file whose name contains "tz", "тз" or "спец" (`plan-lib.js: guessTzFile`). Not found → coverage is simply skipped, not guessed at with a false answer. + +`plan-import.js` reads the files (fs, `fsImpl` injectable for tests) and turns them into the review object; `server.js` owns everything DB-specific (matching against existing cards, writing them). + +## Approval card + +Posted automatically the moment a room's closing step changes `plan/tasks/*.md` (the disk diff the room already computes for the "files changed" note — no extra model call). Shows the task table with checkboxes (deselecting a task also deselects whatever depends on it), errors/warnings, and an "Approve to Backlog" button. Restored identically from the DB on reload (`messages.type = 'plan_review'`); once imported it shows the created/updated/skipped counts instead of checkboxes. + +## Import (`POST /api/plans/import`, and the WS `approve_plan` the card's button sends) + +- Re-reads and re-lints the files **fresh** — never trusts a review the client held on to. +- Cards land as `status: 'backlog'` — approving a plan never starts unattended work; the user moves a card to `todo` when ready. +- **Idempotent**: a card once imported carries `tasks.plan_task_id` ("T-003"); re-approving updates that same card (title/description/context/bot/model/`depends_on`) instead of creating a duplicate, and does **not** reset a status the user already changed (`in_progress`/`done` stay put). +- Deselecting a task's dependency skips the dependent too, named why — never a card with a dangling `depends_on`. +- `depends_on` is remapped from plan ids to real Kanban ids in a second pass, the same two-pass approach `/api/tasks/dispatch` uses. +- Cap: 200 tasks per plan (`PLAN_IMPORT_MAX`) — a user-approved import, not agent-spawned, so no per-run child-task throttle. + +`POST /api/plans/review` is the dry run (no writes) — usable standalone, e.g. after hand-editing plan files, without re-running the room. + +## Known gap + +No manual "review this plan again" button in the UI yet — only the automatic trigger right after a room's closing step and the two HTTP endpoints. A project without a `planner` bot, or a closing step that touches no `plan/tasks/*.md` file, never shows a card. diff --git a/package.json b/package.json index c9e49a9..f62ee6d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/cli-tool-names.test.js && node test/mcp-web.test.js && node test/gateway-models.test.js && node test/room-files.test.js && node test/room-git.test.js && node test/room-run.test.js && node test/worktree-manager.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/task-workers.test.js && node test/git-clone.test.js", + "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/cli-tool-names.test.js && node test/mcp-web.test.js && node test/gateway-models.test.js && node test/plan-lib.test.js && node test/plan-import.test.js && node test/plans-api.test.js && node test/room-files.test.js && node test/room-git.test.js && node test/room-run.test.js && node test/worktree-manager.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/task-workers.test.js && node test/git-clone.test.js", "postinstall": "node scripts/install-hooks.js", "release": "node scripts/release.js", "electron:dev": "electron .", diff --git a/plan-import.js b/plan-import.js new file mode 100644 index 0000000..be1343b --- /dev/null +++ b/plan-import.js @@ -0,0 +1,91 @@ +'use strict'; +// Reads a planner's plan/ package off disk and turns it into the review object the approval card +// shows. Everything DB-specific (matching against existing Kanban cards, writing new ones) stays in +// server.js, next to the other task-writing code it has to stay consistent with; this module only +// knows about files, so it can be tested against a fake filesystem instead of a real project. + +const path = require('path'); +const planLib = require('./plan-lib'); + +/** true where the "no such file/dir" is an ordinary, expected absence — not a real read failure. */ +const isMissing = (e) => e && (e.code === 'ENOENT' || e.code === 'ENOTDIR'); + +/** + * `plan/tasks/*.md` + `plan/00-overview.md`, plus a best-effort guess at the ТЗ file for the + * coverage check. `fsImpl` defaults to the real `fs` — tests pass an object with the same + * `.promises.readdir` / `.promises.readFile` shape backed by an in-memory tree. + * Never throws: a missing plan/ directory is `{ hasPlanDir: false }`, not an error. + */ +async function readPlanFiles({ workdir, fsImpl = require('fs') }) { + const planDir = path.join(workdir, 'plan'); + const tasksDir = path.join(planDir, 'tasks'); + let names; + try { + names = (await fsImpl.promises.readdir(tasksDir)).filter(f => f.endsWith('.md')).sort(); + } catch (e) { + if (isMissing(e)) return { hasPlanDir: false, tasks: [], parseErrors: [], overview: null, tzPath: null, tzHeadings: null }; + return { hasPlanDir: false, tasks: [], parseErrors: [`plan/tasks/: ${e.message}`], overview: null, tzPath: null, tzHeadings: null }; + } + + const tasks = [], parseErrors = []; + for (const name of names) { + const rel = `plan/tasks/${name}`; + let content; + try { content = await fsImpl.promises.readFile(path.join(tasksDir, name), 'utf8'); } + catch (e) { parseErrors.push(`${rel}: could not be read (${e.message})`); continue; } + const r = planLib.parseTaskFile(content, rel); + if (r.ok) tasks.push(r.task); else parseErrors.push(r.error); + } + + let overview = null; + try { overview = await fsImpl.promises.readFile(path.join(planDir, '00-overview.md'), 'utf8'); } catch { /* optional */ } + + let tzPath = null, tzHeadings = null; + try { + const rootNames = await fsImpl.promises.readdir(workdir); + const guess = planLib.guessTzFile(rootNames); + if (guess) { + tzPath = guess; + tzHeadings = planLib.extractHeadings(await fsImpl.promises.readFile(path.join(workdir, guess), 'utf8')); + } + } catch { /* best-effort only — never blocks the review */ } + + return { hasPlanDir: true, tasks, parseErrors, overview, tzPath, tzHeadings }; +} + +/** + * The full review: read + lint, plus each task's card-ready summary (title/description capped to + * CARD_DESCRIPTION_MAX, the rest kept as `body` for the card's `context`). `existing` = the plan_task_id + * -> kanban task id map the caller already looked up in the DB (server.js does that part). + */ +async function reviewPlan({ workdir, botIds, existing = {}, fsImpl } = {}) { + const read = await readPlanFiles({ workdir, fsImpl }); + const lint = planLib.lintPlan({ tasks: read.tasks, parseErrors: read.parseErrors, botIds, tzHeadings: read.tzHeadings }); + const groupByTask = (items) => { + const m = new Map(); + for (const { taskId, message } of items) { if (taskId) { if (!m.has(taskId)) m.set(taskId, []); m.get(taskId).push(message); } } + return m; + }; + const errorsByTask = groupByTask(lint.errors), warningsByTask = groupByTask(lint.warnings); + const globalErrors = lint.errors.filter(e => !e.taskId).map(e => e.message); + const globalWarnings = lint.warnings.filter(w => !w.taskId).map(w => w.message); + + const tasks = read.tasks.map(t => ({ + id: t.id, title: t.title, bot: t.bot, depends_on: t.depends_on, covers: t.covers, + model: t.model, max_turns: t.max_turns, file: t.file, + description: t.body.length > planLib.CARD_DESCRIPTION_MAX ? t.body.slice(0, planLib.CARD_DESCRIPTION_MAX - 1) + '…' : t.body, + context: t.body, + errors: errorsByTask.get(t.id) || [], warnings: warningsByTask.get(t.id) || [], + existingTaskId: existing[t.id] || null, + })); + + return { + workdir, hasPlanDir: read.hasPlanDir, tzPath: read.tzPath, + overview: read.overview ? (read.overview.length > 4000 ? read.overview.slice(0, 4000) + '…' : read.overview) : null, + tasks, errors: globalErrors, warnings: globalWarnings, coverage: lint.coverage, + canApprove: read.hasPlanDir && tasks.length > 0 && lint.errors.length === 0, + generatedAt: new Date().toISOString(), + }; +} + +module.exports = { readPlanFiles, reviewPlan, isMissing }; diff --git a/plan-lib.js b/plan-lib.js new file mode 100644 index 0000000..749ba34 --- /dev/null +++ b/plan-lib.js @@ -0,0 +1,197 @@ +'use strict'; +// Turning a planner's plan/ package into Kanban cards — the part that must never guess. +// +// A room rewrote the user's ТЗ from 25.9 KB to 13.8 KB and a plan claimed "~70 lines" when it was +// 341 (see docs/room-guardrails.md); the model that writes the plan is not the model that gets to +// decide it is correct. This module is the check: pure functions, no fs, no LLM call, so a plan +// package is validated the same way every time and the check itself has its own tests. +// +// Format `plan/tasks/T-001-slug.md` writes (see the `planner` bot's own prompt): +// --- +// id: T-001 +// title: short title +// bot: kolya-prohramist +// depends_on: [] +// model: sonnet +// max_turns: 30 +// covers: [ТЗ §3.2, ТЗ §5] +// status: backlog +// --- +// body... +// `plan/00-overview.md` is prose with `## N. ...`-style headings; those headings, matched loosely +// against every task's `covers`, are the coverage check. + +const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; +const CARD_DESCRIPTION_MAX = 2000; // tasks.description column cap (server.js saveBot has the analogous BOT_DESC_MAX) +const PLAN_TASK_ID_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/; // short, so it reads on a card badge + +/** One `key: value` front-matter line into a JS value. Only what the planner's own format uses: + * a bare scalar, or `[a, b, c]` (no nested structures, no quoting rules beyond trim). */ +function parseScalar(raw) { + const v = String(raw ?? '').trim(); + if (v.startsWith('[') && v.endsWith(']')) { + const inner = v.slice(1, -1).trim(); + return inner ? inner.split(',').map(s => s.trim()).filter(Boolean) : []; + } + return v; +} + +/** + * One `plan/tasks/*.md` file's text -> { ok, task, error }. Never throws: a file that does not + * parse is reported as an error tied to its path, not a crash of the whole import. + */ +function parseTaskFile(content, filePath) { + const m = FRONT_MATTER_RE.exec(String(content ?? '')); + if (!m) return { ok: false, error: `${filePath}: no \`---\` front matter block found` }; + const fields = {}; + for (const line of m[1].split(/\r?\n/)) { + if (!line.trim() || line.trim().startsWith('#')) continue; + const i = line.indexOf(':'); + if (i === -1) continue; // a continuation line of a multi-line value we do not support — ignored, not fatal + fields[line.slice(0, i).trim()] = parseScalar(line.slice(i + 1)); + } + const body = m[2].replace(/^\r?\n/, ''); + const id = typeof fields.id === 'string' ? fields.id.trim() : ''; + if (!id) return { ok: false, error: `${filePath}: missing \`id\`` }; + if (!PLAN_TASK_ID_RE.test(id)) return { ok: false, error: `${filePath}: id "${id}" is not 1-32 characters of a-z, 0-9, _ or -, starting with a letter` }; + const title = typeof fields.title === 'string' ? fields.title.trim() : ''; + const bot = typeof fields.bot === 'string' ? fields.bot.trim().toLowerCase() : ''; + const dependsOn = Array.isArray(fields.depends_on) ? fields.depends_on.filter(Boolean) : (fields.depends_on ? [String(fields.depends_on).trim()] : []); + const covers = Array.isArray(fields.covers) ? fields.covers : (fields.covers ? [String(fields.covers).trim()] : []); + const maxTurns = Number.parseInt(fields.max_turns, 10); + return { + ok: true, + task: { + id, title, bot, depends_on: dependsOn, covers, + model: typeof fields.model === 'string' && fields.model.trim() ? fields.model.trim() : null, + max_turns: Number.isInteger(maxTurns) && maxTurns > 0 ? maxTurns : null, + status: typeof fields.status === 'string' && fields.status.trim() ? fields.status.trim() : 'backlog', + body: body.trim(), + file: filePath, + }, + }; +} + +/** `## 1. Something` / `## Something` headings from the overview or the ТЗ, for the coverage check. + * Loose on purpose: this cross-references free text a bot wrote, not a schema. */ +function extractHeadings(markdown) { + return [...String(markdown ?? '').matchAll(/^#{1,3}[ \t]+(.+?)[ \t]*$/gm)].map(m => m[1].trim()).filter(Boolean); +} + +/** Does any task's `covers` mention this heading? Matched by shared words (3+ letters/digits), not + * exact text — "ТЗ §3.2 Аутентификация" covering "3.2 Аутентификация и сессии" is the normal case. */ +function headingCovered(heading, coversByTask) { + const words = (s) => new Set((String(s).toLowerCase().match(/[a-zа-яё0-9]{3,}/gi) || [])); + const hw = words(heading); + if (!hw.size) return true; // nothing to match against — do not manufacture a false "uncovered" + // A short heading ("Хранение") is one meaningful word; requiring 2 shared words would never match + // it against anything. Half the heading's own words, floor 1, is enough to call it the same topic. + const need = Math.max(1, Math.ceil(hw.size / 2)); + for (const covers of coversByTask) { + for (const c of covers) { + const cw = words(c); + let shared = 0; + for (const w of cw) if (hw.has(w)) shared++; + if (shared >= need) return true; + } + } + return false; +} + +const DEP_CYCLE_LIMIT = 500; // guards a pathological plan; a real one is a few dozen tasks at most + +/** The tasks whose dependencies are all satisfied, given `done` (a Set of ids already placed). */ +function pickReady(remaining, done) { + return remaining.filter(t => t.depends_on.every(d => done.has(d))); +} + +/** + * Lint a parsed set of task files against the real bot roster and (optionally) the ТЗ's headings. + * -> { errors: [{taskId, message}], warnings: [{taskId, message}], coverage: {total, covered, uncovered} } + * `errors` block approval; `warnings` do not. `botIds` is the live, non-deleted roster — a plan + * naming a bot that does not exist, or one that was deleted since, is an error either way. + */ +function lintPlan({ tasks, parseErrors = [], botIds = [], tzHeadings = null } = {}) { + const errors = parseErrors.map(message => ({ taskId: null, message })); + const warnings = []; + const bots = new Set(botIds); + const seen = new Map(); // id -> file, for duplicate detection + + for (const t of tasks) { + if (seen.has(t.id)) errors.push({ taskId: t.id, message: `duplicate id "${t.id}" (${seen.get(t.id)} and ${t.file})` }); + else seen.set(t.id, t.file); + } + const ids = new Set(tasks.map(t => t.id)); + for (const t of tasks) { + if (!t.title) errors.push({ taskId: t.id, message: 'missing title' }); + if (!t.bot) errors.push({ taskId: t.id, message: 'missing bot' }); + else if (!bots.has(t.bot)) errors.push({ taskId: t.id, message: `unknown bot "${t.bot}"` }); + for (const d of t.depends_on) { + if (d === t.id) errors.push({ taskId: t.id, message: 'depends on itself' }); + else if (!ids.has(d)) errors.push({ taskId: t.id, message: `depends on "${d}", which does not exist in this plan` }); + } + if (!/##\s*Критерии приёмки|##\s*Acceptance/i.test(t.body)) warnings.push({ taskId: t.id, message: 'no acceptance-criteria section' }); + if (!/##\s*Проверка|##\s*Verif/i.test(t.body)) warnings.push({ taskId: t.id, message: 'no verification section' }); + if (!t.covers.length) warnings.push({ taskId: t.id, message: 'covers no ТЗ section' }); + } + + // Cycle detection: repeat "place everything that is ready" until nothing more can go. What is + // left over is exactly the cycle (or a dependency this loop already reported as missing above). + { + const byId = new Map(tasks.filter(t => ids.has(t.id) && t.depends_on.every(d => ids.has(d) || d === t.id)).map(t => [t.id, t])); + const remaining = [...byId.values()]; + const done = new Set(); + let guard = 0; + while (remaining.length && guard++ < DEP_CYCLE_LIMIT) { + const ready = pickReady(remaining, done); + if (!ready.length) break; + for (const t of ready) { done.add(t.id); remaining.splice(remaining.indexOf(t), 1); } + } + for (const t of remaining) errors.push({ taskId: t.id, message: 'part of a dependency cycle' }); + } + + let coverage = null; + if (tzHeadings) { + const coversByTask = tasks.map(t => t.covers); + const uncovered = tzHeadings.filter(h => !headingCovered(h, coversByTask)); + coverage = { total: tzHeadings.length, covered: tzHeadings.length - uncovered.length, uncovered }; + if (uncovered.length) warnings.push({ taskId: null, message: `${uncovered.length} of ${tzHeadings.length} ТЗ section(s) not covered by any task: ${uncovered.slice(0, 8).join('; ')}${uncovered.length > 8 ? '…' : ''}` }); + } + + return { errors, warnings, coverage }; +} + +/** + * The board-import plan for one selection: which plan tasks become a NEW Kanban card, which + * UPDATE an existing one (matched by `existing`: plan task id -> kanban task id, already imported + * once), and which are skipped with a reason (not selected, or depends on something not selected). + * Pure: no DB, no fs. `selected` = null means "everything the linter did not block". + */ +function buildImportPlan({ tasks, errors, existing = {}, selected = null }) { + if (errors.length) return { toCreate: [], toUpdate: [], toSkip: tasks.map(t => ({ id: t.id, reason: 'plan has lint errors' })) }; + const want = selected === null ? new Set(tasks.map(t => t.id)) : new Set(selected); + const toCreate = [], toUpdate = [], toSkip = []; + for (const t of tasks) { + if (!want.has(t.id)) { toSkip.push({ id: t.id, reason: 'not selected' }); continue; } + const missingDep = t.depends_on.find(d => !want.has(d)); + if (missingDep) { toSkip.push({ id: t.id, reason: `depends on "${missingDep}", which was not selected` }); continue; } + (existing[t.id] ? toUpdate : toCreate).push(t); + } + return { toCreate, toUpdate, toSkip }; +} + +// Which root-level file is "the ТЗ", for the coverage check — a guess, not a contract: the planner's +// prompt does not fix a filename (in the one project this shipped against it is TZ-easypanel-hub.md). +// Deliberately narrow (word "tz"/"тз" or "спец") rather than e.g. matching "spec" or "readme", which +// would as happily match an unrelated file and produce a coverage report that means nothing. +const TZ_NAME_RE = /(^|[^a-zа-яё])(tz|тз|спец)([^a-zа-яё]|$)/i; +function guessTzFile(names) { + const hit = (names || []).filter(n => /\.md$/i.test(n) && TZ_NAME_RE.test(n)).sort(); + return hit[0] || null; +} + +module.exports = { + CARD_DESCRIPTION_MAX, PLAN_TASK_ID_RE, FRONT_MATTER_RE, + parseScalar, parseTaskFile, extractHeadings, headingCovered, guessTzFile, + lintPlan, buildImportPlan, pickReady, +}; diff --git a/public/index.html b/public/index.html index 451d10f..98279fc 100644 --- a/public/index.html +++ b/public/index.html @@ -695,6 +695,41 @@ .atc-dispatch-btn:hover { background: rgba(124,106,239,.25); border-color: rgba(124,106,239,.4); } .atc-dispatch-btn:disabled { opacity: .5; cursor: default; } +/* ─── Plan review card (planner bot's plan/ package -> Kanban approval) ─── */ +.plan-review-card { + align-self: flex-start; background: var(--s2); border: 1px solid var(--border); border-radius: 12px; + padding: 0; max-width: 560px; width: 100%; overflow: hidden; animation: status-appear .25s var(--ease-out); margin: 4px 0; +} +.prc-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px 8px; border-bottom: 1px solid rgba(124,106,239,.1); } +.prc-header .prc-icon { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%); display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; } +.prc-title { font-size: 12px; font-weight: 700; color: var(--text); letter-spacing: .02em; } +.prc-sub { font-size: 11px; color: var(--muted); margin-top: 1px; } +.prc-msgs { padding: 6px 14px 0; display: flex; flex-direction: column; gap: 4px; } +.prc-errors { font-size: 11px; color: var(--red); } +.prc-warnings { font-size: 11px; color: var(--muted); } +.prc-msgs ul { margin: 2px 0 6px 16px; padding: 0; } +.prc-tasks { padding: 6px 10px 4px; display: flex; flex-direction: column; gap: 3px; max-height: 320px; overflow-y: auto; } +.prc-task { display: flex; align-items: flex-start; gap: 8px; padding: 5px 8px; border-radius: 8px; background: rgba(255,255,255,.02); } +.prc-task.prc-task-err { background: rgba(248,81,79,.07); } +.prc-task input[type=checkbox] { margin-top: 2px; flex-shrink: 0; } +.prc-task-body { min-width: 0; flex: 1; } +.prc-task-row1 { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.prc-task-id { font-size: 10px; font-weight: 700; color: var(--accent2); } +.prc-task-title { font-size: 11.5px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.prc-task-bot { font-size: 10px; color: var(--muted); background: rgba(255,255,255,.05); border-radius: 5px; padding: 1px 5px; flex-shrink: 0; } +.prc-task-existing { font-size: 10px; color: var(--green); flex-shrink: 0; } +.prc-task-deps { font-size: 10px; color: var(--muted); opacity: .75; } +.prc-task-issues { font-size: 10px; margin-top: 2px; } +.prc-task-issues .prc-e { color: var(--red); } +.prc-task-issues .prc-w { color: var(--muted); } +.prc-footer { padding: 8px 14px 12px; border-top: 1px solid rgba(124,106,239,.06); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.prc-footer .prc-count { font-size: 10px; color: var(--muted); } +.prc-approve-btn { background: rgba(124,106,239,.12); color: var(--accent); border: 1px solid rgba(124,106,239,.22); border-radius: 6px; padding: 5px 12px; font-size: 11.5px; cursor: pointer; font-weight: 600; transition: background .15s, border-color .15s, color .15s; margin-left: auto; white-space: nowrap; } +.prc-approve-btn:hover:not(:disabled) { background: rgba(124,106,239,.25); border-color: rgba(124,106,239,.4); } +.prc-approve-btn:disabled { opacity: .4; cursor: default; } +.prc-done { font-size: 11.5px; color: var(--green); font-weight: 600; } +.prc-done-detail { font-size: 10px; color: var(--muted); margin-left: 8px; } + /* ─── Chain progress card (dispatched tasks) ─── */ .chain-progress-card { align-self: flex-start; background: var(--s2); @@ -2664,6 +2699,7 @@ .welcome-prompts { grid-template-columns: 1fr; max-width: 320px; } .proj-dd-menu { min-width: auto; max-width: calc(100vw - 32px); } .agent-team-card { max-width: calc(100vw - 40px); } + .plan-review-card { max-width: calc(100vw - 40px); } .chain-progress-card { max-width: calc(100vw - 40px); } .cd-btn-ok, .cd-btn-cancel { min-height: 44px; padding: 10px 20px; } .fpv-modal { width: 100vw !important; max-width: 100vw !important; height: 100vh !important; height: 100dvh !important; max-height: 100vh !important; max-height: 100dvh !important; border-radius: 0 !important; } @@ -5043,7 +5079,7 @@

Delegate to Agent

'me.err.id_dup':'Сервер з таким ID вже існує.','me.err.url_req':'URL є обов\'язковим для HTTP серверів.', 'me.err.cmd_req':'Команда є обов\'язковою для stdio серверів.','me.err.request':'Помилка запиту — перевірте логи сервера.', 'me.toast.added':'MCP-сервер додано.','me.toast.updated':'MCP-сервер оновлено.','me.kv.del':'Видалити', - 'notif.dismiss':'Закрити','msf.plan_kanban':'План → Kanban','msf.open_kanban':'Відкрити Kanban →','msf.sent':'✅ Надіслано', + 'notif.dismiss':'Закрити','plan.review.title':'Огляд плану','plan.review.sub':'{n} задач(і) на затвердження','plan.review.sub_tz':'{n} задач(і) на затвердження, за {tz}','plan.errors.title':'Не можна затвердити:','plan.warnings.title':'Варто знати:','plan.deps':'Залежить від:','plan.will_update':'оновить картку','plan.empty':'У плані немає задач для імпорту.','plan.selected.count':'{n} із {total} обрано','plan.approve.btn':'Затвердити в Backlog ({n})','plan.approve.none':'Нічого не обрано','plan.approving':'Імпортую…','plan.approve.retry':'Повторити','plan.imported.title':'План імпортовано','plan.imported.done':'Імпортовано в Backlog','plan.imported.detail':'створено {c}, оновлено {u}, пропущено {s}','plan.import.err':'Не вдалося імпортувати план','msf.plan_kanban':'План → Kanban','msf.open_kanban':'Відкрити Kanban →','msf.sent':'✅ Надіслано', 'skill.custom_desc':'Користувацький скіл','skill.custom_cat':'🔧 Кастомні', 'files.remote_unavail':'🌐 Віддалений проект —
файловий браузер недоступний','files.truncated':'… список обрізано — у теці забагато файлів','fpv.remote_binary':'Попередній перегляд недоступний для віддалених проєктів (лише текст)', 'hist.drag':'Перетягнути','tg.device.connected':'📱 Telegram: {name} підключено', @@ -5447,7 +5483,7 @@

Delegate to Agent

'me.err.id_dup':'A server with this ID already exists.','me.err.url_req':'URL is required for HTTP servers.', 'me.err.cmd_req':'Command is required for stdio servers.','me.err.request':'Request failed — check the server logs.', 'me.toast.added':'MCP server added.','me.toast.updated':'MCP server updated.','me.kv.del':'Remove', - 'notif.dismiss':'Dismiss','msf.plan_kanban':'Plan → Kanban','msf.open_kanban':'Open Kanban →','msf.sent':'✅ Sent', + 'notif.dismiss':'Dismiss','plan.review.title':'Plan review','plan.review.sub':'{n} task(s) to approve','plan.review.sub_tz':'{n} task(s) to approve, against {tz}','plan.errors.title':'Cannot approve:','plan.warnings.title':'Worth knowing:','plan.deps':'Depends on:','plan.will_update':'will update the card','plan.empty':'No tasks to import in this plan.','plan.selected.count':'{n} of {total} selected','plan.approve.btn':'Approve to Backlog ({n})','plan.approve.none':'Nothing selected','plan.approving':'Importing…','plan.approve.retry':'Retry','plan.imported.title':'Plan imported','plan.imported.done':'Imported to Backlog','plan.imported.detail':'{c} created, {u} updated, {s} skipped','plan.import.err':'Could not import the plan','msf.plan_kanban':'Plan → Kanban','msf.open_kanban':'Open Kanban →','msf.sent':'✅ Sent', 'skill.custom_desc':'Custom skill','skill.custom_cat':'🔧 Custom', 'files.remote_unavail':'🌐 Remote project —
file browser not available','files.truncated':'… listing truncated — too many files in this folder','fpv.remote_binary':'Preview is not available for remote projects (text only)', 'hist.drag':'Drag to reorder','tg.device.connected':'📱 Telegram: {name} connected', @@ -5851,7 +5887,7 @@

Delegate to Agent

'me.err.id_dup':'Сервер с таким ID уже существует.','me.err.url_req':'URL обязателен для HTTP серверов.', 'me.err.cmd_req':'Команда обязательна для stdio серверов.','me.err.request':'Ошибка запроса — проверьте логи сервера.', 'me.toast.added':'MCP-сервер добавлен.','me.toast.updated':'MCP-сервер обновлён.','me.kv.del':'Удалить', - 'notif.dismiss':'Закрыть','msf.plan_kanban':'План → Kanban','msf.open_kanban':'Открыть Kanban →','msf.sent':'✅ Отправлено', + 'notif.dismiss':'Закрыть','plan.review.title':'Обзор плана','plan.review.sub':'{n} задач(и) на утверждение','plan.review.sub_tz':'{n} задач(и) на утверждение, по {tz}','plan.errors.title':'Нельзя утвердить:','plan.warnings.title':'Стоит знать:','plan.deps':'Зависит от:','plan.will_update':'обновит карточку','plan.empty':'В плане нет задач для импорта.','plan.selected.count':'{n} из {total} выбрано','plan.approve.btn':'Утвердить в Backlog ({n})','plan.approve.none':'Ничего не выбрано','plan.approving':'Импортирую…','plan.approve.retry':'Повторить','plan.imported.title':'План импортирован','plan.imported.done':'Импортировано в Backlog','plan.imported.detail':'создано {c}, обновлено {u}, пропущено {s}','plan.import.err':'Не удалось импортировать план','msf.plan_kanban':'План → Kanban','msf.open_kanban':'Открыть Kanban →','msf.sent':'✅ Отправлено', 'skill.custom_desc':'Пользовательский навык','skill.custom_cat':'🔧 Кастомные', 'files.remote_unavail':'🌐 Удалённый проект —
файловый браузер недоступен','files.truncated':'… список обрезан — в папке слишком много файлов','fpv.remote_binary':'Предпросмотр недоступен для удалённых проектов (только текст)', 'hist.drag':'Перетащить','tg.device.connected':'📱 Telegram: {name} подключён', @@ -6167,7 +6203,7 @@

Delegate to Agent

"me.err.id_fmt":"L'ID du serveur ne peut contenir que des lettres, des chiffres, des tirets et des underscores.","me.err.id_dup":"Un serveur avec cet ID existe déjà.", "me.err.url_req":"L'URL est requise pour les serveurs HTTP.","me.err.cmd_req":"La commande est requise pour les serveurs stdio.", "me.err.request":"La requête a échoué — consultez les journaux du serveur.","me.toast.added":"Serveur MCP ajouté.","me.toast.updated":"Serveur MCP mis à jour.","me.kv.del":"Retirer", - "notif.dismiss":"Ignorer","msf.plan_kanban":"Plan → Kanban","msf.open_kanban":"Ouvrir Kanban →","msf.sent":"✅ Envoyé","skill.custom_desc":"Compétence personnalisée", + "notif.dismiss":"Ignorer","plan.review.title":"Revue du plan","plan.review.sub":"{n} tâche(s) à approuver","plan.review.sub_tz":"{n} tâche(s) à approuver, d'après {tz}","plan.errors.title":"Impossible à approuver :","plan.warnings.title":"À savoir :","plan.deps":"Dépend de :","plan.will_update":"mettra à jour la carte","plan.empty":"Aucune tâche à importer dans ce plan.","plan.selected.count":"{n} sur {total} sélectionnées","plan.approve.btn":"Approuver vers Backlog ({n})","plan.approve.none":"Rien de sélectionné","plan.approving":"Import en cours…","plan.approve.retry":"Réessayer","plan.imported.title":"Plan importé","plan.imported.done":"Importé dans Backlog","plan.imported.detail":"{c} créées, {u} mises à jour, {s} ignorées","plan.import.err":"Impossible d'importer le plan","msf.plan_kanban":"Plan → Kanban","msf.open_kanban":"Ouvrir Kanban →","msf.sent":"✅ Envoyé","skill.custom_desc":"Compétence personnalisée", "skill.custom_cat":"🔧 Personnalisé","files.remote_unavail":"🌐 Projet distant —
navigateur de fichiers non disponible","files.truncated":"… liste tronquée — trop de fichiers dans ce dossier","fpv.remote_binary":"Aperçu indisponible pour les projets distants (texte uniquement)","hist.drag":"Glisser pour réorganiser", "tg.device.connected":"📱 Telegram : {name} connecté","tg.bot.started":"✅ Bot Telegram démarré : @{bot}","compact.btn":"Compacter et nouveau", "compact.title":"Compacter le chat et démarrer une nouvelle session","compact.loading":"Compactage…","compact.success":"✅ Chat compacté — ouverture d'une nouvelle session", @@ -6447,7 +6483,7 @@

Delegate to Agent

"me.saving.add":"מוסיף…","me.saving.edit":"שומר…","me.id.locked":"לא ניתן לשנות את ה-ID של השרת","me.err.json":"JSON לא תקין — תקנו קודם את התחביר.","me.err.json_save":"JSON לא תקין — לא ניתן לשמור.", "me.err.id_req":"נדרש ID לשרת.","me.err.id_fmt":"ה-ID של השרת יכול להכיל רק אותיות, ספרות, מקפים וקווים תחתונים.","me.err.id_dup":"כבר קיים שרת עם ID הזה.","me.err.url_req":"נדרש URL לשרתי HTTP.", "me.err.cmd_req":"נדרשת פקודה לשרתי stdio.","me.err.request":"הבקשה נכשלה — בדקו את לוגי השרת.","me.toast.added":"שרת ה-MCP נוסף.","me.toast.updated":"שרת ה-MCP עודכן.","me.kv.del":"הסרה", - "notif.dismiss":"סגירה","msf.plan_kanban":"תוכנית → Kanban","msf.open_kanban":"פתיחת Kanban →","msf.sent":"✅ נשלח","skill.custom_desc":"Skill מותאם אישית","skill.custom_cat":"🔧 מותאם אישית", + "notif.dismiss":"סגירה","plan.review.title":"סקירת תוכנית","plan.review.sub":"{n} משימות לאישור","plan.review.sub_tz":"{n} משימות לאישור, מול {tz}","plan.errors.title":"לא ניתן לאשר:","plan.warnings.title":"כדאי לדעת:","plan.deps":"תלוי ב:","plan.will_update":"יעדכן את הכרטיס","plan.empty":"אין משימות לייבוא בתוכנית הזו.","plan.selected.count":"{n} מתוך {total} נבחרו","plan.approve.btn":"אישור ל-Backlog ({n})","plan.approve.none":"לא נבחר דבר","plan.approving":"מייבא…","plan.approve.retry":"נסה שוב","plan.imported.title":"התוכנית יובאה","plan.imported.done":"יובא ל-Backlog","plan.imported.detail":"{c} נוצרו, {u} עודכנו, {s} דולגו","plan.import.err":"ייבוא התוכנית נכשל","msf.plan_kanban":"תוכנית → Kanban","msf.open_kanban":"פתיחת Kanban →","msf.sent":"✅ נשלח","skill.custom_desc":"Skill מותאם אישית","skill.custom_cat":"🔧 מותאם אישית", "files.remote_unavail":"🌐 פרויקט מרוחק —
דפדפן הקבצים לא זמין","files.truncated":"… הרשימה נחתכה — יותר מדי קבצים בתיקייה זו","fpv.remote_binary":"תצוגה מקדימה אינה זמינה לפרויקטים מרוחקים (טקסט בלבד)","hist.drag":"גררו לסידור מחדש","tg.device.connected":"📱 Telegram: {name} התחבר","tg.bot.started":"✅ בוט ה-Telegram הופעל: @{bot}", "compact.btn":"כיווץ וחדש","compact.title":"כיווץ הצ'אט ופתיחת סשן חדש","compact.loading":"מכווץ…","compact.success":"✅ הצ'אט כווץ — פותח סשן חדש","compact.err":"הכיווץ נכשל", "git.clean":"על הענף הראשי","git.ahead":"יש שינויים לא ממוזגים","git.dirty":"יש שינויים לא שמורים","git.conflict":"התנגשות מיזוג","git.commit":"קומיט לשינויים","git.merge":"מיזוג לענף הראשי","git.committed":"✅ בוצע קומיט","git.nothing":"אין שינויים לקומיט","git.merged":"✅ מוזג לענף הראשי","git.err":"שגיאת git", @@ -7685,6 +7721,14 @@

Delegate to Agent

} break; + case 'plan_review': + if (isVisible && d.review) _prRender(d.review, d.messageId); + break; + + case 'plan_import_result': + _prShowResult(d); + break; + case 'bots_turn': { // Persisted per-tab (mirrors agentStatus) so a background tab's turn never leaks // into the strip above a DIFFERENT tab's composer — only render when visible. @@ -9419,6 +9463,110 @@

Delegate to Agent

if (barEl) barEl.style.width = `${Math.round((done / total) * 100)}%`; } +// ─── Plan review card (planner's plan/ package -> Kanban) ─── +// review objects and each card's current checkbox selection, keyed by the DB message id the card +// was posted as (so a WS 'plan_import_result' or a later re-render can find the right card/state). +let _planReviews = {}, _planSelections = {}; + +function _prDepMap(review) { + const rev = {}; // taskId -> [ids that depend on it] + for (const t of review.tasks) for (const d of t.depends_on || []) (rev[d] = rev[d] || []).push(t.id); + return rev; +} +function _prTogglePlanTask(messageId, taskId, on) { + const sel = _planSelections[messageId]; const review = _planReviews[messageId]; + if (!sel || !review) return; + if (on) sel.add(taskId); + else { + // Unchecking a task also unchecks whatever depends on it — never leave a selected task with an + // unselected dependency (the server would skip it anyway; this just shows that up front). + const rev = _prDepMap(review); + const drop = [taskId]; const seen = new Set(); + while (drop.length) { const id = drop.pop(); if (seen.has(id)) continue; seen.add(id); sel.delete(id); for (const dep of (rev[id] || [])) drop.push(dep); } + } + _prRerenderTasks(messageId); +} +function _prRerenderTasks(messageId) { + const card = document.querySelector(`.plan-review-card[data-message-id="${messageId}"]`); + if (!card) return; + const review = _planReviews[messageId], sel = _planSelections[messageId]; + card.querySelectorAll('.prc-task input[type=checkbox]').forEach(cb => { cb.checked = sel.has(cb.dataset.taskId); }); + const btn = card.querySelector('.prc-approve-btn'); + if (btn) { const n = sel.size; btn.disabled = n === 0; btn.textContent = n ? t('plan.approve.btn').replace('{n}', n) : t('plan.approve.none'); } +} +function planReviewCardHtml(review, messageId, imported) { + const errBlock = review.errors.length + ? `
⚠️ ${escH(t('plan.errors.title'))}
    ${review.errors.map(e => `
  • ${escH(e)}
  • `).join('')}
` : ''; + const warnBlock = (!imported && review.warnings.length) + ? `
${escH(t('plan.warnings.title'))}
    ${review.warnings.slice(0, 8).map(w => `
  • ${escH(w)}
  • `).join('')}
` : ''; + const depsTitle = (ids) => ids.length ? `${t('plan.deps')} ${ids.join(', ')}` : ''; + const rows = review.tasks.map(tk => { + const checked = imported ? true : (_planSelections[messageId] ? _planSelections[messageId].has(tk.id) : true); + const issues = [...tk.errors.map(m => `⚠ ${escH(m)}`), ...tk.warnings.map(m => `${escH(m)}`)]; + return ``; + }).join(''); + const footer = imported + ? `✅ ${escH(t('plan.imported.done'))} + ${escH(t('plan.imported.detail').replace('{c}', imported.created).replace('{u}', imported.updated).replace('{s}', imported.skipped.length))}` + : `${escH(t('plan.selected.count').replace('{n}', (_planSelections[messageId] || new Set()).size).replace('{total}', review.tasks.length))} + `; + return `
+
+
📋
+
+
${escH(imported ? t('plan.imported.title') : t('plan.review.title'))}
+
${escH((review.tzPath ? t('plan.review.sub_tz').replace('{tz}', review.tzPath) : t('plan.review.sub')).replace('{n}', review.tasks.length))}
+
+
+
${errBlock}${warnBlock}
+
${rows || `
${escH(t('plan.empty'))}
`}
+ +
`; +} +function _prRender(review, messageId) { + _planReviews[messageId] = review; + if (!_planSelections[messageId]) _planSelections[messageId] = new Set(review.tasks.filter(t => !t.errors.length).map(t => t.id)); + const old = document.querySelector(`.plan-review-card[data-message-id="${messageId}"]`); + if (old) old.remove(); + const wrap = document.createElement('div'); + wrap.innerHTML = planReviewCardHtml(review, messageId, null); + msgsEl.appendChild(wrap.firstElementChild); + scrollBottom(); +} +function _prApprove(messageId) { + if (!ws || ws.readyState !== 1) return; + const sel = _planSelections[messageId]; if (!sel || !sel.size) return; + const btn = document.querySelector(`.plan-review-card[data-message-id="${messageId}"] .prc-approve-btn`); + if (btn) { btn.disabled = true; btn.textContent = t('plan.approving'); } + ws.send(JSON.stringify({ type: 'approve_plan', sessionId: currentSessionId, workdir: curWorkdir || undefined, selected: [...sel], messageId, tabId: activeTabId })); +} +function _prShowResult(d) { + const review = _planReviews[d.messageId]; + const card = document.querySelector(`.plan-review-card[data-message-id="${d.messageId}"]`); + if (!d.ok) { + if (card) { const btn = card.querySelector('.prc-approve-btn'); if (btn) { btn.disabled = false; btn.textContent = t('plan.approve.retry'); } } + toast(d.error || t('plan.import.err'), true); + return; + } + if (review && card) { + const wrap = document.createElement('div'); + wrap.innerHTML = planReviewCardHtml(review, d.messageId, { created: d.created, updated: d.updated, skipped: d.skipped }); + card.replaceWith(wrap.firstElementChild); + } +} + // ─── Chain progress card (dispatched tasks tracking) ──────────────────── let _chainData = null; // { chain_id, session_id, tasks } function _chainProgressRender(chainId, tasks) { @@ -13002,7 +13150,16 @@

Delegate to Agent

} // Add "Done" footer for completed assistant messages in history // Show on the last assistant msg before a user msg or end of conversation - if (m.role === 'assistant' && m.type !== 'agent_plan') { + if (m.role === 'assistant' && m.type === 'plan_review') { + try { + const review = JSON.parse(m.content); + const wrap = document.createElement('div'); + wrap.innerHTML = planReviewCardHtml(review, String(m.id), review.imported || null); + w.querySelector('.msg').before(wrap.firstElementChild); + if (!review.imported && !_planSelections[String(m.id)]) _planReviews[String(m.id)] = review; + } catch {} + } + if (m.role === 'assistant' && m.type !== 'agent_plan' && m.type !== 'plan_review') { const next = _allMsgs[idx + 1]; if (!next || next.role === 'user') { w.appendChild(_msfCreate('done', t('msf.done'))); @@ -13222,6 +13379,7 @@

Delegate to Agent

_allMsgs.push(m); continue; } + if (m.type === 'plan_review') { _allMsgs.push(m); continue; } if (m.role === 'assistant' && m.type === 'text') { if (m.agent_id && _agentTools[m.agent_id]) { // Multi-agent: all tools for this agent (regardless of DB order) @@ -17899,7 +18057,7 @@

Delegate to Agent

const title = tab?.title || 'Chat'; const lines = [`# ${title}\n`]; for (const m of _allMsgs) { - if (!m.content || m.type === 'thinking' || m.type === 'agent_plan') continue; + if (!m.content || m.type === 'thinking' || m.type === 'agent_plan' || m.type === 'plan_review') continue; const header = m.role === 'user' ? '## You' : '## Claude'; lines.push(header, m.content.trim(), ''); } diff --git a/server.js b/server.js index 41ef420..aa5698e 100644 --- a/server.js +++ b/server.js @@ -118,6 +118,8 @@ const { buildTerminalCommand: buildDelegateCommand, winTerminalArgs } = require( const { isAgentSuccess, shouldAutoContinue, agentStopReason, roomStopReason } = require('./multi-agent-result'); const roomFiles = require('./room-files'); const roomGit = require('./room-git'); +const planImport = require('./plan-import'); +const planLib = require('./plan-lib'); const { resolveAgentCommands, supportsTerminal, mergeAgentDefaults, parseNewIdOutput, tmuxNameFor, buildLaunchCommand, isReapCandidate, shouldReap, pickOverflow, TMUX_PREFIX, @@ -956,6 +958,10 @@ try { db.exec(`ALTER TABLE bots ADD COLUMN room_tools TEXT`); } catch {} // columns already here (scheduled_at / recurrence) this is what makes a recurring // job belong to a named specialist rather than to nobody. try { db.exec(`ALTER TABLE tasks ADD COLUMN bot_id TEXT`); } catch {} +// plan_task_id: the id a Kanban card was imported FROM, e.g. "T-003" (plan-lib.js parseTaskFile) — +// how a re-import finds "this card already exists" instead of creating a duplicate every time the +// planner's package is re-approved. NULL for every task not created that way. +try { db.exec(`ALTER TABLE tasks ADD COLUMN plan_task_id TEXT`); } catch {} // Sanitize a value for better-sqlite3 bind parameters. // better-sqlite3 EXPANDS arrays: each element counts as a separate bind value. @@ -1084,6 +1090,7 @@ const stmts = { setRoomRoster: db.prepare(`UPDATE sessions SET room_roster=? WHERE id=?`), deleteSession: db.prepare(`DELETE FROM sessions WHERE id=?`), addMsg: db.prepare(`INSERT INTO messages (session_id,role,type,content,tool_name,agent_id,reply_to_id,attachments) VALUES (?,?,?,?,?,?,?,?)`), + updateMsgContent: db.prepare(`UPDATE messages SET content=? WHERE id=?`), addTelegramMsg: db.prepare(`INSERT INTO messages (session_id,role,type,content,tool_name,agent_id,reply_to_id,attachments,source) VALUES (?,?,?,?,?,?,?,?,'telegram')`), markInterruptDelivered: db.prepare(`UPDATE messages SET type='interrupt_delivered' WHERE id=?`), getMsgs: db.prepare(`SELECT * FROM messages WHERE session_id=? ORDER BY created_at ASC, CASE WHEN type='thinking' THEN 0 ELSE 1 END ASC, id ASC`), @@ -5359,6 +5366,22 @@ async function runConversationRoom(p, { bots, prompt, rosterBots }) { : '⚠️ **No files changed.** The bot reported a result, but nothing in the working directory changed: treat the document as not updated.\n\n'; } if (note) { save(note, null); send({ type: 'text', text: note }); } + + // A planner writes plan/ but never a Kanban card (see plan-import.js): this is the review + // that stands between "files on disk" and "cards on the board", posted the moment those files + // changed — no extra model call, the disk diff above already told us they did. + const planTouched = [...diff.added, ...diff.modified].some(p => p.startsWith('plan/tasks/')); + if (planTouched) { + try { + const review = await buildPlanReview(workdir || WORKDIR); + if (review.tasks.length) { + const content = JSON.stringify(review); + const info = stmts.addMsg.run(sessionId, 'assistant', 'plan_review', content, null, closer ? closer.id : null, null, null); + const messageId = Number(info.lastInsertRowid); + send({ type: 'plan_review', messageId, review }); + } + } catch (e) { log.warn('plan review after room close failed', { err: e.message }); } + } } } @@ -8069,6 +8092,114 @@ app.post('/api/tasks/dispatch', (req, res) => { res.json({ chain_id: chainId, session_id: chainSessionId, tasks: createdTasks }); }); +// ─── Plan → Kanban import ─────────────────────────────────────────────── +// A planner bot writes plan/tasks/*.md, a linted structural preview (plan-lib.js / +// plan-import.js), never a Kanban card — that needs the user's approval, which is what this +// section is for. Cards are created as 'backlog' (board only, nothing runs) so approving a +// plan never starts unattended work by itself; the user moves cards to 'todo' from the board +// when ready, same as any imported checklist (see TASK_MANAGER_INSTRUCTION). + +/** The bots this project actually has (global + linked) — same roster a room in this project would use. */ +function projectBotIds(workdir) { + const proj = loadProjects().find(pr => pr.workdir === workdir); + const rows = proj ? stmts.listProjectBots.all(proj.id) : stmts.listBots.all(); + return rows.map(b => b.id); +} + +/** plan_task_id ("T-003") -> kanban task id, for every card this workdir has already imported. */ +function existingPlanTaskMap(workdir) { + const rows = db.prepare(`SELECT id, plan_task_id FROM tasks WHERE workdir=? AND plan_task_id IS NOT NULL`).all(workdir); + return Object.fromEntries(rows.map(r => [r.plan_task_id, r.id])); +} + +async function buildPlanReview(workdir) { + return planImport.reviewPlan({ workdir, botIds: projectBotIds(workdir), existing: existingPlanTaskMap(workdir) }); +} + +const PLAN_IMPORT_MAX = 200; // a plan is user-approved, not agent-spawned — no MAX_TASK_CHILDREN_PER_RUN-style throttle needed, just a sane ceiling + +/** + * Writes `review`'s selected tasks to the board (transaction: all or nothing). `selected` = null + * means "everything the linter allowed" (buildImportPlan's own default). Never runs anything: + * every card lands as 'backlog'. -> { created, updated, skipped: [{id,reason}] } or throws if the + * review itself has lint errors (the caller must not have offered Approve in that case). + */ +function applyPlanImport({ workdir, review, selected }) { + if (!review.canApprove) { const e = new Error('plan has lint errors'); e.code = 'PLAN_LINT'; throw e; } + if (review.tasks.length > PLAN_IMPORT_MAX) { const e = new Error(`plan has ${review.tasks.length} tasks, over the ${PLAN_IMPORT_MAX}-task import limit`); e.code = 'PLAN_TOO_BIG'; throw e; } + const existing = existingPlanTaskMap(workdir); + const byId = new Map(review.tasks.map(t => [t.id, t])); + const graph = planLib.buildImportPlan({ tasks: review.tasks, errors: [], existing, selected }); + + const idMap = { ...Object.fromEntries(graph.toUpdate.map(t => [t.id, existing[t.id]])) }; + let created = 0, updated = 0; + db.transaction(() => { + for (const t of graph.toCreate) { + const full = byId.get(t.id); + const taskId = genId(); + idMap[t.id] = taskId; + stmts.createTask.run( + taskId, String(full.title || full.id).substring(0, 200), String(full.description || '').substring(0, 2000), + '', 'backlog', 0, null, workdir, sqlVal(full.model), 'auto', 'single', full.max_turns || UNATTENDED_MAX_TURNS, + null, null /* depends_on: filled in the remap pass below */, null, null, null, null, null, null, null, full.bot, + ); + created++; + } + for (const t of graph.toUpdate) { + const full = byId.get(t.id); + const taskId = existing[t.id]; + const row = stmts.getTask.get(taskId); + if (!row) continue; // the card was deleted from under us — treat as nothing to update + stmts.updateTask.run( + String(full.title || full.id).substring(0, 200), String(full.description || '').substring(0, 2000), row.notes, + row.status, row.sort_order, row.session_id, row.workdir, sqlVal(full.model) || row.model, row.mode, row.agent_mode, + full.max_turns || row.max_turns, row.attachments, row.depends_on /* remapped below */, row.chain_id, + row.source_session_id, row.scheduled_at, row.recurrence, row.recurrence_end_at, row.effort, row.run_engine, + full.bot, taskId, + ); + updated++; + } + // Second pass: depends_on remapped to real Kanban ids, now that every id in this batch has one + // (mirrors /api/tasks/dispatch's own two-pass id assignment, for the same forward-reference reason). + for (const t of [...graph.toCreate, ...graph.toUpdate]) { + const full = byId.get(t.id); + const taskId = idMap[t.id]; + const deps = (full.depends_on || []).map(d => idMap[d]).filter(Boolean); + db.prepare(`UPDATE tasks SET depends_on=?, updated_at=datetime('now') WHERE id=?`).run(deps.length ? JSON.stringify(deps) : null, taskId); + stmts.setTaskContext.run(String(full.context || '').substring(0, 10000), null, taskId); + db.prepare(`UPDATE tasks SET plan_task_id=? WHERE id=?`).run(full.id, taskId); + } + })(); + return { created, updated, skipped: graph.toSkip }; +} + +// Dry run: parse + lint the plan/ package of a workdir, no writes. Used by the approval card and +// (optionally) as a stand-alone check, e.g. after editing plan files by hand. +app.post('/api/plans/review', express.json(), async (req, res) => { + const workdir = String((req.body || {}).workdir || '').trim(); + if (!workdir) return res.status(400).json({ error: 'workdir required' }); + try { res.json(await buildPlanReview(workdir)); } + catch (e) { log.error('plan review failed', { workdir, err: e.message }); res.status(500).json({ error: 'could not read the plan' }); } +}); + +// The write: re-reads and re-lints the files fresh (never trusts a review object the client held +// on to — the files, or the board, may have changed since it was shown) and applies `selected`. +app.post('/api/plans/import', express.json(), async (req, res) => { + const workdir = String((req.body || {}).workdir || '').trim(); + const selected = Array.isArray((req.body || {}).selected) ? (req.body || {}).selected : null; + if (!workdir) return res.status(400).json({ error: 'workdir required' }); + try { + const review = await buildPlanReview(workdir); + if (!review.canApprove) return res.status(409).json({ error: 'the plan has lint errors and cannot be approved', review }); + const result = applyPlanImport({ workdir, review, selected }); + log.info('Plan imported', { workdir, ...result, skipped: result.skipped.length }); + res.json(result); + } catch (e) { + log.error('plan import failed', { workdir, err: e.message }); + res.status(e.code === 'PLAN_TOO_BIG' ? 413 : 500).json({ error: e.message }); + } +}); + // Sessions app.get('/api/sessions', (req,res) => { const { workdir } = req.query; @@ -13509,6 +13640,46 @@ wss.on('connection', (ws) => { return; } + // The user's decision on a plan_review card. Re-reads and re-lints the files fresh — never + // trusts the review object the card was built from, which may be stale (the files, the board, + // or the bot roster may have changed since). `selected`: null/omitted = everything the linter + // allowed (Approve all); an array = only those plan ids (a deselected task's dependents are + // skipped by applyPlanImport itself, never imported with a dangling depends_on). + if (msg.type === 'approve_plan') { + (async () => { + try { + const { sessionId, workdir, selected, messageId, tabId } = msg; + const session = sessionId ? stmts.getSession.get(sessionId) : null; + const wd = workdir || session?.workdir || WORKDIR; + const review = await buildPlanReview(wd); + if (!review.canApprove) { + ws.send(JSON.stringify({ type: 'plan_import_result', ok: false, error: 'the plan has lint errors and cannot be approved', review, ...(tabId ? { tabId } : {}) })); + return; + } + const result = applyPlanImport({ workdir: wd, review, selected: Array.isArray(selected) ? selected : null }); + log.info('Plan imported via WS', { workdir: wd, ...result, skipped: result.skipped.length }); + // The persisted card is patched with the outcome, so a page reload shows "imported", not an + // approve button that would re-run the import a second time. + if (messageId) { + try { + const patched = { ...review, imported: { ...result, at: new Date().toISOString() } }; + stmts.updateMsgContent.run(JSON.stringify(patched), messageId); + } catch {} + } + const note = getUserLang() === 'ru' + ? `✅ В Backlog: создано ${result.created}, обновлено ${result.updated}${result.skipped.length ? `, пропущено ${result.skipped.length}` : ''}.\n\n` + : `✅ To Backlog: ${result.created} created, ${result.updated} updated${result.skipped.length ? `, ${result.skipped.length} skipped` : ''}.\n\n`; + if (sessionId) { try { stmts.addMsg.run(sessionId, 'assistant', 'text', note, null, null, null, null); } catch {} } + ws.send(JSON.stringify({ type: 'plan_import_result', ok: true, ...result, messageId, ...(tabId ? { tabId } : {}) })); + if (sessionId) ws.send(JSON.stringify({ type: 'text', text: note, ...(tabId ? { tabId } : {}) })); + } catch (e) { + log.error('approve_plan error', { error: e.message }); + ws.send(JSON.stringify({ type: 'plan_import_result', ok: false, error: e.message, ...(msg.tabId ? { tabId: msg.tabId } : {}) })); + } + })(); + return; + } + // The ONLY path that re-runs an interrupted turn. Explicit by construction — the client // sends it from a button, never on its own. The turn is rebuilt from the SESSION's stored // config, not from whatever the browser currently has selected: the old auto-retry re-sent diff --git a/test/plan-import.test.js b/test/plan-import.test.js new file mode 100644 index 0000000..9358770 --- /dev/null +++ b/test/plan-import.test.js @@ -0,0 +1,142 @@ +// plan-import.js against a fake in-memory filesystem — no real project directory needed. +// Run: node test/plan-import.test.js +'use strict'; +const assert = require('assert'); +const path = require('path'); +const { readPlanFiles, reviewPlan } = require('../plan-import'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} +async function checkAsync(label, promise, expected) { check(label, await promise, expected); } + +/** A minimal fs.promises stand-in over a flat { 'abs/path': 'content' } map, `dirs`: which + * paths are directories (readdir lists their immediate children). */ +function fakeFs(files) { + const dirs = new Map(); // dirPath -> Set(childName) + for (const p of Object.keys(files)) { + let d = path.dirname(p); + let child = path.basename(p); + while (true) { + if (!dirs.has(d)) dirs.set(d, new Set()); + dirs.get(d).add(child); + const parent = path.dirname(d); + if (parent === d) break; + child = path.basename(d); d = parent; + } + } + const enoent = (p) => Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' }); + return { + promises: { + async readFile(p) { if (!(p in files)) throw enoent(p); return files[p]; }, + async readdir(p) { if (!dirs.has(p)) throw enoent(p); return [...dirs.get(p)]; }, + }, + }; +} + +const T1 = `--- +id: T-001 +title: Init +bot: kolya-prohramist +depends_on: [] +covers: [1. Цель] +--- +## Критерии приёмки +x +## Проверка +y +`; +const T2 = `--- +id: T-002 +title: Tests +bot: taras-qa +depends_on: [T-001] +covers: [2. Хранение] +--- +## Критерии приёмки +x +## Проверка +y +`; + +console.log('readPlanFiles:'); +(async () => { + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1, '/wd/plan/tasks/T-002.md': T2, '/wd/plan/00-overview.md': 'overview text', '/wd/TZ-project.md': '## 1. Цель\ntext\n## 2. Хранение\ntext' }); + const r = await readPlanFiles({ workdir: '/wd', fsImpl: fs }); + check('finds both task files, parsed', [r.hasPlanDir, r.tasks.map(t => t.id), r.parseErrors], [true, ['T-001', 'T-002'], []]); + check('reads the overview', r.overview, 'overview text'); + check('guesses the ТЗ file from the root, by name', r.tzPath, 'TZ-project.md'); + check('...and extracts its headings for coverage', r.tzHeadings, ['1. Цель', '2. Хранение']); + } + { + const fs = fakeFs({ '/wd/README.md': 'x' }); // no plan/ at all + await checkAsync('no plan/ directory: reported, not thrown', readPlanFiles({ workdir: '/wd', fsImpl: fs }), + { hasPlanDir: false, tasks: [], parseErrors: [], overview: null, tzPath: null, tzHeadings: null }); + } + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1, '/wd/plan/tasks/broken.md': 'no front matter' }); + const r = await readPlanFiles({ workdir: '/wd', fsImpl: fs }); + check('a malformed file is a parse error, the good one still parses', [r.tasks.map(t => t.id), r.parseErrors.length], [['T-001'], 1]); + } + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1 }); // no root .md file at all + const r = await readPlanFiles({ workdir: '/wd', fsImpl: fs }); + check('no ТЗ-looking file: no crash, coverage simply unavailable', [r.tzPath, r.tzHeadings], [null, null]); + } + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1, '/wd/spec.md': 'x', '/wd/other.md': 'y' }); + check('an unrelated .md file is not mistaken for the ТЗ', (await readPlanFiles({ workdir: '/wd', fsImpl: fs })).tzPath, null); + } + { + // a read error on the tasks dir that is NOT "missing" (e.g. permissions) is surfaced, not swallowed + const fs = { promises: { readdir: async () => { throw Object.assign(new Error('EACCES: denied'), { code: 'EACCES' }); }, readFile: async () => { throw new Error('n/a'); } } }; + const r = await readPlanFiles({ workdir: '/wd', fsImpl: fs }); + check('a real read failure is reported as a parse error, plan/ is not silently "absent"', [r.hasPlanDir, r.parseErrors.length > 0], [false, true]); + } + + console.log('reviewPlan:'); + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1, '/wd/plan/tasks/T-002.md': T2, '/wd/TZ.md': '## 1. Цель\nx\n## 2. Хранение\nx\n## 3. Безопасность\nx' }); + const bots = ['kolya-prohramist', 'taras-qa', 'planner']; + const r = await reviewPlan({ workdir: '/wd', botIds: bots, fsImpl: fs }); + check('two clean tasks, no errors, can approve', [r.tasks.length, r.errors, r.canApprove], [2, [], true]); + check('a task carries its per-task errors/warnings (empty here) and existingTaskId', r.tasks[0].errors, []); + check('no card is pre-marked as already imported when `existing` is empty', r.tasks.every(t => t.existingTaskId === null), true); + check('coverage: 2 of 3 ТЗ sections, one uncovered', r.coverage, { total: 3, covered: 2, uncovered: ['3. Безопасность'] }); + check('uncovered section is also a plan-level warning', r.warnings.some(w => w.includes('3. Безопасность')), true); + check('a card description is capped and matches the body when short', [r.tasks[0].description.length <= 2000, r.tasks[0].description], [true, r.tasks[0].context]); + } + { + const fs = fakeFs({}); + const r = await reviewPlan({ workdir: '/wd', botIds: ['x'], fsImpl: fs }); + check('no plan/ at all: cannot approve, no tasks, no crash', [r.hasPlanDir, r.tasks, r.canApprove], [false, [], false]); + } + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1.replace('bot: kolya-prohramist', 'bot: nobody') }); + const r = await reviewPlan({ workdir: '/wd', botIds: ['kolya-prohramist'], fsImpl: fs }); + check('an unknown bot blocks approval and is named on the task', [r.canApprove, r.tasks[0].errors], [false, ['unknown bot "nobody"']]); + } + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1, '/wd/plan/tasks/T-002.md': T2 }); + const r = await reviewPlan({ workdir: '/wd', botIds: ['kolya-prohramist', 'taras-qa'], existing: { 'T-001': 'kanban-abc' }, fsImpl: fs }); + check('a task already imported once carries the kanban id it maps to', [r.tasks.find(t => t.id === 'T-001').existingTaskId, r.tasks.find(t => t.id === 'T-002').existingTaskId], ['kanban-abc', null]); + } + { + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': T1 }); + const r = await reviewPlan({ workdir: '/wd', botIds: ['kolya-prohramist'], fsImpl: fs }); + check('overview.md missing is fine', r.overview, null); + check('generatedAt is a real timestamp', typeof r.generatedAt === 'string' && !Number.isNaN(Date.parse(r.generatedAt)), true); + } + { + const long = T1.replace('## Критерии приёмки\nx\n## Проверка\ny\n', '## Критерии приёмки\n' + 'x'.repeat(2500)); + const fs = fakeFs({ '/wd/plan/tasks/T-001.md': long }); + const r = await reviewPlan({ workdir: '/wd', botIds: ['kolya-prohramist'], fsImpl: fs }); + check('a long task body is capped for the card description, the full text stays in context', [r.tasks[0].description.length <= 2000, r.tasks[0].context.length > 2000], [true, true]); + } + + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})(); diff --git a/test/plan-lib.test.js b/test/plan-lib.test.js new file mode 100644 index 0000000..d6f40d9 --- /dev/null +++ b/test/plan-lib.test.js @@ -0,0 +1,147 @@ +// Pure-logic verification for plan-lib.js. Run: node test/plan-lib.test.js +'use strict'; +const assert = require('assert'); +const { + parseScalar, parseTaskFile, extractHeadings, headingCovered, + lintPlan, buildImportPlan, pickReady, PLAN_TASK_ID_RE, +} = require('../plan-lib'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} + +console.log('parseScalar:'); +check('a bare word', parseScalar('kolya-prohramist'), 'kolya-prohramist'); +check('an inline list', parseScalar('[ТЗ §3.2, ТЗ §5]'), ['ТЗ §3.2', 'ТЗ §5']); +check('an empty list', parseScalar('[]'), []); +check('surrounding whitespace is trimmed', parseScalar(' sonnet '), 'sonnet'); +check('missing / empty', [parseScalar(undefined), parseScalar('')], ['', '']); + +console.log('parseTaskFile:'); +const T1 = `--- +id: T-001 +title: Инициализация репозитория +bot: kolya-prohramist +depends_on: [] +model: sonnet +max_turns: 20 +covers: [ТЗ §1, ТЗ §2 Хранение] +status: backlog +--- +Создать структуру репозитория. + +## Контекст +Монорепо, backend + frontend. + +## Что сделать +- Создать пакеты. + +## Критерии приёмки +- Репозиторий собирается. + +## Проверка +\`npm run build\` +`; +{ + const r = parseTaskFile(T1, 'plan/tasks/T-001-init.md'); + check('parses ok', r.ok, true); + check('id / title / bot', [r.task.id, r.task.title, r.task.bot], ['T-001', 'Инициализация репозитория', 'kolya-prohramist']); + check('depends_on empty list, not the string "[]"', r.task.depends_on, []); + check('covers, two entries', r.task.covers, ['ТЗ §1', 'ТЗ §2 Хранение']); + check('model / max_turns are typed', [r.task.model, r.task.max_turns], ['sonnet', 20]); + check('status defaults are not silently overwritten by a real value', r.task.status, 'backlog'); + check('body is everything after the closing ---, trimmed', r.task.body.startsWith('Создать структуру') && r.task.body.includes('## Критерии приёмки'), true); + check('file path travels through', r.task.file, 'plan/tasks/T-001-init.md'); +} +{ + const r = parseTaskFile('no front matter here', 'x.md'); + check('no --- block: a reported error, not a throw', [r.ok, /no `---` front matter/.test(r.error)], [false, true]); +} +check('missing id', parseTaskFile('---\ntitle: x\n---\nbody', 'x.md').ok, false); +check('a malformed id is rejected (would break a card badge / the depends_on graph)', + ['T 001', 'T-001!', 'a'.repeat(40), ''].map(id => parseTaskFile(`---\nid: ${id || '""'}\ntitle: x\n---\n`, 'x.md').ok), [false, false, false, false]); +check('a single-char and a max-length id are fine', ['t', 'A' + 'b'.repeat(31)].map(id => parseTaskFile(`---\nid: ${id}\ntitle: x\n---\n`, 'x.md').ok), [true, true]); +{ + const r = parseTaskFile('---\nid: T-002\ntitle: x\ndepends_on: T-001\ncovers: solo\n---\nbody', 'x.md'); + check('a single non-list value for depends_on/covers is still an array', [r.task.depends_on, r.task.covers], [['T-001'], ['solo']]); +} +check('missing model/max_turns/covers/depends_on are safe defaults, not crashes', + parseTaskFile('---\nid: T-003\ntitle: x\nbot: kolya-prohramist\n---\n', 'x.md').task, + { id: 'T-003', title: 'x', bot: 'kolya-prohramist', depends_on: [], covers: [], model: null, max_turns: null, status: 'backlog', body: '', file: 'x.md' }); +check('CRLF line endings do not break the front-matter boundary', parseTaskFile('---\r\nid: T-004\r\ntitle: x\r\n---\r\nbody\r\n', 'x.md').ok, true); +check('a comment line inside the front matter is skipped, not treated as a field', parseTaskFile('---\n# a note\nid: T-005\ntitle: x\n---\n', 'x.md').task.id, 'T-005'); + +console.log('headings and coverage:'); +check('## and ### headings, not deeper, in order', extractHeadings('# H0\n## 1. Цель\ntext\n### 1.1 Деталь\n#### too deep\n## 2. Хранение'), + ['H0', '1. Цель', '1.1 Деталь', '2. Хранение']); +check('a heading matched by shared words', headingCovered('2. Хранение данных', [['ТЗ §2 хранение и бэкапы']]), true); +check('an unrelated heading is not', headingCovered('9. Совсем другое', [['ТЗ §2 хранение']]), false); +check('a short/empty heading is never falsely "uncovered"', [headingCovered('', [[]]), headingCovered('AB', [[]])], [true, true]); +check('no covers anywhere', headingCovered('2. Хранение', []), false); + +console.log('lintPlan — errors:'); +const BOTS = ['kolya-prohramist', 'taras-qa', 'planner']; +const task = (over) => ({ id: 'T-001', title: 't', bot: 'kolya-prohramist', depends_on: [], covers: ['x'], model: null, max_turns: null, status: 'backlog', body: '## Критерии приёмки\nx\n## Проверка\ny', file: 'f.md', ...over }); +check('a clean task set has no errors', lintPlan({ tasks: [task()], botIds: BOTS }).errors, []); +check('a parse error becomes a plan-level error', lintPlan({ tasks: [], parseErrors: ['bad.md: no id'], botIds: BOTS }).errors, [{ taskId: null, message: 'bad.md: no id' }]); +check('duplicate ids', lintPlan({ tasks: [task(), task({ file: 'g.md' })], botIds: BOTS }).errors.some(e => /duplicate id "T-001"/.test(e.message)), true); +check('an unknown bot', lintPlan({ tasks: [task({ bot: 'nobody' })], botIds: BOTS }).errors.some(e => /unknown bot "nobody"/.test(e.message)), true); +check('a deleted-but-still-named bot is the same as unknown (botIds is the LIVE roster)', lintPlan({ tasks: [task({ bot: 'ex-bot' })], botIds: BOTS }).errors.length > 0, true); +check('a missing bot', lintPlan({ tasks: [task({ bot: '' })], botIds: BOTS }).errors.some(e => e.message === 'missing bot'), true); +check('a missing title', lintPlan({ tasks: [task({ title: '' })], botIds: BOTS }).errors.some(e => e.message === 'missing title'), true); +check('depends_on itself', lintPlan({ tasks: [task({ depends_on: ['T-001'] })], botIds: BOTS }).errors.some(e => e.message === 'depends on itself'), true); +check('depends_on a ghost id', lintPlan({ tasks: [task({ depends_on: ['T-999'] })], botIds: BOTS }).errors.some(e => /depends on "T-999"/.test(e.message)), true); +{ + const cyc = lintPlan({ tasks: [task({ id: 'A', depends_on: ['B'] }), task({ id: 'B', depends_on: ['A'] })], botIds: BOTS }).errors; + check('a 2-cycle: both named, not a crash', cyc.filter(e => e.message === 'part of a dependency cycle').map(e => e.taskId).sort(), ['A', 'B']); +} +{ + const ok = lintPlan({ tasks: [task({ id: 'A' }), task({ id: 'B', depends_on: ['A'] }), task({ id: 'C', depends_on: ['A', 'B'] })], botIds: BOTS }).errors; + check('a real (non-cyclic) chain raises no cycle error', ok.filter(e => e.message === 'part of a dependency cycle'), []); +} + +console.log('lintPlan — warnings (do not block):'); +check('no acceptance section', lintPlan({ tasks: [task({ body: '## Проверка\nx' })], botIds: BOTS }).warnings.some(w => /acceptance/.test(w.message)), true); +check('no verification section', lintPlan({ tasks: [task({ body: '## Критерии приёмки\nx' })], botIds: BOTS }).warnings.some(w => /verification/.test(w.message)), true); +check('no covers at all', lintPlan({ tasks: [task({ covers: [] })], botIds: BOTS }).warnings.some(w => w.message === 'covers no ТЗ section'), true); +check('warnings never appear in errors', lintPlan({ tasks: [task({ covers: [] })], botIds: BOTS }).errors, []); + +console.log('lintPlan — coverage:'); +{ + const tz = ['1. Цель', '2. Хранение', '3. Безопасность']; + const r = lintPlan({ tasks: [task({ covers: ['1. Цель'] }), task({ id: 'T-002', covers: ['2. Хранение'] })], botIds: BOTS, tzHeadings: tz }); + check('covered / uncovered are counted', r.coverage, { total: 3, covered: 2, uncovered: ['3. Безопасность'] }); + check('an uncovered section is also a warning, named', r.warnings.some(w => w.taskId === null && w.message.includes('3. Безопасность')), true); +} +check('no tzHeadings given: no coverage object, no crash', lintPlan({ tasks: [task()], botIds: BOTS }).coverage, null); +check('every section covered: no warning about it', lintPlan({ tasks: [task({ covers: ['x'] })], botIds: BOTS, tzHeadings: ['x'] }).warnings.some(w => /not covered/.test(w.message)), false); + +console.log('pickReady:'); +{ + const A = { id: 'A', depends_on: [] }, B = { id: 'B', depends_on: ['A'] }, C = { id: 'C', depends_on: ['A', 'B'] }; + check('only A is ready at the start', pickReady([A, B, C], new Set()).map(t => t.id), ['A']); + check('B becomes ready once A is done', pickReady([B, C], new Set(['A'])).map(t => t.id), ['B']); + check('C needs both', pickReady([C], new Set(['A'])).map(t => t.id), []); +} + +console.log('buildImportPlan:'); +{ + const tasks = [task({ id: 'A' }), task({ id: 'B', depends_on: ['A'] }), task({ id: 'C', depends_on: ['A'] })]; + const all = buildImportPlan({ tasks, errors: [] }); + check('selected=null (default): everything creates, nothing skipped', [all.toCreate.map(t => t.id), all.toUpdate, all.toSkip], [['A', 'B', 'C'], [], []]); + const some = buildImportPlan({ tasks, errors: [], selected: ['A', 'B'] }); + check('C (not selected) is skipped, named', some.toSkip, [{ id: 'C', reason: 'not selected' }]); + check('A and B still create', some.toCreate.map(t => t.id), ['A', 'B']); + const brokenDep = buildImportPlan({ tasks, errors: [], selected: ['B'] }); + check('B alone: its dependency A was not selected, so B is skipped too — never a dangling depends_on', + brokenDep.toSkip, [{ id: 'A', reason: 'not selected' }, { id: 'B', reason: 'depends on "A", which was not selected' }, { id: 'C', reason: 'not selected' }]); + const existing = buildImportPlan({ tasks, errors: [], existing: { A: 'kanban-id-1' } }); + check('a plan id already imported updates instead of creating a duplicate', [existing.toUpdate.map(t => t.id), existing.toCreate.map(t => t.id)], [['A'], ['B', 'C']]); + check('lint errors refuse the whole import, every task named with a reason', buildImportPlan({ tasks, errors: [{ taskId: 'A', message: 'x' }] }), + { toCreate: [], toUpdate: [], toSkip: [{ id: 'A', reason: 'plan has lint errors' }, { id: 'B', reason: 'plan has lint errors' }, { id: 'C', reason: 'plan has lint errors' }] }); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/test/plans-api.test.js b/test/plans-api.test.js new file mode 100644 index 0000000..542766c --- /dev/null +++ b/test/plans-api.test.js @@ -0,0 +1,153 @@ +// Integration test for POST /api/plans/review and /api/plans/import. Starts a real server against a +// THROWAWAY data directory and a real plan/ folder on disk, so it never touches the developer's data. +// Run standalone: node test/plans-api.test.js +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const net = require('net'); +const { spawn } = require('child_process'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +const PORT = parseInt(process.env.TEST_PORT || '', 10) || 3998; +const BASE = `http://127.0.0.1:${PORT}`; +const APP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-planstest-app-')); +const WORKDIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-planstest-wd-')); +process.on('exit', () => { for (const d of [APP_DIR, WORKDIR]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } }); +fs.mkdirSync(path.join(APP_DIR, 'data'), { recursive: true }); + +function probePort(port) { + return new Promise(resolve => { + const s = net.createConnection({ host: '127.0.0.1', port }); + const done = busy => { try { s.destroy(); } catch {} resolve(busy); }; + s.once('connect', () => done(true)); + s.once('error', () => done(false)); + setTimeout(() => done(false), 1000); + }); +} +async function api(method, url, body) { + const r = await fetch(BASE + url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined }); + let json = null; try { json = await r.json(); } catch {} + return { status: r.status, json }; +} + +function writeTask(id, { bot = 'kolya-prohramist', depends_on = [], covers = ['1. Цель'], title = 'Задача', extraBody = '## Критерии приёмки\nx\n## Проверка\ny\n' } = {}) { + fs.mkdirSync(path.join(WORKDIR, 'plan', 'tasks'), { recursive: true }); + fs.writeFileSync(path.join(WORKDIR, 'plan', 'tasks', `${id}.md`), + `---\nid: ${id}\ntitle: ${title}\nbot: ${bot}\ndepends_on: [${depends_on.join(', ')}]\ncovers: [${covers.join(', ')}]\n---\n${extraBody}`); +} +function clearPlan() { try { fs.rmSync(path.join(WORKDIR, 'plan'), { recursive: true, force: true }); } catch {} } + +(async () => { + if (await probePort(PORT)) { console.error(`port ${PORT} in use — set TEST_PORT`); process.exit(1); } + + const srv = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { + env: { ...process.env, PORT: String(PORT), CCS_DESKTOP: '1', APP_DIR, WORKDIR, ANTHROPIC_BASE_URL: '', ANTHROPIC_AUTH_TOKEN: '', ANTHROPIC_API_KEY: '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let log = ''; srv.stdout.on('data', d => { log += d; }); srv.stderr.on('data', d => { log += d; }); + const stop = () => { try { srv.kill('SIGTERM'); } catch {} }; + process.on('exit', stop); + for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { stop(); process.exit(1); }); + + let up = false; + for (let i = 0; i < 80; i++) { try { const r = await fetch(`${BASE}/api/health`); if (r.ok) { up = true; break; } } catch {} await sleep(250); } + if (!up) { console.error('server did not start\n' + log); stop(); process.exit(1); } + + // A global bot, visible without registering a project. + const kolya = (await api('POST', '/api/bots', { label: 'Kolya', id: 'kolya-prohramist', isGlobal: true })).json.id; + const taras = (await api('POST', '/api/bots', { label: 'Taras', id: 'taras-qa', isGlobal: true })).json.id; + + console.log('review, no plan/ yet:'); + { + const r = await api('POST', '/api/plans/review', { workdir: WORKDIR }); + check('no plan/ directory: an empty, non-approvable review, not an error', [r.status, r.json.hasPlanDir, r.json.canApprove], [200, false, false]); + } + check('workdir is required', (await api('POST', '/api/plans/review', {})).status, 400); + + console.log('review, a clean plan:'); + writeTask('T-001', { covers: ['1. Введение'] }); + writeTask('T-002', { bot: 'taras-qa', depends_on: ['T-001'], covers: ['2. Тесты'] }); + { + const r = await api('POST', '/api/plans/review', { workdir: WORKDIR }); + check('two tasks, no errors, can approve', [r.status, r.json.tasks.length, r.json.errors, r.json.canApprove], [200, 2, [], true]); + check('bots resolved as the real global roster', r.json.tasks.map(t => t.bot).sort(), ['kolya-prohramist', 'taras-qa']); + } + + console.log('import refuses a plan with lint errors:'); + writeTask('T-003', { bot: 'no-such-bot' }); + { + const r = await api('POST', '/api/plans/import', { workdir: WORKDIR }); + check('409, the review travels with the refusal', [r.status, r.json.error, r.json.review.canApprove], [409, 'the plan has lint errors and cannot be approved', false]); + check('nothing was written to the board', (await api('GET', `/api/tasks?workdir=${encodeURIComponent(WORKDIR)}`)).json.length, 0); + } + fs.rmSync(path.join(WORKDIR, 'plan', 'tasks', 'T-003.md')); + + console.log('import, the whole plan:'); + let created; + { + const r = await api('POST', '/api/plans/import', { workdir: WORKDIR }); + check('creates both, updates none, skips none', [r.status, r.json.created, r.json.updated, r.json.skipped], [200, 2, 0, []]); + const board = (await api('GET', `/api/tasks?workdir=${encodeURIComponent(WORKDIR)}`)).json; + created = board; + check('both land as backlog cards', board.map(t => t.status).sort(), ['backlog', 'backlog']); + check('bot_id is the real handle from the file, for both cards', board.map(t => t.bot_id).sort(), ['kolya-prohramist', 'taras-qa']); + check('titles round-trip', new Set(board.map(t => t.title)), new Set(['Задача'])); + const t2 = board.find(t => t.bot_id === 'taras-qa'); + check('depends_on is remapped to a real Kanban id (T-002 -> T-001s real id)', JSON.parse(t2.depends_on || '[]'), [board.find(t => t.bot_id === 'kolya-prohramist').id]); + } + + console.log('re-import: updates in place, no duplicates:'); + writeTask('T-001', { covers: ['1. Введение'], title: 'Задача (переписана)' }); + { + const r = await api('POST', '/api/plans/import', { workdir: WORKDIR }); + check('T-001 updates, T-002 updates too (title text unchanged but still an update, not a dup)', [r.status, r.json.created, r.json.updated], [200, 0, 2]); + const board = (await api('GET', `/api/tasks?workdir=${encodeURIComponent(WORKDIR)}`)).json; + check('still exactly two cards — no duplicate created', board.length, 2); + check('the title change reached the card', board.some(t => t.title === 'Задача (переписана)'), true); + } + + console.log('a card already moved along keeps its status:'); + { + const t1 = created.find(t => t.bot_id === 'kolya-prohramist'); + await api('PUT', `/api/tasks/${t1.id}`, { status: 'in_progress' }); + writeTask('T-001', { covers: ['1. Введение'], title: 'Задача (ещё раз переписана)' }); + await api('POST', '/api/plans/import', { workdir: WORKDIR }); + const row = (await api('GET', `/api/tasks?workdir=${encodeURIComponent(WORKDIR)}`)).json.find(t => t.id === t1.id); + check('the text updates, the status a human already changed does not get reset to backlog', [row.title, row.status], ['Задача (ещё раз переписана)', 'in_progress']); + } + + console.log('selecting a subset:'); + clearPlan(); + writeTask('A', { covers: ['x'] }); + writeTask('B', { bot: 'taras-qa', depends_on: ['A'], covers: ['x'] }); + writeTask('C', { bot: 'taras-qa', covers: ['x'] }); + { + const before = (await api('GET', `/api/tasks?workdir=${encodeURIComponent(WORKDIR)}`)).json.length; + const r = await api('POST', '/api/plans/import', { workdir: WORKDIR, selected: ['C'] }); + check('only C is created; A and B (not selected) are skipped, B named why', [r.json.created, r.json.skipped.sort((x, y) => x.id.localeCompare(y.id))], + [1, [{ id: 'A', reason: 'not selected' }, { id: 'B', reason: 'not selected' }]]); + check('board grew by exactly one', (await api('GET', `/api/tasks?workdir=${encodeURIComponent(WORKDIR)}`)).json.length, before + 1); + } + { + // deselecting A (a dependency of B) while selecting B: B must not import with a dangling depends_on + const r = await api('POST', '/api/plans/import', { workdir: WORKDIR, selected: ['B'] }); + check('B alone (its dependency A not selected) is skipped, not imported half-broken', r.json.skipped.some(s => s.id === 'B' && /A/.test(s.reason)), true); + } + + console.log('the import cap:'); + clearPlan(); + for (let i = 0; i < 3; i++) writeTask(`M-${i}`, { covers: ['x'] }); + check('a small plan is fine (cap sanity, not exercising the real 200-task limit here)', (await api('POST', '/api/plans/review', { workdir: WORKDIR })).json.canApprove, true); + + stop(); + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(fail ? 1 : 0); +})().catch(e => { console.error(e); stop(); process.exit(1); }); diff --git a/test/render/plan-review.test.mjs b/test/render/plan-review.test.mjs new file mode 100644 index 0000000..2078684 --- /dev/null +++ b/test/render/plan-review.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { loadFn } from './_load.mjs'; + +// The plan-review card (planner's plan/ package -> Kanban approval). planReviewCardHtml is pure +// (given the global selection state it reads); the DOM-driving functions around it are checked by +// wiring assertions against the source, same as bot-engine.test.mjs does for that editor. +const html = readFileSync(new URL('../../public/index.html', import.meta.url), 'utf8'); + +const T = (id, over = {}) => ({ id, title: `Task ${id}`, bot: 'kolya-prohramist', depends_on: [], covers: [], model: null, max_turns: null, file: `plan/tasks/${id}.md`, description: 'd', context: 'c', errors: [], warnings: [], existingTaskId: null, ...over }); +const REVIEW = (over = {}) => ({ workdir: '/wd', hasPlanDir: true, tzPath: null, overview: null, tasks: [T('T-001'), T('T-002', { depends_on: ['T-001'] })], errors: [], warnings: [], coverage: null, canApprove: true, generatedAt: '2026-01-01T00:00:00Z', ...over }); + +Object.assign(globalThis, { + escH: (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'), + t: (k) => ({ + 'plan.review.title': 'Обзор плана', 'plan.review.sub': '{n} задач', 'plan.review.sub_tz': '{n} задач, по {tz}', + 'plan.errors.title': 'Нельзя утвердить:', 'plan.warnings.title': 'Стоит знать:', 'plan.deps': 'Зависит от:', + 'plan.will_update': 'обновит карточку', 'plan.empty': 'Нет задач.', 'plan.selected.count': '{n} из {total} выбрано', + 'plan.approve.btn': 'Утвердить ({n})', 'plan.approve.none': 'Ничего не выбрано', 'plan.approving': 'Импортирую…', + 'plan.approve.retry': 'Повторить', 'plan.imported.title': 'План импортирован', 'plan.imported.done': 'Импортировано', + 'plan.imported.detail': 'создано {c}, обновлено {u}, пропущено {s}', 'plan.import.err': 'Ошибка', + })[k] || k, + _planSelections: {}, _planReviews: {}, +}); +const planReviewCardHtml = loadFn('planReviewCardHtml'); + +// ── not yet imported: checkboxes, both tasks, approve button enabled and counted +{ + globalThis._planSelections['m1'] = new Set(['T-001', 'T-002']); + const out = planReviewCardHtml(REVIEW(), 'm1', null); + assert.ok(out.includes('data-message-id="m1"'), 'card carries the message id (for later updates)'); + assert.strictEqual((out.match(/data-task-id="T-00/g) || []).length, 2, 'one checkbox per task'); + assert.ok(out.includes('checked'), 'selected tasks start checked'); + assert.ok(!/prc-approve-btn"[^>]*disabled/.test(out), 'approve is enabled with everything selected and no lint errors'); + assert.ok(out.includes('Утвердить (2)'), 'the button counts the current selection'); + assert.ok(out.includes('Обзор плана') && !out.includes('План импортирован'), 'review title, not the imported one'); + assert.ok(out.includes('T-001') && out.includes('kolya-prohramist'), 'task id and bot are shown'); + assert.ok(out.includes('Зависит от:') && out.includes('T-001'), "T-002's dependency on T-001 is shown"); +} + +// ── errors block the whole approval; a task with its own error is disabled and unchecked +{ + const withErr = REVIEW({ errors: ['duplicate id "T-001"'], canApprove: false, tasks: [T('T-001', { errors: ['duplicate id "T-001"'] }), T('T-002')] }); + globalThis._planSelections['m2'] = new Set(['T-002']); + const out = planReviewCardHtml(withErr, 'm2', null); + assert.ok(/prc-approve-btn"[^>]*disabled/.test(out), 'a plan-level error disables approval'); + assert.ok(out.includes('Нельзя утвердить:') && out.includes('duplicate id'), 'the error is shown, named'); + assert.ok(/data-task-id="T-001"[^>]*disabled/.test(out), "a task WITH its own error can't be checked"); + assert.ok(out.includes('prc-task-err'), 'that row is visually flagged'); +} + +// ── warnings show but do not disable +{ + const withWarn = REVIEW({ warnings: ['1 of 2 ТЗ sections not covered'] }); + globalThis._planSelections['m3'] = new Set(['T-001', 'T-002']); + const out = planReviewCardHtml(withWarn, 'm3', null); + assert.ok(out.includes('Стоит знать:') && out.includes('not covered'), 'warnings are shown'); + assert.ok(!/prc-approve-btn"[^>]*disabled/.test(out), 'a warning alone does not disable approval'); +} + +// ── the ТЗ file name, when known, is in the subtitle +{ + globalThis._planSelections['m4'] = new Set(['T-001', 'T-002']); + const out = planReviewCardHtml(REVIEW({ tzPath: 'TZ-project.md' }), 'm4', null); + assert.ok(out.includes('TZ-project.md'), 'the guessed ТЗ file is named'); +} + +// ── a task already imported once is marked, without a checkbox re-creating a duplicate concern +{ + globalThis._planSelections['m5'] = new Set(['T-001', 'T-002']); + const out = planReviewCardHtml(REVIEW({ tasks: [T('T-001', { existingTaskId: 'kanban-1' }), T('T-002')] }), 'm5', null); + assert.ok(out.includes('обновит карточку'), 'an already-imported task says it will update, not duplicate'); +} + +// ── no tasks at all: a plain message, not a broken empty table +{ + const out = planReviewCardHtml(REVIEW({ tasks: [], canApprove: false }), 'm6', null); + assert.ok(out.includes('Нет задач.'), 'an empty plan says so'); +} + +// ── the imported (done) state: no checkboxes, no approve button, counts shown +{ + const out = planReviewCardHtml(REVIEW(), 'm7', { created: 2, updated: 1, skipped: [{ id: 'X', reason: 'not selected' }] }); + assert.ok(!out.includes('type="checkbox"') && !out.includes('prc-approve-btn'), 'no interactive controls once imported'); + assert.ok(out.includes('План импортирован') && out.includes('Импортировано'), 'the done title and line are shown'); + assert.ok(out.includes('создано 2, обновлено 1, пропущено 1'), 'the counts are substituted in'); +} + +// ── wiring: WS cases, DB-restore path, ingestion-loop branch, exclusions, saveBot-style hidden state +assert.ok(/case 'plan_review':[\s\S]{0,120}_prRender\(d\.review, d\.messageId\)/.test(html), "the WS 'plan_review' frame renders the live card"); +assert.ok(/case 'plan_import_result':[\s\S]{0,60}_prShowResult\(d\)/.test(html), "the WS 'plan_import_result' frame updates it"); +assert.ok(/m\.role === 'assistant' && m\.type === 'plan_review'/.test(html), 'a reloaded session restores the card from its DB row'); +assert.ok(/m\.type === 'plan_review'\) \{ _allMsgs\.push\(m\); continue; \}/.test(html), 'the ingestion loop keeps plan_review rows for the restore pass'); +assert.ok(/m\.type !== 'agent_plan' && m\.type !== 'plan_review'/.test(html), 'no stray "Done" footer glued under the card'); +assert.ok(/m\.type === 'agent_plan' \|\| m\.type === 'plan_review'\) continue;/.test(html), 'markdown export skips the raw JSON row, not just agent_plan\'s'); +assert.ok(/type: 'approve_plan', sessionId: currentSessionId, workdir: curWorkdir \|\| undefined, selected: \[\.\.\.sel\], messageId, tabId: activeTabId/.test(html), 'approving sends the exact selection, not "everything"'); + +console.log('plan-review: ok');