@guanlin4924/devflow-pi 0.1.39 → 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 (53) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
  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 +334 -30
  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 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  45. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  46. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
  47. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
  48. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  49. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  50. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  51. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  52. package/src/constants.ts +1 -1
  53. package/src/runtime.ts +61 -24
@@ -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())
@@ -4,6 +4,11 @@
4
4
  # Validates the existing workflow topology as a GateContract source. This is
5
5
  # intentionally declarative and read-only: it does not advance STATE or alter
6
6
  # failure-branch behavior.
7
+ require "json"
8
+ require "optparse"
9
+ require "yaml"
10
+ require_relative "checker_registry"
11
+
7
12
  module WorkflowGateContract
8
13
  CHECKPOINT_FIELDS = %w[gate_after producing_step required_outputs required_meta_output required_meta_fields prompt confirm_options skip_policy resume_behavior].freeze
9
14
 
@@ -11,6 +16,11 @@ module WorkflowGateContract
11
16
 
12
17
  def validate(workflow)
13
18
  errors = []
19
+ unless workflow.is_a?(Hash)
20
+ return [error("WORKFLOW_SCHEMA_INVALID", "workflow must be a mapping")]
21
+ end
22
+ steps = Array(workflow["steps"])
23
+ step_by_id = steps.each_with_object({}) { |step, index| index[step["id"].to_s] = step if step.is_a?(Hash) && step["id"] }
14
24
  checkpoints = workflow["checkpoints"]
15
25
  unless checkpoints.is_a?(Hash)
16
26
  return [error("CHECKPOINTS_SCHEMA_INVALID", "workflow checkpoints must be a mapping")]
@@ -33,13 +43,79 @@ module WorkflowGateContract
33
43
  errors << error("CHECKPOINT_REF_NOT_FOUND", "enforced checkpoint is not defined", checkpoint: id) unless checkpoints.key?(id)
34
44
  end
35
45
 
46
+ step_checkers = workflow.dig("governance", "step_checkers")
47
+ if step_checkers.is_a?(Hash)
48
+ step_checkers.each do |step_id, checker_ids|
49
+ errors << error("CHECKER_STEP_REF_NOT_FOUND", "checker declaration references an unknown step", step: step_id) unless step_by_id.key?(step_id.to_s)
50
+ Array(checker_ids).each do |checker_id|
51
+ errors << error("UNKNOWN_CHECKER_ID", "workflow references an unknown checker", step: step_id, checker: checker_id) unless CheckerRegistry.known?(checker_id)
52
+ end
53
+ end
54
+ end
55
+
36
56
  Array(workflow["parallel_windows"]).each do |window|
37
- errors << error("PARALLEL_WINDOW_SCHEMA_INVALID", "parallel window must define join_step and branches", window: window["id"]) unless window.is_a?(Hash) && window["join_step"] && window["branches"].is_a?(Hash)
38
- next unless window.is_a?(Hash)
57
+ unless window.is_a?(Hash) && window["join_step"] && window["branches"].is_a?(Hash)
58
+ errors << error("PARALLEL_WINDOW_SCHEMA_INVALID", "parallel window must define join_step and branches", window: window.is_a?(Hash) && window["id"])
59
+ next
60
+ end
61
+ window_id = window["id"]
62
+ errors << error("PARALLEL_AFTER_STEP_NOT_FOUND", "parallel window after_step is not a workflow step", window: window_id, step: window["after_step"]) if window["after_step"] && !step_by_id.key?(window["after_step"].to_s)
63
+ errors << error("PARALLEL_JOIN_STEP_NOT_FOUND", "parallel join_step is not a workflow step", window: window_id, step: window["join_step"]) unless step_by_id.key?(window["join_step"].to_s)
39
64
  errors << error("PARALLEL_WINDOW_CHECKPOINT_INVALID", "parallel join checkpoint is not defined", window: window["id"], checkpoint: window["checkpoint"]) if window["checkpoint"] && !checkpoints.key?(window["checkpoint"])
