@harmonyos-arkts/d2h 0.1.27 → 0.1.29

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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/AGENTS.md +2 -0
  3. package/README.md +43 -3
  4. package/agents/d2h.md +16 -14
  5. package/dist/bin/install-opencode.js +28 -23
  6. package/dist/src/install-codex.js +5 -126
  7. package/dist/src/install-skills.js +147 -0
  8. package/dist/src/install-vscode.js +2 -2
  9. package/dist/src/select-agent.js +207 -0
  10. package/docs/environment-check.md +3 -3
  11. package/package.json +6 -6
  12. package/skills/arkts-mvvm-architecture/SKILL.md +2 -0
  13. package/skills/d2h-architecture-plan/SKILL.md +19 -26
  14. package/skills/d2h-architecture-plan/references/architecture-plan.example.json +80 -315
  15. package/skills/d2h-architecture-plan/references/directory-creation.md +27 -34
  16. package/skills/d2h-architecture-plan/references/hybrid-design.md +13 -15
  17. package/skills/d2h-architecture-plan/references/json-contract.md +29 -91
  18. package/skills/d2h-architecture-plan/references/layout-reference.md +30 -27
  19. package/skills/d2h-architecture-plan/references/plan-contract.md +27 -34
  20. package/skills/d2h-architecture-plan/references/source-review.md +1 -1
  21. package/skills/d2h-architecture-plan/scripts/architecture_plan.py +29 -163
  22. package/skills/d2h-architecture-plan/scripts/test_architecture_plan.py +205 -342
  23. package/skills/d2h-spec-develop/SKILL.md +5 -4
  24. package/{install-opencode.sh → tooling/install-opencode.sh} +3 -3
  25. package/{opencode → tooling/opencode}/interactive-tools/advance-progress-demo.mjs +1 -1
  26. package/{opencode → tooling/opencode}/interactive-tools/demo.mjs +3 -3
  27. package/{opencode → tooling/opencode}/interactive-tools/prepare-demo.mjs +1 -1
  28. package/{scripts → tooling/scripts}/check-environment.mjs +3 -3
  29. package/skills/d2h-architecture-plan/references/selection-flow.md +0 -71
  30. /package/{opencode → tooling/opencode}/agents.json +0 -0
  31. /package/{opencode → tooling/opencode}/interactive-tools/browser-host.ts +0 -0
  32. /package/{opencode → tooling/opencode}/interactive-tools/core.ts +0 -0
  33. /package/{opencode → tooling/opencode}/interactive-tools/demo-workflow.ts +0 -0
  34. /package/{opencode → tooling/opencode}/interactive-tools/package-lock.json +0 -0
  35. /package/{opencode → tooling/opencode}/interactive-tools/package.json +0 -0
  36. /package/{opencode → tooling/opencode}/interactive-tools/panel.ts +0 -0
  37. /package/{opencode → tooling/opencode}/interactive-tools/prepare-progress-demo.mjs +0 -0
  38. /package/{opencode → tooling/opencode}/interactive-tools/progress.ts +0 -0
  39. /package/{opencode → tooling/opencode}/interactive-tools/server-legacy.mjs +0 -0
  40. /package/{opencode → tooling/opencode}/interactive-tools/server.mjs +0 -0
  41. /package/{opencode → tooling/opencode}/interactive-tools/tui.tsx +0 -0
  42. /package/{opencode → tooling/opencode}/vscode-d2h-package.json +0 -0
  43. /package/{opencode → tooling/opencode}/vscode-d2h-plugin.mjs +0 -0
@@ -1,8 +1,6 @@
1
- """Behavior checks for the dashboard selection boundary; no real project is touched."""
1
+ """Final-display contract checks. No source or HarmonyOS project is touched."""
2
2
 
3
3
  import copy
4
- import contextlib
5
- import io
6
4
  import json
7
5
  import os
8
6
  from pathlib import Path
@@ -10,10 +8,8 @@ import subprocess
10
8
  import sys
11
9
  import tempfile
12
10
  import unittest
13
- from unittest.mock import patch
14
11
 
15
- import architecture_plan as helper
16
- from architecture_plan import ContractError, apply_selection, read_selection, validate_plan
12
+ from architecture_plan import ContractError, directory_tree, load_json, validate_plan
17
13
 
18
14
 
19
15
  SKILL_ROOT = Path(__file__).resolve().parents[1]
@@ -22,356 +18,223 @@ HELPER = SKILL_ROOT / "scripts" / "architecture_plan.py"
22
18
 
23
19
  class ArchitecturePlanTests(unittest.TestCase):
24
20
  def setUp(self):
25
- self.plan = json.loads((SKILL_ROOT / "references" / "architecture-plan.example.json").read_text(encoding="utf-8"))
26
- self.decision = {
27
- "requestId": "request-1", "planId": self.plan["planId"],
28
- "selectedProposalId": "android-mirror",
29
- "action": "select-and-create", "source": "dashboard", "notes": "",
30
- }
21
+ self.plan = load_json(SKILL_ROOT / "references" / "architecture-plan.example.json")
31
22
 
