@harmonyos-arkts/d2h 0.1.22 → 0.1.27

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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -1
  3. package/agents/d2h.md +25 -45
  4. package/dist/bin/install-opencode.js +16 -2
  5. package/dist/src/install-opencode.js +12 -2
  6. package/dist/src/install-tui-extension.js +133 -0
  7. package/dist/src/install-vscode.js +7 -2
  8. package/dist/src/metrics-plugin.js +54 -0
  9. package/dist/src/toolchain.js +2 -2
  10. package/package.json +1 -1
  11. package/skills/d2h-app-solution/SKILL.md +2 -0
  12. package/skills/d2h-architecture-plan/SKILL.md +21 -11
  13. package/skills/d2h-architecture-plan/references/architecture-plan.example.json +357 -0
  14. package/skills/d2h-architecture-plan/references/directory-creation.md +24 -14
  15. package/skills/d2h-architecture-plan/references/json-contract.md +111 -0
  16. package/skills/d2h-architecture-plan/references/plan-contract.md +18 -15
  17. package/skills/d2h-architecture-plan/references/selection-flow.md +71 -0
  18. package/skills/d2h-architecture-plan/references/source-review.md +14 -0
  19. package/skills/d2h-architecture-plan/scripts/architecture_plan.py +281 -0
  20. package/skills/d2h-architecture-plan/scripts/test_architecture_plan.py +378 -0
  21. package/skills/d2h-cloud-knowledge/SKILL.md +1 -1
  22. package/skills/d2h-dashboard/SKILL.md +35 -21
  23. package/skills/d2h-spec-develop/SKILL.md +135 -41
  24. package/skills/d2h-spec-generate/SKILL.md +22 -70
  25. package/skills/d2h-spec-generate/assets/spec-template.md +1 -1
  26. package/skills/d2h-spec-generate/references/from-plan.md +13 -5
  27. package/skills/d2h-spec-generate/references/spec-content.md +4 -6
  28. package/skills/d2h-spec-plan/SKILL.md +22 -23
  29. package/skills/d2h-spec-plan/agents/openai.yaml +3 -3
  30. package/skills/d2h-spec-plan/references/plan-contract.md +32 -7
  31. package/skills/d2h-spec-plan/references/planning-method.md +13 -37
  32. package/skills/d2h-spec-plan/references/planning-review.md +23 -0
  33. package/skills/d2h-spec-plan/references/round-scope-assessment.md +12 -39
  34. package/skills/d2h-spec-plan/references/work-packets.md +7 -55
  35. package/skills/managing-d2h-toolchain/environment-requirements.json +1 -1
  36. package/skills/managing-d2h-toolchain/toolchain.json +1 -1
  37. package/skills/d2h-spec-develop/agents/openai.yaml +0 -7
  38. package/skills/d2h-spec-develop/references/worker-workflow.md +0 -26
  39. package/skills/d2h-spec-plan/references/architecture-handoff.md +0 -9
@@ -0,0 +1,378 @@
1
+ """Behavior checks for the dashboard selection boundary; no real project is touched."""
2
+
3
+ import copy
4
+ import contextlib
5
+ import io
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from unittest.mock import patch
14
+
15
+ import architecture_plan as helper
16
+ from architecture_plan import ContractError, apply_selection, read_selection, validate_plan
17
+
18
+
19
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
20
+ HELPER = SKILL_ROOT / "scripts" / "architecture_plan.py"
21
+
22
+
23
+ class ArchitecturePlanTests(unittest.TestCase):
24
+ 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
+ }
31
+
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")
67
+
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")
73
+
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)
80
+
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
+ })
108
+ 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)
142
+
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]
149
+ 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"):
154
+ 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):
164
+ validate_plan(self.plan)
165
+
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)
337
+
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)
348
+
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"})
360
+ 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)
373
+ self.assertEqual(json.loads(result.stdout)["status"], "error")
374
+ self.assertEqual(plan_file.read_bytes(), original)
375
+
376
+
377
+ if __name__ == "__main__":
378
+ unittest.main()
@@ -27,7 +27,7 @@ version: 1.3.0
27
27
 
28
28
  | 环节 | 触发条件 | 查什么 |
29
29
  |------|----------|--------|