65
+ policy = window["execution_policy"]
66
+ scheduler = policy.is_a?(Hash) && policy["scheduler"]
67
+ unless scheduler.is_a?(Hash)
68
+ errors << error("PARALLEL_SCHEDULER_SCHEMA_INVALID", "parallel window must define execution_policy.scheduler", window: window_id)
69
+ else
70
+ errors << error("PARALLEL_SCHEDULER_FIELD_INVALID", "one_agent_per_step must be boolean", window: window_id, field: "one_agent_per_step") unless [true, false].include?(scheduler["one_agent_per_step"])
71
+ errors << error("PARALLEL_SCHEDULER_FIELD_INVALID", "max_concurrency must be a positive integer", window: window_id, field: "max_concurrency") unless scheduler["max_concurrency"].is_a?(Integer) && scheduler["max_concurrency"] > 0
72
+ errors << error("PARALLEL_SCHEDULER_FIELD_INVALID", "track_order must be declared", window: window_id, field: "track_order") unless scheduler["track_order"] == "declared"
73
+ end
74
+ errors << error("PARALLEL_BRANCH_STEPS_INVALID", "branch_steps must be an array", window: window_id) if window.key?("branch_steps") && !window["branch_steps"].is_a?(Array)
75
+ Array(window["branch_steps"]).each do |step_id|
76
+ errors << error("PARALLEL_BRANCH_STEP_NOT_FOUND", "branch_steps references an unknown step", window: window_id, step: step_id) unless step_by_id.key?(step_id.to_s)
77
+ end
78
+ branch_ids = []
79
+ step_tracks = {}
80
+ window["branches"].each do |track, branch_steps|
81
+ unless branch_steps.is_a?(Array) && !branch_steps.empty?
82
+ errors << error("PARALLEL_TRACK_STEPS_INVALID", "parallel track must contain an ordered step array", window: window_id, track: track)
83
+ next
84
+ end
85
+ positions = branch_steps.each_with_index.to_h { |step_id, position| [step_id.to_s, position] }
86
+ branch_steps.each_with_index do |step_id, position|
87
+ if positions.count { |candidate, _| candidate == step_id.to_s } > 1
88
+ errors << error("PARALLEL_TRACK_DUPLICATE_STEP", "parallel track repeats a step", window: window_id, track: track, step: step_id)
89
+ end
90
+ branch_ids << step_id.to_s
91
+ step_tracks[step_id.to_s] = track.to_s
92
+ errors << error("PARALLEL_BRANCH_STEP_NOT_FOUND", "parallel track references an unknown step", window: window_id, track: track, step: step_id) unless step_by_id.key?(step_id.to_s)
93
+ Array(step_by_id.dig(step_id.to_s, "depends_on")).each do |dependency|
94
+ dependency_position = positions[dependency.to_s]
95
+ next unless dependency_position
96
+ errors << error("PARALLEL_TRACK_ORDER_INVALID", "track dependency must precede dependent step", window: window_id, track: track, step: step_id, dependency: dependency) if dependency_position >= position
97
+ end
98
+ end
99
+ end
100
+ window["branches"].each do |track, branch_steps|
101
+ Array(branch_steps).each do |step_id|
102
+ Array(step_by_id.dig(step_id.to_s, "depends_on")).each do |dependency|
103
+ dependency_track = step_tracks[dependency.to_s]
104
+ next unless dependency_track && dependency_track != track.to_s
105
+
106
+ errors << error("PARALLEL_CROSS_TRACK_DEPENDENCY", "parallel branch depends on a step in another track", window: window_id, track: track, step: step_id, dependency: dependency, dependency_track: dependency_track)
107
+ end
108
+ end
109
+ end
110
+ counts = branch_ids.each_with_object(Hash.new(0)) { |step_id, result| result[step_id] += 1 }
111
+ duplicate_steps = counts.select { |_, count| count > 1 }.keys
112
+ duplicate_steps.each { |step_id| errors << error("PARALLEL_STEP_DUPLICATED", "step appears in multiple parallel branches", window: window_id, step: step_id) }
113
+ errors << error("PARALLEL_JOIN_IN_BRANCH", "join_step cannot also be a branch step", window: window_id, step: window["join_step"]) if branch_ids.include?(window["join_step"].to_s)
40
114
  end
41
115
 
42
116
  (workflow["failure_branches"] || {}).each do |source_step, branch|
117
+ errors << error("FAILURE_SOURCE_STEP_NOT_FOUND", "failure branch source step is not defined", source_step: source_step) unless step_by_id.key?(source_step.to_s)
118
+ errors << error("FAILURE_BRANCH_SCHEMA_INVALID", "failure branch must be a mapping", source_step: source_step) unless branch.is_a?(Hash)
43
119
  resume = branch.is_a?(Hash) && branch["resume_after_success"]
44
120
  unless resume.is_a?(Hash) && %w[flow step status checkpoint].all? { |field| resume.key?(field) }
45
121
  errors << error("FAILURE_RETURN_TO_INVALID", "failure branch must declare flow, step, status and checkpoint", source_step: source_step)
@@ -54,3 +130,19 @@ module WorkflowGateContract
54
130
  { "code" => code, "message" => message }.merge(extra)
55
131
  end
56
132
  end
133
+
134
+ if $PROGRAM_NAME == __FILE__
135
+ begin
136
+ options = {}
137
+ OptionParser.new do |opts|
138
+ opts.on("--workflow PATH") { |value| options[:workflow] = value }
139
+ opts.on("--json") { options[:json] = true }
140
+ end.parse!
141
+ raise ArgumentError, "--workflow is required" unless options[:workflow]
142
+ result = WorkflowGateContract.validate(YAML.safe_load(File.read(options[:workflow]), permitted_classes: [], aliases: false))
143
+ puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
144
+ rescue StandardError => e
145
+ warn e.message
146
+ exit 1
147
+ end
148
+ end