@guanlin4924/devflow-pi 0.1.40 → 0.1.48
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/bin/apply-opencode-go-session-hotfix.mjs +61 -0
- package/package.json +2 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- 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())
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml
CHANGED
|
@@ -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
|
-
-
|
|
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.
|