30
- | 页面 spec 生成(`d2h-spec-plan`) | spec 标记了平台能力项(相机/定位/文件/通知/蓝牙/分享/Web/后台任务等) | 该能力对应 Kit/API 是否可用、是否受系统能力限制,写入 spec 依据 |
30
+ | Spec 行为或平台能力核验 | spec 标记了平台能力项(相机/定位/文件/通知/蓝牙/分享/Web/后台任务等) | 该能力对应 Kit/API 是否可用、是否受系统能力限制,写入 spec 依据 |
31
31
  | 页面实现前(`d2h-spec-develop` 步骤 1-2) | 功能清单(F-xxx)中存在平台能力或不确定的 @kit.* Kit / @ohos.* 模块 API | API 签名、参数/返回值、所需权限、错误码、推荐用法与指南 |
32
32
  | 页面实现中 | 不确定 ArkUI 组件/属性行为(如 Tabs、Swiper、手势、懒加载) | 开发指南与 FAQ 中的权威行为描述 |
33
33
  | 构建失败分诊 | 编译错误指向 @kit.* / @ohos.* API 签名、类型、import 模块名 | 核对该 API 的真实签名与起始版本,避免臆造签名反复试错 |
@@ -14,43 +14,57 @@ npx --yes d2h-dashboard@latest --dataDir <项目>/.migration [--port 8080] [--ho
14
14
  - `--dataDir` 必填,指向迁移工作区 `.migration`;`--port` 默认 8080,占用自动 +1 重试至 8090;`--host` 默认 `127.0.0.1`。
15
15
  - 启动链:npx 拉包并执行 bin → `node …/server/serve.mjs` 常驻,同时托管前端与 `/api/data/*`。
16
16
  - 成功后 stdout 固定两行,实际 URL 以第二行为准:`[dashboard] dataDir=<绝对路径>`、`[dashboard] http://<host>:<实际端口>`。
17
- - 健康检查:`GET /api/data/manifest` 返回 200 且 `root` 等于传入 dataDir(绝对路径规范化比较)。
18
17
 
19
18
  ## 自动启动(迁移会话内)
20
19
 
21
20
  - `.migration/progress.json` 出现(`progress init` 后)或恢复会话已存在 → 自动启动看板并用默认浏览器打开 URL,不等用户请求;每轮会话执行一次,复用实例不重复启动、不重复打开。
22
21
  - 启动或打开失败只记录不阻塞迁移,下次阶段更新重试一次。
22
+ - 确认 `$url` 后按「TUI 常驻横幅」一节更新横幅;端口漂移(重启落到新端口)同样触发更新。
23
23
 
24
- ## 后台启动与打开浏览器
24
+ ## 后台启动与验证(必做)
25
25
 
26
- 常驻进程必须后台启动(非交互、明确超时):
26
+ 用 `--detach` 自守护启动,一条命令完成(服务器常驻、日志按项目隔离、父进程就绪后自行退出),无需任何 cmd 包装或 shell 重定向:
27
27
 
28
- ```bash
29
- # bash
30
- nohup npx --yes d2h-dashboard@latest --dataDir "$DATA_DIR" > /tmp/d2h-dashboard.log 2>&1 &
28
+ ```powershell
29
+ # Windows:npx 必须用 npx.cmd(npx.ps1 被执行策略拦截)
30
+ npx.cmd --yes d2h-dashboard@latest --dataDir "$dataDir" --detach
31
31
  ```
32
32
 
33
- ```powershell
34
- # Windows PowerShell 5.1:npx 必须用 npx.cmd(npx.ps1 被执行策略拦截);
35
- # 重定向交给脱离的 cmd,禁止 Start-Process -RedirectStandardOutput——会卡死会话(已实测)
36
- $log = "$env:TEMP\d2h-dashboard.log"
37
- $arg = "/c npx.cmd --yes d2h-dashboard@latest --dataDir `"$dataDir`" > `"$log`" 2> `"$log.err`""
38
- Start-Process -FilePath cmd.exe -ArgumentList $arg -WindowStyle Hidden | Out-Null
39
- Start-Sleep -Seconds 2; Get-Content $log -ErrorAction SilentlyContinue
33
+ ```bash
34
+ # bash(macOS/Linux)
35
+ npx --yes d2h-dashboard@latest --dataDir "$DATA_DIR" --detach
40
36
  ```
41
37
 
42
- 打开浏览器(Windows 必须 `cmd /c start`,`Start-Process "http://…"` 静默不弹窗,已实测):
38
+ 事实源铁律:**实例唯一事实源 = 端口探测的 manifest root;日志只是辅助排查,禁止用日志内容判定实例归属或端口。** 启动后必须轮询验证成功,禁止只看输出就宣布就绪:
43
39
 
44
40
  ```powershell
45
- cmd /c start "" "http://127.0.0.1:<port>" # Windows
41
+ $deadline = (Get-Date).AddSeconds(20); $url = $null
42
+ do {
43
+ Start-Sleep -Milliseconds 500
44
+ foreach ($p in 8080..8090) {
45
+ try { $j = (Invoke-WebRequest "http://127.0.0.1:$p/api/data/manifest" -TimeoutSec 1 -UseBasicParsing).Content | ConvertFrom-Json
46
+ if ($j.root -eq $dataDir) { $url = "http://127.0.0.1:$p"; break } } catch {}
47
+ }
48
+ } while (-not $url -and (Get-Date) -lt $deadline)
46
49
  ```
47
50
 
48
- ```bash
49
- open "http://…" # macOS
50
- xdg-open "http://…" # Linux
51
- ```
51
+ 未命中 → 读 stdout 里的 `log=…` 路径尾部定位原因,重试一次;仍失败只记录不阻塞。
52
+
53
+ 确认 `$url` 后打开浏览器(Windows 必须 `cmd /c start "" "<url>"`,`Start-Process "<url>"` 静默不弹窗;macOS `open`、Linux `xdg-open`)。
54
+
55
+ **URL 告知规范**:URL 单独成行或用反引号包裹,其后不得紧跟任何文字(含中文括号说明)——否则 markdown 会把 URL+后缀渲染成单个失效链接,说明放下一行。
56
+
57
+ ## TUI 常驻横幅
58
+
59
+ - 确认 `$url` 后(启动/复用/端口漂移)执行,10s 超时防卡死:
60
+ ```powershell
61
+ Start-Process cmd.exe -ArgumentList '/c opencode-tui-extension.cmd set "<url>"' -NoNewWindow -PassThru | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue
62
+ ```
63
+ bash:`timeout 10 opencode-tui-extension set "<url>"`
64
+ - 停止看板后同法执行 `clear`
65
+ - 失败/超时/命令不存在:只记录不阻塞,降级为 URL 单独成行输出;安装注册由 `d2h install` 负责,本 skill 不做初始化
52
66
 
53
67
  ## 复用与停止
54
68
 
55
- - **复用**:启动前探测 8080–8090 `GET /api/data/manifest`(1s 超时),`root` == 目标 dataDir 且 `GET /` 返回 200 才复用该 URL;仅 root 匹配但 `GET /` 非 200 属损坏实例(如资产被删/版本切换残留),按停止规则杀掉后重新启动。root 不匹配跳过(其他流程的服务器)。服务器常驻,会话结束不回收,残留进程属预期。
56
- - **停止**:仅用户显式要求(「关闭看板」「关闭 XX 项目看板」)才执行,且必须 root 归属校验通过:Windows `Get-NetTCPConnection -LocalPort <port> -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess }`(杀 node 真实 PID,cmd 包装 PID 无效);bash `kill <pid>`。未找到 → 报告未在运行;root 不匹配 → 跳过并说明属于其他流程。
69
+ - **复用**:启动前探测 8080–8090 `GET /api/data/manifest`(1s 超时),`root` == 目标 dataDir 且 `GET /` 返回 200 才复用该 URL;仅 root 匹配但 `GET /` 非 200 属损坏实例(资产被删/版本切换残留),按停止规则杀掉后重新启动。root 不匹配跳过(其他流程的服务器)。服务器常驻,会话结束不回收,残留进程属预期。
70
+ - **停止**:仅用户显式要求(「关闭看板」「关闭 XX 项目看板」)才执行,且必须 root 归属校验通过:Windows `Get-NetTCPConnection -LocalPort <port> -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess }`(杀 node 真实 PID,cmd 包装 PID 无效);bash `kill <pid>`。未找到 → 报告未在运行;root 不匹配 → 跳过并说明属于其他流程。停止成功后按「TUI 常驻横幅」执行 `clear`。