32
- def test_example_has_three_independently_valid_trees_and_no_user_selection(self):
33
- validate_plan(self.plan)
34
- self.assertEqual({p["id"] for p in self.plan["proposals"]}, {"android-mirror", "harmony-practice", "hybrid"})
35
- self.assertIsNone(self.plan["selectedProposalId"])
36
-
37
- def test_minimal_content_needs_no_execution_paths_or_architecture_relationship_models(self):
38
- self.plan["androidAnalysis"]["risks"] = []
39
- validate_plan(self.plan)
40
- self.assertEqual(set(self.plan["androidAnalysis"]), {"summary", "techStack", "risks"})
41
- self.assertEqual(set(self.plan["project"]), {"name", "summary"})
42
- for proposal in self.plan["proposals"]:
43
- self.assertEqual(set(proposal), {"id", "name", "summary", "directoryTree"})
44
- updated, result = apply_selection(self.plan, self.decision)
45
- self.assertEqual(result["nextAction"], "create-directory-shell")
46
- self.assertEqual(updated["androidAnalysis"], self.plan["androidAnalysis"])
47
-
48
- def test_selecting_nonrecommended_scheme_does_not_change_recommendation_or_trees(self):
49
- original = copy.deepcopy(self.plan)
50
- updated, result = apply_selection(self.plan, self.decision)
51
- self.assertEqual(self.plan, original)
52
- self.assertEqual(updated["recommendedProposalId"], "hybrid")
53
- self.assertEqual(updated["selectedProposalId"], "android-mirror")
54
- self.assertEqual(updated["proposals"], original["proposals"])
55
- self.assertNotIn("selection", updated)
56
- proposal, confirmation = read_selection(updated, self.decision)
57
- self.assertEqual(proposal, updated["proposals"][0])
58
- self.assertEqual(confirmation["requestId"], self.decision["requestId"])
59
- self.assertEqual(result["nextAction"], "create-directory-shell")
60
- validate_plan(updated)
61
-
62
- def test_select_only_does_not_instruct_creation(self):
63
- self.decision["action"] = "select"
64
- updated, result = apply_selection(self.plan, self.decision)
65
- self.assertEqual(result["nextAction"], "selection-recorded")
66
- self.assertEqual(updated["selectedProposalId"], "android-mirror")
23
+ def nodes(self):
24
+ def visit(node):
25
+ yield node
26
+ for child in node["children"]:
27
+ yield from visit(child)
28
+ return list(visit(self.plan["directoryTree"]))
67
29
 
68
- def test_exact_retry_does_not_change_plan(self):
69
- updated, _ = apply_selection(self.plan, self.decision)
70
- retry, result = apply_selection(updated, self.decision, self.decision)
71
- self.assertEqual(retry, updated)
72
- self.assertEqual(result["status"], "already-applied")
30
+ def node(self, path):
31
+ return next(node for node in self.nodes() if node["path"] == path)
73
32
 
74
- def test_request_id_cannot_be_reused_with_changed_content(self):
75
- updated, _ = apply_selection(self.plan, self.decision)
76
- for key, value in (("selectedProposalId", "hybrid"), ("action", "select"), ("notes", "changed")):
77
- changed = {**self.decision, key: value}
78
- with self.subTest(key=key), self.assertRaisesRegex(ContractError, "requestId"):
79
- apply_selection(updated, changed, self.decision)
33
+ def cli(self, *args, env=None):
34
+ return subprocess.run([sys.executable, "-X", "utf8", "-B", str(HELPER), *map(str, args)],
35
+ capture_output=True, encoding="utf-8", env=env, check=False)
80
36
 
81
- def test_new_request_can_change_the_selected_scheme(self):
82
- updated, _ = apply_selection(self.plan, self.decision)
83
- changed = {**self.decision, "requestId": "request-2", "selectedProposalId": "harmony-practice"}
84
- final, result = apply_selection(updated, changed, self.decision)
85
- self.assertEqual(result["status"], "applied")
86
- self.assertEqual(final["selectedProposalId"], "harmony-practice")
87
- _, confirmation = read_selection(final, changed)
88
- self.assertEqual(confirmation["selectedProposalId"], "harmony-practice")
89
-
90
- def test_wrong_plan_scheme_action_and_extra_paths_are_rejected(self):
91
- for key, value in (
92
- ("planId", "another-project"), ("planId", True),
93
- ("selectedProposalId", "unknown"), ("action", "run-command"),
94
- ("targetRoot", "E:/other"), ("directoryTree", {}),
95
- ):
96
- with self.subTest(key=key), self.assertRaises(ContractError):
97
- apply_selection(self.plan, {**self.decision, key: value})
98
- self.assertIsNone(self.plan["selectedProposalId"])
99
-
100
- def test_plan_and_callback_only_need_content_and_selection_metadata(self):
101
- self.assertEqual(set(self.plan), {
102
- "planId", "project", "androidAnalysis", "proposals", "recommendedProposalId",
103
- "recommendationReason", "selectedProposalId",
104
- })
105
- self.assertEqual(set(self.decision), {
106
- "requestId", "planId", "selectedProposalId", "action", "source", "notes",
107
- })
37
+ def test_example_is_one_final_tree_with_decision_notes(self):
38
+ before = copy.deepcopy(self.plan)
108
39
  validate_plan(self.plan)
