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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/plan-kanban-import.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
91 changes: 91 additions & 0 deletions plan-import.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading