cadence-skill-installer 0.2.32 → 0.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadence-skill-installer",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Install the Cadence skill into supported AI tool skill directories.",
5
5
  "repository": "https://github.com/snowdamiz/cadence",
6
6
  "private": false,
package/skill/SKILL.md CHANGED
@@ -66,7 +66,7 @@ description: Structured project operating system for end-to-end greenfield or br
66
66
 
67
67
  ## Prerequisite Gate (Conditional)
68
68
  1. Invoke `skills/prerequisite-gate/SKILL.md` only when `route.skill_name` is `prerequisite-gate`.
69
- 2. If `route.skill_name` is not `prerequisite-gate` (for example `brownfield-intake`, `brownfield-documenter`, `ideator`, or `researcher`), skip prerequisite gate and follow the active route instead.
69
+ 2. If `route.skill_name` is not `prerequisite-gate` (for example `brownfield-intake`, `brownfield-documenter`, `ideator`, `researcher`, or `planner`), skip prerequisite gate and follow the active route instead.
70
70
 
71
71
  ## Project Mode Intake Gate (Conditional)
72
72
  1. Invoke `skills/brownfield-intake/SKILL.md` only when `route.skill_name` is `brownfield-intake`.
@@ -107,6 +107,12 @@ description: Structured project operating system for end-to-end greenfield or br
107
107
  2. Enforce one research pass per conversation so context stays bounded.
108
108
  3. When the researcher flow reports additional passes remain (`handoff_required=true`), end with this exact line: `Start a new chat and say "continue research".`
109
109
  4. Continue routing to researcher on subsequent chats until workflow reports the research task complete.
110
+ 5. For greenfield projects, when research completes and route advances to `planner`, invoke `skills/planner/SKILL.md` in the next routed conversation.
111
+
112
+ ## Planner Flow
113
+ 1. If the workflow route is `planner`, invoke `skills/planner/SKILL.md`.
114
+ 2. Planner is greenfield-only and should not run for brownfield routes.
115
+ 3. Keep planner output at milestone/phase level in `.cadence/cadence.json` and defer waves/tasks decomposition to a later planning subskill.
110
116
 
111
117
  ## Ideation Update Flow
112
118
  1. If the user wants to modify or discuss existing ideation, invoke `skills/ideation-updater/SKILL.md`.
@@ -14,4 +14,4 @@ interface:
14
14
  For each successful subskill conversation, run scripts/finalize-skill-checkpoint.py from PROJECT_ROOT with
15
15
  that subskill's --scope/--checkpoint and --paths ., allow status=no_changes, and treat checkpoint/push
16
16
  failures as blocking.
17
- Use the exact handoff lines in SKILL.md for ideator, brownfield-documenter, and researcher transitions.
17
+ Use the exact handoff lines in SKILL.md for ideator, brownfield-documenter, researcher, and planner transitions.
@@ -10,7 +10,7 @@
10
10
  "brownfield-documentation-completed": false
11
11
  },
12
12
  "workflow": {
13
- "schema_version": 5,
13
+ "schema_version": 6,
14
14
  "plan": [
15
15
  {
16
16
  "id": "milestone-foundation",
@@ -86,6 +86,16 @@
86
86
  "skill_path": "skills/researcher/SKILL.md",
87
87
  "reason": "Ideation research agenda has not been completed yet."
88
88
  }
89
+ },
90
+ {
91
+ "id": "task-roadmap-planning",
92
+ "kind": "task",
93
+ "title": "Plan project roadmap",
94
+ "route": {
95
+ "skill_name": "planner",
96
+ "skill_path": "skills/planner/SKILL.md",
97
+ "reason": "Project roadmap planning has not been completed yet."
98
+ }
89
99
  }
90
100
  ]
91
101
  }
@@ -135,5 +145,16 @@
135
145
  "handoff_required": false,
136
146
  "handoff_message": "Start a new chat and say \"continue research\"."
137
147
  }
148
+ },
149
+ "planning": {
150
+ "version": 1,
151
+ "status": "pending",
152
+ "detail_level": "",
153
+ "decomposition_pending": true,
154
+ "created_at": "",
155
+ "updated_at": "",
156
+ "summary": "",
157
+ "assumptions": [],
158
+ "milestones": []
138
159
  }
139
160
  }
@@ -133,6 +133,12 @@
133
133
  "checkpoints": {
134
134
  "research-pass-recorded": "persist one ideation research pass"
135
135
  }
136
+ },
137
+ "planner": {
138
+ "description": "Greenfield roadmap planning persistence",
139
+ "checkpoints": {
140
+ "plan-created": "persist milestone and phase roadmap plan"
141
+ }
136
142
  }
137
143
  }