109
- updated, _ = apply_selection(self.plan, self.decision)
110
- self.assertEqual(set(updated), set(self.plan))
111
-
112
- def test_fixed_analysis_fields_and_concise_risk_cards_are_validated(self):
113
- stack = self.plan["androidAnalysis"]["techStack"]
114
- invalid = (
115
- {"techStack": "Kotlin"}, {"techStack": [""]},
116
- {"techStack": {key: value for key, value in stack.items() if key != "storage"}},
117
- {"techStack": {**stack, "ui": ""}},
118
- {"techStack": {**stack, "language": ["Kotlin", "Java"]}},
119
- {"techStack": {**stack, "frameworks": "extra category"}},
120
- {"risks": ["long unstructured risk"]},
121
- {"risks": [{"title": "存储适配", "impact": ""}]},
122
- {"risks": [{"title": "存储适配"}]},
123
- {"risks": [{"title": "存储适配", "impact": "需核验", "answerRequired": True}]},
124
- {"risks": [{"title": str(index), "impact": "影响"} for index in range(6)]},
125
- )
126
- for fields in invalid:
127
- with self.subTest(fields=fields), self.assertRaises(ContractError):
128
- validate_plan({**self.plan, "androidAnalysis": {**self.plan["androidAnalysis"], **fields}})
129
-
130
- def test_removed_fields_are_not_reintroduced_into_compact_contract(self):
131
- cases = (("project", "targetRoot"), ("project", "scope"), ("androidAnalysis", "regions"),
132
- ("androidAnalysis", "structure"), ("androidAnalysis", "openQuestions"),
133
- ("androidAnalysis", "dependencies"), ("proposal", "mappings"),
134
- ("proposal", "dependencies"), ("proposal", "sharedOwners"),
135
- ("proposal", "benefits"), ("proposal", "costs"), ("proposal", "risks"))
136
- for section, field in cases:
137
- broken = copy.deepcopy(self.plan)
138
- value = broken["proposals"][0] if section == "proposal" else broken[section]
139
- value[field] = []
140
- with self.subTest(section=section, field=field), self.assertRaisesRegex(ContractError, "unknown fields"):
141
- validate_plan(broken)
40
+ self.assertEqual(self.plan, before)
41
+ self.assertEqual(set(self.plan), {"projectName", "directoryTree", "decisions"})
42
+ paths = directory_tree(self.plan["directoryTree"])
43
+ self.assertIn("entry/src/main/ets/features/mail/messageview", paths)
44
+ self.assertNotIn("products/thunderbird", paths)
45
+ self.assertTrue(self.plan["decisions"])
46
+
47
+ def test_missing_extra_and_legacy_top_fields_are_rejected(self):
48
+ for key in self.plan:
49
+ changed = copy.deepcopy(self.plan)
50
+ del changed[key]
51
+ with self.subTest(missing=key), self.assertRaises(ContractError):
52
+ validate_plan(changed)
53
+ for key in ("project", "androidAnalysis", "proposals", "recommendedProposalId", "recommendationReason",
54
+ "selectedProposalId", "selection", "planId", "revision", "schemaVersion", "targetRoot", "dependencies"):
55
+ with self.subTest(extra=key), self.assertRaises(ContractError):
56
+ validate_plan({**self.plan, key: None})
57
+
58
+ def test_project_name_and_decision_fields_must_be_nonempty_text(self):
59
+ for value in (None, True, 0, [], {}, "", " \n"):
60
+ with self.subTest(projectName=value), self.assertRaises(ContractError):
61
+ validate_plan({**self.plan, "projectName": value})
62
+ for key in ("title", "description"):
63
+ changed = copy.deepcopy(self.plan)
64
+ changed["decisions"][0][key] = value
65
+ with self.subTest(decision=key, value=value), self.assertRaises(ContractError):
66
+ validate_plan(changed)
67
+
68
+ def test_decisions_require_a_nonempty_exactly_shaped_array(self):
69
+ for value in (None, {}, "decision", [], [{}], [{"title": "保留"}],
70
+ [{"title": "保留", "description": "原包族", "confirmed": True}]):
71
+ with self.subTest(value=value), self.assertRaises(ContractError):
72
+ validate_plan({**self.plan, "decisions": value})
73
+
74
+ def test_old_dependency_array_and_embedded_relationship_fields_are_rejected(self):
75
+ for value in ([], [{"fromPath": "entry", "toPath": "AppScope", "description": "旧关系模型"}]):
76
+ with self.subTest(value=value), self.assertRaises(ContractError):
77
+ validate_plan({**self.plan, "dependencies": value})
78
+ for key, value in (("fromPath", "entry"), ("toPath", "AppScope"), ("dependencies", [])):
79
+ changed = copy.deepcopy(self.plan)
80
+ changed["decisions"][0][key] = value
81
+ with self.subTest(key=key), self.assertRaises(ContractError):
82
+ validate_plan(changed)
83
+
84
+ def test_nodes_require_description_children_and_known_fields(self):
85
+ for key in ("name", "path", "description", "children"):
86
+ changed = copy.deepcopy(self.plan)
87
+ del changed["directoryTree"][key]
88
+ with self.subTest(missing=key), self.assertRaises(ContractError):
89
+ validate_plan(changed)
90
+ for key, value in (("description", ""), ("children", {}), ("kind", "module"),
91
+ ("kind", None), ("selected", True)):
92
+ changed = copy.deepcopy(self.plan)
93
+ changed["directoryTree"][key] = value
94
+ with self.subTest(key=key), self.assertRaises(ContractError):
95
+ validate_plan(changed)
142
96
 
