From f2239852a13dddc47b5a132b285d455e82e11497 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 22:09:27 +0100 Subject: [PATCH] feat(cli): --criterion on create epictask, and file the reconciliation follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the Epic reconciliation (task 170) and closes the gap that made it inert from the terminal. `mcpctl create epictask` had no `--criterion` flag while `create epic` did, so a roadmap built from the CLI had no criteria on its nodes. The reconciliation attributes a criterion to a node by id, so every criterion would have reported as `unowned` and both mismatch lists would always have been empty — the feature would have looked like it worked and detected nothing. Files two follow-ups the reconciliation surfaced: - 175: criteria and roadmap nodes are linked only by a free-form id convention that nothing enforces. EpicTaskService never validates ids against the parent Epic, ids can repeat across nodes, and a node can carry an id the Epic never had. The reconciliation reports those as `unowned` rather than guessing an owner, which is right, but the convention should become an invariant before more is built on it. - 176: `get epic -o yaml` now emits computed `completeness`/`reconciliation`, which are not settable. apply tolerates them, but the round-trip rule says get output is apply input. Local: build clean, workspace 2499 passed, lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 38 +++++++++++++++++++++++++++++----- src/cli/src/commands/create.ts | 13 ++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index ed8a527..239e6de 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -3358,9 +3358,9 @@ "131", "126" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-09T19:36:34.052Z" + "updatedAt": "2026-08-09T21:09:27.647Z" }, { "id": "171", @@ -3415,16 +3415,44 @@ "status": "pending", "subtasks": [], "updatedAt": "2026-08-09T20:50:38.660Z" + }, + { + "id": "175", + "title": "Give criteria a real link to the roadmap node that delivers them", + "description": "Epic.doneCriteria and EpicTask.doneCriteria are connected only by a free-form id convention that nothing enforces. The reconciliation added in 170 depends on it.", + "details": "Found while building the reconciliation. EpicTaskService.create/update never validates criterion ids against the parent Epic; ids can be duplicated across nodes, and a node can carry an id the Epic never had. Every failure of the convention currently surfaces as `unowned`.\n\nOptions:\n- an EpicCriterion table with a real FK (strongest, biggest change);\n- validate on epictask write that each doneCriteria[].id exists on the parent Epic, turning the convention into an invariant, plus a startup check for existing rows (cheapest honest fix).\n\nDecide before more is built on top of the convention.", + "testStrategy": "See details.", + "priority": "high", + "dependencies": [ + "170" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T21:09:27.819Z" + }, + { + "id": "176", + "title": "Keep computed fields out of the get -o yaml round-trip", + "description": "`get epic X -o yaml` now emits `completeness` and `reconciliation`, which are computed, not settable. apply -f tolerates them (zod ignores unknown keys) but the round-trip rule says get output is apply input.", + "details": "Stripping them in stripInternalFields was tried and reverted: an existing smoke assertion reads `completeness` out of `get epics -o json`, and stripping would also hide the new mismatch from the CLI entirely.\n\nThe real fix is probably to keep computed fields in -o json but drop them from -o yaml, and surface the mismatch in `describe epic` (task 167) rather than in the apply document.", + "testStrategy": "See details.", + "priority": "medium", + "dependencies": [ + "167" + ], + "status": "pending", + "subtasks": [], + "updatedAt": "2026-08-09T21:09:27.819Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-09T20:55:31.468Z", + "lastModified": "2026-08-09T21:09:27.647Z", "taskCount": 174, - "completedCount": 129, + "completedCount": 130, "tags": [ "master" ] } } -} \ No newline at end of file +} diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index cab750f..ed9ab5c 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -179,11 +179,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .requiredOption('--epic ', 'Parent Epic') .option('--project ', 'Disambiguate the Epic by project') .option('--description ', 'Description') + .option('--criterion ', 'Done-criterion "id=text" (repeat); the id must match one on the parent Epic, and prefix with q: for a quality criterion', collect, []) .option('--depends-on ', 'Prerequisite epictask in the same Epic (repeat)', collect, []) .action(async (name: string, opts) => { const body: Record = { name, epic: opts.epic }; if (opts.project !== undefined) body.project = opts.project; if (opts.description !== undefined) body.description = opts.description; + // Without this a CLI-built roadmap has no criteria on its nodes, so the + // Epic reconciliation reports every criterion as `unowned` and both + // mismatch lists are always empty — the feature is inert from the terminal. + const doneCriteria = (opts.criterion as string[]).map((raw) => { + const eq = raw.indexOf('='); + if (eq === -1) throw new Error(`Invalid --criterion '${raw}'. Expected id=text`); + let id = raw.slice(0, eq); + const quality = id.startsWith('q:'); + if (quality) id = id.slice(2); + return { id, text: raw.slice(eq + 1), kind: quality ? 'quality' : 'outcome', met: false }; + }); + if (doneCriteria.length > 0) body.doneCriteria = doneCriteria; const created = await client.post<{ id: string; name: string }>('/api/v1/epictasks', body); log(`Created epictask '${created.name}'`); for (const dep of opts.dependsOn as string[]) {