diff --git a/.claude/skills/board/SKILL.md b/.claude/skills/board/SKILL.md index d002b748..b26e35e9 100644 --- a/.claude/skills/board/SKILL.md +++ b/.claude/skills/board/SKILL.md @@ -30,7 +30,7 @@ Read-only. `BIN=scripts/pipeline` (relative to the engine root, every session's Nothing runs before a refusal except `mp-task scope`, `mp-task project` and `project list`. 1. `$BIN/mp-task board

` (with `all` when typed) — JSON lines with `key`, - `status`, `title`, `dir`, `worktreePath`. The project's `checkoutPath` and + `status`, `title`, `parent`, `dir`, `worktreePath`. The project's `checkoutPath` and `home` come from the `mp-task project

` output of step 0. 2. For each row, the files in `dir`, only those that exist: `derive.json` (plan URL), `pr.md` (PR), `progress.md` (stage). For each task with a `derive.json` @@ -40,7 +40,7 @@ Read-only. `BIN=scripts/pipeline` (relative to the engine root, every session's `checkoutPath` is not `null`, the entry of `git -C worktree list` whose path equals `--`; otherwise —. Run `git worktree list` once per project. -4. Print one table: key, title, status, plan (URL or —), open threads, PR, +4. Print one table: key, title, parent (key or —), status, plan (URL or —), open threads, PR, worktree, next. The next column, for any project: `/plan` for `new`; `/do` for `planning` with no open threads; `/plan` again when threads are open; «ручная проверка → /ship» for `review`; — for `merged`/`done`; the reason for diff --git a/.claude/skills/do/SKILL.md b/.claude/skills/do/SKILL.md index 5d11e9ec..68c59aa4 100644 --- a/.claude/skills/do/SKILL.md +++ b/.claude/skills/do/SKILL.md @@ -15,7 +15,8 @@ teammate. `$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to `/do `: `global` — any key; `P` — continue only when the key has the code `P`; -`P-N` — continue only for `/do P-N`; anything else (an argument that is not a key, a +`P-N` — continue only when the key is `P-N` or its subtask by +`$BIN/mp-task subtasks P-N`; anything else (an argument that is not a key, a malformed session key, a non-zero exit) — print the refusal text from `docs/PIPELINE.md` and stop. This step runs before «Preconditions»: no `$BIN/mp-task show`, no `catch_up`, no `mp-worktree` before it. diff --git a/.claude/skills/flow/SKILL.md b/.claude/skills/flow/SKILL.md index f34530b1..a9e20201 100644 --- a/.claude/skills/flow/SKILL.md +++ b/.claude/skills/flow/SKILL.md @@ -23,12 +23,13 @@ reads. `$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to `/flow`: `global` — continue when every key has the same code (keys of several projects get the «mixes projects» text); `P` — continue when every key has the code -`P`; `P-N` — refused; an argument that is not a key (other than the run name), a -malformed session key or a non-zero exit — refused. A refusal prints its text from +`P`; `P-N` — continue when every key is `P-N` or its subtask by +`$BIN/mp-task subtasks P-N`; an argument that is not a key (other than the run +name), a malformed session key or a non-zero exit — refused. A refusal prints its text from `docs/PIPELINE.md` and stops. This step runs before «Preconditions», before `git fetch`, before the flow branch is created or pushed and before the flow score is written. The nested plan and do runs go in this same session and pass their -own Step 0 with the same key. +own Step 0 with the same key; the subtasks of `P-N` pass it by the same rule. ## Preconditions @@ -41,14 +42,29 @@ own Step 0 with the same key. - `$BIN/mp-task flow ` gives the project's `integration` and `prTarget`; a non-zero exit → print its error and stop before any write. +## Subtasks + +Before the flow score is written, run `$BIN/mp-task subtasks ` for every typed +key. The run list: for each typed key, in the typed order, its descendants in the +order of the `subtasks` output, then the key itself; a repeated key is dropped and +its first occurrence stays. The status of each subtask decides by «Subtasks» of +`docs/PIPELINE.md`: skipped, taken like a typed key, or one question — header +`Подзадача`, options «Пропустить (рекомендую)» and «Включить», the reason from +`$BIN/mp-task show ` in the question text. An included subtask gets +`$BIN/mp-task status planning "в потоке : включена инженером"`. + ## Order -Before any write, compute the order of the keys: first the dependencies from the -«Связи» section of each `source.md` (a key goes after the keys of this run it -depends on); then the files each card names — tasks touching the same files go -one after another, the one the others build on first; otherwise the order typed. -Announce it in the chat: the keys in order, one line of reasoning per key. Then go -on without waiting for a confirmation. A dependency outside the run that is not +Before any write, compute the order of the keys. The base is the run list +(subtasks before their parent). Then the dependencies from the «Связи» section of +each `source.md` (a key goes after the keys of this run it depends on): a stable +topological reordering that never puts a parent before its subtasks. Then the files +each card names — tasks touching the same files go one after another, the one the +others build on first — where those constraints allow it. A cycle between the tree +and «Связи» is a question to the engineer. Announce it in the chat: the keys in +order, a mark `подзадача ` on each subtask, one line of reasoning per key, +then on a line of its own the skipped subtasks with their status. Then go on +without waiting for a confirmation. A dependency outside the run that is not `review`, `merged` or `done` is a question to the engineer before the start (wait for it, or go on and record it as `/do --force` does). @@ -63,7 +79,8 @@ on without waiting for a confirmation. A dependency outside the run that is not (`git -C worktree add flow/` when missing). Merges happen there; never in the base clone. 3. Flow score: `/flows/.md` — the branch with its base SHA, the - worktree, the announced order with a checkbox per key, later the PR URL. Commit + worktree, the announced order expanded with subtasks with a checkbox per key, + the list of skipped subtasks, later the PR URL. Commit with `$BIN/mp-home-commit ""` (it stages `flows` too). ## Reconnaissance up front @@ -113,8 +130,8 @@ key is recorded in the flow score. merged"`. Then shut the key's teammate down (`SendMessage` «shut down»; `TaskStop` if it does not answer). -Re-typing the same command resumes: keys already in `review` for this flow are -skipped. +Re-typing the same command resumes: it expands the subtasks again, and keys +already in `review` for this flow are skipped. ## Questions diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md index b18047f3..10eda36d 100644 --- a/.claude/skills/plan/SKILL.md +++ b/.claude/skills/plan/SKILL.md @@ -17,8 +17,8 @@ order typed. `$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to `/plan` and every typed key: `global` — any key; `P` — continue only when every -typed key has the code `P`; `P-N` — continue only when every typed key equals -`P-N`; anything else (an argument that is not a key, a malformed session key, a +typed key has the code `P`; `P-N` — continue only when every typed key is `P-N` +or its subtask by `$BIN/mp-task subtasks P-N`; anything else (an argument that is not a key, a malformed session key, a non-zero exit) — print the refusal text from `docs/PIPELINE.md` and stop. This step runs before the status check in «Input», before `$BIN/mp-task show` and before any derive call or subagent, so `/plan MP-41` in a TBA chat stops on the scope, not on diff --git a/.claude/skills/ship/SKILL.md b/.claude/skills/ship/SKILL.md index 2f6503d8..809a465a 100644 --- a/.claude/skills/ship/SKILL.md +++ b/.claude/skills/ship/SKILL.md @@ -15,7 +15,10 @@ one entered. `BIN=scripts/pipeline` (relative to the engine root, every session' `$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to `/ship` and every typed argument: `global` — any keys and `flow/`; `P` — keys with the code `P` and a flow of project `P`; `P-N` — continue only when every -typed argument equals `P-N` (`flow/` in a task chat is refused); anything +typed key is `P-N` or its subtask by `$BIN/mp-task subtasks P-N`, and for +`flow/` when every key of the checkboxes of the flow score +`/flows/.md` is `P-N` or its subtask (no file — the refusal text +from `docs/PIPELINE.md`); anything else (an argument that is not a key, a malformed session key, a non-zero exit) — print the refusal text from `docs/PIPELINE.md` and stop. This step runs before `pr.md` is read, before `git fetch`, merge, push, `gh`, any status change and any worktree removal. diff --git a/.yoke/context.md b/.yoke/context.md index cde8ff38..e05ce40a 100644 --- a/.yoke/context.md +++ b/.yoke/context.md @@ -21,16 +21,19 @@ The Session with the `global` Scope. Started from the engine root; administers a The Session with a `project` Scope. Exactly one per Project. Works in the context of the whole Project, opens and manages its Task chats, receives their Events. ## Task chat -The Session with a `task` Scope. Bound to one Task; works only in that Task's context, in its own worktree. Manages nothing. +The Session with a `task` Scope. Bound to one Task; works only in that Task's context, in its own worktree. Manages nothing except the pipeline skills the engineer types in it, which may run its Task and its Subtasks (docs/PIPELINE.md «Session scope»). ## Task The unit of work recorded in the database (formerly "request"). A Task belongs to a Project. A Task chat exists only for an existing Task. +## Parent task / Subtask +A Task may name one Parent task of the same Project (`parent`); Subtasks form a tree of any depth. Not the Parent of a Session: a Subtask's Task chat still has the Project chat as its Parent, and its Events and Milestones go there. + ## Project / Organization A registered Project belongs to one Organization. Organization is a grouping and a context source only; it has no Session of its own. ## Parent -The structural owner of a Session: Task chat → Project chat → Main chat. Parenthood never changes and does not depend on who opened the Session. +The structural owner of a Session: Task chat → Project chat → Main chat. Parenthood never changes and does not depend on who opened the Session. For Tasks see Parent task / Subtask. ## Event A record created when a child Session's turn that a Parent message started stops, carrying the final message of that turn (`turn_end`), or stops mid-turn waiting for a human (`waiting`), and when a Task reaches a Milestone (`milestone`). Events flow only from a Task chat to its Project chat; the Main chat receives none. Parent messages are kept in the same table as kind `message` and go down, from a Parent to its child. diff --git a/AGENTS.md b/AGENTS.md index 4147650b..587ff44c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,7 @@ logging or Herdr tab is a prerequisite. Follow the user's requested scope and se the older record moves to `~.json`. A fork started by hand without `MYPI_SCOPE` is not registered. `mypi sessions` (MCP `sessions`) shows the tree; `mypi session forget ` drops a row on the user's request. Without - `XDG_RUNTIME_DIR` or an initialized schema 12 database registration fails and the + `XDG_RUNTIME_DIR` or an initialized schema 13 database registration fails and the warmup still runs. /open (MCP open_session, CLI mypi open) focuses a live session's Herdr tab or opens a new tab in its project's Herdr workspace (label org/project, created when missing; a task gets its tab there without starting diff --git a/README.md b/README.md index ad285fc1..b83ef88e 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ order: the row for this native id, the live record of the same process, the older record moves to `~.json`. A fork started by hand without `MYPI_SCOPE` is not registered. `mypi sessions` (MCP `sessions`) shows the tree; `mypi session forget ` drops a row on the user's request. Without -`XDG_RUNTIME_DIR` or an initialized schema 12 database registration fails and the +`XDG_RUNTIME_DIR` or an initialized schema 13 database registration fails and the warmup still runs. `/open ` (the Claude skill `/open`, the Pi command `/open`, CLI @@ -273,6 +273,9 @@ command that opens the database, including warmup, asks for it. Upgrading to schema 12: run `mypi db init` once after updating (back up the database first). +Upgrading to schema 13: run `mypi db init` once after updating (back up the +database first). + ### Updating without overwriting customizations The installed engine is not a development checkout; contribute through the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57476270..d283261f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,7 +67,7 @@ organizations(id, slug) projects(id, org_id, slug, code, checkout_path) task_statuses(id, code, is_terminal) tasks(id, project_id, number, title, status_id, - context_dir, worktree_path, created_at, updated_at) + context_dir, worktree_path, parent_id, created_at, updated_at) sessions(key, scope_type, project_id, task_id, agent, native_id, created_at) events(id, from_key, to_key, kind, message, created_at, delivered_at) ``` diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 273af058..56cd0a39 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -97,7 +97,7 @@ A task key is `CODE-N` (`MP-12`), a project code is `CODE` (`MP`). | `GET /v1/tasks?project&status&q&cursor&limit` | read | `task_page` | `{ items: task[], next }` | | `GET /v1/tasks/{KEY}` | read | `task_show` | `task` | | `POST /v1/tasks` | write | `task_create` | `task`, status 201 | -| `PATCH /v1/tasks/{KEY}` | write | `task_status`, `task_title`, `task_progress`, `task_touch`, then `task_show` | `task` | +| `PATCH /v1/tasks/{KEY}` | write | `task_status`, `task_title`, `task_parent`, `task_progress`, `task_touch`, then `task_show` | `task` | | `GET /v1/events?to&from&kind&delivered&q&cursor&limit` | read | `event_list` | `{ items: event[], next }` | | `GET /v1/journal?scope&from&to&type&q&cursor&limit` | read | `journal_page` | `{ items: journal[], next }` | | `POST /v1/journal` | write | `journal_add` | `{ id }`, status 201 | @@ -125,11 +125,13 @@ Bodies are JSON objects; every field is a string unless noted, unknown fields ar refused: - `POST /v1/tasks`: `project`, `title`, `status`, `slug`, `source` (the exact source - text). No file sources, no `adoptSource`. -- `PATCH /v1/tasks/{KEY}`: `status`, `title`, `reason`, `progress`, `touch` (only - `true`). At least one of status, title, progress, touch; `reason` is required with - status or title and refused without them; `touch` only alone. Parts apply in the - order status, title, progress, touch; progress carries no artifacts. + text), optional `parent` (the key of a parent task of the same project). No file + sources, no `adoptSource`. +- `PATCH /v1/tasks/{KEY}`: `status`, `title`, `parent` (a task key or `null` to + clear), `reason`, `progress`, `touch` (only `true`). At least one of status, title, + parent, progress, touch; `reason` is required with status, title or parent and + refused without them; `touch` only alone. Parts apply in the order status, title, + parent, progress, touch; progress carries no artifacts. - `POST /v1/journal`: `scope`, `text` (a note). `scope` takes the same references as the journal query (`global`, an org slug, `org/project`, `project:FX`, `task:FX-1`). @@ -139,7 +141,8 @@ Response shapes: - `project`: `{ code, org, slug, identity, repository, checkoutPath }` - `flow`: `{ project, code, source, integration, taskBranch, prTarget, mergeMethod, release }` -- `task`: `{ key, project, number, title, status, isTerminal, contextDir, worktreePath, createdAt, updatedAt }` +- `task`: `{ key, project, number, title, parent, status, isTerminal, contextDir, worktreePath, createdAt, updatedAt }` + (`parent` is the key of the parent task or `null`) - `event`: `{ id, from, to, kind, text, createdAt, deliveredAt }` - `journal`: `{ id, at, scope: { type, key? }, type, text, artifacts: [{ path, commit }] }` (`commit` is `null` for historical path-only artifacts) @@ -184,7 +187,7 @@ field or route needs `/v2`. ## Engine updates and migrations -The server opens a database of schema 12 and keeps its code in memory. After an +The server opens a database of schema 13 and keeps its code in memory. After an engine update or a migration, restart it. ## Access log diff --git a/docs/M1-CLI.md b/docs/M1-CLI.md index e9b771f4..6b23673e 100644 --- a/docs/M1-CLI.md +++ b/docs/M1-CLI.md @@ -59,7 +59,7 @@ and projects without a clone exit 1 with a JSON error. Over 64 KiB the oldest fa the farthest memory zone are hidden with a marker naming the MCP `memory` show call and, in parentheses, `memory show -s …`. `--max-chars n` (1–21845) applies the same cut at n characters instead; the Claude Code hook uses 9500. `journal read` global remains global-only. Task cards from task show and task list include worktreePath (null until a worktree is -recorded). +recorded) and parent (the key of the parent task, or null). `project resolve` returns `{match:"project",identity,code,scope}` or `{match:"none"}` or `{match:"ambiguous",projects}` using the registered checkout resolver; sibling worktrees are not inferred. @@ -78,11 +78,14 @@ the remaining folder or null. ```text task create --project org/project --title "Title" --status research --slug short-slug --file /path/to/source +task create --project org/project --title "Subtask" --status new --slug subtask --parent MP-1 --file /path/to/source task list [--project org/project] [--status code] task show MP-1 task worktree MP-1 task status MP-1 review --reason "ready for review" task title MP-1 "Better title" --reason "clarification" +task parent MP-2 MP-1 --reason "part of the epic" +task parent MP-2 none --reason "split out" task progress MP-1 "actual outcome" [--artifacts /path/to/artifacts.json] status list status add accepted --terminal @@ -96,7 +99,7 @@ journal read --all --type status_changed ``` These are independent syntax examples, not a mandatory sequence. `task status` to `review`, `blocked`, `merged` or a terminal status also queues a `milestone` event for the project chat (inbox, warmup), unless the caller is the project chat itself (its registered key, else `MYPI_SCOPE`, as for `warmup`); milestones are never sent live. Tasks belong to a -registered project; existing standalone REQ records remain readable. +registered project; existing standalone REQ records remain readable. A parent task is a task of the same project, never the task itself and never one of its own subtasks (no cycles); `task parent` writes a `note` `Parent: → . ` to the task's journal. `project flow MP` (or `org/project`) prints the project's branch flow from `home/projects///flow.json`: diff --git a/docs/MCP.md b/docs/MCP.md index 3ee83840..404765e6 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -48,7 +48,7 @@ verification command. See the installed Pi MCP/security documentation for setup. ## Tool and transport contract 15 tools are available through standard tools/list: warmup, task_list, task_show, task_create, task_update, memory, note, journal, context_read, context_write, projects, sessions, open_session, send_message and inbox. Inspect live schemas for fields. The remote /mcp endpoint offers a narrower subset ([below](#remote-mcp-over-http)). Maintenance — db init, bootstrap, backup and its publication, restore, journal import, the status dictionary, context restore, project and organization removal, home migrate-tasks and task worktree — is CLI-only ([M1-CLI](M1-CLI.md)); over MCP these names are unknown tools. -`memory` and `projects` take `action`, `note` takes `kind`, plus that action's fields; a field of another action is rejected. `task_update` runs its parts as the CLI's task status, title and progress commands (or touch alone) in that order within one serial slot; a failure after an earlier part saved returns `partial` with the saved and missing parts, a failure of the first part is a plain error. A status change to review, blocked, merged or a terminal status queues a milestone event for the project chat (inbox, warmup), unless the caller is the project chat itself (its registered key, else `MYPI_SCOPE`, as for `warmup`); milestones never start a turn. +`memory` and `projects` take `action`, `note` takes `kind`, plus that action's fields; a field of another action is rejected. `task_update` runs its parts as the CLI's task status, title, parent and progress commands (or touch alone) in that order within one serial slot; `parent` (a task key of the same project, or `null` to clear) needs a reason like status and title and is refused for another project, the task itself or a cycle; a failure after an earlier part saved returns `partial` with the saved and missing parts, a failure of the first part is a plain error. A status change to review, blocked, merged or a terminal status queues a milestone event for the project chat (inbox, warmup), unless the caller is the project chat itself (its registered key, else `MYPI_SCOPE`, as for `warmup`); milestones never start a turn. Arguments are strict, including nested fields. Context scopes: global/org/project; task scope is supported for journal, not MEMORY; warmup takes a scope key string (below). Journal read also accepts explicit `"all"`. A project scope key is its code; task_create.project is org/project diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 19143981..78ace8ca 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -79,8 +79,9 @@ The rule: `/ship flow/` take the keys of one project. - `P` (any project): every typed key has the code `P`; a flow holds keys of `P`, `/ship flow/` takes a flow of project `P`. -- `P-N`: `/plan` and `/do` only when every typed key equals `P-N`; `/ship` only when - every typed argument equals `P-N`; `/flow` and `/ship flow/` are refused. +- `P-N`: `/plan`, `/do`, `/flow` and `/ship` take P-N and its subtasks — every task + whose chain of parents reaches P-N (`mp-task subtasks P-N`); `/ship flow/` — + when every key of the flow score `/flows/.md` is P-N or its subtask. - A typed argument that is not a key `CODE-N` (other than `flow/` of `/ship` and the run name of `/flow`), a malformed session key (`project` is `null` and the key is not `global`) or a non-zero exit of `mp-task scope`: refused. @@ -92,14 +93,17 @@ The rule: A refusal prints its text and stops. Nothing runs before it except `mp-task scope`: no `mp-task show/status/progress`, no `git fetch`, no `mp-worktree`, no `mp-home-commit`, no derive calls, no subagents (`/board` also runs `mp-task project` -and `project list`). The texts (``, the typed +and `project list`). In a `P-N` session `mp-task subtasks P-N` may run too, and for +`/ship flow/` also `mp-task project P` and reading the flow score. The texts (``, the typed arguments and `` substituted): - `/board` with another project's code: `/board is not allowed in session scope ; this chat shows the board of

only. Use /board here or /open global for another project.` - `/board` with a malformed key: `The board cannot tell this session's project (scope ). Open the main chat with /open global.` - `/board` with an unknown code: `Unknown project code

; registered codes: .` - a changing skill with another project's key: `/ is not allowed in session scope ; nothing was written. This chat runs tasks of

only; run it in that task's or project's chat (/open ) or in the main chat (/open global).` - (`` is the code of the typed key.) In a `P-N` chat append: ` This chat may run /plan P-N, /do P-N and /ship P-N only.` + (`` is the code of the typed key.) In a `P-N` chat append: ` This chat may run /plan, /do, /flow and /ship for P-N and its subtasks (|none) only.` +- a non-zero exit of `mp-task subtasks`: `/ cannot read the subtasks of : ; nothing was written.` +- `/ship flow/` in a `P-N` chat without the flow score: `/ship flow/: no flow score ; nothing was written.` - a changing skill with a malformed session key or a non-zero `mp-task scope`: `/ cannot tell this session's project (scope ); nothing was written. Run it in the main chat (/open global).` - a changing skill with an argument that is not a key: `/: is not a task key (CODE-N); nothing was written.` - `/flow` in `global` with keys of several projects: `/flow mixes projects ; a flow holds tasks of one project; nothing was written.` @@ -107,6 +111,34 @@ arguments and `` substituted): This is an instruction for the agent, not access control: the scripts still work from any session. +## Subtasks + +A task may name one parent task of the same project; the relation is stored in mypi +(`parent` of a task card), set with `mypi task create --parent ` and changed or +cleared with `mypi task parent --reason …`. Subtasks form a +tree of any depth. `mp-task subtasks ` prints the recursive descendants of a +task as JSON lines, subtasks before their parent. + +`/flow` expands every typed key into its subtasks and runs them in the same cycle +as the typed keys (`.claude/skills/flow/SKILL.md`, «Subtasks»). The status of a +subtask decides: + +- `merged` or a terminal status: skipped, named in the announced order; +- `new`, `planning`, `in_progress`, or `review` with `pr.md` of this flow: runs + like a typed key; +- `review` not of this flow: skipped, named; it has its own PR or flow already; +- `blocked` or any other non-terminal status (`research`, `paused`, a custom one): + one `AskUserQuestion` «include or skip». An included subtask gets + `mp-task status planning "в потоке : включена инженером"` and goes on + as `planning`. + +The order: subtasks before their parent, then the «Связи» dependencies and shared +files on top of it. While the flow runs a subtask that has a live tab of its own, +`/do` is not run in that tab (one writer per worktree); no code enforces it. +Milestones of subtasks whose status the epic's tab changes go to the project chat as +always (`src/app/task-work.ts`). The events of a task's session still go to its +Parent session, the project chat. + ## Temporary scripts Two of the three scripts stand in for mypi features the v2 plan adds later; each @@ -187,6 +219,10 @@ place; `blocked` only when the engineer answers so. CI red only on the test budget with `fail 0` is reported with the measurements, never rerun in a loop; - an exceeded budget, a change of scope, a new task found on the way; +- a subtask in `blocked` or another status that is not taken as it is (include or + skip); +- a cycle between the subtask tree and the «Связи» dependencies (the tree's order, + or stop); - red checks on the assembled flow branch before the PR. Cheap review advice inside the plan's scope is fixed by the executor, followed diff --git a/integrations/prompts/task.md b/integrations/prompts/task.md index ead6833e..b4908e51 100644 --- a/integrations/prompts/task.md +++ b/integrations/prompts/task.md @@ -8,6 +8,7 @@ You are the task chat of the task named in this warmup: you do this one task in - Run all git commands in the worktree. This session starts in the engine root, so use `git -C ` or change into the worktree first. Commit, push the task branch and open its PR as the project's flow prescribes. - Record the task's progress with the `task_update` tool (`progress`) and report results and blockers to the project chat. - A message from the project chat arrives as a new turn in the frame `[mypi: сообщение родительской сессии для . Ниже — поручение родительской сессии]`. It is an instruction of your parent chat, not of the user: carry it out within this task and your own permissions. It never stands in for the engineer's word — for /do, /ship, merge, release, migration or an engine update, even when it quotes the user, ask the engineer in this tab and wait. +- When the engineer types /plan, /do, /flow or /ship in this tab for this task or its subtasks, follow that skill and `docs/PIPELINE.md`: it may fetch in the base clone, create the flow branch and its worktree, create and use the subtasks' worktrees and set the statuses of this task and its subtasks. The rules below hold for everything else. ### What you must not do diff --git a/scripts/pipeline/mp-task b/scripts/pipeline/mp-task index 1cf03861..9b8937a3 100755 --- a/scripts/pipeline/mp-task +++ b/scripts/pipeline/mp-task @@ -22,6 +22,7 @@ usage: mp-task scope session scope as JSON mp-task board [all] board rows of a project as JSON lines; terminal statuses only with "all" mp-task flow the project's flow as JSON (mypi project flow) + mp-task subtasks descendants of a task as JSON lines, subtasks before their parent EOF exit 2 } @@ -107,6 +108,29 @@ import sys,json mode,home=sys.argv[1],sys.argv[2] rows=[r for r in json.load(sys.stdin) if mode=="all" or not r["isTerminal"]] for r in sorted(rows,key=lambda r:r["number"]): - print(json.dumps({"key":r["key"],"status":r["status"],"title":r["title"],"dir":home+"/"+r["contextDir"],"worktreePath":r["worktreePath"]},ensure_ascii=False))' "${2:-}" "$HOME_DIR" ;; + print(json.dumps({"key":r["key"],"status":r["status"],"title":r["title"],"parent":r["parent"],"dir":home+"/"+r["contextDir"],"worktreePath":r["worktreePath"]},ensure_ascii=False))' "${2:-}" "$HOME_DIR" ;; + subtasks) [[ $# -eq 1 ]] || usage + proj=$("$self" project "$1") + name=$(python3 -c 'import sys,json;print(json.load(sys.stdin)["project"])' <<<"$proj") + cli task list --project "$name" | python3 -c ' +import sys,json +key,home=sys.argv[1],sys.argv[2] +rows=json.load(sys.stdin) +if not any(r["key"]==key for r in rows): + print(json.dumps({"error":"unknown task "+key}),file=sys.stderr) + sys.exit(1) +children={} +for r in sorted(rows,key=lambda r:r["number"]): + if r["parent"] is not None: + children.setdefault(r["parent"],[]).append(r) +seen={key} +def walk(parent,depth): + for r in children.get(parent,[]): + if r["key"] in seen: + continue + seen.add(r["key"]) + walk(r["key"],depth+1) + print(json.dumps({"key":r["key"],"parent":r["parent"],"depth":depth,"status":r["status"],"isTerminal":r["isTerminal"],"title":r["title"],"dir":home+"/"+r["contextDir"]},ensure_ascii=False)) +walk(key,1)' "$1" "$HOME_DIR" ;; *) usage ;; esac diff --git a/src/app/commands.ts b/src/app/commands.ts index 83a6b2d8..8b5f5756 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -27,9 +27,10 @@ export type AppCommand = | { name: 'task_list'; project?: string; status?: string } | { name: 'task_page'; project?: string; status?: string; q?: string; before?: number; limit: number } | { name: 'task_show' | 'task_touch' | 'task_worktree'; key: string } - | { name: 'task_create'; project: string; title: string; status: string; slug: string; source: TextInput; adoptSource?: boolean } + | { name: 'task_create'; project: string; title: string; status: string; slug: string; source: TextInput; adoptSource?: boolean; parent?: string } | { name: 'task_status'; key: string; status: string; reason: string; session?: SessionHint; envScope?: string } | { name: 'task_title'; key: string; title: string; reason: string } + | { name: 'task_parent'; key: string; parent: string | null; reason: string } | { name: 'task_progress'; key: string; text: string; artifacts?: Artifact[] } | { name: 'status_add' | 'status_terminal'; code: string; terminal: boolean } | { name: 'status_rename'; code: string; newCode: string } diff --git a/src/app/execute-command.ts b/src/app/execute-command.ts index 8f7afe42..ce7fc351 100644 --- a/src/app/execute-command.ts +++ b/src/app/execute-command.ts @@ -103,12 +103,14 @@ export async function executeCommand(c: AppCommand, filename: string, root?: str title: c.title, status: c.status, slug: c.slug, source: inputText(c.source), project: c.project, ...(c.adoptSource === undefined ? {} : { adoptSource: c.adoptSource }), + ...(c.parent === undefined ? {} : { parent: c.parent }), }); case 'task_list': return app.tasks.list(c); case 'task_page': return app.tasks.page(c); case 'task_show': return app.tasks.get(c.key); case 'task_status': return app.tasks.change(c.key, { status: c.status }, c.reason, callerKey(filename, c)); case 'task_title': return app.tasks.change(c.key, { title: c.title }, c.reason); + case 'task_parent': return app.tasks.setParent(c.key, c.parent, c.reason); case 'task_touch': return app.tasks.touch(c.key); case 'task_progress': return app.tasks.progress(c.key, c.text, c.artifacts); case 'context_read': { diff --git a/src/app/task-work.ts b/src/app/task-work.ts index b1529a93..69f9e98c 100644 --- a/src/app/task-work.ts +++ b/src/app/task-work.ts @@ -16,14 +16,19 @@ export function taskWork(core: ReturnType, files: ContextFiles if (!result) throw new InputError('Unknown task key'); return result; } + function parents() { return new Map(core.tasks.list().map(card => [card.id, card])); } + function view(card: Card, byId: Map) { + return { ...card, parent: card.parentId === null ? null : key(byId.get(card.parentId)!) }; + } return { - get: find, + get: (publicKey: string) => view(find(publicKey), parents()), list(options: { project?: string | undefined; status?: string | undefined } = {}) { const scope = options.project === undefined ? undefined : core.projects.resolveScope(options.project); if (scope && scope.type !== 'project') throw new InputError('Project identity required'); if (options.status !== undefined && !core.tasks.statuses().some(s => s.code === options.status)) throw new InputError('Unknown status'); - return core.tasks.list().filter(c => (!scope || (scope.type === 'project' && c.projectId === scope.project.id)) - && (options.status === undefined || c.status === options.status)).map(card => ({ ...card, key: key(card) })); + const cards = core.tasks.list(), byId = new Map(cards.map(card => [card.id, card])); + return cards.filter(c => (!scope || (scope.type === 'project' && c.projectId === scope.project.id)) + && (options.status === undefined || c.status === options.status)).map(card => ({ ...view(card, byId), key: key(card) })); }, page(options: { project?: string; status?: string; q?: string; before?: number; limit: number }) { let project = options.project; @@ -34,7 +39,7 @@ export function taskWork(core: ReturnType, files: ContextFiles } return pageTasks(this.list({ project, status: options.status }), { q: options.q, before: options.before, limit: options.limit }); }, - create(input: { project: string; title: string; status: string; slug: string; source: string; adoptSource?: boolean }) { + create(input: { project: string; title: string; status: string; slug: string; source: string; adoptSource?: boolean; parent?: string }) { if (typeof input.source !== 'string') throw new InputError('Source text required'); if (typeof input.project !== 'string') throw new InputError('Project identity required'); const scope = core.projects.resolveScope(input.project); @@ -42,18 +47,21 @@ export function taskWork(core: ReturnType, files: ContextFiles const project = scope.project; let path: string | undefined, saved = false; try { + const parent = input.parent ?? null; const card = core.serialize(() => { - const result = core.tasks.create({ projectId: project.id, slug: input.slug, title: input.title, status: input.status }, dir => { + const parentId = parent === null ? null : find(parent).id; + const result = core.tasks.create({ projectId: project.id, slug: input.slug, title: input.title, status: input.status, parentId }, dir => { path = dir + '/source.md'; if (input.adoptSource) { if (files.read(path) !== input.source) throw new InputError('Existing source differs/missing; inspect before adoption'); } else files.create(path, input.source); saved = true; }); - journal.append(journal.record({ type: 'task', key: key(result) }, 'Task registered.', 'task_created')); + journal.append(journal.record({ type: 'task', key: key(result) }, + parent === null ? 'Task registered.' : 'Task registered. Parent: ' + parent + '.', 'task_created')); return result; }); - return { ...card, key: key(card) }; + return { ...card, key: key(card), parent }; } catch (e) { if (path) throw new PartialError(String(e), saved ? ['source'] : [], ['inspect source', 'database', 'journal'], [path]); throw e; @@ -77,6 +85,17 @@ export function taskWork(core: ReturnType, files: ContextFiles return result.after; }); }, + setParent(publicKey: string, parent: string | null, reason: string) { + const card = find(publicKey); + return core.serialize(() => { + const parentId = parent === null ? null : find(parent).id; + const result = core.tasks.setParent(card.id, parentId, reason); + const name = (id: number | null) => id === null ? 'none' : key(core.tasks.get(id)); + if (result.changed) journal.append(journal.record({ type: 'task', key: key(result.after) }, + 'Parent: ' + name(result.before.parentId) + ' → ' + name(result.after.parentId) + '. ' + reason, 'note')); + return view(result.after, parents()); + }); + }, progress(publicKey: string, text: string, artifacts: { path: string; text?: string }[] = []) { const card = find(publicKey), entry = journal.record({ type: 'task', key: publicKey }, text, 'note'); const paths = artifacts.map(a => card.contextDir + '/' + a.path); diff --git a/src/cli/app-command.ts b/src/cli/app-command.ts index f2bfd26c..065714bb 100644 --- a/src/cli/app-command.ts +++ b/src/cli/app-command.ts @@ -54,7 +54,8 @@ export function appCommand(command: Exclude, env: Rea ...(opt('type') === undefined ? {} : { eventType: opt('type') as 'note' | 'task_created' | 'status_changed' }), ...(opt('limit') === undefined ? {} : { limit: Number(opt('limit')) }) }; case 'task create': return { name: 'task_create', title: required('title'), status: required('status'), - slug: required('slug'), source: source(0), project: required('project'), adoptSource: Boolean(o['adopt-source']) }; + slug: required('slug'), source: source(0), project: required('project'), adoptSource: Boolean(o['adopt-source']), + ...(opt('parent') === undefined ? {} : { parent: opt('parent')! }) }; case 'task list': return { name: 'task_list', ...(opt('project') === undefined ? {} : { project: opt('project')! }), ...(opt('status') === undefined ? {} : { status: opt('status')! }) }; @@ -67,6 +68,7 @@ export function appCommand(command: Exclude, env: Rea ...(env['MYPI_SCOPE'] === undefined ? {} : { envScope: env['MYPI_SCOPE'] }) }; } case 'task title': return { name: 'task_title', key: a[0]!, title: a[1]!, reason: required('reason') }; + case 'task parent': return { name: 'task_parent', key: a[0]!, parent: a[1] === 'none' ? null : a[1]!, reason: required('reason') }; case 'task progress': { const artifacts: unknown = opt('artifacts') ? JSON.parse(readInputFile(required('artifacts'))) : []; if (!Array.isArray(artifacts) || artifacts.some(a => !a || typeof a !== 'object' diff --git a/src/cli/command.ts b/src/cli/command.ts index 14f55df1..534a2a3d 100644 --- a/src/cli/command.ts +++ b/src/cli/command.ts @@ -34,9 +34,9 @@ mypi memory add | show | remove --expected-revision SHA256 [-s s mypi note [text | --file source] [-s scope] mypi error <org/project> <text> mypi journal add <text> [-s scope] | read [-s scope | --all] [--from UTC --to UTC --type type --limit n] | import -mypi task create [source | --file source] --title title --status code --slug slug --project org/project [--adopt-source] +mypi task create [source | --file source] --title title --status code --slug slug --project org/project [--parent <key>] [--adopt-source] mypi task list [--project org/project --status code] | show <key> -mypi task status <key> <code> --reason text | title <key> <title> --reason text +mypi task status <key> <code> --reason text | title <key> <title> --reason text | parent <key> <parent-key|none> --reason text mypi task progress <key> <text> [--artifacts JSON-file] | touch <key> | worktree <key> mypi status list | add <code> [--terminal] | rename <code> <new> | terminal <code> <true|false> | remove <code> mypi context read <path> [--revision SHA] | commit <paths...> --message text | restore <path> --revision SHA diff --git a/src/cli/workspace-command.ts b/src/cli/workspace-command.ts index 8efcbc32..ccd766d6 100644 --- a/src/cli/workspace-command.ts +++ b/src/cli/workspace-command.ts @@ -22,11 +22,12 @@ const specs: Record<string, [number, number, string[], string[]]> = { 'journal add': [1, 1, ['scope'], []], 'journal import': [0, 0, [], []], 'journal read': [0, 0, ['scope', 'from', 'to', 'type', 'limit'], ['all']], - 'task create': [0, 1, ['project', 'title', 'status', 'slug', 'file'], ['adopt-source']], + 'task create': [0, 1, ['project', 'title', 'status', 'slug', 'file', 'parent'], ['adopt-source']], 'task list': [0, 0, ['project', 'status'], []], 'task show': [1, 1, [], []], 'task status': [2, 2, ['reason'], []], 'task title': [2, 2, ['reason'], []], + 'task parent': [2, 2, ['reason'], []], 'task progress': [2, 2, ['artifacts'], []], 'task touch': [1, 1, [], []], 'task worktree': [1, 1, [], []], diff --git a/src/http/api.ts b/src/http/api.ts index 663fff88..a68d3310 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -3,7 +3,7 @@ import { runSequence } from '../app/task-sequence.js'; import { InputError } from '../shared/errors.js'; import { bearer } from '../shared/bearer.js'; import { PartialError } from '../shared/context.js'; -import { ApiError, decodeCursor, encodeCursor, fields, jsonBody, limit, literalTrue, query, str } from './validate.js'; +import { ApiError, decodeCursor, encodeCursor, fields, jsonBody, limit, literalTrue, nullableStr, query, str } from './validate.js'; import * as shape from './shape.js'; import { clearSessionCookie, readSessionCookie, sessionCookie } from './session.js'; @@ -85,9 +85,11 @@ export function createApi(deps: ApiDeps): (request: ApiRequest) => Promise<ApiRe } }, { method: 'POST', pattern: /^\/v1\/tasks$/, permission: 'write', run: async (_, r) => { query(r.query, []); - const b = body(r, ['project', 'title', 'status', 'slug', 'source'], []); + const b = body(r, ['project', 'title', 'status', 'slug', 'source'], ['parent']); const [project, title, status, slug, source] = ['project', 'title', 'status', 'slug', 'source'].map(key => str(b, key)!) as [string, string, string, string, string]; - const card = await deps.execute({ name: 'task_create', project, title, status, slug, source: { text: source } }) as { key: string }; + const parent = str(b, 'parent'); + const card = await deps.execute({ name: 'task_create', project, title, status, slug, source: { text: source }, + ...(parent === undefined ? {} : { parent }) }) as { key: string }; return { status: 201, data: shape.task(card as never, card.key) }; } }, { method: 'GET', pattern: new RegExp('^/v1/tasks/' + KEY + '$'), permission: 'read', run: async (m, r) => { @@ -96,16 +98,17 @@ export function createApi(deps: ApiDeps): (request: ApiRequest) => Promise<ApiRe } }, { method: 'PATCH', pattern: new RegExp('^/v1/tasks/' + KEY + '$'), permission: 'write', run: async (m, r, token) => { query(r.query, []); - const key = m[1]!, b = body(r, [], ['status', 'title', 'reason', 'progress', 'touch']); - const status = str(b, 'status'), title = str(b, 'title'), reason = str(b, 'reason'), progress = str(b, 'progress'), touch = literalTrue(b, 'touch'); - const change = status !== undefined || title !== undefined; - if (!change && progress === undefined && touch === undefined) throw invalid('PATCH needs status, title, progress or touch'); - if (change && reason === undefined) throw invalid('reason is required with status or title'); - if (!change && reason !== undefined) throw invalid('reason is allowed only with status or title'); + const key = m[1]!, b = body(r, [], ['status', 'title', 'parent', 'reason', 'progress', 'touch']); + const status = str(b, 'status'), title = str(b, 'title'), parent = nullableStr(b, 'parent'), reason = str(b, 'reason'), progress = str(b, 'progress'), touch = literalTrue(b, 'touch'); + const change = status !== undefined || title !== undefined || parent !== undefined; + if (!change && progress === undefined && touch === undefined) throw invalid('PATCH needs status, title, parent, progress or touch'); + if (change && reason === undefined) throw invalid('reason is required with status, title or parent'); + if (!change && reason !== undefined) throw invalid('reason is allowed only with status, title or parent'); if (touch !== undefined && (change || progress !== undefined)) throw invalid('touch is allowed only alone'); const commands: AppCommand[] = []; if (status !== undefined) commands.push({ name: 'task_status', key, status, reason: reason!, envScope: token.sender! }); if (title !== undefined) commands.push({ name: 'task_title', key, title, reason: reason! }); + if (parent !== undefined) commands.push({ name: 'task_parent', key, parent, reason: reason! }); if (progress !== undefined) commands.push({ name: 'task_progress', key, text: progress, artifacts: [] }); if (touch) commands.push({ name: 'task_touch', key }); await runSequence(commands, deps.execute); diff --git a/src/http/shape.ts b/src/http/shape.ts index 9cdf1e60..0a7a96b0 100644 --- a/src/http/shape.ts +++ b/src/http/shape.ts @@ -1,7 +1,7 @@ type ProjectRow = { code: string; org: string; slug: string; repository: string | null; checkoutPath: string | null }; type FlowRow = { project: string; code: string; source: string; integration: string; taskBranch: string; prTarget: string; mergeMethod: string; release: unknown }; -type CardRow = { projectId: number | null; number: number; title: string; status: string; isTerminal: boolean; contextDir: string; +type CardRow = { projectId: number | null; number: number; title: string; parent: string | null; status: string; isTerminal: boolean; contextDir: string; worktreePath: string | null; createdAt: string; updatedAt: string }; type EventRow = { id: number; fromKey: string; toKey: string; kind: string; message: string; createdAt: string; deliveredAt: string | null }; type JournalRow = { id: number; entry: { at: string; scope: { type: 'global' } | { type: string; key: string }; event_type: string; text: string; @@ -20,7 +20,7 @@ export function flow(f: FlowRow) { export function task(c: CardRow, key: string) { return { key, project: c.projectId === null ? null : key.slice(0, key.indexOf('-')), number: c.number, title: c.title, - status: c.status, isTerminal: c.isTerminal, contextDir: c.contextDir, worktreePath: c.worktreePath, + parent: c.parent, status: c.status, isTerminal: c.isTerminal, contextDir: c.contextDir, worktreePath: c.worktreePath, createdAt: c.createdAt, updatedAt: c.updatedAt }; } diff --git a/src/http/validate.ts b/src/http/validate.ts index 0e05ec27..d11d513a 100644 --- a/src/http/validate.ts +++ b/src/http/validate.ts @@ -61,6 +61,12 @@ export function str(body: Record<string, unknown>, key: string): string | undefi return value; } +export function nullableStr(body: Record<string, unknown>, key: string): string | null | undefined { + const value = body[key]; + if (value !== undefined && value !== null && typeof value !== 'string') throw invalid('Field must be a string or null: ' + key); + return value; +} + export function literalTrue(body: Record<string, unknown>, key: string): true | undefined { const value = body[key]; if (value !== undefined && value !== true) throw invalid('Field must be true: ' + key); diff --git a/src/infrastructure/database/database.ts b/src/infrastructure/database/database.ts index 77565421..214bb945 100644 --- a/src/infrastructure/database/database.ts +++ b/src/infrastructure/database/database.ts @@ -2,7 +2,7 @@ import Database from 'better-sqlite3'; import { chmodSync, mkdirSync, readFileSync, openSync, closeSync } from 'node:fs'; import { dirname } from 'node:path'; -export const SCHEMA_VERSION = 12; +export const SCHEMA_VERSION = 13; function connect(filename: string, readonly: boolean, fileMustExist: boolean): Database.Database { const db = new Database(filename, { readonly, fileMustExist, timeout: 1000 }); @@ -81,6 +81,10 @@ export function initializeDatabase(filename: string, createOnly = false): void { db.exec(readFileSync(new URL('./migrations/012-project-repository.sql', import.meta.url), 'utf8')); db.pragma('user_version = 12'); } + if (current < 13) { + db.exec(readFileSync(new URL('./migrations/013-task-parent.sql', import.meta.url), 'utf8')); + db.pragma('user_version = 13'); + } }).immediate(); chmodSync(filename, 0o600); } finally { diff --git a/src/infrastructure/database/migrations/013-task-parent.sql b/src/infrastructure/database/migrations/013-task-parent.sql new file mode 100644 index 00000000..f51b60b8 --- /dev/null +++ b/src/infrastructure/database/migrations/013-task-parent.sql @@ -0,0 +1,3 @@ +-- A task's parent task (same project, checked by the application); NULL for a top-level task. +ALTER TABLE tasks ADD COLUMN parent_id INTEGER REFERENCES tasks(id) ON DELETE RESTRICT; +CREATE INDEX tasks_parent ON tasks(parent_id); diff --git a/src/mcp/remote-tools.ts b/src/mcp/remote-tools.ts index ef5afa5d..dd9d365c 100644 --- a/src/mcp/remote-tools.ts +++ b/src/mcp/remote-tools.ts @@ -8,8 +8,8 @@ import { sessionTools } from './tools/sessions.js'; export type RemoteToken = { name: string; permission: 'read' | 'write'; sender: string | null }; -const REMOTE_TASK_CREATE = 'Call only when the user explicitly asks to register a task, one task per independent problem: creates a task of a registered org/project with an explicit status code, slug and the exact source text ({text}). When the request names several problems, register only the one asked for and ask about the rest; keep the user\'s wording and scope in the title and source. Does not plan or execute the work and creates no branch or worktree. Not idempotent: never repeat it blindly after an unknown outcome.'; -const REMOTE_TASK_UPDATE = 'Call to change a task in one call: status and/or title with a reason, progress text, or touch:true alone to mark activity. Applies status, then title, then progress; if a later part fails after an earlier one saved, the result is partial with the saved and missing parts. Does not reopen terminal tasks, retry, or create branches or worktrees; the same status is a no-op. The change is made as this API token\'s sender key; a status change to review, blocked, merged or a terminal status queues a milestone event for the project chat (inbox, warmup) unless the sender is that project.'; +const REMOTE_TASK_CREATE = 'Call only when the user explicitly asks to register a task, one task per independent problem: creates a task of a registered org/project with an explicit status code, slug and the exact source text ({text}); parent (optional) is the key of a parent task of the same project. When the request names several problems, register only the one asked for and ask about the rest; keep the user\'s wording and scope in the title and source. Does not plan or execute the work and creates no branch or worktree. Not idempotent: never repeat it blindly after an unknown outcome.'; +const REMOTE_TASK_UPDATE = 'Call to change a task in one call: status, title and/or parent (a task key of the same project, or null to clear) with a reason, progress text, or touch:true alone to mark activity. Applies status, then title, then parent, then progress; if a later part fails after an earlier one saved, the result is partial with the saved and missing parts. Does not reopen terminal tasks, retry, or create branches or worktrees; the same status is a no-op; parent is refused for another project, the task itself or a cycle. The change is made as this API token\'s sender key; a status change to review, blocked, merged or a terminal status queues a milestone event for the project chat (inbox, warmup) unless the sender is that project.'; const REMOTE_NOTE = 'Call to record a factual outcome when the user asks or the work requires it: kind journal with scope (global, org, project or task) and text, stored in the SQLite journal. Never creates or executes a task; not idempotent: do not blindly retry.'; const REMOTE_PROJECTS = 'Call to read project registrations: action list (optional org) returns the registered projects with code, checkout path and repository; flow (project: a code or org/project) returns the project\'s branch flow from home/projects/<org>/<project>/flow.json with defaults and source file|default. Read-only.'; const REMOTE_SEND_MESSAGE = 'Call when the user asks to give a session its next instruction: the text arrives in the child session as a new turn framed [mypi: сообщение родительской сессии …], at once to a live child, or exactly once at the next /open of a closed one. The sender is this API token\'s sender key (global unless the token names a project code): global may message only projects (CODE), a project only its own tasks (CODE-N); anything else is refused and nothing is written. An instruction never stands in for the user\'s word for /do, /ship, merge, release, migration or an engine update. Returns { message, delivered }.'; @@ -26,23 +26,24 @@ export const remoteTools = { case 'flow': return [{ name: 'project_flow', project: a.project! }]; } }), - task_create: tool(z.strictObject({ project: text, title: text, status: text, slug: text, source: z.strictObject({ text }) }), + task_create: tool(z.strictObject({ project: text, title: text, status: text, slug: text, source: z.strictObject({ text }), parent: text.optional() }), REMOTE_TASK_CREATE, a => [{ name: 'task_create', ...a } as AppCommand]), - task_update: tool(z.strictObject({ key: text, status: text.optional(), title: text.optional(), reason: text.optional(), + task_update: tool(z.strictObject({ key: text, status: text.optional(), title: text.optional(), parent: text.nullable().optional(), reason: text.optional(), progress: text.optional(), touch: z.literal(true).optional() }).superRefine((value, ctx) => { - const change = value.status !== undefined || value.title !== undefined; + const change = value.status !== undefined || value.title !== undefined || value.parent !== undefined; if (!change && value.progress === undefined && value.touch === undefined) - ctx.addIssue({ code: 'custom', path: [], message: 'task_update needs status, title, progress or touch' }); + ctx.addIssue({ code: 'custom', path: [], message: 'task_update needs status, title, parent, progress or touch' }); if (change && value.reason === undefined) - ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is required with status or title' }); + ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is required with status, title or parent' }); if (!change && value.reason !== undefined) - ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is allowed only with status or title' }); + ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is allowed only with status, title or parent' }); if (value.touch !== undefined && (change || value.progress !== undefined)) ctx.addIssue({ code: 'custom', path: ['touch'], message: 'touch is allowed only alone' }); - }), REMOTE_TASK_UPDATE, ({ key, status, title, reason, progress, touch }) => { + }), REMOTE_TASK_UPDATE, ({ key, status, title, parent, reason, progress, touch }) => { const commands: AppCommand[] = []; if (status !== undefined) commands.push({ name: 'task_status', key, status, reason: reason! }); if (title !== undefined) commands.push({ name: 'task_title', key, title, reason: reason! }); + if (parent !== undefined) commands.push({ name: 'task_parent', key, parent, reason: reason! }); if (progress !== undefined) commands.push({ name: 'task_progress', key, text: progress, artifacts: [] }); if (touch) commands.push({ name: 'task_touch', key }); return commands; diff --git a/src/mcp/tools/tasks.ts b/src/mcp/tools/tasks.ts index 8b00a946..b3125071 100644 --- a/src/mcp/tools/tasks.ts +++ b/src/mcp/tools/tasks.ts @@ -7,26 +7,27 @@ export const tasks = { task_show: tool(z.strictObject({ key: text }), 'Call to read one task card by key (CODE-number, or a legacy REQ-number), including status and worktreePath. Read-only; source and artifacts are files read with context_read.', a => [{ name: 'task_show', ...a } as AppCommand]), task_create: tool(z.strictObject({ project: text, title: text, status: text, slug: text, - source: textInput, adoptSource: z.boolean().optional() }), 'Call when the user asks to register a task: creates a task of a registered org/project with an explicit status code, slug and exact source. adoptSource only after partial reconciliation. Does not plan or execute the work and creates no branch or worktree.', + source: textInput, adoptSource: z.boolean().optional(), parent: text.optional() }), 'Call when the user asks to register a task: creates a task of a registered org/project with an explicit status code, slug and exact source. parent (optional) is the key of a parent task of the same project. adoptSource only after partial reconciliation. Does not plan or execute the work and creates no branch or worktree.', a => [{ name: 'task_create', ...a } as AppCommand]), - task_update: tool(z.strictObject({ key: text, status: text.optional(), title: text.optional(), reason: text.optional(), + task_update: tool(z.strictObject({ key: text, status: text.optional(), title: text.optional(), parent: text.nullable().optional(), reason: text.optional(), progress: text.optional(), artifacts: z.array(artifact).optional(), touch: z.literal(true).optional() }).superRefine((value, ctx) => { - const change = value.status !== undefined || value.title !== undefined; + const change = value.status !== undefined || value.title !== undefined || value.parent !== undefined; if (!change && value.progress === undefined && value.touch === undefined) - ctx.addIssue({ code: 'custom', path: [], message: 'task_update needs status, title, progress or touch' }); + ctx.addIssue({ code: 'custom', path: [], message: 'task_update needs status, title, parent, progress or touch' }); if (change && value.reason === undefined) - ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is required with status or title' }); + ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is required with status, title or parent' }); if (!change && value.reason !== undefined) - ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is allowed only with status or title' }); + ctx.addIssue({ code: 'custom', path: ['reason'], message: 'reason is allowed only with status, title or parent' }); if (value.artifacts !== undefined && value.progress === undefined) ctx.addIssue({ code: 'custom', path: ['artifacts'], message: 'artifacts require progress' }); if (value.touch !== undefined && (change || value.progress !== undefined)) ctx.addIssue({ code: 'custom', path: ['touch'], message: 'touch is allowed only alone' }); - }), 'Call to change a task in one call: status and/or title with a reason, progress text with optional artifacts (paths relative to the task folder; an artifact without text references an existing file; progress commits the named files in home (no push) and records each as {path, commit}), or touch:true alone to mark activity. Applies status, then title, then progress; if a later part fails after an earlier one saved, the result is partial with the saved and missing parts. Does not reopen terminal tasks, overwrite artifacts, retry, or create branches or worktrees; the same status is a no-op. A status change to review, blocked, merged or a terminal status queues a milestone event for the project chat (inbox, warmup), unless the project chat itself makes it; milestones never start a turn.', - ({ key, status, title, reason, progress, artifacts, touch }) => { + }), 'Call to change a task in one call: status, title and/or parent (a task key of the same project, or null to clear) with a reason, progress text with optional artifacts (paths relative to the task folder; an artifact without text references an existing file; progress commits the named files in home (no push) and records each as {path, commit}), or touch:true alone to mark activity. Applies status, then title, then parent, then progress; if a later part fails after an earlier one saved, the result is partial with the saved and missing parts. Does not reopen terminal tasks, overwrite artifacts, retry, or create branches or worktrees; the same status is a no-op; parent is refused for another project, the task itself or a cycle. A status change to review, blocked, merged or a terminal status queues a milestone event for the project chat (inbox, warmup), unless the project chat itself makes it; milestones never start a turn.', + ({ key, status, title, parent, reason, progress, artifacts, touch }) => { const commands: AppCommand[] = []; if (status !== undefined) commands.push({ name: 'task_status', key, status, reason: reason! }); if (title !== undefined) commands.push({ name: 'task_title', key, title, reason: reason! }); + if (parent !== undefined) commands.push({ name: 'task_parent', key, parent, reason: reason! }); if (progress !== undefined) commands.push({ name: 'task_progress', key, text: progress, artifacts: (artifacts ?? []) as Artifact[] }); if (touch) commands.push({ name: 'task_touch', key }); return commands; diff --git a/src/modules/tasks/adapters/sqlite-task-store.ts b/src/modules/tasks/adapters/sqlite-task-store.ts index 2da92173..2363b748 100644 --- a/src/modules/tasks/adapters/sqlite-task-store.ts +++ b/src/modules/tasks/adapters/sqlite-task-store.ts @@ -3,7 +3,7 @@ import { InputError } from '../../../shared/errors.js'; import type { Card, TaskStore, Status } from '../ports.js'; const select = `SELECT r.id, r.project_id AS projectId, r.number, r.title, r.status_id AS statusId, s.code AS status, s.is_terminal AS isTerminal, r.context_dir AS contextDir, r.worktree_path AS worktreePath, - r.created_at AS createdAt, r.updated_at AS updatedAt FROM tasks r JOIN task_statuses s ON s.id=r.status_id`; + r.parent_id AS parentId, r.created_at AS createdAt, r.updated_at AS updatedAt FROM tasks r JOIN task_statuses s ON s.id=r.status_id`; function card(row: unknown): Card { if (!row) throw new InputError('Unknown task'); const value = row as Card; return { ...value, isTerminal: Boolean(value.isTerminal) }; @@ -23,8 +23,8 @@ export function sqliteTaskStore(db: Database): TaskStore { removeStatus(code) { db.prepare('DELETE FROM task_statuses WHERE code=?').run(code); }, nextNumber(projectId) { return (db.prepare('SELECT COALESCE(MAX(number),0)+1 AS n FROM tasks WHERE project_id IS ?').get(projectId) as { n: number }).n; }, insert(input) { - const row = db.prepare(`INSERT INTO tasks(project_id,number,title,status_id,context_dir,created_at,updated_at) - VALUES (?,?,?,?,?,?,?)`).run(input.projectId,input.number,input.title,input.statusId,input.contextDir,input.createdAt,input.updatedAt); + const row = db.prepare(`INSERT INTO tasks(project_id,number,title,status_id,context_dir,parent_id,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?)`).run(input.projectId,input.number,input.title,input.statusId,input.contextDir,input.parentId,input.createdAt,input.updatedAt); return get(Number(row.lastInsertRowid)); }, list: () => db.prepare(select + ' ORDER BY r.id').all().map(card), @@ -41,5 +41,9 @@ export function sqliteTaskStore(db: Database): TaskStore { db.prepare('UPDATE tasks SET worktree_path=? WHERE id=?').run(worktreePath, id); return get(id); }, + setParent(id, parentId, at) { + db.prepare('UPDATE tasks SET parent_id=?, updated_at=? WHERE id=?').run(parentId, at, id); + return get(id); + }, }; } diff --git a/src/modules/tasks/ports.ts b/src/modules/tasks/ports.ts index 883642d6..6d2447b6 100644 --- a/src/modules/tasks/ports.ts +++ b/src/modules/tasks/ports.ts @@ -1,7 +1,7 @@ export interface Status { id: number; code: string; isTerminal: boolean } export interface Card { id: number; projectId: number | null; number: number; title: string; statusId: number; - status: string; isTerminal: boolean; contextDir: string; worktreePath: string | null; createdAt: string; updatedAt: string; + status: string; isTerminal: boolean; contextDir: string; worktreePath: string | null; parentId: number | null; createdAt: string; updatedAt: string; } export interface TaskStore { transaction<T>(fn: () => T): T; @@ -17,4 +17,5 @@ export interface TaskStore { update(id: number, title: string, statusId: number, at: string): Card; relocate(id: number, contextDir: string): Card; setWorktree(id: number, worktreePath: string): Card; + setParent(id: number, parentId: number | null, at: string): Card; } diff --git a/src/modules/tasks/public.ts b/src/modules/tasks/public.ts index d26762be..565ed026 100644 --- a/src/modules/tasks/public.ts +++ b/src/modules/tasks/public.ts @@ -31,13 +31,15 @@ export function createTasks(store: TaskStore, clock: () => string, store.transaction(() => { status(code); store.terminalStatus(code, terminal); }); }, removeStatus(code: string) { store.transaction(() => { status(code); store.removeStatus(code); }); }, - create(input: { projectId: number | null; slug: string; title: string; status: string }, + create(input: { projectId: number | null; slug: string; title: string; status: string; parentId?: number | null }, saveSource: (contextDir: string) => void) { nonempty(input.title); if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug)) throw new InputError('Invalid English slug'); if (input.projectId === null) throw new InputError('Project required'); const projectId = input.projectId; return store.transaction(() => { + if (input.parentId !== undefined && input.parentId !== null && store.get(input.parentId).projectId !== projectId) + throw new InputError('Parent task must belong to the same project'); const state = status(input.status), number = store.nextNumber(projectId); if (!Number.isSafeInteger(number) || number < 1) throw new InputError('Number exhausted'); const parent = project(projectId); @@ -45,7 +47,7 @@ export function createTasks(store: TaskStore, clock: () => string, const at = clock(); saveSource(contextDir); return store.insert({ projectId, number, title: input.title, statusId: state.id, - contextDir, createdAt: at, updatedAt: at }); + contextDir, parentId: input.parentId ?? null, createdAt: at, updatedAt: at }); }); }, change(id: number, input: { title?: string; status?: string }, reason: string) { @@ -60,6 +62,21 @@ export function createTasks(store: TaskStore, clock: () => string, return { before, after: store.update(id, title, next, clock()), changed: true }; }); }, + setParent(id: number, parentId: number | null, reason: string) { + nonempty(reason); + return store.transaction(() => { + const before = store.get(id); + if (parentId === before.parentId) return { before, after: before, changed: false }; + if (parentId !== null) { + if (parentId === id) throw new InputError('A task cannot be its own parent'); + const parent = store.get(parentId); + if (parent.projectId !== before.projectId) throw new InputError('Parent task must belong to the same project'); + for (let at: number | null = parent.parentId; at !== null; at = store.get(at).parentId) + if (at === id) throw new InputError('Parent would create a cycle'); + } + return { before, after: store.setParent(id, parentId, clock()), changed: true }; + }); + }, relocate(id: number, contextDir: string) { nonempty(contextDir); return store.transaction(() => { store.get(id); return store.relocate(id, contextDir); }); diff --git a/test/boundary/http-api.test.ts b/test/boundary/http-api.test.ts index faa83429..6440afcf 100644 --- a/test/boundary/http-api.test.ts +++ b/test/boundary/http-api.test.ts @@ -62,7 +62,7 @@ test('real mypi serve on loopback: bearer auth, read paging with a cursor, write assert.equal(info.status, 200); const version = (JSON.parse(readFileSync('/work/package.json', 'utf8')) as { version: string }).version; const { schemaVersion, version: served, changeId: c0 } = info.body.data as { schemaVersion: number; version: string; changeId: number }; - assert.deepEqual([schemaVersion, served], [12, version]); + assert.deepEqual([schemaVersion, served], [13, version]); const page = await fetch(url + '/'); const html = await page.text(); diff --git a/test/boundary/tasks.test.ts b/test/boundary/tasks.test.ts index 430c222c..301525b2 100644 --- a/test/boundary/tasks.test.ts +++ b/test/boundary/tasks.test.ts @@ -96,6 +96,15 @@ test('tasks preserve source, allocate separate numbers, filter parents, extend s assert.equal(app.read(sourcePath), interrupted.source); app.tasks.create({ ...interrupted, adoptSource: true }); assert.equal(app.tasks.list().length, rows + 1); + const c = app.tasks.create({ project: 'one/project', title: 'Child', status: 'new', slug: 'child', source: 'child', parent: a.key }); + assert.equal(c.parent, 'MP-1'); + assert.equal(app.tasks.get(c.key).parent, 'MP-1'); + assert(app.history({ type: 'task', key: c.key }).some(e => e.text === 'Task registered. Parent: MP-1.')); + assert.equal(app.tasks.setParent(c.key, null, 'detach').parent, null); + assert.equal(app.history({ type: 'task', key: c.key }).at(-1)!.text, 'Parent: MP-1 → none. detach'); + assert.equal(app.tasks.list({ project: 'one/project' }).find(card => card.key === c.key)!.parent, null); + assert.throws(() => app.tasks.setParent(c.key, b.key, 'x'), /same project/); + assert.throws(() => app.tasks.setParent(c.key, 'MP-999', 'x'), /Unknown task key/); } finally { app.close(); } }); diff --git a/test/fast/database.test.ts b/test/fast/database.test.ts index b52d730b..f20f7ce0 100644 --- a/test/fast/database.test.ts +++ b/test/fast/database.test.ts @@ -51,7 +51,7 @@ test('readonly connection observes committed rows in an active writer WAL withou assert.equal(reader.pragma('journal_mode', { simple: true }), 'wal'); assert.deepEqual(reader.prepare('SELECT slug FROM organizations WHERE slug = ?').get('visible'), { slug: 'visible' }); assert.throws(() => reader.prepare("INSERT INTO organizations(slug) VALUES ('forbidden')").run(), /readonly/i); - assert.equal((reader.pragma('user_version', { simple: true }) as number), 12); + assert.equal((reader.pragma('user_version', { simple: true }) as number), 13); } finally { reader.close(); } } finally { writer.close(); } }); @@ -71,8 +71,8 @@ test('initialization is a no-op on a current database and rejects a newer schema db = openDatabase(filename); try { assert.deepEqual(db.prepare('SELECT * FROM task_statuses ORDER BY id').all(), expected); - assert.equal(db.pragma('user_version', { simple: true }), 12); - db.pragma('user_version = 13'); + assert.equal(db.pragma('user_version', { simple: true }), 13); + db.pragma('user_version = 14'); assert.equal(db.pragma('journal_mode = DELETE', { simple: true }), 'delete'); } finally { db.close(); } assert.throws(() => initializeDatabase(filename), /newer/); @@ -80,7 +80,7 @@ test('initialization is a no-op on a current database and rejects a newer schema const unsupported = new Database(filename, { readonly: true, fileMustExist: true }); try { assert.equal(unsupported.pragma('journal_mode', { simple: true }), 'delete'); - assert.equal(unsupported.pragma('user_version', { simple: true }), 13); + assert.equal(unsupported.pragma('user_version', { simple: true }), 14); } finally { unsupported.close(); } }); @@ -139,7 +139,7 @@ test('schemas 6 and 7 upgrade a v0.7.0 database: work_chats dropped, requests re t.diagnostic('migrations 006+007 on 50 tasks and ' + seed.length + ' journal rows: ' + Math.round(performance.now() - started) + ' ms'); const upgraded = openDatabase(filename); try { - assert.equal(upgraded.pragma('user_version', { simple: true }), 12); + assert.equal(upgraded.pragma('user_version', { simple: true }), 13); const names = (upgraded.prepare('SELECT name FROM sqlite_master').all() as { name: string }[]).map(x => x.name); for (const name of ['tasks', 'task_statuses', 'tasks_unassigned_number', 'used_status_terminality', 'used_project_code', 'preserve_task_numbers', 'knowledge_journal', 'knowledge_journal_order']) assert.ok(names.includes(name), name); @@ -171,11 +171,11 @@ test('schemas 6 and 7 upgrade a v0.7.0 database: work_chats dropped, requests re assert.equal(existsSync(join(dir, 'snapshot.sqlite3')), true); }); -test('empty database initializes at schema 12 with the tasks, sessions and events tables', t => { +test('empty database initializes at schema 13 with the tasks, sessions and events tables', t => { const { filename } = state(t); const db = openDatabase(filename); try { - assert.equal(db.pragma('user_version', { simple: true }), 12); + assert.equal(db.pragma('user_version', { simple: true }), 13); const names = (db.prepare('SELECT name FROM sqlite_master').all() as { name: string }[]).map(x => x.name); for (const name of ['tasks', 'task_statuses', 'tasks_unassigned_number', 'preserve_task_numbers', 'sessions', 'events']) assert.ok(names.includes(name), name); for (const name of ['requests', 'request_statuses', 'requests_unassigned_number', 'preserve_request_numbers']) assert.ok(!names.includes(name), name); @@ -208,7 +208,7 @@ test('schema 9 upgrades a schema 8 database and keeps its events', t => { initializeDatabase(filename); const upgraded = openDatabase(filename); try { - assert.equal(upgraded.pragma('user_version', { simple: true }), 12); + assert.equal(upgraded.pragma('user_version', { simple: true }), 13); assert.deepEqual(upgraded.prepare('SELECT * FROM events ORDER BY id').all(), before); upgraded.prepare("INSERT INTO events(from_key, to_key, kind, message, created_at) VALUES ('MP-1', 'MP', 'milestone', 'review', '2026-10-30T00:00:00.000Z')").run(); assert.equal(upgraded.pragma('integrity_check', { simple: true }), 'ok'); @@ -237,7 +237,7 @@ test('schema 10 upgrades a schema 9 database and keeps its events', t => { initializeDatabase(filename); const upgraded = openDatabase(filename); try { - assert.equal(upgraded.pragma('user_version', { simple: true }), 12); + assert.equal(upgraded.pragma('user_version', { simple: true }), 13); assert.deepEqual(upgraded.prepare('SELECT * FROM events ORDER BY id').all(), before); upgraded.prepare("INSERT INTO events(from_key, to_key, kind, message, created_at) VALUES ('MP', 'MP-1', 'message', 'go', '2026-10-31T00:00:00.000Z')").run(); assert.equal(upgraded.pragma('integrity_check', { simple: true }), 'ok'); @@ -263,7 +263,7 @@ test('schema 12 adds projects.repository and keeps the projects of a schema 11 d initializeDatabase(filename); const upgraded = openDatabase(filename); try { - assert.equal(upgraded.pragma('user_version', { simple: true }), 12); + assert.equal(upgraded.pragma('user_version', { simple: true }), 13); assert.deepEqual(upgraded.prepare('SELECT * FROM projects').all(), [{ id: 2, org_id: 1, slug: 'project', code: 'MP', checkout_path: '/srv/one/project', repository: null }]); assert.throws(() => upgraded.prepare("INSERT INTO projects(org_id, slug, code, repository) VALUES (1, 'blank', 'BL', ' ')").run(), /CHECK constraint failed/); @@ -271,6 +271,37 @@ test('schema 12 adds projects.repository and keeps the projects of a schema 11 d } finally { upgraded.close(); } }); +test('schema 13 adds tasks.parent_id and keeps the tasks of a schema 12 database', t => { + const dir = mkdtempSync(join(tmpdir(), 'mypi-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const filename = join(dir, 'v12.sqlite3'); + const db = new Database(filename); + db.pragma('foreign_keys = ON'); + for (const migration of ['001-initial.sql', '002-knowledge-journal.sql', '003-work-chats.sql', + '004-work-chat-auth-pending.sql', '005-work-chat-process-origin.sql', '006-drop-work-chats.sql', + '007-rename-requests-tasks.sql', '008-sessions-events.sql', '009-milestone-events.sql', + '010-parent-messages.sql', '011-artifact-version-refs.sql', '012-project-repository.sql']) { + db.exec(readFileSync(new URL('../../src/infrastructure/database/migrations/' + migration, import.meta.url), 'utf8')); + } + db.pragma('user_version = 12'); + db.prepare("INSERT INTO organizations(id, slug) VALUES (1, 'one')").run(); + db.prepare("INSERT INTO projects(id, org_id, slug, code, checkout_path) VALUES (2, 1, 'project', 'MP', '/srv/one/project')").run(); + db.prepare(`INSERT INTO tasks(id, project_id, number, title, status_id, context_dir, created_at, updated_at) + VALUES (3, 2, 1, 'First', (SELECT id FROM task_statuses WHERE code = 'new'), + 'projects/one/project/tasks/MP-1-first', '2026-11-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z')`).run(); + db.close(); + initializeDatabase(filename); + const upgraded = openDatabase(filename); + try { + assert.equal(upgraded.pragma('user_version', { simple: true }), 13); + assert.deepEqual(upgraded.prepare('SELECT id, parent_id FROM tasks').all(), [{ id: 3, parent_id: null }]); + assert.throws(() => upgraded.prepare(`INSERT INTO tasks(project_id, number, title, status_id, context_dir, created_at, updated_at, parent_id) + VALUES (2, 2, 'Orphan', (SELECT id FROM task_statuses WHERE code = 'new'), 'projects/one/project/tasks/MP-2-orphan', + '2026-11-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z', 999)`).run(), /FOREIGN KEY constraint failed/); + assert.equal(upgraded.pragma('integrity_check', { simple: true }), 'ok'); + } finally { upgraded.close(); } +}); + test('sessions rows keep the scope shape, one native id per agent and the project code', t => { const { filename } = state(t); const app = createApp(filename, false); diff --git a/test/fast/http-api.test.ts b/test/fast/http-api.test.ts index 4a3e4d27..d2bbecb0 100644 --- a/test/fast/http-api.test.ts +++ b/test/fast/http-api.test.ts @@ -7,9 +7,9 @@ import { decodeCursor, encodeCursor, requestUrl } from '../../src/http/validate. import { InputError } from '../../src/shared/errors.js'; import { PartialError } from '../../src/shared/context.js'; -const card = { id: 3, projectId: 1, number: 1, title: 'Задача', statusId: 2, status: 'new', isTerminal: false, +const card = { id: 3, projectId: 1, number: 1, title: 'Задача', parent: 'MP-7', statusId: 2, status: 'new', isTerminal: false, contextDir: 'projects/one/project/tasks/MP-1-task', worktreePath: null, createdAt: '2026-11-01T00:00:00.000Z', updatedAt: '2026-11-02T00:00:00.000Z' }; -const taskShape = { key: 'MP-1', project: 'MP', number: 1, title: 'Задача', status: 'new', isTerminal: false, +const taskShape = { key: 'MP-1', project: 'MP', number: 1, title: 'Задача', parent: 'MP-7', status: 'new', isTerminal: false, contextDir: 'projects/one/project/tasks/MP-1-task', worktreePath: null, createdAt: '2026-11-01T00:00:00.000Z', updatedAt: '2026-11-02T00:00:00.000Z' }; const info = { version: '0.10.0', schemaVersion: 12, changeId: 4, instance: 'instance' }; @@ -123,7 +123,10 @@ test("writes translate with the token's sender, strict JSON bodies and the task_ assert.equal(patched.status, 200); assert.deepEqual(commands, [{ name: 'task_status', key: 'MP-1', status: 'done', reason: 'ok', envScope: 'global' }, { name: 'task_show', key: 'MP-1' }]); commands.length = 0; - for (const body of [{ reason: 'x' }, { touch: true, progress: 'p' }, { artifacts: [] }, { touch: false }]) { + assert.equal((await call('PATCH', '/v1/tasks/MP-1', { token: 'w-token', body: { parent: null, reason: 'r' } })).status, 200); + assert.deepEqual(commands, [{ name: 'task_parent', key: 'MP-1', parent: null, reason: 'r' }, { name: 'task_show', key: 'MP-1' }]); + commands.length = 0; + for (const body of [{ reason: 'x' }, { touch: true, progress: 'p' }, { artifacts: [] }, { touch: false }, { parent: 'MP-2' }, { parent: 5, reason: 'r' }]) { assert.equal((await call('PATCH', '/v1/tasks/MP-1', { token: 'w-token', body })).status, 400); } assert.equal((await call('POST', '/v1/tasks', { token: 'w-token', body: { project: 'MP', title: 't', status: 'new', slug: 's', source: { file: '/etc/passwd' } } })).status, 400); @@ -135,6 +138,8 @@ test("writes translate with the token's sender, strict JSON bodies and the task_ const created = await call('POST', '/v1/tasks', { token: 'w-token', body: { project: 'MP', title: 't', status: 'new', slug: 's', source: 'text' } }); assert.deepEqual([created.status, created.body.data], [201, taskShape]); assert.deepEqual(commands.at(-1), { name: 'task_create', project: 'MP', title: 't', status: 'new', slug: 's', source: { text: 'text' } }); + await call('POST', '/v1/tasks', { token: 'w-token', body: { project: 'MP', title: 't', status: 'new', slug: 's', source: 'text', parent: 'MP-7' } }); + assert.deepEqual(commands.at(-1), { name: 'task_create', project: 'MP', title: 't', status: 'new', slug: 's', source: { text: 'text' }, parent: 'MP-7' }); const note = await call('POST', '/v1/journal', { token: 'w-token', body: { scope: 'MP', text: 'n' } }); assert.deepEqual([note.status, note.body.data], [201, { id: 12 }]); assert.deepEqual(commands.at(-1), { name: 'journal_add', scope: { reference: 'MP' }, text: 'n' }); diff --git a/test/fast/mcp-contract.test.ts b/test/fast/mcp-contract.test.ts index c45488cb..5106d894 100644 --- a/test/fast/mcp-contract.test.ts +++ b/test/fast/mcp-contract.test.ts @@ -55,8 +55,12 @@ test('every MCP call translates to the same AppCommands as its CLI commands', () ['task_show', { key: 'MP-1' }, [['task', 'show', 'MP-1']]], ['task_create', { project: 'one/project', title: 'T', status: 'custom', slug: 'task', source: { text: 's' }, adoptSource: false }, [['task', 'create', 's', '--project', 'one/project', '--title', 'T', '--status', 'custom', '--slug', 'task']]], + ['task_create', { project: 'one/project', title: 'T', status: 'custom', slug: 'task', source: { text: 's' }, adoptSource: false, parent: 'MP-1' }, + [['task', 'create', 's', '--project', 'one/project', '--title', 'T', '--status', 'custom', '--slug', 'task', '--parent', 'MP-1']]], ['task_update', { key: 'MP-1', status: 'done', reason: 'verified' }, [['task', 'status', 'MP-1', 'done', '--reason', 'verified']]], ['task_update', { key: 'MP-1', title: 'new', reason: 'clarified' }, [['task', 'title', 'MP-1', 'new', '--reason', 'clarified']]], + ['task_update', { key: 'MP-2', parent: 'MP-1', reason: 'epic' }, [['task', 'parent', 'MP-2', 'MP-1', '--reason', 'epic']]], + ['task_update', { key: 'MP-2', parent: null, reason: 'r' }, [['task', 'parent', 'MP-2', 'none', '--reason', 'r']]], ['task_update', { key: 'MP-1', progress: 'done' }, [['task', 'progress', 'MP-1', 'done']]], ['task_update', { key: 'MP-1', touch: true }, [['task', 'touch', 'MP-1']]], ['task_update', { key: 'MP-1', status: 'done', title: 'new', reason: 'verified', progress: 'done' }, @@ -106,8 +110,9 @@ test('action tools and task_update reject missing, foreign and malformed fields' ['projects', { action: 'list', path: '/tmp' }, /path is not allowed for action list/], ['projects', { action: 'add', identity: 'one/project', code: 'MP', expectedCheckoutPath: null }, /expectedCheckoutPath is not allowed for action add/], ['projects', { action: 'remove', identity: 'one/project', code: 'MP' }, /action/], - ['task_update', { key: 'MP-1' }, /needs status, title, progress or touch/], + ['task_update', { key: 'MP-1' }, /needs status, title, parent, progress or touch/], ['task_update', { key: 'MP-1', status: 'done' }, /reason is required/], + ['task_update', { key: 'MP-1', parent: 'MP-2' }, /reason is required/], ['task_update', { key: 'MP-1', progress: 'x', reason: 'r' }, /reason is allowed only/], ['task_update', { key: 'MP-1', touch: true, status: 'done', reason: 'r' }, /touch is allowed only alone/], ['task_update', { key: 'MP-1', artifacts: [{ path: 'x' }] }, /artifacts require progress/], diff --git a/test/fast/remote-mcp.test.ts b/test/fast/remote-mcp.test.ts index 8cb95bec..8ddf112b 100644 --- a/test/fast/remote-mcp.test.ts +++ b/test/fast/remote-mcp.test.ts @@ -48,6 +48,8 @@ test('remote tools reuse local definitions, translate like the local server and { name: 'task_status', key: 'MP-1', status: 'done', reason: 'ok', envScope: 'MP' }, { name: 'task_progress', key: 'MP-1', text: 'p', artifacts: [] }, ]); + assert.deepEqual(remoteToolCommand('task_update', { key: 'MP-1', parent: null, reason: 'r' }, projectWriter, undefined), + [{ name: 'task_parent', key: 'MP-1', parent: null, reason: 'r' }]); assert.deepEqual(remoteToolCommand('send_message', { key: 'MP', text: 'go' }, writer, '/run/u'), [{ name: 'message_send', key: 'MP', body: { text: 'go' }, envScope: 'global', runtime: '/run/u' }]); assert.deepEqual(remoteToolCommand('send_message', { key: 'MP', text: 'go' }, writer, undefined), diff --git a/test/fast/task-parent.test.ts b/test/fast/task-parent.test.ts new file mode 100644 index 00000000..7f6eadfd --- /dev/null +++ b/test/fast/task-parent.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp } from '../../src/app/create-app.js'; +import { state } from '../support/state.js'; + +test('a parent task belongs to the same project, is not the task itself and creates no cycle', t => { + const { filename } = state(t); + const app = createApp(filename, false); + try { + const first = app.projects.add('one/project', 'MP'); + const other = app.projects.add('two/project', 'YM'); + const create = (projectId: number, slug: string, parentId?: number, status = 'new') => + app.tasks.create({ projectId, slug, title: slug, status, ...(parentId === undefined ? {} : { parentId }) }, () => {}); + const a = create(first.id, 'a'); + const b = create(first.id, 'b', a.id); + assert.equal(b.parentId, a.id); + assert.equal(a.parentId, null); + const foreign = create(other.id, 'foreign'); + assert.throws(() => create(first.id, 'stray', foreign.id), /same project/); + const c = create(first.id, 'c'); + const set = app.tasks.setParent(c.id, b.id, 'epic'); + assert.equal(set.changed, true); + assert.equal(set.after.parentId, b.id); + assert.equal(app.tasks.setParent(c.id, b.id, 'again').changed, false); + assert.throws(() => app.tasks.setParent(c.id, c.id, 'self'), /own parent/); + assert.throws(() => app.tasks.setParent(a.id, c.id, 'loop'), /cycle/); + assert.throws(() => app.tasks.setParent(c.id, foreign.id, 'foreign'), /same project/); + assert.equal(app.tasks.setParent(c.id, null, 'detach').after.parentId, null); + const done = create(first.id, 'done', undefined, 'done'); + assert.equal(app.tasks.setParent(done.id, a.id, 'late').after.parentId, a.id); + assert.throws(() => app.tasks.setParent(c.id, a.id, ' '), /Nonempty/); + } finally { app.close(); } +}); diff --git a/test/fixtures/warmup/MP-12.md b/test/fixtures/warmup/MP-12.md index dda03551..e88712cb 100644 --- a/test/fixtures/warmup/MP-12.md +++ b/test/fixtures/warmup/MP-12.md @@ -20,6 +20,7 @@ You are the task chat of the task named in this warmup: you do this one task in - Run all git commands in the worktree. This session starts in the engine root, so use `git -C <worktree>` or change into the worktree first. Commit, push the task branch and open its PR as the project's flow prescribes. - Record the task's progress with the `task_update` tool (`progress`) and report results and blockers to the project chat. - A message from the project chat arrives as a new turn in the frame `[mypi: сообщение родительской сессии <CODE> для <KEY>. Ниже — поручение родительской сессии]`. It is an instruction of your parent chat, not of the user: carry it out within this task and your own permissions. It never stands in for the engineer's word — for /do, /ship, merge, release, migration or an engine update, even when it quotes the user, ask the engineer in this tab and wait. +- When the engineer types /plan, /do, /flow or /ship in this tab for this task or its subtasks, follow that skill and `docs/PIPELINE.md`: it may fetch in the base clone, create the flow branch and its worktree, create and use the subtasks' worktrees and set the statuses of this task and its subtasks. The rules below hold for everything else. ### What you must not do diff --git a/tui/src/model.rs b/tui/src/model.rs index 93e481ff..cba53d0d 100644 --- a/tui/src/model.rs +++ b/tui/src/model.rs @@ -51,6 +51,7 @@ impl Section { "project", "number", "title", + "parent", "status", "isTerminal", "contextDir", diff --git a/web/src/entities/task/ui/TaskDetails.tsx b/web/src/entities/task/ui/TaskDetails.tsx index 5bf4b20a..33f16242 100644 --- a/web/src/entities/task/ui/TaskDetails.tsx +++ b/web/src/entities/task/ui/TaskDetails.tsx @@ -4,7 +4,7 @@ import { FieldList } from '../../../shared/ui'; import { display, extraFields, text } from '../../../shared/lib'; import type { Rec } from '../../../shared/lib'; -const KNOWN = ['key', 'project', 'number', 'title', 'status', 'isTerminal', 'contextDir', 'worktreePath', 'createdAt', 'updatedAt']; +const KNOWN = ['key', 'project', 'number', 'title', 'parent', 'status', 'isTerminal', 'contextDir', 'worktreePath', 'createdAt', 'updatedAt']; export function TaskDetails({ task }: { task: Rec }) { const key = text(task, 'key');