143
- def test_invalid_paths_case_collisions_and_skipped_levels_are_rejected(self):
144
- for path in ("../escape", "/absolute", "E:/absolute", "a\\b", "a//b", "a/./b", "a/../b", "NUL", "x.", "{pages,state}", "a/b"):
145
- broken = copy.deepcopy(self.plan)
146
- leaf = broken["proposals"][0]["directoryTree"]["children"][0]
147
- leaf["path"] = path
148
- leaf["name"] = path.split("/")[-1]
97
+ def test_root_is_a_normal_directory_at_dot(self):
98
+ for key, value in (("path", "entry"), ("kind", "HAP")):
99
+ changed = copy.deepcopy(self.plan)
100
+ changed["directoryTree"][key] = value
101
+ with self.subTest(key=key), self.assertRaises(ContractError):
102
+ validate_plan(changed)
103
+
104
+ def test_names_and_direct_nesting_must_match_paths(self):
105
+ for path, name in (("entry/src/main", "main"), ("entry/src", "sources")):
106
+ changed = copy.deepcopy(self.plan)
107
+ node = changed["directoryTree"]["children"][1]["children"][0]
108
+ node.update(path=path, name=name)
109
+ with self.subTest(path=path, name=name), self.assertRaises(ContractError):
110
+ validate_plan(changed)
111
+
112
+ def test_unsafe_and_placeholder_paths_are_rejected(self):
113
+ for path in ("/absolute", "E:/target", "../source", "a/../b", "a//b", "./entry", "a\\b",
114
+ "pages/*", "{pages,state}", "<产品>", "…", "CON", "NUL.txt", "aux", "COM¹", "lpt².log", "a.", "a ",
115
+ "bad\x00path", "bad?path"):
116
+ changed = copy.deepcopy(self.plan)
117
+ changed["directoryTree"]["children"][0].update(path=path, name=path.split("/")[-1])
149
118
  with self.subTest(path=path), self.assertRaises(ContractError):
150
- validate_plan(broken)
151
- root = self.plan["proposals"][0]["directoryTree"]
152
- root["children"].append({"name": "ENTRY", "path": "ENTRY", "description": "collision", "kind": "directory", "children": []})
153
- with self.assertRaisesRegex(ContractError, "case-conflicting"):
119
+ validate_plan(changed)
120
+
121
+ def test_duplicate_and_case_conflicting_paths_are_rejected(self):
122
+ for name in ("AppScope", "appscope"):
123
+ changed = copy.deepcopy(self.plan)
124
+ duplicate = copy.deepcopy(changed["directoryTree"]["children"][0])
125
+ duplicate.update(name=name, path=name)
126
+ changed["directoryTree"]["children"].append(duplicate)
127
+ with self.subTest(name=name), self.assertRaisesRegex(ContractError, "duplicate/case-conflicting"):
128
+ validate_plan(changed)
129
+
130
+ def test_application_hap_and_expanded_production_root_are_required(self):
131
+ changed = copy.deepcopy(self.plan)
132
+ changed["directoryTree"]["children"] = []
133
+ with self.assertRaisesRegex(ContractError, "application HAP"):
134
+ validate_plan(changed)
135
+ self.node("entry")["kind"] = "HAR"
136
+ with self.assertRaisesRegex(ContractError, "application HAP"):
154
137
  validate_plan(self.plan)
155
-
156
- def test_nested_module_and_missing_child_array_are_rejected(self):
157
- entry = self.plan["proposals"][0]["directoryTree"]["children"][1]
158
- entry["children"][0]["kind"] = "HAR"
159
- with self.assertRaisesRegex(ContractError, "nested module"):
160
- validate_plan(self.plan)
161
- entry["children"][0]["kind"] = "directory"
162
- del entry["children"][0]["children"]
163
- with self.assertRaises(ContractError):
138
+ self.node("entry")["kind"] = "HAP"
139
+ self.node("entry")["children"] = []
140
+ with self.assertRaisesRegex(ContractError, "src/main"):
164
141
  validate_plan(self.plan)
165
142
 
