@guanlin4924/devflow-pi 0.1.40 → 0.1.47

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  3. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  4. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  5. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  6. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  44. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  45. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  47. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  48. package/src/constants.ts +1 -1
  49. package/src/runtime.ts +53 -22
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed validator and canonical builder for wireframe proposals."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import unicodedata
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ DEFAULT_SCHEMA = Path(__file__).resolve().parents[1] / "contracts" / "proposal.schema.yaml"
16
+ DEFAULT_QUESTIONS_SCHEMA = Path(__file__).resolve().parents[1] / "contracts" / "questions.schema.yaml"
17
+ ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
18
+ STATES = {"default", "empty", "loading", "error"}
19
+ PRESENTATIONS = {"page", "modal", "drawer"}
20
+ PLATFORMS = {"web", "mobile"}
21
+ ACTION_TYPES = {"open", "close", "back"}
22
+ ELEMENT_TYPES = set()
23
+ ELEMENT_ALLOWED: dict[str, set[str]] = {}
24
+ ATOMIC = {"input", "select", "button", "icon_button"}
25
+
26
+
27
+ def load_yaml(path: Path) -> Any:
28
+ try:
29
+ import yaml # type: ignore
30
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
31
+ except ImportError:
32
+ result = subprocess.run(
33
+ ["ruby", "-ryaml", "-rjson", "-e", "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))", str(path)],
34
+ text=True, capture_output=True, check=False,
35
+ )
36
+ if result.returncode != 0:
37
+ raise ValueError(result.stderr.strip() or "YAML parse failed")
38
+ return json.loads(result.stdout)
39
+
40
+
41
+ def schema_values(schema: dict[str, Any]) -> tuple[re.Pattern[str], set[str], set[str], set[str], set[str], dict[str, set[str]]]:
42
+ pattern = str(schema.get("id_pattern", r"^[A-Za-z0-9][A-Za-z0-9_-]*$"))
43
+ screens = schema.get("screen", {})
44
+ screen_props = screens.get("properties", {}) if isinstance(screens, dict) else {}
45
+ states = set(screen_props.get("states", {}).get("values", ["default", "empty", "loading", "error"]))
46
+ presentations = set(screen_props.get("presentation", {}).get("values", ["page", "modal", "drawer"]))
47
+ platforms = set(screen_props.get("platforms", {}).get("values", ["web", "mobile"]))
48
+ actions = schema.get("action", {}).get("properties", {}).get("type", {}).get("values", ["open", "close", "back"])
49
+ element_specs = schema.get("elements", {})
50
+ element_allowed = {typ: {"id", "type"} | set(spec) for typ, spec in element_specs.items() if isinstance(spec, dict)}
51
+ return re.compile(pattern), states, presentations, platforms, set(actions), element_allowed
52
+
53
+
54
+ def issue(errors: list[dict[str, str]], code: str, path: str, message: str) -> None:
55
+ errors.append({"code": code, "path": path, "message": message})
56
+
57
+
58
+ def unknown_keys(value: Any, allowed: set[str], path: str, errors: list[dict[str, str]]) -> None:
59
+ if not isinstance(value, dict):
60
+ return
61
+ for key in value:
62
+ if key not in allowed:
63
+ issue(errors, "WIREFRAME_UNKNOWN_FIELD", f"{path}.{key}", f"unknown field {key}")
64
+
65
+
66
+ def require_id(value: Any, code: str, path: str, errors: list[dict[str, str]], seen: set[str]) -> None:
67
+ if not isinstance(value, str) or not value:
68
+ issue(errors, code, path, "id is required")
69
+ elif not ID_RE.fullmatch(value):
70
+ issue(errors, code, path, "id must match ^[A-Za-z0-9][A-Za-z0-9_-]*$")
71
+ elif value in seen:
72
+ issue(errors, "WIREFRAME_DUPLICATE_ELEMENT_ID" if code == "WIREFRAME_ELEMENT_ID_MISSING" else "WIREFRAME_DUPLICATE_SCREEN_ID", path, f"duplicate id {value}")
73
+ else:
74
+ seen.add(value)
75
+
76
+
77
+ def non_empty(value: Any, code: str, path: str, errors: list[dict[str, str]]) -> None:
78
+ if not isinstance(value, str) or not value.strip():
79
+ issue(errors, code, path, "non-empty string required")
80
+
81
+
82
+ def validate_questions(value: Any, feature: str, schema: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], dict[str, Any]]:
83
+ errors: list[dict[str, str]] = []
84
+ blocking_open: list[str] = []
85
+ metadata = {"status": "present", "unresolved": []}
86
+ if not isinstance(value, dict):
87
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "$", "questions root must be an object")
88
+ return errors, blocking_open, {"status": "invalid", "unresolved": []}
89
+ unknown_keys(value, {"schema_version", "feature", "questions"}, "$", errors)
90
+ if value.get("schema_version") != schema.get("schema_version", 1):
91
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "schema_version", "must match questions schema")
92
+ if value.get("feature") != feature:
93
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "feature", "must match proposal feature")
94
+ questions = value.get("questions")
95
+ if not isinstance(questions, list):
96
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "questions", "must be an array")
97
+ return errors, blocking_open, metadata
98
+ ids: set[str] = set()
99
+ for i, question in enumerate(questions):
100
+ path = f"questions[{i}]"
101
+ if not isinstance(question, dict):
102
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", path, "question must be an object")
103
+ continue
104
+ unknown_keys(question, {"id", "text", "kind", "status", "target", "answer"}, path, errors)
105
+ qid = question.get("id")
106
+ if not isinstance(qid, str) or not ID_RE.fullmatch(qid):
107
+ issue(errors, "WIREFRAME_QUESTION_ID_MISSING", f"{path}.id", "id must match the proposal id pattern")
108
+ elif qid in ids:
109
+ issue(errors, "WIREFRAME_DUPLICATE_QUESTION_ID", f"{path}.id", f"duplicate id {qid}")
110
+ else:
111
+ ids.add(qid)
112
+ non_empty(question.get("text"), "WIREFRAME_QUESTION_TEXT_MISSING", f"{path}.text", errors)
113
+ if question.get("kind") not in {"blocking", "informational"}:
114
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.kind", "must be blocking or informational")
115
+ if question.get("status") not in {"open", "resolved"}:
116
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.status", "must be open or resolved")
117
+ target = question.get("target")
118
+ if target is not None:
119
+ if not isinstance(target, dict):
120
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.target", "must be an object")
121
+ else:
122
+ unknown_keys(target, {"screen", "element"}, f"{path}.target", errors)
123
+ if any(not isinstance(target.get(k), str) or not ID_RE.fullmatch(target[k]) for k in target):
124
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.target", "target IDs must match the proposal id pattern")
125
+ if "answer" in question and question["answer"] is not None and not isinstance(question["answer"], str):
126
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.answer", "answer must be null or a string")
127
+ if question.get("kind") == "blocking" and question.get("status") == "open" and isinstance(qid, str):
128
+ blocking_open.append(qid)
129
+ metadata["unresolved"] = blocking_open
130
+ return errors, blocking_open, metadata
131
+
132
+
133
+ def validate_inline(element: dict[str, Any], index: int, errors: list[dict[str, str]]) -> None:
134
+ typ = element.get("type")
135
+ path = f"screens[].elements[{index}]"
136
+ if typ == "page_header":
137
+ non_empty(element.get("title"), "WIREFRAME_MISSING_FIELD", f"{path}.title", errors)
138
+ refs = element.get("action_refs", [])
139
+ if not isinstance(refs, list) or not all(isinstance(x, str) for x in refs):
140
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.action_refs", "must be an array of IDs")
141
+ elif typ == "filter_bar":
142
+ fields = element.get("fields")
143
+ if not isinstance(fields, list) or not fields:
144
+ issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.fields", "fields must be a non-empty array")
145
+ else:
146
+ ids: set[str] = set()
147
+ for j, field in enumerate(fields):
148
+ if not isinstance(field, dict):
149
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.fields[{j}]", "field must be an object")
150
+ continue
151
+ unknown_keys(field, {"id", "label", "type"}, f"{path}.fields[{j}]", errors)
152
+ require_id(field.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.fields[{j}].id", errors, ids)
153
+ non_empty(field.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.fields[{j}].label", errors)
154
+ if field.get("type") not in {"input", "select"}:
155
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.fields[{j}].type", "must be input or select")
156
+ elif typ == "table":
157
+ columns = element.get("columns")
158
+ if not isinstance(columns, list) or not columns:
159
+ issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.columns", "columns must be a non-empty array")
160
+ else:
161
+ ids: set[str] = set()
162
+ for j, col in enumerate(columns):
163
+ if not isinstance(col, dict):
164
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.columns[{j}]", "column must be an object")
165
+ continue
166
+ unknown_keys(col, {"id", "label"}, f"{path}.columns[{j}]", errors)
167
+ require_id(col.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.columns[{j}].id", errors, ids)
168
+ non_empty(col.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.columns[{j}].label", errors)
169
+ elif typ == "card_list":
170
+ items = element.get("items")
171
+ if not isinstance(items, list) or not items:
172
+ issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.items", "items must be a non-empty array")
173
+ else:
174
+ ids: set[str] = set()
175
+ for j, item in enumerate(items):
176
+ if not isinstance(item, dict):
177
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}]", "item must be an object")
178
+ continue
179
+ unknown_keys(item, {"id", "title", "meta"}, f"{path}.items[{j}]", errors)
180
+ require_id(item.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.items[{j}].id", errors, ids)
181
+ non_empty(item.get("title"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].title", errors)
182
+ meta = item.get("meta", [])
183
+ if not isinstance(meta, list) or any(not isinstance(x, dict) or set(x) != {"label", "value"} for x in meta):
184
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].meta", "must contain label/value objects")
185
+ elif typ == "bottom_navigation":
186
+ items = element.get("items")
187
+ if not isinstance(items, list) or not items:
188
+ issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.items", "items must be a non-empty array")
189
+ else:
190
+ for j, item in enumerate(items):
191
+ if not isinstance(item, dict):
192
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}]", "item must be an object")
193
+ continue
194
+ unknown_keys(item, {"id", "label", "action_ref"}, f"{path}.items[{j}]", errors)
195
+ non_empty(item.get("id"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].id", errors)
196
+ non_empty(item.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].label", errors)
197
+ if "action_ref" in item:
198
+ non_empty(item.get("action_ref"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].action_ref", errors)
199
+ elif typ == "tabs":
200
+ tabs = element.get("tabs")
201
+ if not isinstance(tabs, list) or not tabs:
202
+ issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.tabs", "tabs must be a non-empty array")
203
+ else:
204
+ for j, tab in enumerate(tabs):
205
+ if not isinstance(tab, dict):
206
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.tabs[{j}]", "tab must be an object")
207
+ continue
208
+ unknown_keys(tab, {"id", "label"}, f"{path}.tabs[{j}]", errors)
209
+ non_empty(tab.get("id"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.tabs[{j}].id", errors)
210
+ non_empty(tab.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.tabs[{j}].label", errors)
211
+ elif typ in ATOMIC:
212
+ non_empty(element.get("label"), "WIREFRAME_MISSING_FIELD", f"{path}.label", errors)
213
+
214
+
215
+ def validate(data: Any, traceability: Any, schema: dict[str, Any], business_model: Any = None) -> tuple[list[dict[str, str]], dict[str, Any] | None]:
216
+ global ID_RE, STATES, PRESENTATIONS, PLATFORMS, ACTION_TYPES, ELEMENT_TYPES, ELEMENT_ALLOWED, ATOMIC
217
+ ID_RE, STATES, PRESENTATIONS, PLATFORMS, ACTION_TYPES, ELEMENT_ALLOWED = schema_values(schema)
218
+ ELEMENT_TYPES = set(ELEMENT_ALLOWED)
219
+ ATOMIC = ELEMENT_TYPES - {"page_header", "filter_bar", "table", "card_list", "bottom_navigation", "tabs"}
220
+ errors: list[dict[str, str]] = []
221
+ business_actions = set()
222
+ if business_model is not None and isinstance(business_model, dict):
223
+ business_actions = {item.get("id") for item in business_model.get("actions", []) if isinstance(item, dict)}
224
+ if not business_actions:
225
+ issue(errors, "WIREFRAME_BUSINESS_MODEL_INVALID", "business_model", "business model has no actions")
226
+ if not isinstance(data, dict):
227
+ issue(errors, "WIREFRAME_SCHEMA_VERSION_INVALID", "$", "root must be an object")
228
+ return errors, None
229
+ unknown_keys(data, {"schema_version", "feature", "screens"}, "$", errors)
230
+ if data.get("schema_version") != 1:
231
+ issue(errors, "WIREFRAME_SCHEMA_VERSION_INVALID", "schema_version", "must be 1")
232
+ non_empty(data.get("feature"), "WIREFRAME_FEATURE_MISSING", "feature", errors)
233
+ screens = data.get("screens")
234
+ if not isinstance(screens, list) or not screens:
235
+ issue(errors, "WIREFRAME_SCREENS_MISSING", "screens", "must be a non-empty array")
236
+ return errors, None
237
+ reqs = set()
238
+ if isinstance(traceability, dict):
239
+ reqs = {r.get("id") for r in traceability.get("requirements", []) if isinstance(r, dict) and r.get("status") in {"confirmed", "approved"} and r.get("scope", "in") == "in"}
240
+ screen_ids: set[str] = set()
241
+ canonical_screens: list[dict[str, Any]] = []
242
+ for i, screen in enumerate(screens):
243
+ path = f"screens[{i}]"
244
+ if not isinstance(screen, dict):
245
+ issue(errors, "WIREFRAME_SCREENS_MISSING", path, "screen must be an object")
246
+ continue
247
+ unknown_keys(screen, {"id", "title", "presentation", "platforms", "requirement_refs", "elements", "actions", "states"}, path, errors)
248
+ sid = screen.get("id")
249
+ if not isinstance(sid, str) or not sid:
250
+ issue(errors, "WIREFRAME_SCREEN_ID_MISSING", f"{path}.id", "id is required")
251
+ elif not ID_RE.fullmatch(sid):
252
+ issue(errors, "WIREFRAME_SCREEN_ID_MISSING", f"{path}.id", "id format is invalid")
253
+ elif sid in screen_ids:
254
+ issue(errors, "WIREFRAME_DUPLICATE_SCREEN_ID", f"{path}.id", f"duplicate id {sid}")
255
+ else:
256
+ screen_ids.add(sid)
257
+ non_empty(screen.get("title"), "WIREFRAME_SCREEN_TITLE_MISSING", f"{path}.title", errors)
258
+ presentation = screen.get("presentation", "page")
259
+ if presentation not in PRESENTATIONS:
260
+ issue(errors, "WIREFRAME_INVALID_PRESENTATION", f"{path}.presentation", "invalid presentation")
261
+ platforms = screen.get("platforms", ["web", "mobile"])
262
+ if not isinstance(platforms, list) or not platforms or any(x not in PLATFORMS for x in platforms) or len(set(platforms)) != len(platforms):
263
+ issue(errors, "WIREFRAME_INVALID_PLATFORM", f"{path}.platforms", "must be unique web/mobile values")
264
+ refs = screen.get("requirement_refs")
265
+ if not isinstance(refs, list) or not refs:
266
+ issue(errors, "WIREFRAME_SCREEN_REQUIREMENT_REFS_EMPTY", f"{path}.requirement_refs", "at least one requirement ref is required")
267
+ else:
268
+ for j, ref in enumerate(refs):
269
+ if ref not in reqs:
270
+ issue(errors, "WIREFRAME_UNKNOWN_REQUIREMENT_REF", f"{path}.requirement_refs[{j}]", f"unknown confirmed requirement {ref}")
271
+ elements = screen.get("elements")
272
+ if not isinstance(elements, list):
273
+ issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.elements", "elements must be an array")
274
+ elements = []
275
+ actions = screen.get("actions", [])
276
+ if not isinstance(actions, list):
277
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.actions", "actions must be an array")
278
+ actions = []
279
+ states = screen.get("states")
280
+ if not isinstance(states, list):
281
+ issue(errors, "WIREFRAME_STATES_MISSING", f"{path}.states", "states must be an array")
282
+ states = []
283
+ if "default" not in states:
284
+ issue(errors, "WIREFRAME_STATE_DEFAULT_MISSING", f"{path}.states", "default state is required")
285
+ if len(states) != len(set(states)):
286
+ issue(errors, "WIREFRAME_INVALID_STATE", f"{path}.states", "states must not contain duplicates")
287
+ for j, state in enumerate(states):
288
+ if state not in STATES:
289
+ issue(errors, "WIREFRAME_INVALID_STATE", f"{path}.states[{j}]", "invalid state")
290
+ element_ids: set[str] = set()
291
+ for j, element in enumerate(elements):
292
+ if not isinstance(element, dict):
293
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.elements[{j}]", "element must be an object")
294
+ continue
295
+ typ = element.get("type")
296
+ allowed = ELEMENT_ALLOWED.get(typ, {"id", "type"})
297
+ unknown_keys(element, allowed, f"{path}.elements[{j}]", errors)
298
+ require_id(element.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.elements[{j}].id", errors, element_ids)
299
+ if typ not in ELEMENT_TYPES:
300
+ issue(errors, "WIREFRAME_UNKNOWN_ELEMENT_TYPE", f"{path}.elements[{j}].type", f"unknown element type {typ}")
301
+ else:
302
+ validate_inline(element, j, errors)
303
+ action_ids: set[str] = set()
304
+ for j, action in enumerate(actions):
305
+ apath = f"{path}.actions[{j}]"
306
+ if not isinstance(action, dict):
307
+ issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", apath, "action must be an object")
308
+ continue
309
+ unknown_keys(action, {"id", "type", "target", "business_action_ref"}, apath, errors)
310
+ aid = action.get("id")
311
+ if not isinstance(aid, str) or not aid:
312
+ issue(errors, "WIREFRAME_ACTION_ID_MISSING", f"{apath}.id", "id is required")
313
+ elif not ID_RE.fullmatch(aid):
314
+ issue(errors, "WIREFRAME_ACTION_ID_MISSING", f"{apath}.id", "id format is invalid")
315
+ elif aid in action_ids:
316
+ issue(errors, "WIREFRAME_DUPLICATE_ACTION_ID", f"{apath}.id", f"duplicate id {aid}")
317
+ else:
318
+ action_ids.add(aid)
319
+ if action.get("type") not in ACTION_TYPES:
320
+ issue(errors, "WIREFRAME_INVALID_ACTION_TYPE", f"{apath}.type", "must be open, close or back")
321
+ elif action.get("type") == "open" and not isinstance(action.get("target"), str):
322
+ issue(errors, "WIREFRAME_ACTION_TARGET_SHAPE", f"{apath}.target", "open target must be a screen id")
323
+ elif action.get("type") in {"close", "back"} and action.get("target") is not None:
324
+ issue(errors, "WIREFRAME_ACTION_TARGET_SHAPE", f"{apath}.target", "close/back target must be null")
325
+ if business_model is not None and action.get("type") == "open":
326
+ ref = action.get("business_action_ref")
327
+ if not isinstance(ref, str) or not ref:
328
+ issue(errors, "WIREFRAME_BUSINESS_ACTION_REF_MISSING", f"{apath}.business_action_ref", "open action must reference a business action")
329
+ elif ref not in business_actions:
330
+ issue(errors, "WIREFRAME_BUSINESS_ACTION_REF_UNRESOLVED", f"{apath}.business_action_ref", f"business action {ref} does not exist")
331
+ refs_used: list[str] = []
332
+ for element in elements:
333
+ if isinstance(element, dict):
334
+ refs_used += element.get("action_refs", []) if isinstance(element.get("action_refs"), list) else []
335
+ if isinstance(element.get("action_ref"), str): refs_used.append(element["action_ref"])
336
+ if element.get("type") == "bottom_navigation":
337
+ refs_used += [x.get("action_ref") for x in element.get("items", []) if isinstance(x, dict) and isinstance(x.get("action_ref"), str)]
338
+ for ref in refs_used:
339
+ if ref not in action_ids:
340
+ issue(errors, "WIREFRAME_ACTION_REF_UNRESOLVED", f"{path}.elements", f"action ref {ref} is not defined on this screen")
341
+ canonical = dict(screen)
342
+ canonical.setdefault("presentation", "page")
343
+ canonical.setdefault("platforms", ["web", "mobile"])
344
+ canonical.setdefault("actions", [])
345
+ canonical_screens.append(canonical)
346
+ # Validate targets after the full screen namespace is known; forward screen
347
+ # references are valid and must not depend on YAML traversal order.
348
+ for i, screen in enumerate(screens):
349
+ if not isinstance(screen, dict) or not isinstance(screen.get("actions", []), list):
350
+ continue
351
+ for j, action in enumerate(screen.get("actions", [])):
352
+ if isinstance(action, dict) and action.get("type") == "open" and action.get("target") not in screen_ids:
353
+ issue(errors, "WIREFRAME_INVALID_ACTION_TARGET", f"screens[{i}].actions[{j}].target", "target screen does not exist")
354
+ canonical = {"schema_version": 1, "feature": data.get("feature"), "screens": canonical_screens}
355
+ return errors, canonical
356
+
357
+
358
+ def normalize(value: Any) -> Any:
359
+ if isinstance(value, str):
360
+ return unicodedata.normalize("NFKC", value).rstrip()
361
+ if isinstance(value, list):
362
+ return [normalize(x) for x in value]
363
+ if isinstance(value, dict):
364
+ return {k: normalize(v) for k, v in value.items()}
365
+ return value
366
+
367
+
368
+ ROOT_ORDER = ["schema_version", "feature", "screens"]
369
+ SCREEN_ORDER = ["id", "title", "presentation", "platforms", "requirement_refs", "elements", "actions", "states"]
370
+ ELEMENT_ORDER = ["id", "type", "title", "label", "action_refs", "action_ref", "fields", "columns", "items", "tabs"]
371
+ ACTION_ORDER = ["id", "type", "target", "business_action_ref"]
372
+ INLINE_ORDER = ["id", "label", "type", "title", "meta", "value", "action_ref"]
373
+
374
+
375
+ def reorder(value: Any, order: list[str]) -> Any:
376
+ if isinstance(value, list):
377
+ return [reorder(x, INLINE_ORDER) for x in value]
378
+ if not isinstance(value, dict):
379
+ return value
380
+ keys = [key for key in order if key in value]
381
+ keys += sorted(key for key in value if key not in keys)
382
+ return {key: reorder(value[key], INLINE_ORDER) for key in keys}
383
+
384
+
385
+ def canonical_order(value: dict[str, Any]) -> dict[str, Any]:
386
+ result = {"schema_version": value["schema_version"], "feature": value["feature"], "screens": []}
387
+ for screen in value["screens"]:
388
+ ordered_screen = {key: screen[key] for key in SCREEN_ORDER if key in screen}
389
+ ordered_screen["elements"] = []
390
+ for element in screen.get("elements", []):
391
+ ordered_element = {key: element[key] for key in ELEMENT_ORDER if key in element}
392
+ ordered_element = reorder(ordered_element, ELEMENT_ORDER)
393
+ ordered_screen["elements"].append(ordered_element)
394
+ ordered_screen["actions"] = [reorder(action, ACTION_ORDER) for action in screen.get("actions", [])]
395
+ ordered_screen["states"] = list(screen.get("states", []))
396
+ result["screens"].append(ordered_screen)
397
+ return result
398
+
399
+
400
+ def main() -> int:
401
+ parser = argparse.ArgumentParser()
402
+ parser.add_argument("--proposal", required=True, type=Path)
403
+ parser.add_argument("--traceability", required=True, type=Path)
404
+ parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
405
+ parser.add_argument("--questions", type=Path)
406
+ parser.add_argument("--questions-schema", type=Path, default=DEFAULT_QUESTIONS_SCHEMA)
407
+ parser.add_argument("--canonical-out", type=Path)
408
+ parser.add_argument("--business-canonical", type=Path)
409
+ args = parser.parse_args()
410
+ try:
411
+ proposal = load_yaml(args.proposal)
412
+ schema = load_yaml(args.schema)
413
+ questions_schema = load_yaml(args.questions_schema)
414
+ traceability = json.loads(args.traceability.read_text(encoding="utf-8"))
415
+ business_model = json.loads(args.business_canonical.read_text(encoding="utf-8")) if args.business_canonical else None
416
+ except Exception as error:
417
+ print(json.dumps({"valid": False, "errors": [{"code": "WIREFRAME_PARSE_ERROR", "path": "$", "message": str(error)}]}, ensure_ascii=False))
418
+ return 1
419
+ if not isinstance(schema, dict):
420
+ print(json.dumps({"valid": False, "errors": [{"code": "WIREFRAME_PARSE_ERROR", "path": "schema", "message": "schema root must be an object"}]}, ensure_ascii=False))
421
+ return 1
422
+ errors, canonical = validate(proposal, traceability, schema, business_model)
423
+ questions_path = args.questions
424
+ if questions_path is None and isinstance(proposal, dict) and proposal.get("feature"):
425
+ questions_path = Path(".planning") / "features" / str(proposal["feature"]) / "questions.yaml"
426
+ question_meta: dict[str, Any]
427
+ blocking_open: list[str] = []
428
+ if questions_path is None or not questions_path.exists():
429
+ question_meta = {"status": "unavailable", "unresolved": []}
430
+ else:
431
+ try:
432
+ questions = load_yaml(questions_path)
433
+ if not isinstance(questions_schema, dict):
434
+ raise ValueError("questions schema root must be an object")
435
+ question_errors, blocking_open, question_meta = validate_questions(questions, proposal.get("feature", "") if isinstance(proposal, dict) else "", questions_schema)
436
+ errors.extend(question_errors)
437
+ except Exception as error:
438
+ issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", str(questions_path), str(error))
439
+ question_meta = {"status": "invalid", "unresolved": []}
440
+ if errors or canonical is None:
441
+ print(json.dumps({"valid": False, "verdict": "block_unsafe", "questions": question_meta, "errors": errors}, ensure_ascii=False, indent=2))
442
+ return 1
443
+ if blocking_open:
444
+ print(json.dumps({"valid": False, "verdict": "await_user", "questions": question_meta, "errors": [{"code": "WIREFRAME_QUESTIONS_UNRESOLVED", "path": "questions", "message": "blocking questions remain open: " + ", ".join(blocking_open), "question_ids": blocking_open}]}, ensure_ascii=False, indent=2))
445
+ return 1
446
+ canonical = canonical_order(normalize(canonical))
447
+ encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + "\n"
448
+ fingerprint = "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
449
+ if args.canonical_out:
450
+ args.canonical_out.parent.mkdir(parents=True, exist_ok=True)
451
+ args.canonical_out.write_text(encoded, encoding="utf-8")
452
+ print(json.dumps({"valid": True, "verdict": "allow", "questions": question_meta, "canonical_fingerprint": fingerprint, "canonical": canonical}, ensure_ascii=False, indent=2))
453
+ return 0
454
+
455
+
456
+ if __name__ == "__main__":
457
+ raise SystemExit(main())
@@ -223,6 +223,30 @@ checkpoints:
223
223
  - approve
224
224
  - needs_fix
225
225
  skip_policy: never
226
+ resume_behavior: "user confirms, then continue with wireframe-proposal"
227
+ CP-WIREFRAME:
228
+ name: Low-fidelity wireframe confirmation
229
+ gate_after: wireframe-review
230
+ producing_step: wireframe-review
231
+ required_outputs:
232
+ - ".planning/features/{feature}/canonical.json"
233
+ - ".planning/features/{feature}/canonical.meta.json"
234
+ - ".planning/features/{feature}/wireframes/preview.md"
235
+ - ".planning/features/{feature}/wireframes/flow.svg"
236
+ required_meta_output: ".planning/features/{feature}/canonical.meta.json"
237
+ required_meta_fields:
238
+ - prototype_fingerprint
239
+ - source_document_fingerprint
240
+ - state_revision
241
+ summary_fields:
242
+ - prototype_fingerprint
243
+ - source_document_fingerprint
244
+ - state_revision
245
+ prompt: "确认页面、元素、动作和平台范围;阻塞问题必须已回答,并允许进入 API 契约设计。"
246
+ confirm_options:
247
+ - approve
248
+ - needs_fix
249
+ skip_policy: never
226
250
  resume_behavior: "user confirms, then continue with api-contract"
227
251
  CP-API:
228
252
  name: API contract confirmation
@@ -487,6 +511,84 @@ steps:
487
511
  traceability_status: { type: string, required: true }
488
512
  checkpoint: CP-1
489
513
 
514
+ - id: wireframe-proposal
515
+ order: 11
516
+ label: Wireframe proposal
517
+ skill: wireframe-proposal
518
+ engine_evidence_gate: enforced
519
+ depends_on:
520
+ - requirement-baseline
521
+ inputs:
522
+ - ".planning/requirements/baseline.md"
523
+ - ".planning/requirements/{feature}/traceability.json"
524
+ - ".planning/features/{feature}/questions.yaml"
525
+ outputs:
526
+ - path: ".planning/features/{feature}/proposal.yaml"
527
+ type: wireframe-proposal
528
+ required: true
529
+ - path: ".planning/features/{feature}/questions.yaml"
530
+ type: wireframe-questions
531
+ required: false
532
+
533
+ - id: wireframe-validate
534
+ order: 12
535
+ label: Validate wireframe proposal
536
+ skill: wireframe-validate
537
+ engine_evidence_gate: enforced
538
+ depends_on:
539
+ - wireframe-proposal
540
+ inputs:
541
+ - ".planning/features/{feature}/proposal.yaml"
542
+ - ".planning/features/{feature}/questions.yaml"
543
+ - ".planning/requirements/{feature}/traceability.json"
544
+ outputs:
545
+ - path: ".planning/features/{feature}/canonical.json"
546
+ type: wireframe-canonical
547
+ required: true
548
+ meta_output:
549
+ path: ".planning/features/{feature}/wireframe-validation.meta.json"
550
+ fields:
551
+ verdict: { type: string, required: true }
552
+ canonical_fingerprint: { type: string, required: false }
553
+ unresolved_question_count: { type: integer, required: true }
554
+
555
+ - id: wireframe-render
556
+ order: 13
557
+ label: Render wireframe previews
558
+ skill: wireframe-render
559
+ engine_evidence_gate: enforced
560
+ depends_on:
561
+ - wireframe-validate
562
+ inputs:
563
+ - ".planning/features/{feature}/canonical.json"
564
+ - ".planning/features/{feature}/canonical.meta.json"
565
+ outputs:
566
+ - path: ".planning/features/{feature}/wireframes/preview.md"
567
+ type: wireframe-preview
568
+ required: true
569
+ - path: ".planning/features/{feature}/wireframes/flow.svg"
570
+ type: wireframe-flow
571
+ required: true
572
+
573
+ - id: wireframe-review
574
+ order: 14
575
+ label: Wireframe review
576
+ skill: wireframe-review
577
+ engine_evidence_gate: enforced
578
+ depends_on:
579
+ - wireframe-render
580
+ inputs:
581
+ - ".planning/features/{feature}/canonical.json"
582
+ - ".planning/features/{feature}/canonical.meta.json"
583
+ - ".planning/features/{feature}/questions.yaml"
584
+ - ".planning/features/{feature}/wireframes/preview.md"
585
+ - ".planning/features/{feature}/wireframes/flow.svg"
586
+ outputs:
587
+ - path: ".planning/features/{feature}/approval.json"
588
+ type: wireframe-approval
589
+ required: false
590
+ checkpoint: CP-WIREFRAME
591
+
490
592
  - id: api-contract
491
593
  order: 15
492
594
  label: API contract
@@ -878,7 +980,7 @@ steps:
878
980
  skill: workflow-generate-mainflow-cases
879
981
  engine_evidence_gate: enforced
880
982
  depends_on:
881
- - code-index-refresh
983
+ - execute
882
984
  inputs:
883
985
  - ".planning/STATE.md"
884
986
  - ".planning/navigation/INDEX.md"
@@ -892,6 +994,9 @@ steps:
892
994
  - ".planning/features/{feature}/PROGRESS.md"
893
995
  - ".planning/requirements/{feature}/tests.json"
894
996
  outputs:
997
+ - path: ".planning/generated-cases/{feature}/codebase-discovery.json"
998
+ type: mainflow-codebase-discovery
999
+ required: true
895
1000
  - path: ".planning/generated-cases/{feature}/source-map.md"
896
1001
  type: mainflow-source-map
897
1002
  required: true
@@ -18,6 +18,12 @@ autoloadSkills:
18
18
 
19
19
  Implement only the assigned plan slice. Do not re-plan the feature, compare alternatives, or explore unrelated modules. Read only the approved files, their immediate dependencies, and the focused tests needed for this slice. Preserve unrelated work and report deviations instead of silently expanding scope.
20
20
 
21
+ Before any protected source access, read the project's required code-index evidence
22
+ files in the exact order listed by INDEX_GATE (typically `.planning/code-index/INDEX.md`
23
+ followed by the ordered files named by the gate). Do not attempt `grep`, `rg`, or source
24
+ reads first: each subagent has its own evidence session and cannot inherit the parent's
25
+ observations. If the gate lists different files, follow that list exactly.
26
+
21
27
  If the execution contract is missing, ambiguous, or contradicted by the code, stop before editing and return `STATUS: escalate` with the conflicting evidence. Run the smallest focused validation once; report failures rather than repeatedly trying speculative fixes.
22
28
 
23
29
  For a task whose frozen `test-bindings.json` marks `test_mode: tdd_required`, use the Core tool in this exact order: `devflow_tdd_evidence(action="prepare")` before changing declared production paths; create/change the test and record `red`; implement; record `green`; then record `regression`. Do not mark the task done if any phase is missing or fails. The tool may write only its own Core-owned TDD evidence record; this exception does not authorize edits to any other Devflow control-plane file.