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
+ ? `