166
- def test_empty_tree_or_tree_without_application_module_is_rejected(self):
167
- for mode in ("empty", "no-hap", "hap-without-source"):
168
- broken = copy.deepcopy(self.plan)
169
- tree = broken["proposals"][0]["directoryTree"]
170
- if mode == "empty":
171
- tree["children"] = []
172
- elif mode == "no-hap":
173
- tree["children"][1].pop("kind")
174
- else:
175
- tree["children"][1]["children"] = []
176
- with self.subTest(mode=mode), self.assertRaises(ContractError):
177
- validate_plan(broken)
178
-
179
- def test_read_preserves_user_notes_for_implementation_review(self):
180
- answer = {**self.decision, "notes": "保留现有目录,只补充风险说明。"}
181
- selected, _ = apply_selection(self.plan, answer)
182
- _, result = read_selection(selected, answer)
183
- self.assertEqual(result["notes"], answer["notes"])
184
-
185
- def test_new_submission_cannot_overwrite_the_confirmation_input_in_place(self):
186
- with tempfile.TemporaryDirectory() as directory:
187
- root = Path(directory)
188
- plan_file, saved_file = root / "architecture-plan.json", root / "architecture-selection.json"
189
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
190
- saved_file.write_text(json.dumps(self.decision), encoding="utf-8")
191
- original = plan_file.read_bytes()
192
- result = self.run_cli("apply-selection", plan_file, saved_file)
193
- self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
194
- self.assertEqual(plan_file.read_bytes(), original)
195
-
196
- def test_required_content_scheme_identity_and_selection_consistency_are_checked(self):
197
- mutations = (
198
- lambda p: p["project"].pop("summary"),
199
- lambda p: p["androidAnalysis"].pop("risks"),
200
- lambda p: p["androidAnalysis"].pop("techStack"),
201
- lambda p: p["androidAnalysis"].update(risks="unknown"),
202
- lambda p: p["proposals"][1].pop("directoryTree"),
203
- lambda p: p["proposals"].pop(),
204
- lambda p: p["proposals"][0].update(id="hybrid"),
205
- lambda p: p.update(selectedProposalId="unknown"),
206
- lambda p: p.update(selection=None),
207
- )
208
- for index, mutate in enumerate(mutations):
209
- broken = copy.deepcopy(self.plan)
210
- mutate(broken)
211
- with self.subTest(index=index), self.assertRaises(ContractError):
212
- validate_plan(broken)
213
-
214
- def run_cli(self, *args):
215
- return subprocess.run([sys.executable, str(HELPER), *map(str, args)],
216
- capture_output=True, text=True, encoding="utf-8", timeout=10)
217
-
218
- def test_dashboard_and_question_use_the_same_durable_confirmation_and_read_step(self):
219
- for source in ("dashboard", "question"):
220
- with self.subTest(source=source), tempfile.TemporaryDirectory() as directory:
221
- root = Path(directory)
222
- plan_file, answer_file = root / "architecture-plan.json", root / "tool-answer.json"
223
- saved_file = root / "architecture-selection.json"
224
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
225
- answer = {**self.decision, "source": source}
226
- answer_file.write_text(json.dumps(answer), encoding="utf-8")
227
- applied = self.run_cli("apply-selection", plan_file, answer_file)
228
- self.assertEqual(applied.returncode, 0, applied.stdout + applied.stderr)
229
- self.assertEqual(json.loads(saved_file.read_text(encoding="utf-8")), answer)
230
- saved_plan = json.loads(plan_file.read_text(encoding="utf-8"))
231
- self.assertEqual(saved_plan["selectedProposalId"], answer["selectedProposalId"])
232
- self.assertNotIn("selection", saved_plan)
233
- resumed = self.run_cli("read-selection", plan_file)
234
- self.assertEqual(resumed.returncode, 0, resumed.stdout + resumed.stderr)
235
- output = json.loads(resumed.stdout)
236
- self.assertEqual(output["source"], source)
237
- self.assertEqual(output["nextAction"], "create-directory-shell")
238
- self.assertEqual(output["selectedProposalId"], "android-mirror")
239
- self.assertEqual({p.name for p in root.iterdir()}, {plan_file.name, answer_file.name, saved_file.name})
240
-
241
- def test_read_rejects_unconfirmed_or_mismatched_selection(self):
242
- with self.assertRaisesRegex(ContractError, "no confirmed"):
243
- read_selection(self.plan, self.decision)
244
- updated, _ = apply_selection(self.plan, self.decision)
245
- for key, value in (("planId", "other-plan"), ("selectedProposalId", "hybrid")):
246
- with self.subTest(key=key), self.assertRaisesRegex(ContractError, "does not match"):
247
- read_selection(updated, {**self.decision, key: value})
248
-
249
- def test_read_select_only_never_requests_creation(self):
250
- answer = {**self.decision, "action": "select", "source": "question"}
251
- updated, _ = apply_selection(self.plan, answer)
252
- _, result = read_selection(updated, answer)
253
- self.assertEqual(result["nextAction"], "selection-recorded")
254
-
255
- def test_missing_confirmation_file_prevents_directory_implementation(self):
256
- with tempfile.TemporaryDirectory() as directory:
257
- plan_file = Path(directory) / "architecture-plan.json"
258
- selected, _ = apply_selection(self.plan, self.decision)
259
- plan_file.write_text(json.dumps(selected), encoding="utf-8")
260
- result = self.run_cli("read-selection", plan_file)
261
- self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
262
- self.assertEqual(json.loads(result.stdout)["status"], "error")
263
-
264
- def test_recovery_applies_saved_question_answer_before_reading_implementation_input(self):
265
- with tempfile.TemporaryDirectory() as directory:
266
- root = Path(directory)
267
- plan_file, saved_file = root / "architecture-plan.json", root / "architecture-selection.json"
268
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
269
- answer = {**self.decision, "source": "question"}
270
- saved_file.write_text(json.dumps(answer), encoding="utf-8")
271
- pending = self.run_cli("read-selection", plan_file)
272
- self.assertEqual(pending.returncode, 1)
273
- recovered = self.run_cli("apply-selection", plan_file, saved_file, "--recover")
274
- self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr)
275
- ready = self.run_cli("read-selection", plan_file)
276
- self.assertEqual(ready.returncode, 0, ready.stdout + ready.stderr)
277
- self.assertEqual(json.loads(ready.stdout)["source"], "question")
278
-
279
- def test_cli_retries_without_writing_and_never_creates_target(self):
280
- with tempfile.TemporaryDirectory() as directory:
281
- root = Path(directory)
282
- target = root / "not-created"
283
- plan_file, decision_file = root / "architecture-plan.json", root / "incoming-selection.json"
284
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
285
- decision_file.write_text(json.dumps(self.decision), encoding="utf-8-sig")
286
- result = self.run_cli("apply-selection", plan_file, decision_file)
287
- self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
288
- self.assertEqual(json.loads(result.stdout)["nextAction"], "create-directory-shell")
289
- first_write = plan_file.stat().st_mtime_ns
290
- retry = self.run_cli("apply-selection", plan_file, decision_file)
291
- self.assertEqual(retry.returncode, 0, retry.stdout + retry.stderr)
292
- self.assertEqual(json.loads(retry.stdout)["status"], "already-applied")
293
- self.assertEqual(plan_file.stat().st_mtime_ns, first_write)
294
- self.assertFalse(target.exists())
295
- self.assertEqual({p.name for p in root.iterdir()}, {plan_file.name, decision_file.name, "architecture-selection.json"})
296
-
297
- def test_changed_retry_is_rejected_without_replacing_confirmation(self):
298
- with tempfile.TemporaryDirectory() as directory:
299
- root = Path(directory)
300
- plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
301
- saved = root / "architecture-selection.json"
302
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
303
- incoming.write_text(json.dumps(self.decision), encoding="utf-8")
304
- self.assertEqual(self.run_cli("apply-selection", plan_file, incoming).returncode, 0)
305
- plan_bytes, confirmed_bytes = plan_file.read_bytes(), saved.read_bytes()
306
- incoming.write_text(json.dumps({**self.decision, "action": "select"}), encoding="utf-8")
307
- rejected = self.run_cli("apply-selection", plan_file, incoming)
308
- self.assertEqual(rejected.returncode, 1, rejected.stdout)
309
- self.assertIn("requestId", json.loads(rejected.stdout)["error"])
310
- self.assertEqual(plan_file.read_bytes(), plan_bytes)
311
- self.assertEqual(saved.read_bytes(), confirmed_bytes)
312
-
313
- def test_recovery_flag_requires_the_script_owned_confirmation(self):
314
- with tempfile.TemporaryDirectory() as directory:
315
- root = Path(directory)
316
- plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
317
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
318
- incoming.write_text(json.dumps(self.decision), encoding="utf-8")
319
- rejected = self.run_cli("apply-selection", plan_file, incoming, "--recover")
320
- self.assertEqual(rejected.returncode, 1)
321
- self.assertFalse((root / "architecture-selection.json").exists())
322
-
323
- def test_failed_plan_write_cannot_enter_implementation_and_can_recover(self):
324
- with tempfile.TemporaryDirectory() as directory:
325
- root = Path(directory)
326
- plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
327
- saved = root / "architecture-selection.json"
328
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
329
- incoming.write_text(json.dumps(self.decision), encoding="utf-8")
330
- original = plan_file.read_bytes()
331
- save_json = helper.save_json
332
-
333
- def fail_plan_write(path, value):
334
- if path == plan_file:
335
- raise OSError("simulated plan write failure")
336
- save_json(path, value)
143
+ def test_empty_hap_production_root_is_rejected(self):
144
+ self.node("entry/src/main")["children"] = []
145
+ with self.assertRaisesRegex(ContractError, "src/main"):
146
+ validate_plan(self.plan)
337
147
 
