feat(cli): --criterion on create epictask, and file the reconciliation follow-ups

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-09 22:09:27 +01:00
parent b6f3ff8a26
commit f2239852a1
2 changed files with 46 additions and 5 deletions

View File

@@ -3358,9 +3358,9 @@
"131", "131",
"126" "126"
], ],
"status": "pending", "status": "done",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-08-09T19:36:34.052Z" "updatedAt": "2026-08-09T21:09:27.647Z"
}, },
{ {
"id": "171", "id": "171",
@@ -3415,16 +3415,44 @@
"status": "pending", "status": "pending",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-08-09T20:50:38.660Z" "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": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-08-09T20:55:31.468Z", "lastModified": "2026-08-09T21:09:27.647Z",
"taskCount": 174, "taskCount": 174,
"completedCount": 129, "completedCount": 130,
"tags": [ "tags": [
"master" "master"
] ]
} }
} }
} }

View File

@@ -179,11 +179,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.requiredOption('--epic <name>', 'Parent Epic') .requiredOption('--epic <name>', 'Parent Epic')
.option('--project <name>', 'Disambiguate the Epic by project') .option('--project <name>', 'Disambiguate the Epic by project')
.option('--description <text>', 'Description') .option('--description <text>', 'Description')
.option('--criterion <text>', '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 <name>', 'Prerequisite epictask in the same Epic (repeat)', collect, []) .option('--depends-on <name>', 'Prerequisite epictask in the same Epic (repeat)', collect, [])
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
const body: Record<string, unknown> = { name, epic: opts.epic }; const body: Record<string, unknown> = { name, epic: opts.epic };
if (opts.project !== undefined) body.project = opts.project; if (opts.project !== undefined) body.project = opts.project;
if (opts.description !== undefined) body.description = opts.description; 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); const created = await client.post<{ id: string; name: string }>('/api/v1/epictasks', body);
log(`Created epictask '${created.name}'`); log(`Created epictask '${created.name}'`);
for (const dep of opts.dependsOn as string[]) { for (const dep of opts.dependsOn as string[]) {