138
144
  }
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+ """Discover and persist roadmap planning for greenfield Cadence projects."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from datetime import datetime, timezone
8
+ import json
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from project_root import resolve_project_root, write_project_root_hint
16
+ from workflow_state import default_data, reconcile_workflow_state, set_workflow_item_status
17
+
18
+
19
+ SCRIPT_DIR = Path(__file__).resolve().parent
20
+ ROUTE_GUARD_SCRIPT = SCRIPT_DIR / "assert-workflow-route.py"
21
+ FUZZY_QUERY_SCRIPT = SCRIPT_DIR / "query-json-fuzzy.py"
22
+ CADENCE_JSON_REL = Path(".cadence") / "cadence.json"
23
+ DETAIL_LEVEL = "milestone_phase_v1"
24
+
25
+
26
+ def parse_args() -> argparse.Namespace:
27
+ parser = argparse.ArgumentParser(
28
+ description="Discover and persist Cadence roadmap planning.",
29
+ )
30
+ parser.add_argument(
31
+ "--project-root",
32
+ default="",
33
+ help="Explicit project root path override.",
34
+ )
35
+
36
+ subparsers = parser.add_subparsers(dest="command", required=True)
37
+
38
+ discover = subparsers.add_parser(
39
+ "discover",
40
+ help="Extract planning-ready context from cadence state.",
41
+ )
42
+ discover.add_argument(
43
+ "--fuzzy-query",
44
+ action="append",
45
+ default=[],
46
+ help="Optional fuzzy query text to search against cadence.json (repeatable).",
47
+ )
48
+ discover.add_argument(
49
+ "--fuzzy-threshold",
50
+ type=float,
51
+ default=0.76,
52
+ help="Minimum fuzzy score threshold when using --fuzzy-query (default: 0.76).",
53
+ )
54
+ discover.add_argument(
55
+ "--fuzzy-limit",
56
+ type=int,
57
+ default=8,
58
+ help="Maximum results per fuzzy query (default: 8).",
59
+ )
60
+ discover.add_argument(
61
+ "--fuzzy-field",
62
+ action="append",
63
+ default=[],
64
+ help="Optional field path pattern passed to query-json-fuzzy.py --field (repeatable).",
65
+ )
66
+
67
+ complete = subparsers.add_parser(
68
+ "complete",
69
+ help="Persist roadmap planning payload and advance workflow.",
70
+ )
71
+ payload_group = complete.add_mutually_exclusive_group(required=True)
72
+ payload_group.add_argument("--file", help="Path to planner payload JSON file")
73
+ payload_group.add_argument("--json", help="Inline planner payload JSON")
74
+ payload_group.add_argument("--stdin", action="store_true", help="Read planner payload JSON from stdin")
75
+
76
+ return parser.parse_args()
77
+
78
+
79
+ def utc_now() -> str:
80
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
81
+
82
+
83
+ def run_command(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
84
+ return subprocess.run(
85
+ command,
86
+ cwd=str(cwd),
87
+ capture_output=True,
88
+ text=True,
89
+ check=False,
90
+ )
91
+
92
+
93
+ def assert_expected_route(project_root: Path) -> None:
94
+ result = run_command(
95
+ [
96
+ sys.executable,
97
+ str(ROUTE_GUARD_SCRIPT),
98
+ "--skill-name",
99
+ "planner",
100
+ "--project-root",
101
+ str(project_root),
102
+ ],
103
+ project_root,
104
+ )
105
+ if result.returncode != 0:
106
+ detail = result.stderr.strip() or result.stdout.strip() or "WORKFLOW_ROUTE_CHECK_FAILED"
107
+ print(detail, file=sys.stderr)
108
+ raise SystemExit(result.returncode)
109
+
110
+
111
+ def cadence_json_path(project_root: Path) -> Path:
112
+ return project_root / CADENCE_JSON_REL
113
+
114
+
115
+ def load_state(project_root: Path) -> dict[str, Any]:
116
+ state_path = cadence_json_path(project_root)
117
+ if not state_path.exists():
118
+ return default_data()
119
+
120
+ try:
121
+ payload = json.loads(state_path.read_text(encoding="utf-8"))
122
+ except json.JSONDecodeError as exc:
123
+ print(f"INVALID_CADENCE_JSON: {exc} path={state_path}", file=sys.stderr)
124
+ raise SystemExit(1)
125
+ if not isinstance(payload, dict):
126
+ return default_data()
127
+ return reconcile_workflow_state(payload, cadence_dir_exists=True)
128
+
129
+
130
+ def save_state(project_root: Path, data: dict[str, Any]) -> None:
131
+ path = cadence_json_path(project_root)
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
134
+
135
+
136
+ def _coerce_text(value: Any) -> str:
137
+ return str(value).strip() if value is not None else ""
138
+
139
+
140
+ def _coerce_text_list(value: Any) -> list[str]:
141
+ if value is None:
142
+ return []
143
+ if isinstance(value, (list, tuple, set)):
144
+ raw_values = list(value)
145
+ else:
146
+ raw_values = [value]
147
+
148
+ values: list[str] = []
149
+ for raw in raw_values:
150
+ text = _coerce_text(raw)
151
+ if text and text not in values:
152
+ values.append(text)
153
+ return values
154
+
155
+
156
+ def _slug_token(value: Any, fallback: str) -> str:
157
+ token = re.sub(r"[^a-z0-9]+", "-", _coerce_text(value).lower()).strip("-")
158
+ if token:
159
+ return token
160
+ fallback_token = re.sub(r"[^a-z0-9]+", "-", _coerce_text(fallback).lower()).strip("-")
161
+ return fallback_token or "item"
162
+
163
+
164
+ def ensure_planner_prerequisites(data: dict[str, Any]) -> None:
165
+ state = data.get("state")
166
+ state = state if isinstance(state, dict) else {}
167
+ mode = _coerce_text(state.get("project-mode", "unknown")).lower()
168
+ if mode != "greenfield":
169
+ raise ValueError(f"PLANNER_REQUIRES_GREENFIELD_MODE: project-mode={mode or 'unknown'}")
170
+ if not bool(state.get("ideation-completed", False)):
171
+ raise ValueError("IDEATION_NOT_COMPLETE")
172
+ if not bool(state.get("research-completed", False)):
173
+ raise ValueError("RESEARCH_NOT_COMPLETE")
174
+
175
+
176
+ def planning_contract() -> dict[str, Any]:
177
+ return {
178
+ "detail_level": DETAIL_LEVEL,
179
+ "required_fields": ["summary", "milestones"],
180
+ "optional_fields": ["assumptions"],
181
+ "milestone_fields": [
182
+ "milestone_id",
183
+ "title",
184
+ "objective",
185
+ "success_criteria",
186
+ "phases",
187
+ ],
188
+ "phase_fields": [
189
+ "phase_id",
190
+ "title",
191
+ "objective",
192
+ "deliverables",
193
+ "exit_criteria",
194
+ "notes",
195
+ ],
196
+ "constraints": [
197
+ "At least one milestone is required.",
198
+ "Each milestone must include at least one phase.",
199
+ "Do not include waves or tasks in this planner version.",
200
+ ],
201
+ }
202
+
203
+
204
+ def summarize_context(data: dict[str, Any]) -> dict[str, Any]:
205
+ ideation = data.get("ideation")
206
+ ideation = ideation if isinstance(ideation, dict) else {}
207
+ agenda = ideation.get("research_agenda")
208
+ agenda = agenda if isinstance(agenda, dict) else {}
209
+ agenda_summary = agenda.get("summary")
210
+ agenda_summary = agenda_summary if isinstance(agenda_summary, dict) else {}
211
+ execution = ideation.get("research_execution")
212
+ execution = execution if isinstance(execution, dict) else {}
213
+ execution_summary = execution.get("summary")
214
+ execution_summary = execution_summary if isinstance(execution_summary, dict) else {}
215
+ blocks = agenda.get("blocks")
216
+ blocks = blocks if isinstance(blocks, list) else []
217
+ pass_history = execution.get("pass_history")
218
+ pass_history = pass_history if isinstance(pass_history, list) else []
219
+
220
+ block_summaries: list[dict[str, Any]] = []
221
+ for block in blocks:
222
+ if not isinstance(block, dict):
223
+ continue
224
+ topics = block.get("topics")
225
+ topics = topics if isinstance(topics, list) else []
226
+ topic_titles = [
227
+ _coerce_text(topic.get("title", ""))
228
+ for topic in topics
229
+ if isinstance(topic, dict) and _coerce_text(topic.get("title", ""))
230
+ ]
231
+ block_summaries.append(
232
+ {
233
+ "block_id": _coerce_text(block.get("block_id")),
234
+ "title": _coerce_text(block.get("title")),
235
+ "rationale": _coerce_text(block.get("rationale")),
236
+ "topic_count": len(topics),
237
+ "topic_titles": topic_titles[:8],
238
+ }
239
+ )
240
+
241
+ recent_passes: list[dict[str, Any]] = []
242
+ for entry in pass_history[-4:]:
243
+ if not isinstance(entry, dict):
244
+ continue
245
+ topics = entry.get("topics")
246
+ topics = topics if isinstance(topics, list) else []
247
+ recent_passes.append(
248
+ {
249
+ "pass_id": _coerce_text(entry.get("pass_id")),
250
+ "completed_at": _coerce_text(entry.get("completed_at")),
251
+ "pass_summary": _coerce_text(entry.get("pass_summary")),
252
+ "topic_count": len(topics),
253
+ }
254
+ )
255
+
256
+ planning = data.get("planning")
257
+ planning = planning if isinstance(planning, dict) else {}
258
+ milestones = planning.get("milestones")
259
+ milestones = milestones if isinstance(milestones, list) else []
260
+
261
+ return {
262
+ "objective": _coerce_text(ideation.get("objective")),
263
+ "core_outcome": _coerce_text(ideation.get("core_outcome")),
264
+ "target_audience": ideation.get("target_audience", ""),
265
+ "in_scope": _coerce_text_list(ideation.get("in_scope")),
266
+ "out_of_scope": _coerce_text_list(ideation.get("out_of_scope")),
267
+ "constraints": _coerce_text_list(ideation.get("constraints")),
268
+ "risks": _coerce_text_list(ideation.get("risks")),
269
+ "success_signals": _coerce_text_list(ideation.get("success_signals")),
270
+ "research_agenda_summary": {
271
+ "block_count": int(agenda_summary.get("block_count", 0) or 0),
272
+ "topic_count": int(agenda_summary.get("topic_count", 0) or 0),
273
+ "entity_count": int(agenda_summary.get("entity_count", 0) or 0),
274
+ },
275
+ "research_execution_summary": {
276
+ "topic_total": int(execution_summary.get("topic_total", 0) or 0),
277
+ "topic_complete": int(execution_summary.get("topic_complete", 0) or 0),
278
+ "pass_complete": int(execution_summary.get("pass_complete", 0) or 0),
279
+ },
280
+ "research_blocks": block_summaries,
281
+ "recent_research_passes": recent_passes,
282
+ "existing_plan_summary": {
283
+ "status": _coerce_text(planning.get("status", "pending")),
284
+ "detail_level": _coerce_text(planning.get("detail_level")),
285
+ "milestone_count": len(milestones),
286
+ "updated_at": _coerce_text(planning.get("updated_at")),
287
+ },
288
+ "planner_payload_contract": planning_contract(),
289
+ }
290
+
291
+
292
+ def run_fuzzy_queries(args: argparse.Namespace, project_root: Path) -> list[dict[str, Any]]:
293
+ queries = [query for query in (_coerce_text(value) for value in args.fuzzy_query) if query]
294
+ if not queries:
295
+ return []
296
+
297
+ threshold = float(args.fuzzy_threshold)
298
+ if threshold < 0.0 or threshold > 1.0:
299
+ raise ValueError("INVALID_FUZZY_THRESHOLD: must be between 0.0 and 1.0")
300
+
301
+ limit = int(args.fuzzy_limit)
302
+ if limit < 1:
303
+ raise ValueError("INVALID_FUZZY_LIMIT: must be >= 1")
304
+
305
+ fields = [field for field in (_coerce_text(value) for value in args.fuzzy_field) if field]
306
+ cadence_path = cadence_json_path(project_root)
307
+ responses: list[dict[str, Any]] = []
308
+
309
+ for query in queries:
310
+ command = [
311
+ sys.executable,
312
+ str(FUZZY_QUERY_SCRIPT),
313
+ "--file",
314
+ str(cadence_path),
315
+ "--text",
316
+ query,
317
+ "--threshold",
318
+ str(threshold),
319
+ "--limit",
320
+ str(limit),
321
+ ]
322
+ for field in fields:
323
+ command.extend(["--field", field])
324
+
325
+ result = run_command(command, project_root)
326
+ if result.returncode != 0:
327
+ responses.append(
328
+ {
329
+ "query": query,
330
+ "status": "error",
331
+ "error": result.stderr.strip() or result.stdout.strip() or "FUZZY_QUERY_FAILED",
332
+ }
333
+ )
334
+ continue
335
+
336
+ raw = result.stdout.strip()
337
+ if not raw:
338
+ responses.append(
339
+ {
340
+ "query": query,
341
+ "status": "error",
342
+ "error": "FUZZY_QUERY_EMPTY_OUTPUT",
343
+ }
344
+ )
345
+ continue
346
+
347
+ try:
348
+ payload = json.loads(raw)
349
+ except json.JSONDecodeError as exc:
350
+ responses.append(
351
+ {
352
+ "query": query,
353
+ "status": "error",
354
+ "error": f"FUZZY_QUERY_INVALID_JSON: {exc}",
355
+ }
356
+ )
357
+ continue
358
+
359
+ responses.append(
360
+ {
361
+ "query": query,
362
+ "status": "ok",
363
+ "summary": payload.get("summary", {}),
364
+ "results": payload.get("results", []),
365
+ }
366
+ )
367
+
368
+ return responses
369
+
370
+
371
+ def parse_payload(args: argparse.Namespace, project_root: Path) -> dict[str, Any]:
372
+ if args.file:
373
+ payload_path = Path(args.file).expanduser()
374
+ if not payload_path.is_absolute():
375
+ payload_path = (project_root / payload_path).resolve()
376
+ try:
377
+ raw = payload_path.read_text(encoding="utf-8")
378
+ except OSError as exc:
379
+ raise ValueError(f"PAYLOAD_READ_FAILED: {exc}") from exc
380
+ elif args.json:
381
+ raw = args.json
382
+ else:
383
+ raw = sys.stdin.read()
384
+
385
+ try:
386
+ payload = json.loads(raw)
387
+ except json.JSONDecodeError as exc:
388
+ raise ValueError(f"INVALID_PAYLOAD_JSON: {exc}") from exc
389
+
390
+ if not isinstance(payload, dict):
391
+ raise ValueError("PLANNER_PAYLOAD_MUST_BE_OBJECT")
392
+ return payload
393
+
394
+
395
+ def normalize_phase(raw_phase: Any, *, fallback_index: int, milestone_seed: str) -> dict[str, Any]:
396
+ phase = dict(raw_phase) if isinstance(raw_phase, dict) else {}
397
+ if phase.get("waves") is not None or phase.get("tasks") is not None:
398
+ raise ValueError("PHASE_WAVES_AND_TASKS_NOT_ALLOWED_IN_V1")
399
+
400
+ phase_id_raw = phase.get("phase_id", phase.get("id", ""))
401
+ title = _coerce_text(phase.get("title")) or f"Phase {fallback_index}"
402
+ phase_id = _slug_token(phase_id_raw, f"{milestone_seed}-phase-{fallback_index}")
403
+
404
+ return {
405
+ "phase_id": phase_id,
406
+ "title": title,
407
+ "objective": _coerce_text(phase.get("objective")),
408
+ "deliverables": _coerce_text_list(phase.get("deliverables")),
409
+ "exit_criteria": _coerce_text_list(phase.get("exit_criteria")),
410
+ "notes": _coerce_text(phase.get("notes")),
411
+ }
412
+
413
+
414
+ def normalize_milestone(raw_milestone: Any, *, fallback_index: int) -> dict[str, Any]:
415
+ milestone = dict(raw_milestone) if isinstance(raw_milestone, dict) else {}
416
+ if milestone.get("waves") is not None or milestone.get("tasks") is not None:
417
+ raise ValueError("MILESTONE_WAVES_AND_TASKS_NOT_ALLOWED_IN_V1")
418
+
419
+ milestone_id_raw = milestone.get("milestone_id", milestone.get("id", ""))
420
+ title = _coerce_text(milestone.get("title")) or f"Milestone {fallback_index}"
421
+ milestone_id = _slug_token(milestone_id_raw, f"milestone-{fallback_index}")
422
+
423
+ raw_phases = milestone.get("phases")
424
+ if not isinstance(raw_phases, list) or not raw_phases:
425
+ raise ValueError(f"MILESTONE_PHASES_REQUIRED: milestone_id={milestone_id}")
426
+
427
+ phases: list[dict[str, Any]] = []
428
+ seen_phase_ids: set[str] = set()
429
+ for index, raw_phase in enumerate(raw_phases, start=1):
430
+ normalized_phase = normalize_phase(raw_phase, fallback_index=index, milestone_seed=milestone_id)
431
+ phase_id = normalized_phase["phase_id"]
432
+ if phase_id in seen_phase_ids:
433
+ raise ValueError(f"DUPLICATE_PHASE_ID: {phase_id}")
434
+ seen_phase_ids.add(phase_id)
435
+ phases.append(normalized_phase)
436
+
437
+ return {
438
+ "milestone_id": milestone_id,
439
+ "title": title,
440
+ "objective": _coerce_text(milestone.get("objective")),
441
+ "success_criteria": _coerce_text_list(milestone.get("success_criteria")),
442
+ "phases": phases,
443
+ }
444
+
445
+
446
+ def normalize_planning_payload(payload: dict[str, Any], *, current_planning: dict[str, Any]) -> dict[str, Any]:
447
+ seed = payload.get("planning")
448
+ seed = seed if isinstance(seed, dict) else payload
449
+
450
+ detail_level = _coerce_text(seed.get("detail_level", DETAIL_LEVEL)) or DETAIL_LEVEL
451
+ if detail_level != DETAIL_LEVEL:
452
+ raise ValueError(f"UNSUPPORTED_DETAIL_LEVEL: {detail_level}")
453
+
454
+ raw_milestones = seed.get("milestones")
455
+ if not isinstance(raw_milestones, list) or not raw_milestones:
456
+ raise ValueError("PLANNER_MILESTONES_REQUIRED")
457
+
458
+ milestones: list[dict[str, Any]] = []
459
+ seen_milestones: set[str] = set()
460
+ for index, raw_milestone in enumerate(raw_milestones, start=1):
461
+ milestone = normalize_milestone(raw_milestone, fallback_index=index)
462
+ milestone_id = milestone["milestone_id"]
463
+ if milestone_id in seen_milestones:
464
+ raise ValueError(f"DUPLICATE_MILESTONE_ID: {milestone_id}")
465
+ seen_milestones.add(milestone_id)
466
+ milestones.append(milestone)
467
+
468
+ created_at = _coerce_text(current_planning.get("created_at")) or utc_now()
469
+ return {
470
+ "version": 1,
471
+ "status": "complete",
472
+ "detail_level": detail_level,
473
+ "decomposition_pending": True,
474
+ "created_at": created_at,
475
+ "updated_at": utc_now(),
476
+ "summary": _coerce_text(seed.get("summary")),
477
+ "assumptions": _coerce_text_list(seed.get("assumptions")),
478
+ "milestones": milestones,
479
+ }
480
+
481
+
482
+ def discover_flow(args: argparse.Namespace, project_root: Path, data: dict[str, Any]) -> dict[str, Any]:
483
+ ensure_planner_prerequisites(data)
484
+ fuzzy_results = run_fuzzy_queries(args, project_root)
485
+
486
+ return {
487
+ "status": "ok",
488
+ "mode": "greenfield",
489
+ "action": "discover",
490
+ "project_root": str(project_root),
491
+ "context": summarize_context(data),
492
+ "fuzzy_context": fuzzy_results,
493
+ }
494
+
495
+
496
+ def complete_flow(args: argparse.Namespace, project_root: Path, data: dict[str, Any]) -> dict[str, Any]:
497
+ ensure_planner_prerequisites(data)
498
+ payload = parse_payload(args, project_root)
499
+
500
+ planning_seed = data.get("planning")
501
+ planning_seed = planning_seed if isinstance(planning_seed, dict) else {}
502
+ normalized = normalize_planning_payload(payload, current_planning=planning_seed)
503
+ data["planning"] = normalized
504
+
505
+ data, found = set_workflow_item_status(
506
+ data,
507
+ item_id="task-roadmap-planning",
508
+ status="complete",
509
+ cadence_dir_exists=True,
510
+ )
511
+ if not found:
512
+ raise ValueError("WORKFLOW_ITEM_NOT_FOUND: task-roadmap-planning")
513
+
514
+ data = reconcile_workflow_state(data, cadence_dir_exists=True)
515
+ save_state(project_root, data)
516
+
517
+ milestones = normalized.get("milestones")
518
+ milestones = milestones if isinstance(milestones, list) else []
519
+ phase_count = sum(
520
+ len(milestone.get("phases", []))
521
+ for milestone in milestones
522
+ if isinstance(milestone, dict) and isinstance(milestone.get("phases"), list)
523
+ )
524
+
525
+ return {
526
+ "status": "ok",
527
+ "mode": "greenfield",
528
+ "action": "complete",
529
+ "project_root": str(project_root),
530
+ "planning_summary": {
531
+ "detail_level": normalized.get("detail_level", DETAIL_LEVEL),
532
+ "milestone_count": len(milestones),
533
+ "phase_count": phase_count,
534
+ "decomposition_pending": bool(normalized.get("decomposition_pending", True)),
535
+ },
536
+ "next_route": data.get("workflow", {}).get("next_route", {}),
537
+ }
538
+
539
+
540
+ def main() -> int:
541
+ args = parse_args()
542
+ explicit_project_root = args.project_root.strip() or None
543
+ try:
544
+ project_root, project_root_source = resolve_project_root(
545
+ script_dir=SCRIPT_DIR,
546
+ explicit_project_root=explicit_project_root,
547
+ require_cadence=True,
548
+ allow_hint=True,
549
+ )
550
+ except ValueError as exc:
551
+ print(str(exc), file=sys.stderr)
552
+ return 1
553
+
554
+ write_project_root_hint(SCRIPT_DIR, project_root)
555
+ assert_expected_route(project_root)
556
+ data = load_state(project_root)
557
+
558
+ try:
559
+ if args.command == "discover":
560
+ response = discover_flow(args, project_root, data)
561
+ else:
562
+ response = complete_flow(args, project_root, data)
563
+ except ValueError as exc:
564
+ print(str(exc), file=sys.stderr)
565
+ return 2
566
+
567
+ response["project_root_source"] = project_root_source
568
+ print(json.dumps(response))
569
+ return 0
570
+
571
+
572
+ if __name__ == "__main__":
573
+ raise SystemExit(main())
@@ -29,7 +29,7 @@ else
29
29
  "brownfield-documentation-completed": false
30
30
  },
31
31
  "workflow": {
32
- "schema_version": 5,
32
+ "schema_version": 6,
33
33
  "plan": [
34
34
  {
35
35
  "id": "milestone-foundation",
@@ -105,6 +105,16 @@ else
105
105
  "skill_path": "skills/researcher/SKILL.md",
106
106
  "reason": "Ideation research agenda has not been completed yet."
107
107
  }
108
+ },
109
+ {
110
+ "id": "task-roadmap-planning",
111
+ "kind": "task",
112
+ "title": "Plan project roadmap",
113
+ "route": {
114
+ "skill_name": "planner",
115
+ "skill_path": "skills/planner/SKILL.md",
116
+ "reason": "Project roadmap planning has not been completed yet."
117
+ }
108
118
  }
109
119
  ]
110
120
  }
@@ -154,6 +164,17 @@ else
154
164
  "handoff_required": false,
155
165
  "handoff_message": "Start a new chat and say \"continue research\"."
156
166
  }
167
+ },
168
+ "planning": {
169
+ "version": 1,
170
+ "status": "pending",
171
+ "detail_level": "",
172
+ "decomposition_pending": true,
173
+ "created_at": "",
174
+ "updated_at": "",
175
+ "summary": "",
176
+ "assumptions": [],
177
+ "milestones": []
157
178
  }
158
179
  }
159
180
  JSON
@@ -12,10 +12,11 @@ from typing import Any
12
12
 
13
13
  from ideation_research import ensure_ideation_research_defaults
14
14
 
15
- WORKFLOW_SCHEMA_VERSION = 5
15
+ WORKFLOW_SCHEMA_VERSION = 6
16
16
  VALID_STATUSES = {"pending", "in_progress", "complete", "blocked", "skipped"}
17
17
  COMPLETED_STATUSES = {"complete", "skipped"}
18
18
  LEGACY_PRESERVE_STATUSES = {"in_progress", "blocked", "skipped"}
19
+ PLANNING_STATUSES = {"pending", "in_progress", "complete", "skipped"}
19
20
 
20
21
  ROUTE_KEYS = {"skill_name", "skill_path", "reason"}
21
22
  DEFAULT_ROUTE_BY_ITEM_ID = {
@@ -49,6 +50,11 @@ DEFAULT_ROUTE_BY_ITEM_ID = {
49
50
  "skill_path": "skills/researcher/SKILL.md",
50
51
  "reason": "Ideation research agenda has not been completed yet.",
51
52
  },
53
+ "task-roadmap-planning": {
54
+ "skill_name": "planner",
55
+ "skill_path": "skills/planner/SKILL.md",
56
+ "reason": "Project roadmap planning has not been completed yet.",
57
+ },
52
58
  }
53
59
 
54
60
  DERIVED_WORKFLOW_KEYS = {
@@ -126,6 +132,12 @@ def default_workflow_plan() -> list[dict[str, Any]]:
126
132
  "title": "Research ideation agenda",
127
133
  "route": DEFAULT_ROUTE_BY_ITEM_ID["task-research"],
128
134
  },
135
+ {
136
+ "id": "task-roadmap-planning",
137
+ "kind": "task",
138
+ "title": "Plan project roadmap",
139
+ "route": DEFAULT_ROUTE_BY_ITEM_ID["task-roadmap-planning"],
140
+ },
129
141
  ],
130
142
  }
131
143
  ],
@@ -149,6 +161,17 @@ def default_data() -> dict[str, Any]:
149
161
  },
150
162
  "project-details": {},
151
163
  "ideation": ensure_ideation_research_defaults({}),
164
+ "planning": {
165
+ "version": 1,
166
+ "status": "pending",
167
+ "detail_level": "",
168
+ "decomposition_pending": True,
169
+ "created_at": "",
170
+ "updated_at": "",
171
+ "summary": "",
172
+ "assumptions": [],
173
+ "milestones": [],
174
+ },
152
175
  "workflow": {
153
176
  "schema_version": WORKFLOW_SCHEMA_VERSION,
154
177
  "plan": default_workflow_plan(),
@@ -222,6 +245,7 @@ def _normalize_plan(plan: Any) -> list[dict[str, Any]]:
222
245
  _ensure_brownfield_intake_task(normalized)
223
246
  _ensure_brownfield_documentation_task(normalized)
224
247
  _ensure_research_task(normalized)
248
+ _ensure_roadmap_planning_task(normalized)
225
249
  return normalized
226
250
 
227
251
 
@@ -418,6 +442,57 @@ def _ensure_research_task(plan: list[dict[str, Any]]) -> None:
418
442
  plan.append(deepcopy(research_task))
419
443
 
420
444
 
445
+ def _ensure_roadmap_planning_task(plan: list[dict[str, Any]]) -> None:
446
+ if _find_item_by_id(plan, "task-roadmap-planning") is not None:
447
+ return
448
+
449
+ planning_task = _normalize_item(
450
+ {
451
+ "id": "task-roadmap-planning",
452
+ "kind": "task",
453
+ "title": "Plan project roadmap",
454
+ "route": DEFAULT_ROUTE_BY_ITEM_ID["task-roadmap-planning"],
455
+ },
456
+ "task-roadmap-planning",
457
+ )
458
+
459
+ def inject_after_research(items: list[dict[str, Any]]) -> bool:
460
+ for item in items:
461
+ children = item.get("children", [])
462
+ if not isinstance(children, list):
463
+ continue
464
+
465
+ for index, child in enumerate(children):
466
+ if not isinstance(child, dict):
467
+ continue
468
+ if child.get("id") == "task-research":
469
+ children.insert(index + 1, deepcopy(planning_task))
470
+ return True
471
+
472
+ if inject_after_research(children):
473
+ return True
474
+ return False
475
+
476
+ if inject_after_research(plan):
477
+ return
478
+
479
+ # Fallback for custom plans lacking task-research; append to first actionable container.
480
+ def append_to_first_container(items: list[dict[str, Any]]) -> bool:
481
+ for item in items:
482
+ children = item.get("children", [])
483
+ if not isinstance(children, list):
484
+ continue
485
+ if children and all(isinstance(child, dict) and not child.get("children") for child in children):
486
+ children.append(deepcopy(planning_task))
487
+ return True
488
+ if append_to_first_container(children):
489
+ return True
490
+ return False
491
+
492
+ if not append_to_first_container(plan):
493
+ plan.append(deepcopy(planning_task))
494
+
495
+
421
496
  def _set_item_status(items: list[dict[str, Any]], item_id: str, status: str) -> bool:
422
497
  found = False
423
498
  for item in items:
@@ -515,6 +590,10 @@ def _collect_nodes(
515
590
  def _legacy_completion_map(data: dict[str, Any], *, cadence_dir_exists: bool) -> dict[str, bool]:
516
591
  state = data.get("state")
517
592
  state = state if isinstance(state, dict) else {}
593
+ planning = data.get("planning")
594
+ planning = planning if isinstance(planning, dict) else {}
595
+ planning_status = str(planning.get("status", "pending")).strip().lower()
596
+ planning_completed = planning_status in COMPLETED_STATUSES
518
597
  mode = _normalize_project_mode(state.get("project-mode", "unknown"))
519
598
  brownfield_completed = state.get("brownfield-intake-completed")
520
599
  if brownfield_completed is None:
@@ -529,6 +608,7 @@ def _legacy_completion_map(data: dict[str, Any], *, cadence_dir_exists: bool) ->
529
608
  "task-brownfield-documentation": bool(brownfield_doc_completed),
530
609
  "task-ideation": bool(state.get("ideation-completed", False)),
531
610
  "task-research": bool(state.get("research-completed", False)),
611
+ "task-roadmap-planning": bool(planning_completed),
532
612
  }
533
613
 
534
614
 
@@ -563,6 +643,7 @@ def _sync_legacy_flags_from_plan(data: dict[str, Any], plan: list[dict[str, Any]
563
643
  brownfield_doc_item = _find_item_by_id(plan, "task-brownfield-documentation")
564
644
  ideation_item = _find_item_by_id(plan, "task-ideation")
565
645
  research_item = _find_item_by_id(plan, "task-research")
646
+ planner_item = _find_item_by_id(plan, "task-roadmap-planning")
566
647
  mode = _normalize_project_mode(state.get("project-mode", "unknown"))
567
648
 
568
649
  if prerequisite_item is not None:
@@ -580,6 +661,18 @@ def _sync_legacy_flags_from_plan(data: dict[str, Any], plan: list[dict[str, Any]
580
661
  if research_item is not None:
581
662
  state["research-completed"] = _is_complete_status(_coerce_status(research_item.get("status")))
582
663
 
664
+ planning = data.get("planning")
665
+ planning = dict(planning) if isinstance(planning, dict) else {}
666
+ if planner_item is not None:
667
+ planner_status = _coerce_status(planner_item.get("status", "pending"))
668
+ if planner_status in PLANNING_STATUSES:
669
+ planning["status"] = planner_status
670
+ else:
671
+ planning["status"] = "pending"
672
+ if planning["status"] == "skipped":
673
+ planning["decomposition_pending"] = False
674
+ data["planning"] = planning
675
+
583
676
 
584
677
  def _normalize_project_mode(value: Any) -> str:
585
678
  mode = str(value).strip().lower()
@@ -591,7 +684,8 @@ def _normalize_project_mode(value: Any) -> str:
591
684
  def _apply_project_mode_status_overrides(plan: list[dict[str, Any]], *, project_mode: str) -> None:
592
685
  ideation_item = _find_item_by_id(plan, "task-ideation")
593
686
  brownfield_doc_item = _find_item_by_id(plan, "task-brownfield-documentation")
594
- if ideation_item is None and brownfield_doc_item is None:
687
+ planner_item = _find_item_by_id(plan, "task-roadmap-planning")
688
+ if ideation_item is None and brownfield_doc_item is None and planner_item is None:
595
689
  return
596
690
 
597
691
  mode = _normalize_project_mode(project_mode)
@@ -600,6 +694,10 @@ def _apply_project_mode_status_overrides(plan: list[dict[str, Any]], *, project_
600
694
  current = _coerce_status(brownfield_doc_item.get("status", "pending"))
601
695
  if current == "pending":
602
696
  brownfield_doc_item["status"] = "skipped"
697
+ if planner_item is not None:
698
+ current = _coerce_status(planner_item.get("status", "pending"))
699
+ if current == "skipped":
700
+ planner_item["status"] = "pending"
603
701
  elif mode == "brownfield":
604
702
  if ideation_item is not None:
605
703
  current = _coerce_status(ideation_item.get("status", "pending"))
@@ -609,6 +707,43 @@ def _apply_project_mode_status_overrides(plan: list[dict[str, Any]], *, project_
609
707
  current = _coerce_status(brownfield_doc_item.get("status", "pending"))
610
708
  if current == "skipped":
611
709
  brownfield_doc_item["status"] = "pending"
710
+ if planner_item is not None:
711
+ current = _coerce_status(planner_item.get("status", "pending"))
712
+ if current == "pending":
713
+ planner_item["status"] = "skipped"
714
+ else:
715
+ if planner_item is not None:
716
+ current = _coerce_status(planner_item.get("status", "pending"))
717
+ if current == "pending":
718
+ planner_item["status"] = "skipped"
719
+
720
+
721
+ def _ensure_planning_defaults(planning: Any) -> dict[str, Any]:
722
+ payload = dict(planning) if isinstance(planning, dict) else {}
723
+
724
+ status = str(payload.get("status", "pending")).strip().lower()
725
+ if status not in PLANNING_STATUSES:
726
+ status = "pending"
727
+
728
+ assumptions = payload.get("assumptions")
729
+ if not isinstance(assumptions, list):
730
+ assumptions = []
731
+
732
+ milestones = payload.get("milestones")
733
+ if not isinstance(milestones, list):
734
+ milestones = []
735
+
736
+ return {
737
+ "version": 1,
738
+ "status": status,
739
+ "detail_level": str(payload.get("detail_level", "")).strip(),
740
+ "decomposition_pending": bool(payload.get("decomposition_pending", True)),
741
+ "created_at": str(payload.get("created_at", "")).strip(),
742
+ "updated_at": str(payload.get("updated_at", "")).strip(),
743
+ "summary": str(payload.get("summary", "")).strip(),
744
+ "assumptions": assumptions,
745
+ "milestones": milestones,
746
+ }
612
747
 
613
748
 
614
749
  def _build_node_ref(node: dict[str, Any]) -> dict[str, Any]:
@@ -751,6 +886,7 @@ def reconcile_workflow_state(data: dict[str, Any], *, cadence_dir_exists: bool)
751
886
  if "ideation" not in data or not isinstance(data.get("ideation"), dict):
752
887
  data["ideation"] = {}
753
888
  data["ideation"] = ensure_ideation_research_defaults(data.get("ideation"))
889
+ data["planning"] = _ensure_planning_defaults(data.get("planning"))
754
890
 
755
891
  workflow_seed = data.get("workflow")
756
892
  workflow_seed = dict(workflow_seed) if isinstance(workflow_seed, dict) else {}
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: planner
3
+ description: Create a greenfield project roadmap from cadence ideation and research by producing high-level milestones and phases only. Use when Cadence route points to planner after research is complete.
4
+ ---
5
+
6
+ # Planner
7
+
8
+ 1. Run shared skill entry gates once at conversation start:
9
+ - `python3 ../../scripts/run-skill-entry-gate.py --require-cadence --assert-skill-name planner`
10
+ - Parse JSON and store `PROJECT_ROOT` from `project_root`, `CADENCE_SCRIPTS_DIR` from `cadence_scripts_dir`, and push mode from `repo_enabled` (`false` means local-only commits).
11
+ - Never manually edit `.cadence/cadence.json`; all Cadence state writes must go through Cadence scripts.
12
+ 2. Discover planning context directly from Cadence state:
13
+ - `python3 "$CADENCE_SCRIPTS_DIR/run-planner.py" --project-root "$PROJECT_ROOT" discover`
14
+ 3. Optionally enrich discovery with targeted fuzzy search when details are unclear:
15
+ - `python3 "$CADENCE_SCRIPTS_DIR/run-planner.py" --project-root "$PROJECT_ROOT" discover --fuzzy-query "<query>"`
16
+ 4. Use discovery output to draft a roadmap payload with this scope:
17
+ - Include overarching milestones and phases only.
18
+ - Do not include waves or tasks in this planner version.
19
+ - Keep `detail_level` as `milestone_phase_v1`.
20
+ 5. Present the proposed milestone/phase roadmap to the user and ask for confirmation.
21
+ 6. After confirmation, persist the finalized roadmap by piping payload JSON:
22
+ - `python3 "$CADENCE_SCRIPTS_DIR/run-planner.py" --project-root "$PROJECT_ROOT" complete --stdin`
23
+ 7. Verify persistence and route progression:
24
+ - `python3 "$CADENCE_SCRIPTS_DIR/read-workflow-state.py" --project-root "$PROJECT_ROOT"`
25
+ 8. At end of this successful skill conversation, run:
26
+ - `cd "$PROJECT_ROOT" && python3 "$CADENCE_SCRIPTS_DIR/finalize-skill-checkpoint.py" --scope planner --checkpoint plan-created --paths .`
27
+ 9. If `finalize-skill-checkpoint.py` returns `status=no_changes`, continue without failure.
28
+ 10. If `finalize-skill-checkpoint.py` reports an error, stop and surface it verbatim.
29
+ 11. In normal user-facing updates, report roadmap outcomes without raw command traces or internal routing details unless explicitly requested.
@@ -0,0 +1,15 @@
1
+ interface:
2
+ display_name: "Planner"
3
+ short_description: "Generate high-level greenfield milestones and phases roadmap"
4
+ default_prompt: >-
5
+ Follow skills/planner/SKILL.md for exact planning behavior.
6
+ Run scripts/run-skill-entry-gate.py --require-cadence --assert-skill-name planner, then use its JSON output
7
+ for PROJECT_ROOT (project_root), CADENCE_SCRIPTS_DIR (cadence_scripts_dir), and push/local-only mode
8
+ (repo_enabled).
9
+ Never edit .cadence/cadence.json manually.
10
+ Gather context with scripts/run-planner.py --project-root "$PROJECT_ROOT" discover, then draft and confirm a
11
+ roadmap containing milestones and phases only (no waves/tasks) using detail_level milestone_phase_v1.
12
+ Persist with scripts/run-planner.py --project-root "$PROJECT_ROOT" complete --stdin.
13
+ Finalize from PROJECT_ROOT with scripts/finalize-skill-checkpoint.py --scope planner
14
+ --checkpoint plan-created --paths . (allow status=no_changes; treat failures as blocking).
15
+ Keep replies concise and hide internal traces unless asked.
@@ -30,7 +30,10 @@ description: Execute ideation research agenda topics through dynamic multi-pass
30
30
  - `python3 "$CADENCE_SCRIPTS_DIR/run-research-pass.py" --project-root "$PROJECT_ROOT" complete --pass-id "<pass_id_from_start_output>" --stdin`
31
31
  7. Never start a second pass in the same conversation.
32
32
  8. If complete output returns `handoff_required=true`, end with this exact handoff line and stop: `Start a new chat and say "continue research".`
33
- 9. If complete output returns `research_complete=true`, report that research phase is complete.
33
+ 9. If complete output returns `research_complete=true`:
34
+ - Run `python3 "$CADENCE_SCRIPTS_DIR/read-workflow-state.py" --project-root "$PROJECT_ROOT"` and inspect `route.skill_name`.
35
+ - If `route.skill_name` is `planner`, end with this exact handoff line: `Start a new chat and say "plan my project".`
36
+ - If workflow is complete, report that research and currently tracked workflow items are complete.
34
37
  10. At end of this successful skill conversation, run `cd "$PROJECT_ROOT" && python3 "$CADENCE_SCRIPTS_DIR/finalize-skill-checkpoint.py" --scope researcher --checkpoint research-pass-recorded --paths .`.
35
38
  11. If `finalize-skill-checkpoint.py` returns `status=no_changes`, continue without failure.
36
39
  12. If `finalize-skill-checkpoint.py` reports an error, stop and surface it verbatim.
@@ -10,5 +10,7 @@ interface:
10
10
  Run exactly one pass per chat: scripts/run-research-pass.py start, research only that pass's topics,
11
11
  then persist with scripts/run-research-pass.py complete. Do not run a second pass in the same chat.
12
12
  If more work remains, end with: Start a new chat and say "continue research".
13
+ If research is complete and route advances to planner, end with:
14
+ Start a new chat and say "plan my project".
13
15
  Finalize from PROJECT_ROOT with scripts/finalize-skill-checkpoint.py --scope researcher
14
16
  --checkpoint research-pass-recorded --paths . (allow status=no_changes; treat failures as blocking).
@@ -0,0 +1,174 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
9
+ if str(SCRIPTS_DIR) not in sys.path:
10
+ sys.path.insert(0, str(SCRIPTS_DIR))
11
+
12
+ from workflow_state import default_data, set_workflow_item_status
13
+
14
+ RUN_PLANNER_SCRIPT = SCRIPTS_DIR / "run-planner.py"
15
+
16
+
17
+ def build_planner_ready_state() -> dict:
18
+ data = default_data()
19
+ state = data.setdefault("state", {})
20
+ state["project-mode"] = "greenfield"
21
+
22
+ for task_id in (
23
+ "task-scaffold",
24
+ "task-prerequisite-gate",
25
+ "task-brownfield-intake",
26
+ "task-ideation",
27
+ "task-research",
28
+ ):
29
+ data, found = set_workflow_item_status(
30
+ data,
31
+ item_id=task_id,
32
+ status="complete",
33
+ cadence_dir_exists=True,
34
+ )
35
+ if not found:
36
+ raise RuntimeError(f"Missing workflow item in fixture: {task_id}")
37
+
38
+ ideation = data.setdefault("ideation", {})
39
+ ideation["objective"] = "Ship an MVP"
40
+ ideation["core_outcome"] = "Deliver an end-to-end first release"
41
+ ideation["in_scope"] = ["core authentication", "dashboard"]
42
+ ideation["out_of_scope"] = ["native mobile"]
43
+ ideation["constraints"] = ["small team", "6-week target"]
44
+ ideation["risks"] = ["scope creep"]
45
+ ideation["success_signals"] = ["first 50 active users"]
46
+ return data
47
+
48
+
49
+ class RunPlannerTests(unittest.TestCase):
50
+ def test_discover_returns_context(self) -> None:
51
+ with tempfile.TemporaryDirectory() as tmp_dir:
52
+ project_root = Path(tmp_dir)
53
+ cadence_dir = project_root / ".cadence"
54
+ cadence_dir.mkdir(parents=True, exist_ok=True)
55
+ cadence_json = cadence_dir / "cadence.json"
56
+ cadence_json.write_text(json.dumps(build_planner_ready_state(), indent=4) + "\n", encoding="utf-8")
57
+
58
+ result = subprocess.run(
59
+ [
60
+ sys.executable,
61
+ str(RUN_PLANNER_SCRIPT),
62
+ "--project-root",
63
+ str(project_root),
64
+ "discover",
65
+ ],
66
+ capture_output=True,
67
+ text=True,
68
+ check=False,
69
+ )
70
+
71
+ self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
72
+ payload = json.loads(result.stdout)
73
+ self.assertEqual(payload.get("status"), "ok")
74
+ self.assertEqual(payload.get("action"), "discover")
75
+ self.assertEqual(payload.get("mode"), "greenfield")
76
+ self.assertIn("context", payload)
77
+ self.assertEqual(payload["context"]["planner_payload_contract"]["detail_level"], "milestone_phase_v1")
78
+
79
+ def test_complete_persists_milestone_phase_plan(self) -> None:
80
+ with tempfile.TemporaryDirectory() as tmp_dir:
81
+ project_root = Path(tmp_dir)
82
+ cadence_dir = project_root / ".cadence"
83
+ cadence_dir.mkdir(parents=True, exist_ok=True)
84
+ cadence_json = cadence_dir / "cadence.json"
85
+ cadence_json.write_text(json.dumps(build_planner_ready_state(), indent=4) + "\n", encoding="utf-8")
86
+
87
+ planner_payload = {
88
+ "summary": "Roadmap for MVP and launch readiness.",
89
+ "milestones": [
90
+ {
91
+ "milestone_id": "milestone-mvp",
92
+ "title": "MVP",
93
+ "objective": "Release first usable product.",
94
+ "success_criteria": ["Core flows complete", "Smoke tests pass"],
95
+ "phases": [
96
+ {
97
+ "phase_id": "phase-foundations",
98
+ "title": "Foundations",
99
+ "objective": "Set up architecture and standards.",
100
+ "deliverables": ["repo setup", "baseline app shell"],
101
+ "exit_criteria": ["CI green"],
102
+ }
103
+ ],
104
+ }
105
+ ],
106
+ }
107
+
108
+ result = subprocess.run(
109
+ [
110
+ sys.executable,
111
+ str(RUN_PLANNER_SCRIPT),
112
+ "--project-root",
113
+ str(project_root),
114
+ "complete",
115
+ "--json",
116
+ json.dumps(planner_payload),
117
+ ],
118
+ capture_output=True,
119
+ text=True,
120
+ check=False,
121
+ )
122
+
123
+ self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
124
+ payload = json.loads(result.stdout)
125
+ self.assertEqual(payload.get("status"), "ok")
126
+ self.assertEqual(payload.get("action"), "complete")
127
+ self.assertEqual(payload["planning_summary"]["milestone_count"], 1)
128
+ self.assertEqual(payload["planning_summary"]["phase_count"], 1)
129
+
130
+ updated = json.loads(cadence_json.read_text(encoding="utf-8"))
131
+ self.assertEqual(updated["planning"]["detail_level"], "milestone_phase_v1")
132
+ self.assertEqual(updated["planning"]["status"], "complete")
133
+ self.assertEqual(updated["workflow"]["next_item"]["id"], "complete")
134
+
135
+ def test_complete_rejects_waves_in_v1_payload(self) -> None:
136
+ with tempfile.TemporaryDirectory() as tmp_dir:
137
+ project_root = Path(tmp_dir)
138
+ cadence_dir = project_root / ".cadence"
139
+ cadence_dir.mkdir(parents=True, exist_ok=True)
140
+ cadence_json = cadence_dir / "cadence.json"
141
+ cadence_json.write_text(json.dumps(build_planner_ready_state(), indent=4) + "\n", encoding="utf-8")
142
+
143
+ planner_payload = {
144
+ "summary": "Invalid payload with waves.",
145
+ "milestones": [
146
+ {
147
+ "title": "MVP",
148
+ "waves": ["wave-1"],
149
+ "phases": [{"title": "Phase 1"}],
150
+ }
151
+ ],
152
+ }
153
+
154
+ result = subprocess.run(
155
+ [
156
+ sys.executable,
157
+ str(RUN_PLANNER_SCRIPT),
158
+ "--project-root",
159
+ str(project_root),
160
+ "complete",
161
+ "--json",
162
+ json.dumps(planner_payload),
163
+ ],
164
+ capture_output=True,
165
+ text=True,
166
+ check=False,
167
+ )
168
+
169
+ self.assertEqual(result.returncode, 2)
170
+ self.assertIn("MILESTONE_WAVES_AND_TASKS_NOT_ALLOWED_IN_V1", result.stderr)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ unittest.main()
@@ -29,9 +29,11 @@ class WorkflowStateStatusTests(unittest.TestCase):
29
29
  order = [child.get("id") for child in children if isinstance(child, dict)]
30
30
  self.assertIn("task-brownfield-intake", order)
31
31
  self.assertIn("task-brownfield-documentation", order)
32
+ self.assertIn("task-roadmap-planning", order)
32
33
  self.assertLess(order.index("task-prerequisite-gate"), order.index("task-brownfield-intake"))
33
34
  self.assertLess(order.index("task-brownfield-intake"), order.index("task-brownfield-documentation"))
34
35
  self.assertLess(order.index("task-brownfield-documentation"), order.index("task-ideation"))
36
+ self.assertLess(order.index("task-research"), order.index("task-roadmap-planning"))
35
37
 
36
38
  def test_brownfield_mode_routes_to_documenter_not_ideator(self) -> None:
37
39
  data = default_data()
@@ -101,6 +103,55 @@ class WorkflowStateStatusTests(unittest.TestCase):
101
103
  self.assertIsNotNone(intake)
102
104
  self.assertEqual(intake["status"], "complete")
103
105
 
106
+ def test_greenfield_routes_to_planner_after_research(self) -> None:
107
+ data = default_data()
108
+ state = data.setdefault("state", {})
109
+ state["project-mode"] = "greenfield"
110
+
111
+ for task_id in (
112
+ "task-scaffold",
113
+ "task-prerequisite-gate",
114
+ "task-brownfield-intake",
115
+ "task-ideation",
116
+ "task-research",
117
+ ):
118
+ data, found = set_workflow_item_status(
119
+ data,
120
+ item_id=task_id,
121
+ status="complete",
122
+ cadence_dir_exists=True,
123
+ )
124
+ self.assertTrue(found)
125
+
126
+ route = data["workflow"]["next_route"]
127
+ self.assertEqual(route.get("skill_name"), "planner")
128
+
129
+ def test_brownfield_skips_planner(self) -> None:
130
+ data = default_data()
131
+ state = data.setdefault("state", {})
132
+ state["project-mode"] = "brownfield"
133
+
134
+ for task_id in (
135
+ "task-scaffold",
136
+ "task-prerequisite-gate",
137
+ "task-brownfield-intake",
138
+ "task-brownfield-documentation",
139
+ "task-research",
140
+ ):
141
+ data, found = set_workflow_item_status(
142
+ data,
143
+ item_id=task_id,
144
+ status="complete",
145
+ cadence_dir_exists=True,
146
+ )
147
+ self.assertTrue(found)
148
+
149
+ updated = reconcile_workflow_state(data, cadence_dir_exists=True)
150
+ planner_task = find_item(updated["workflow"]["plan"], "task-roadmap-planning")
151
+ self.assertIsNotNone(planner_task)
152
+ self.assertEqual(planner_task["status"], "skipped")
153
+ self.assertEqual(updated["workflow"]["next_item"]["id"], "complete")
154
+
104
155
 
105
156
  if __name__ == "__main__":
106
157
  unittest.main()