338
- arguments = [str(HELPER), "apply-selection", str(plan_file), str(incoming)]
339
- with patch.object(sys, "argv", arguments), patch.object(helper, "save_json", side_effect=fail_plan_write):
340
- with contextlib.redirect_stdout(io.StringIO()):
341
- self.assertEqual(helper.main(), 1)
342
- self.assertEqual(plan_file.read_bytes(), original)
343
- self.assertEqual(json.loads(saved.read_text(encoding="utf-8")), self.decision)
344
- self.assertEqual(self.run_cli("read-selection", plan_file).returncode, 1)
345
- recovered = self.run_cli("apply-selection", plan_file, saved, "--recover")
346
- self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr)
347
- self.assertEqual(self.run_cli("read-selection", plan_file).returncode, 0)
148
+ def test_hap_tree_validation_is_not_tied_to_one_source_language(self):
149
+ for name in ("js", "cpp"):
150
+ changed = copy.deepcopy(self.plan)
151
+ source = next(node for node in self.nodes() if node["path"] == "entry/src/main/ets")
152
+ source = copy.deepcopy(source)
153
+ def rebase(node):
154
+ node["path"] = node["path"].replace("entry/src/main/ets", f"entry/src/main/{name}", 1)
155
+ for child in node["children"]:
156
+ rebase(child)
157
+ rebase(source)
158
+ source["name"] = name
159
+ changed["directoryTree"]["children"][1]["children"][0]["children"][0]["children"][0] = source
160
+ with self.subTest(source=name):
161
+ validate_plan(changed)
162
+
163
+ def test_module_cannot_be_inside_another_modules_source_tree(self):
164
+ for kind in ("HAP", "HAR", "HSP"):
165
+ self.node("entry/src/main/ets/features")["kind"] = kind
166
+ with self.subTest(kind=kind), self.assertRaisesRegex(ContractError, "source tree"):
167
+ validate_plan(self.plan)
168
+
169
+ def test_module_grouping_outside_a_source_tree_is_not_rejected_as_nesting(self):
170
+ self.node("entry")["children"].append({
171
+ "name": "libraries", "path": "entry/libraries", "description": "库目录分组", "children": [{
172
+ "name": "mail", "path": "entry/libraries/mail", "kind": "HAR",
173
+ "description": "由独立构建配置注册的库模块", "children": [{
174
+ "name": "src", "path": "entry/libraries/mail/src", "description": "库源码", "children": [{
175
+ "name": "main", "path": "entry/libraries/mail/src/main", "description": "生产根", "children": [{
176
+ "name": "ets", "path": "entry/libraries/mail/src/main/ets",
177
+ "description": "库的 ArkTS 源码", "children": [],
178
+ }],
179
+ }],
180
+ }],
181
+ }],
182
+ })
183
+ validate_plan(self.plan)
348
184
 
