@harmonyos-arkts/d2h 0.1.27 → 0.1.28
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/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/README.md +34 -2
- package/agents/d2h.md +16 -14
- package/dist/bin/install-opencode.js +25 -22
- package/dist/src/install-codex.js +5 -126
- package/dist/src/install-skills.js +147 -0
- package/dist/src/install-vscode.js +2 -2
- package/docs/environment-check.md +3 -3
- package/package.json +6 -6
- package/skills/arkts-mvvm-architecture/SKILL.md +2 -0
- package/skills/d2h-architecture-plan/SKILL.md +19 -26
- package/skills/d2h-architecture-plan/references/architecture-plan.example.json +80 -315
- package/skills/d2h-architecture-plan/references/directory-creation.md +27 -34
- package/skills/d2h-architecture-plan/references/hybrid-design.md +13 -15
- package/skills/d2h-architecture-plan/references/json-contract.md +29 -91
- package/skills/d2h-architecture-plan/references/layout-reference.md +30 -27
- package/skills/d2h-architecture-plan/references/plan-contract.md +27 -34
- package/skills/d2h-architecture-plan/references/source-review.md +1 -1
- package/skills/d2h-architecture-plan/scripts/architecture_plan.py +29 -163
- package/skills/d2h-architecture-plan/scripts/test_architecture_plan.py +205 -342
- package/skills/d2h-spec-develop/SKILL.md +5 -4
- package/{install-opencode.sh → tooling/install-opencode.sh} +3 -3
- package/{opencode → tooling/opencode}/interactive-tools/advance-progress-demo.mjs +1 -1
- package/{opencode → tooling/opencode}/interactive-tools/demo.mjs +3 -3
- package/{opencode → tooling/opencode}/interactive-tools/prepare-demo.mjs +1 -1
- package/{scripts → tooling/scripts}/check-environment.mjs +3 -3
- package/skills/d2h-architecture-plan/references/selection-flow.md +0 -71
- /package/{opencode → tooling/opencode}/agents.json +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/browser-host.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/core.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/demo-workflow.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/package-lock.json +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/package.json +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/panel.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/prepare-progress-demo.mjs +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/progress.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/server-legacy.mjs +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/server.mjs +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/tui.tsx +0 -0
- /package/{opencode → tooling/opencode}/vscode-d2h-package.json +0 -0
- /package/{opencode → tooling/opencode}/vscode-d2h-plugin.mjs +0 -0
|
@@ -1,31 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Read-only validation of the confirmed architecture shown on the dashboard.
|
|
3
3
|
|
|
4
|
-
Standard library only.
|
|
5
|
-
|
|
4
|
+
Standard library only. User answers and authorization stay in architecture-plan.md;
|
|
5
|
+
valid JSON is neither proof of confirmation nor permission to create a project.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
|
-
import copy
|
|
12
11
|
import json
|
|
13
|
-
import os
|
|
14
12
|
from pathlib import Path, PurePosixPath
|
|
15
13
|
import re
|
|
16
14
|
import sys
|
|
17
|
-
import tempfile
|
|
18
15
|
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
PLAN_FIELDS = {
|
|
22
|
-
"planId", "project", "androidAnalysis", "proposals",
|
|
23
|
-
"recommendedProposalId", "recommendationReason", "selectedProposalId",
|
|
24
|
-
}
|
|
25
|
-
DECISION_FIELDS = {
|
|
26
|
-
"requestId", "planId", "selectedProposalId",
|
|
27
|
-
"action", "source", "notes",
|
|
28
|
-
}
|
|
17
|
+
PLAN_FIELDS = {"projectName", "directoryTree", "decisions"}
|
|
29
18
|
|
|
30
19
|
|
|
31
20
|
class ContractError(ValueError):
|
|
@@ -46,9 +35,8 @@ def record(value: object, fields: set[str], label: str, *, optional: set[str] |
|
|
|
46
35
|
return value
|
|
47
36
|
|
|
48
37
|
|
|
49
|
-
def string(value: object, label: str
|
|
50
|
-
require(isinstance(value, str) and
|
|
51
|
-
f"{label} must be {'a' if empty else 'a non-empty'} string")
|
|
38
|
+
def string(value: object, label: str) -> str:
|
|
39
|
+
require(isinstance(value, str) and bool(value.strip()), f"{label} must be a non-empty string")
|
|
52
40
|
return value
|
|
53
41
|
|
|
54
42
|
|
|
@@ -61,21 +49,21 @@ def array(value: object, label: str, *, nonempty: bool = False) -> list:
|
|
|
61
49
|
def relative_path(value: object, label: str) -> str:
|
|
62
50
|
value = string(value, label)
|
|
63
51
|
require(value == "." or all(
|
|
64
|
-
part not in ("", ".", "..") and
|
|
52
|
+
part not in ("", ".", "..", "…") and
|
|
65
53
|
not re.search(r'[<>:"\\|?*{}\x00-\x1f]', part) and
|
|
66
54
|
not part.endswith((".", " ")) and
|
|
67
|
-
not re.fullmatch(r"(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?", part)
|
|
55
|
+
not re.fullmatch(r"(?i:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\..*)?", part)
|
|
68
56
|
for part in value.split("/")
|
|
69
57
|
), f"{label} must be a normalized, safe root-relative directory path")
|
|
70
58
|
return value
|
|
71
59
|
|
|
72
60
|
|
|
73
|
-
def directory_tree(value: object, label: str) -> set[str]:
|
|
61
|
+
def directory_tree(value: object, label: str = "directoryTree") -> set[str]:
|
|
74
62
|
paths: set[str] = set()
|
|
75
63
|
folded: set[str] = set()
|
|
76
64
|
hap_paths: list[str] = []
|
|
77
65
|
|
|
78
|
-
def visit(node: object, parent: str | None,
|
|
66
|
+
def visit(node: object, parent: str | None, module_roots: tuple[str, ...]) -> None:
|
|
79
67
|
node = record(node, {"name", "path", "description", "children"}, label, optional={"kind"})
|
|
80
68
|
name = string(node["name"], f"{label}.name")
|
|
81
69
|
path = relative_path(node["path"], f"{label}.path")
|
|
@@ -89,120 +77,36 @@ def directory_tree(value: object, label: str) -> set[str]:
|
|
|
89
77
|
str(PurePosixPath(path).parent) == parent,
|
|
90
78
|
f"{label}[{path}] must be a direct child of {parent}, named by its last segment")
|
|
91
79
|
require(path.casefold() not in folded, f"{label}: duplicate/case-conflicting path {path}")
|
|
92
|
-
require(
|
|
80
|
+
require(kind == "directory" or all(
|
|
81
|
+
path != f"{root}/src" and not path.startswith(f"{root}/src/") for root in module_roots
|
|
82
|
+
), f"{label}[{path}]: module inside another module's source tree")
|
|
93
83
|
if kind == "HAP":
|
|
94
84
|
hap_paths.append(path)
|
|
95
85
|
paths.add(path)
|
|
96
86
|
folded.add(path.casefold())
|
|
87
|
+
child_roots = module_roots + (path,) if kind != "directory" else module_roots
|
|
97
88
|
for child in array(node["children"], f"{label}[{path}].children"):
|
|
98
|
-
visit(child, path,
|
|
89
|
+
visit(child, path, child_roots)
|
|
99
90
|
|
|
100
|
-
visit(value, None,
|
|
91
|
+
visit(value, None, ())
|
|
101
92
|
require(bool(hap_paths), f"{label} must include an application HAP")
|
|
102
93
|
for path in hap_paths:
|
|
103
|
-
|
|
94
|
+
production_root = f"{path}/src/main"
|
|
95
|
+
require(production_root in paths and any(p.startswith(f"{production_root}/") for p in paths),
|
|
96
|
+
f"{label}[{path}] must include an expanded src/main production root with key directories")
|
|
104
97
|
return paths
|
|
105
98
|
|
|
106
99
|
|
|
107
|
-
def reference(value: object, allowed: set[str], label: str) -> str:
|
|
108
|
-
value = string(value, label)
|
|
109
|
-
require(value in allowed, f"{label}: unknown reference {value}")
|
|
110
|
-
return value
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def validate_decision(value: object) -> dict:
|
|
114
|
-
decision = record(value, DECISION_FIELDS, "selection")
|
|
115
|
-
for key in ("requestId", "planId"):
|
|
116
|
-
string(decision[key], f"selection.{key}")
|
|
117
|
-
reference(decision["selectedProposalId"], PROPOSAL_IDS, "selection.selectedProposalId")
|
|
118
|
-
require(decision["action"] in ("select", "select-and-create"), "selection.action invalid")
|
|
119
|
-
require(decision["source"] in ("dashboard", "question", "conversation"), "selection.source invalid")
|
|
120
|
-
string(decision["notes"], "selection.notes", empty=True)
|
|
121
|
-
return decision
|
|
122
|
-
|
|
123
|
-
|
|
124
100
|
def validate_plan(value: object) -> dict:
|
|
125
101
|
plan = record(value, PLAN_FIELDS, "plan")
|
|
126
|
-
string(plan["
|
|
127
|
-
|
|
128
|
-
for
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
string(analysis["summary"], "androidAnalysis.summary")
|
|
133
|
-
stack = record(analysis["techStack"], {"language", "ui", "storage", "network"}, "androidAnalysis.techStack")
|
|
134
|
-
for key in ("language", "ui", "storage", "network"):
|
|
135
|
-
string(stack[key], f"androidAnalysis.techStack.{key}")
|
|
136
|
-
risks = array(analysis["risks"], "androidAnalysis.risks")
|
|
137
|
-
require(len(risks) <= 5, "androidAnalysis.risks must contain at most five key risks")
|
|
138
|
-
for risk in risks:
|
|
139
|
-
risk = record(risk, {"title", "impact"}, "androidAnalysis.risks[]")
|
|
140
|
-
string(risk["title"], "androidAnalysis.risks[].title")
|
|
141
|
-
string(risk["impact"], "androidAnalysis.risks[].impact")
|
|
142
|
-
|
|
143
|
-
proposals = array(plan["proposals"], "proposals", nonempty=True)
|
|
144
|
-
require(len(proposals) == 3, "proposals must contain exactly three schemes")
|
|
145
|
-
proposal_ids: set[str] = set()
|
|
146
|
-
for proposal in proposals:
|
|
147
|
-
proposal = record(proposal, {"id", "name", "summary", "directoryTree"}, "proposal")
|
|
148
|
-
proposal_id = reference(proposal["id"], PROPOSAL_IDS, "proposal.id")
|
|
149
|
-
require(proposal_id not in proposal_ids, f"duplicate proposal ID: {proposal_id}")
|
|
150
|
-
proposal_ids.add(proposal_id)
|
|
151
|
-
for key in ("name", "summary"):
|
|
152
|
-
string(proposal[key], f"{proposal_id}.{key}")
|
|
153
|
-
directory_tree(proposal["directoryTree"], f"{proposal_id}.directoryTree")
|
|
154
|
-
|
|
155
|
-
reference(plan["recommendedProposalId"], proposal_ids, "recommendedProposalId")
|
|
156
|
-
string(plan["recommendationReason"], "recommendationReason")
|
|
157
|
-
if plan["selectedProposalId"] is not None:
|
|
158
|
-
reference(plan["selectedProposalId"], proposal_ids, "selectedProposalId")
|
|
159
|
-
return plan
|
|
102
|
+
string(plan["projectName"], "projectName")
|
|
103
|
+
directory_tree(plan["directoryTree"])
|
|
104
|
+
for decision in array(plan["decisions"], "decisions", nonempty=True):
|
|
105
|
+
decision = record(decision, {"title", "description"}, "decisions[]")
|
|
106
|
+
for key in ("title", "description"):
|
|
107
|
+
string(decision[key], f"decisions[].{key}")
|
|
160
108
|
|
|
161
|
-
|
|
162
|
-
def apply_selection(plan: dict, decision: dict, saved: dict | None = None) -> tuple[dict, dict]:
|
|
163
|
-
"""Return a validated new plan and continuation instruction; no filesystem effects."""
|
|
164
|
-
validate_plan(plan)
|
|
165
|
-
validate_decision(decision)
|
|
166
|
-
require(decision["planId"] == plan["planId"], "planId mismatch; refresh the displayed plan")
|
|
167
|
-
if saved is not None:
|
|
168
|
-
validate_decision(saved)
|
|
169
|
-
if saved is not None and decision["requestId"] == saved["requestId"]:
|
|
170
|
-
require(decision == saved, "requestId already used with different content")
|
|
171
|
-
if decision == saved and plan["selectedProposalId"] == decision["selectedProposalId"]:
|
|
172
|
-
status = "already-applied"
|
|
173
|
-
updated = plan
|
|
174
|
-
else:
|
|
175
|
-
updated = copy.deepcopy(plan)
|
|
176
|
-
updated["selectedProposalId"] = decision["selectedProposalId"]
|
|
177
|
-
validate_plan(updated)
|
|
178
|
-
status = "applied"
|
|
179
|
-
return updated, {
|
|
180
|
-
"status": status,
|
|
181
|
-
"planId": updated["planId"],
|
|
182
|
-
"selectedProposalId": updated["selectedProposalId"],
|
|
183
|
-
"nextAction": "create-directory-shell" if decision["action"] == "select-and-create" else "selection-recorded",
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def read_selection(plan: dict, decision: dict) -> tuple[dict, dict]:
|
|
188
|
-
"""Resolve only a persisted confirmation matching the plan's selected ID."""
|
|
189
|
-
validate_plan(plan)
|
|
190
|
-
validate_decision(decision)
|
|
191
|
-
require(plan["selectedProposalId"] is not None, "no confirmed proposal selected")
|
|
192
|
-
require(decision["planId"] == plan["planId"] and
|
|
193
|
-
decision["selectedProposalId"] == plan["selectedProposalId"],
|
|
194
|
-
"confirmation does not match the plan/selected proposal")
|
|
195
|
-
proposal = next(p for p in plan["proposals"] if p["id"] == plan["selectedProposalId"])
|
|
196
|
-
return proposal, {
|
|
197
|
-
"status": "confirmed",
|
|
198
|
-
"planId": plan["planId"],
|
|
199
|
-
"selectedProposalId": proposal["id"],
|
|
200
|
-
"requestId": decision["requestId"],
|
|
201
|
-
"source": decision["source"],
|
|
202
|
-
"action": decision["action"],
|
|
203
|
-
"notes": decision["notes"],
|
|
204
|
-
"nextAction": "create-directory-shell" if decision["action"] == "select-and-create" else "selection-recorded",
|
|
205
|
-
}
|
|
109
|
+
return plan
|
|
206
110
|
|
|
207
111
|
|
|
208
112
|
def unique_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
@@ -222,57 +126,19 @@ def load_json(path: Path) -> object:
|
|
|
222
126
|
parse_constant=reject_constant)
|
|
223
127
|
|
|
224
128
|
|
|
225
|
-
def save_json(path: Path, value: dict) -> None:
|
|
226
|
-
temporary = None
|
|
227
|
-
try:
|
|
228
|
-
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
|
229
|
-
prefix=f".{path.name}.", suffix=".tmp", delete=False) as stream:
|
|
230
|
-
temporary = Path(stream.name)
|
|
231
|
-
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
232
|
-
stream.write("\n")
|
|
233
|
-
os.replace(temporary, path)
|
|
234
|
-
finally:
|
|
235
|
-
if temporary is not None and temporary.exists():
|
|
236
|
-
temporary.unlink()
|
|
237
|
-
|
|
238
|
-
|
|
239
129
|
def main() -> int:
|
|
240
130
|
if hasattr(sys.stdout, "reconfigure"):
|
|
241
131
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
242
132
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
243
133
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
244
|
-
check = subparsers.add_parser("validate", help="read-only plan validation")
|
|
134
|
+
check = subparsers.add_parser("validate", help="read-only final-plan validation")
|
|
245
135
|
check.add_argument("plan", type=Path)
|
|
246
|
-
apply = subparsers.add_parser("apply-selection", help="record a real user request; never creates a project")
|
|
247
|
-
apply.add_argument("plan", type=Path)
|
|
248
|
-
apply.add_argument("decision", type=Path)
|
|
249
|
-
apply.add_argument("--recover", action="store_true", help="reuse the script-owned confirmation after an interrupted apply")
|
|
250
|
-
read = subparsers.add_parser("read-selection", help="reload the saved confirmation before directory implementation")
|
|
251
|
-
read.add_argument("plan", type=Path)
|
|
252
136
|
args = parser.parse_args()
|
|
253
137
|
try:
|
|
254
138
|
plan = validate_plan(load_json(args.plan))
|
|
255
|
-
|
|
256
|
-
result = {"status": "valid", "planId": plan["planId"]}
|
|
257
|
-
elif args.command == "apply-selection":
|
|
258
|
-
confirmation_file = args.plan.parent / "architecture-selection.json"
|
|
259
|
-
same_file = args.decision.resolve() == confirmation_file.resolve()
|
|
260
|
-
require(same_file == args.recover,
|
|
261
|
-
"new submissions need a separate input file; use --recover only with architecture-selection.json")
|
|
262
|
-
decision = load_json(args.decision)
|
|
263
|
-
saved = load_json(confirmation_file) if confirmation_file.exists() else None
|
|
264
|
-
updated, result = apply_selection(plan, decision, saved)
|
|
265
|
-
# Persist confirmation first. If the selected ID changes and the
|
|
266
|
-
# second write fails, read-selection rejects the mismatch.
|
|
267
|
-
if saved != decision:
|
|
268
|
-
save_json(confirmation_file, decision)
|
|
269
|
-
if result["status"] == "applied":
|
|
270
|
-
save_json(args.plan, updated)
|
|
271
|
-
else:
|
|
272
|
-
_, result = read_selection(plan, load_json(args.plan.parent / "architecture-selection.json"))
|
|
273
|
-
print(json.dumps(result, ensure_ascii=False))
|
|
139
|
+
print(json.dumps({"status": "valid", "projectName": plan["projectName"]}, ensure_ascii=False))
|
|
274
140
|
return 0
|
|
275
|
-
except (
|
|
141
|
+
except (OSError, ValueError, RecursionError) as error:
|
|
276
142
|
print(json.dumps({"status": "error", "error": str(error)}, ensure_ascii=False))
|
|
277
143
|
return 1
|
|
278
144
|
|