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 +1 -1
- package/skill/SKILL.md +7 -1
- package/skill/agents/openai.yaml +1 -1
- package/skill/assets/cadence.json +22 -1
- package/skill/config/commit-conventions.json +6 -0
- package/skill/scripts/run-planner.py +573 -0
- package/skill/scripts/scaffold-project.sh +22 -1
- package/skill/scripts/workflow_state.py +138 -2
- package/skill/skills/planner/SKILL.md +29 -0
- package/skill/skills/planner/agents/openai.yaml +15 -0
- package/skill/skills/researcher/SKILL.md +4 -1
- package/skill/skills/researcher/agents/openai.yaml +2 -0
- package/skill/tests/test_run_planner.py +174 -0
- package/skill/tests/test_workflow_state.py +51 -0
package/package.json
CHANGED
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 `
|
|
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`.
|
package/skill/agents/openai.yaml
CHANGED
|
@@ -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
|
|
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":
|
|
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":
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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()
|