349
- def test_reloaded_notes_are_utf8_even_with_an_ascii_console(self):
350
- with tempfile.TemporaryDirectory() as directory:
351
- root = Path(directory)
352
- plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
353
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
354
- answer = {**self.decision, "notes": "保留目录,补充说明 📝"}
355
- incoming.write_text(json.dumps(answer, ensure_ascii=False), encoding="utf-8")
356
- self.assertEqual(self.run_cli("apply-selection", plan_file, incoming).returncode, 0)
357
- result = subprocess.run([sys.executable, str(HELPER), "read-selection", str(plan_file)],
358
- capture_output=True, timeout=10,
359
- env={**os.environ, "PYTHONIOENCODING": "ascii"})
185
+ def test_multiple_hap_modules_and_sibling_libraries_are_supported(self):
186
+ for kind in ("HAP", "HAR", "HSP"):
187
+ with self.subTest(kind=kind):
188
+ changed = copy.deepcopy(self.plan)
189
+ module = copy.deepcopy(changed["directoryTree"]["children"][1])
190
+ def rebase(node):
191
+ node["path"] = "second" + node["path"][len("entry"):]
192
+ for child in node["children"]:
193
+ rebase(child)
194
+ rebase(module)
195
+ module.update(name="second", kind=kind)
196
+ changed["directoryTree"]["children"].append(module)
197
+ validate_plan(changed)
198
+
199
+ def test_invalid_json_duplicate_keys_and_nonstandard_numbers_are_rejected(self):
200
+ with tempfile.TemporaryDirectory() as temporary:
201
+ path = Path(temporary) / "plan.json"
202
+ for content in ('{"projectName": "a", "projectName": "b"}', '{"bad": NaN}',
203
+ '{"bad": Infinity}', '{"bad": -Infinity}', '{broken', 'null'):
204
+ path.write_text(content, encoding="utf-8")
205
+ with self.subTest(content=content), self.assertRaises(ValueError):
206
+ validate_plan(load_json(path))
207
+
208
+ def test_cli_validates_utf8_bom_and_makes_no_writes_or_confirmation_files(self):
209
+ with tempfile.TemporaryDirectory() as temporary:
210
+ path = Path(temporary) / "architecture-plan.json"
211
+ path.write_text(json.dumps(self.plan, ensure_ascii=False), encoding="utf-8-sig")
212
+ before = path.read_bytes()
213
+ result = self.cli("validate", path, env={**os.environ, "PYTHONIOENCODING": "ascii"})
360
214
  self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
361
- self.assertEqual(json.loads(result.stdout.decode("utf-8"))["notes"], answer["notes"])
362
-
363
- def test_cli_error_leaves_plan_bytes_unchanged_and_returns_structured_error(self):
364
- with tempfile.TemporaryDirectory() as directory:
365
- root = Path(directory)
366
- plan_file, decision_file = root / "plan.json", root / "decision.json"
367
- plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
368
- original = plan_file.read_bytes()
369
- for content in (json.dumps({**self.decision, "planId": "other-plan"}), '{"action":"select","action":"select-and-create"}', '[]', '{invalid'):
370
- decision_file.write_text(content, encoding="utf-8")
371
- result = self.run_cli("apply-selection", plan_file, decision_file)
372
- self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
215
+ self.assertEqual(json.loads(result.stdout), {"status": "valid", "projectName": "示例邮件客户端"})
216
+ self.assertEqual(path.read_bytes(), before)
217
+ self.assertEqual([p.name for p in path.parent.iterdir()], [path.name])
218
+
219
+ def test_cli_errors_do_not_rewrite_input_and_missing_file_is_reported(self):
220
+ with tempfile.TemporaryDirectory() as temporary:
221
+ path = Path(temporary) / "architecture-plan.json"
222
+ for content in ('{"proposals": []}', '{broken', '[]'):
223
+ path.write_text(content, encoding="utf-8")
224
+ result = self.cli("validate", path)
225
+ self.assertEqual(result.returncode, 1)
373
226
  self.assertEqual(json.loads(result.stdout)["status"], "error")
374
- self.assertEqual(plan_file.read_bytes(), original)
227
+ self.assertEqual(path.read_text(encoding="utf-8"), content)
228
+ result = self.cli("validate", path.parent / "missing.json")
229
+ self.assertEqual(result.returncode, 1)
230
+ self.assertEqual(json.loads(result.stdout)["status"], "error")
231
+
232
+ def test_legacy_selection_commands_are_unavailable(self):
233
+ for command in ("apply-selection", "read-selection"):
234
+ result = self.cli(command, "architecture-plan.json")
235
+ with self.subTest(command=command):
236
+ self.assertNotEqual(result.returncode, 0)
237
+ self.assertIn("invalid choice", result.stderr)
375
238
 
376
239
 
377
240
  if __name__ == "__main__":
@@ -5,7 +5,7 @@ description: Implement ordered Android-to-HarmonyOS page tasks from pages.json,
5
5
 
6
6
  # Page development loop
7
7
 
8
- Each page-worker implements only the selected page and brings its build to a pass. After development is complete, the parent agent takes over testing, defect fixes, and acceptance for the current task.
8
+ Each page-worker implements only the selected page and brings its build to a pass. After development is complete, the parent agent takes over testing, defect fixes, and acceptance for the current task. A worker may be reused for a fixed ordered group of up to three related Specs. Implement one assigned task per handoff; the parent accepts it before authorizing the same worker to continue with the next member. Task identities, states, evidence and acceptance remain separate. Reuse is conditional on the host supporting child-session continuation and the parent confirming that the next task shares relevant context, the worker can use the refreshed baseline and interfaces, and no unresolved task or ownership conflict remains. Otherwise, explicitly revise the group and dispatch a fresh worker for the remaining scope.
9
9
 
10
10
  ## Inputs and boundaries
11
11
 
@@ -21,17 +21,18 @@ Each page-worker implements only the selected page and brings its build to a pas
21
21
  ## Parent-agent mode
22
22
 
23
23
  1. Read `pages.json` and `batch_report.json`; verify task identities, Spec paths and dependencies against pages.json, and use ledger planning for development scope and execution order. Verify planning references, counts and states. Reject missing, stale or invalid planning instead of falling back to all tasks when a selected scope or planning is present.
24
- 2. Select the earliest eligible task in planning milestone/wave order (or legacy ledger tasks order when allowed) whose development/build/validation states are not `implemented/passed/passed` and whose declared dependencies are satisfied.
24
+ 2. Select the earliest eligible task in planning milestone/wave order (or legacy ledger tasks order when allowed) whose development/build/validation states are not `implemented/passed/passed` and whose declared dependencies are satisfied. When bound planning is present, the parent may group it with adjacent unfinished tasks in the same wave that share page, module or component context, normally two or three tasks and never more than three. Fix the ordered task IDs before dispatch; keep complex or unclear work as a single task and verify dependencies again before each member starts.
25
25
  3. Resolve the page package, Android root, HarmonyOS root, repository root, current `HEAD`, Git status, device target, and task evidence directory.
26
- 4. Dispatch exactly one page-worker with absolute paths, selected task ID, dispatch baseline, device target, and evidence directory. Do not run page-workers in parallel. Wait for its result before the parent begins testing and fixes.
26
+ 4. Dispatch exactly one page-worker with absolute paths, the current task ID, ordered group task IDs when present, dispatch baseline, device target, and task evidence directory. Do not run page-workers in parallel. Wait for its result before the parent begins testing and fixes. Preserve the returned child-session identifier for possible continuation; use the host's actual resume parameter and confirm that the continuation returns the same session ID. A new child session is a fresh dispatch, not context reuse.
27
27
  5. After the worker returns, the parent takes ownership of the current page and executes testing and all fixes according to **Managed page tests and fixes** below until current build and required tests pass.
28
28
  6. Execute **Parent report and commits** below.
29
- 7. Apply **Parent verification** below. Only after the page reaches `implemented/passed/passed`, evidence is current, and all page-owned changes are committed may the parent select the next page and dispatch a new worker.
29
+ 7. Apply **Parent verification** below. Only after the current page reaches `implemented/passed/passed`, evidence is current, and all page-owned changes are committed may the parent authorize the same worker to implement the next group member. Refresh its task package, baseline, permitted paths, dependency evidence and evidence directory. If continuation is unsupported or reuse review fails, explicitly revise the group and dispatch a fresh worker for the next eligible task with these refreshed inputs; lack of reuse support must not block normal serial development. If a member is blocked or the group becomes invalid, return to the parent with remaining tasks unchanged; do not skip members or expand the group. Dispatch a worker for a new group only after the current group closes or is explicitly revised.
30
30
 
31
31
  Use this worker prompt contract:
32
32
 
33
33
  ```text
34
34
  Use $d2h-spec-develop in page-worker mode. Complete the initial implementation and managed build for exactly <TASK_ID>.
35
+ Assigned task IDs: <fixed ordered group of 1–3 task IDs; implement only TASK_ID until parent acceptance and explicit continuation>
35
36
  Skill path: <absolute path to this SKILL.md>
36
37
  Migration root: <absolute path>
37
38
  Page package: <absolute path>