@harmonyos-arkts/d2h 0.1.19 → 0.1.21
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/README.md +40 -0
- package/agents/android-to-hmos-00-orchestrator.md +3 -315
- package/agents/d2h.md +53 -38
- package/contracts/workflow/artifacts.md +22 -0
- package/contracts/workflow/delivery.md +54 -0
- package/contracts/workflow/dispatch.md +26 -0
- package/contracts/workflow/execution.md +125 -0
- package/contracts/workflow/generation.md +40 -0
- package/contracts/workflow/orchestration.md +49 -0
- package/contracts/workflow/overview.md +58 -0
- package/contracts/workflow/plan.md +119 -0
- package/contracts/workflow/scope.md +151 -0
- package/dist/bin/install-opencode.js +34 -15
- package/dist/src/install-codex.js +131 -0
- package/dist/src/install-opencode.js +5 -5
- package/dist/src/workflow.js +54 -0
- package/docs/architecture-plan-guide.zh-CN.md +70 -0
- package/docs/incremental-spec-evaluation-design.md +670 -0
- package/docs/spec-plan-guide.md +226 -0
- package/docs/workflow-decoupling.md +59 -0
- package/opencode/interactive-tools/advance-progress-demo.mjs +21 -0
- package/opencode/interactive-tools/browser-host.ts +110 -0
- package/opencode/interactive-tools/core.ts +242 -0
- package/opencode/interactive-tools/demo-workflow.ts +55 -0
- package/opencode/interactive-tools/demo.mjs +37 -0
- package/opencode/interactive-tools/package-lock.json +1876 -0
- package/opencode/interactive-tools/package.json +23 -0
- package/opencode/interactive-tools/panel.ts +40 -0
- package/opencode/interactive-tools/prepare-demo.mjs +17 -0
- package/opencode/interactive-tools/prepare-progress-demo.mjs +13 -0
- package/opencode/interactive-tools/progress.ts +118 -0
- package/opencode/interactive-tools/server-legacy.mjs +2 -0
- package/opencode/interactive-tools/server.mjs +40 -0
- package/opencode/interactive-tools/tui.tsx +230 -0
- package/package.json +9 -1
- package/schemas/native-libs-analysis-report.schema.json +695 -0
- package/schemas/native-libs-verify-report.schema.json +566 -0
- package/schemas/spec-native-libs-shard.schema.json +110 -0
- package/skills/arkts-icon-sizing/SKILL.md +0 -10
- package/skills/cpp/d2h-native-library-analyse/SKILL.md +91 -0
- package/skills/cpp/d2h-native-library-analyse/scripts/analyse-android-so.py +630 -0
- package/skills/cpp/d2h-native-library-build/SKILL.md +403 -0
- package/skills/cpp/d2h-native-library-build/references/build-config-mapping.md +180 -0
- package/skills/cpp/d2h-native-library-build/references/jni-to-napi-mapping.md +74 -0
- package/skills/cpp/d2h-native-library-build/scripts/check-android-so.py +580 -0
- package/skills/cpp/d2h-native-library-verify/SKILL.md +111 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-build-sign-run.md +67 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/AppScope/app.json5 +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/AppScope/resources/base/element/string.json +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/README.md +25 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/build-profile.json5 +24 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/build-profile.json5 +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/hvigorfile.ts +2 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/oh-package.json5 +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/ets/entryability/EntryAbility.ets +5 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/ets/pages/Index.ets +3 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/ets/pages/NativeVerifyDemo.ets +276 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/module.json5 +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/resources/base/element/color.json +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/resources/base/element/string.json +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/main/resources/base/profile/main_pages.json +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/ohosTest/ets/test/FFmpegVerify.test.ets +176 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/ohosTest/ets/test/List.test.ets +2 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/entry/src/ohosTest/module.json5 +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/hvigor/hvigor-config.json5 +1 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/hvigorfile.ts +2 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-project-template/oh-package.json5 +9 -0
- package/skills/cpp/d2h-native-library-verify/references/demo-template.md +176 -0
- package/skills/cpp/d2h-native-library-verify/references/hypium-test-template.md +163 -0
- package/skills/cpp/d2h-native-library-verify/scripts/__pycache__/native_common.cpython-313.pyc +0 -0
- package/skills/cpp/d2h-native-library-verify/scripts/device-verify.py +304 -0
- package/skills/cpp/d2h-native-library-verify/scripts/native_common.py +341 -0
- package/skills/cpp/d2h-native-library-verify/scripts/verify-ohos-native.py +1099 -0
- package/skills/d2h-app-solution/SKILL.md +198 -0
- package/skills/d2h-app-solution/agents/openai.yaml +4 -0
- package/skills/d2h-app-solution/references/framework-evidence.md +117 -0
- package/skills/d2h-app-solution/references/hierarchy-rules.md +88 -0
- package/skills/d2h-app-solution/references/inventory-schema.md +305 -0
- package/skills/d2h-app-solution/references/technical-analysis.md +101 -0
- package/skills/d2h-app-solution/scripts/scan_repositories.py +350 -0
- package/skills/d2h-app-solution/scripts/validate_inventory.py +337 -0
- package/skills/d2h-architecture-plan/SKILL.md +35 -0
- package/skills/d2h-architecture-plan/references/directory-creation.md +43 -0
- package/skills/d2h-architecture-plan/references/hybrid-design.md +84 -0
- package/skills/d2h-architecture-plan/references/layout-reference.md +83 -0
- package/skills/d2h-architecture-plan/references/plan-contract.md +51 -0
- package/skills/d2h-architecture-plan/references/source-review.md +50 -0
- package/skills/d2h-cloud-knowledge/SKILL.md +28 -7
- package/skills/d2h-cloud-knowledge/kb_query.mjs +8 -0
- package/skills/d2h-dashboard/SKILL.md +56 -0
- package/skills/d2h-product-family-migration/SKILL.md +103 -0
- package/skills/d2h-product-family-migration/agents/openai.yaml +3 -0
- package/skills/d2h-product-family-migration/assets/incremental-plan.template.md +55 -0
- package/skills/d2h-product-family-migration/references/advanced/behavioral-verification.md +59 -0
- package/skills/d2h-product-family-migration/references/advanced/existing-skill-integration.md +30 -0
- package/skills/d2h-product-family-migration/references/advanced/packaging-and-verification.md +43 -0
- package/skills/d2h-product-family-migration/references/advanced/platform-probes.md +26 -0
- package/skills/d2h-product-family-migration/references/advanced/product-line-design.md +40 -0
- package/skills/d2h-product-family-migration/references/baseline-and-delta.md +50 -0
- package/skills/d2h-product-family-migration/tests/markdown-cases.md +76 -0
- package/skills/{d2h-page-develop → d2h-spec-develop}/SKILL.md +143 -132
- package/skills/d2h-spec-develop/agents/openai.yaml +7 -0
- package/skills/d2h-spec-develop/references/worker-workflow.md +26 -0
- package/skills/{d2h-page-spec-generate → d2h-spec-generate}/SKILL.md +24 -13
- package/skills/d2h-spec-generate/assets/spec-template.md +109 -0
- package/skills/d2h-spec-generate/references/from-plan.md +19 -0
- package/skills/d2h-spec-generate/references/spec-content.md +34 -0
- package/skills/{d2h-page-spec-generate → d2h-spec-generate}/scripts/build_batch_report.py +35 -84
- package/skills/d2h-spec-plan/SKILL.md +38 -0
- package/skills/d2h-spec-plan/agents/openai.yaml +4 -0
- package/skills/d2h-spec-plan/references/architecture-handoff.md +9 -0
- package/skills/d2h-spec-plan/references/plan-contract.md +13 -0
- package/skills/d2h-spec-plan/references/planning-method.md +46 -0
- package/skills/d2h-spec-plan/references/round-scope-assessment.md +49 -0
- package/skills/d2h-spec-plan/references/work-packets.md +61 -0
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +2 -2
- package/skills/d2h-utg-ai-generate/references/page-schema.json +2 -2
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +2 -2
- package/skills/d2h-utg-static-generate/SKILL.md +2 -2
- package/skills/managing-d2h-toolchain/environment-requirements.json +1 -1
- package/skills/managing-d2h-toolchain/toolchain.json +1 -1
- package/skills/preparing-migration-workspace/SKILL.md +32 -2
- package/workflow/__pycache__/artifact_bindings.cpython-313.pyc +0 -0
- package/workflow/__pycache__/spec_runtime.cpython-313.pyc +0 -0
- package/workflow/artifact_bindings.py +224 -0
- package/workflow/assets/dashboard.html +88 -0
- package/workflow/build_batch_report.py +8 -0
- package/workflow/collect_code_metrics.py +75 -0
- package/workflow/dashboard_overview.py +109 -0
- package/workflow/delivery_view.py +82 -0
- package/workflow/feature_plan.py +234 -0
- package/workflow/page_batches.py +223 -0
- package/workflow/spec_generation.py +120 -0
- package/workflow/spec_runtime.py +890 -0
- package/workflow/work_packets.py +85 -0
- package/skills/d2h-page-develop/agents/openai.yaml +0 -7
- package/skills/harmonyos-migration-playbook/SKILL.md +0 -83
- package/skills/harmonyos-migration-playbook/agents/openai.yaml +0 -7
- package/skills/harmonyos-migration-playbook/references/arkts-compile.md +0 -24
- package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +0 -53
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +0 -45
- package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +0 -23
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +0 -53
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +0 -38
- package/skills/native-library-substitution/SKILL.md +0 -385
- package/skills/native-library-substitution/references/native-library-substitution.json +0 -56906
- package/skills/native-library-substitution/references/native-library-substitution.md +0 -163
- package/skills/porting-cmake-native-libraries/SKILL.md +0 -57
- package/skills/porting-cmake-native-libraries/references/platform-adaptation.md +0 -49
- package/skills/porting-cmake-native-libraries/references/report-and-build.md +0 -60
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate a feature planning handoff, without inferring feature semantics."""
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_delivery(data, require):
|
|
11
|
+
"""Optional, explicit scope tiers; scope_items remain the authorization source."""
|
|
12
|
+
delivery = data.get('delivery')
|
|
13
|
+
if delivery is None:
|
|
14
|
+
return
|
|
15
|
+
require(data.get('schema') == 'd2h-feature-plan/v2', 'delivery requires v2 scope tracking')
|
|
16
|
+
if not isinstance(delivery, dict):
|
|
17
|
+
require(False, 'delivery must be an object')
|
|
18
|
+
return
|
|
19
|
+
tiers = {'mvp': 0, 'core': 1, 'full': 2}
|
|
20
|
+
target = delivery.get('target')
|
|
21
|
+
require(isinstance(target, str) and target in tiers, 'delivery target must be mvp/core/full')
|
|
22
|
+
require(type(delivery.get('customized')) is bool, 'delivery customized must be boolean')
|
|
23
|
+
require(isinstance(delivery.get('reason'), str) and bool(delivery['reason'].strip()), 'delivery reason required')
|
|
24
|
+
feature_rows = data.get('features') if isinstance(data.get('features'), list) else []
|
|
25
|
+
items = {(f.get('feature_id'), i.get('scope_id')): (f, i) for f in feature_rows if isinstance(f, dict) and isinstance(f.get('scope_items'), list) for i in f['scope_items'] if isinstance(i, dict) and isinstance(f.get('feature_id'), str) and isinstance(i.get('scope_id'), str)}
|
|
26
|
+
remaining = {key for key, (f, i) in items.items() if f.get('scope_status') != 'excluded' and i.get('status') != 'excluded'}
|
|
27
|
+
rows = delivery.get('scope_layers')
|
|
28
|
+
require(isinstance(rows, list), 'delivery scope_layers must be an array')
|
|
29
|
+
mapping = {}
|
|
30
|
+
for row in rows if isinstance(rows, list) else []:
|
|
31
|
+
if not isinstance(row, dict) or not isinstance(row.get('feature_id'), str) or not isinstance(row.get('scope_id'), str):
|
|
32
|
+
require(False, 'delivery needs feature_id/scope_id references')
|
|
33
|
+
continue
|
|
34
|
+
key = (row['feature_id'], row['scope_id'])
|
|
35
|
+
require(key in items, 'delivery references unknown scope')
|
|
36
|
+
require(key not in mapping, 'delivery scope mapped more than once')
|
|
37
|
+
layer = row.get('layer')
|
|
38
|
+
require(isinstance(layer, str) and layer in tiers, 'delivery layer must be mvp/core/full')
|
|
39
|
+
mapping[key] = layer if isinstance(layer, str) else None
|
|
40
|
+
require(remaining <= mapping.keys(), 'delivery must map every non-excluded scope, including deferred/unresolved')
|
|
41
|
+
if delivery.get('customized') is False and isinstance(target, str) and target in tiers:
|
|
42
|
+
expected = {key for key in remaining if tiers.get(mapping.get(key), 99) <= tiers[delivery['target']]}
|
|
43
|
+
selected = {key for key in remaining if items[key][0].get('scope_status') == 'included' and not items[key][0].get('needs_clarification') and items[key][1].get('status') == 'included' and items[key][1].get('milestone') != 'unassigned'}
|
|
44
|
+
require(expected == selected, 'delivery preset must match authorized scope; use customized=true for local adjustments')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate(data, previous=None, check_analysis=True, require_delivery=False):
|
|
48
|
+
errors = []
|
|
49
|
+
|
|
50
|
+
def require(condition, message):
|
|
51
|
+
if not condition:
|
|
52
|
+
errors.append(message)
|
|
53
|
+
|
|
54
|
+
def text(value):
|
|
55
|
+
return isinstance(value, str) and bool(value.strip())
|
|
56
|
+
|
|
57
|
+
def records(key, id_key):
|
|
58
|
+
items = data.get(key)
|
|
59
|
+
if not isinstance(items, list):
|
|
60
|
+
errors.append(f"{key} must be an array")
|
|
61
|
+
return {}
|
|
62
|
+
result = {}
|
|
63
|
+
for item in items:
|
|
64
|
+
if not isinstance(item, dict) or not text(item.get(id_key)):
|
|
65
|
+
errors.append(f"{key} entries require {id_key}")
|
|
66
|
+
continue
|
|
67
|
+
identity = item[id_key]
|
|
68
|
+
require(identity not in result, f"duplicate {id_key}: {identity}")
|
|
69
|
+
result[identity] = item
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
return ["plan must be an object"]
|
|
74
|
+
require(data.get("schema") in {"d2h-feature-plan/v1", "d2h-feature-plan/v2"}, "unsupported schema")
|
|
75
|
+
revision = data.get("revision")
|
|
76
|
+
require(type(revision) is int and revision > 0, "revision must be a positive integer")
|
|
77
|
+
sources = records("sources", "source_id")
|
|
78
|
+
milestones = records("milestones", "milestone_id")
|
|
79
|
+
features = records("features", "feature_id")
|
|
80
|
+
require("unassigned" not in milestones, "unassigned is a reserved milestone")
|
|
81
|
+
for identity, source in sources.items():
|
|
82
|
+
require(source.get("kind") in {"facts", "utg", "inventory", "source", "user"}, f"{identity}: invalid source kind")
|
|
83
|
+
require(text(source.get("locator")) and text(source.get("revision")), f"{identity}: locator and revision required")
|
|
84
|
+
for identity, milestone in milestones.items():
|
|
85
|
+
require(text(milestone.get("title")), f"{identity}: title required")
|
|
86
|
+
checks = milestone.get("acceptance")
|
|
87
|
+
require(isinstance(checks, list) and bool(checks) and all(text(c) for c in checks), f"{identity}: acceptance required")
|
|
88
|
+
|
|
89
|
+
graph = {}
|
|
90
|
+
for identity, feature in features.items():
|
|
91
|
+
require(text(feature.get("title")), f"{identity}: title required")
|
|
92
|
+
origin = feature.get("origin")
|
|
93
|
+
require(origin in {"extracted", "user", "merged"}, f"{identity}: invalid origin")
|
|
94
|
+
refs = feature.get("source_refs")
|
|
95
|
+
kinds = set()
|
|
96
|
+
require(isinstance(refs, list) and bool(refs), f"{identity}: source_refs required")
|
|
97
|
+
for ref in refs if isinstance(refs, list) else []:
|
|
98
|
+
if not isinstance(ref, dict) or not text(ref.get("ref")) or ref.get("source_id") not in sources:
|
|
99
|
+
errors.append(f"{identity}: invalid source reference")
|
|
100
|
+
continue
|
|
101
|
+
kind = sources[ref["source_id"]].get("kind")
|
|
102
|
+
if isinstance(kind, str):
|
|
103
|
+
kinds.add(kind)
|
|
104
|
+
if origin in {"user", "merged"}:
|
|
105
|
+
require("user" in kinds, f"{identity}: user provenance required")
|
|
106
|
+
if origin in {"extracted", "merged"}:
|
|
107
|
+
require(bool(kinds - {"user"}), f"{identity}: extracted provenance required")
|
|
108
|
+
require(feature.get("user_priority") in {"must", "normal", "later"}, f"{identity}: invalid priority")
|
|
109
|
+
status = feature.get("scope_status")
|
|
110
|
+
require(status in {"included", "deferred", "excluded"}, f"{identity}: invalid scope status")
|
|
111
|
+
if status in {"deferred", "excluded"}:
|
|
112
|
+
require(text(feature.get("reason")), f"{identity}: scope change reason required")
|
|
113
|
+
require(feature.get("milestone") in [*milestones, "unassigned"], f"{identity}: unknown milestone")
|
|
114
|
+
for key in (("required_scope", "acceptance") if data.get("schema") == "d2h-feature-plan/v1" else ("acceptance",)):
|
|
115
|
+
values = feature.get(key)
|
|
116
|
+
require(isinstance(values, list) and bool(values) and all(text(v) for v in values), f"{identity}: {key} must be nonempty strings")
|
|
117
|
+
deferred = feature.get("deferred_scope", [])
|
|
118
|
+
require(isinstance(deferred, list), f"{identity}: deferred_scope must be an array")
|
|
119
|
+
for item in deferred if isinstance(deferred, list) else []:
|
|
120
|
+
require(isinstance(item, dict) and text(item.get("description")) and item.get("target_milestone") in [*milestones, "unassigned"], f"{identity}: invalid deferred scope")
|
|
121
|
+
if data.get("schema") == "d2h-feature-plan/v2":
|
|
122
|
+
items = feature.get("scope_items")
|
|
123
|
+
require(isinstance(items, list) and bool(items), f"{identity}: scope_items required")
|
|
124
|
+
seen = set()
|
|
125
|
+
for item in items if isinstance(items, list) else []:
|
|
126
|
+
if not isinstance(item, dict) or not text(item.get("scope_id")):
|
|
127
|
+
errors.append(f"{identity}: scope_id required")
|
|
128
|
+
continue
|
|
129
|
+
scope_id = item["scope_id"]
|
|
130
|
+
require(scope_id not in seen, f"{identity}: duplicate scope_id {scope_id}")
|
|
131
|
+
seen.add(scope_id)
|
|
132
|
+
require(text(item.get("description")), f"{identity}/{scope_id}: description required")
|
|
133
|
+
require(item.get("status") in {"included", "deferred", "excluded", "unresolved"}, f"{identity}/{scope_id}: invalid scope status")
|
|
134
|
+
require(item.get("milestone") in [*milestones, "unassigned"], f"{identity}/{scope_id}: unknown milestone")
|
|
135
|
+
require(isinstance(item.get("acceptance"), list) and bool(item["acceptance"]) and all(text(v) for v in item["acceptance"]), f"{identity}/{scope_id}: acceptance required")
|
|
136
|
+
if item.get("status") != "included":
|
|
137
|
+
require(text(item.get("reason")), f"{identity}/{scope_id}: disposition reason required")
|
|
138
|
+
require("required_scope" not in feature and "deferred_scope" not in feature, f"{identity}: v2 uses scope_items as sole scope source")
|
|
139
|
+
derived = feature.get("derived_from")
|
|
140
|
+
require(isinstance(derived, list) and all(text(v) and v != identity for v in derived), f"{identity}: invalid derived_from")
|
|
141
|
+
require(type(feature.get("needs_clarification")) is bool, f"{identity}: needs_clarification must be boolean")
|
|
142
|
+
targets = feature.get("requested_before")
|
|
143
|
+
require(isinstance(targets, list), f"{identity}: requested_before must be an array")
|
|
144
|
+
graph[identity] = []
|
|
145
|
+
for target in targets if isinstance(targets, list) else []:
|
|
146
|
+
if not text(target) or target not in features or target == identity:
|
|
147
|
+
errors.append(f"{identity}: invalid requested_before target: {target}")
|
|
148
|
+
continue
|
|
149
|
+
require(feature.get("milestone") == features[target].get("milestone"), f"{identity}: ordering crosses milestones: {target}")
|
|
150
|
+
graph[identity].append(target)
|
|
151
|
+
|
|
152
|
+
validate_delivery(data, require)
|
|
153
|
+
if require_delivery and not errors:
|
|
154
|
+
delivery = data.get('delivery')
|
|
155
|
+
require(isinstance(delivery, dict), 'usable delivery presets require explicit delivery configuration')
|
|
156
|
+
if isinstance(delivery, dict):
|
|
157
|
+
eligible = {(f.get('feature_id'), i.get('scope_id')) for f in features.values() if f.get('scope_status') != 'excluded' for i in f.get('scope_items', []) if i.get('status') != 'excluded'}
|
|
158
|
+
rows = delivery.get('scope_layers')
|
|
159
|
+
for index, target in enumerate(('mvp', 'core', 'full')):
|
|
160
|
+
layers = ('mvp', 'core', 'full')[:index + 1]
|
|
161
|
+
keys = {(r.get('feature_id'), r.get('scope_id')) for r in rows if isinstance(r, dict) and r.get('layer') in layers} if isinstance(rows, list) else set()
|
|
162
|
+
require(bool(keys & eligible), f'{target} preset has no selectable scope; assess scope_layers instead of assigning every item to full')
|
|
163
|
+
|
|
164
|
+
# Iterative topological check supports large inventories without recursion limits.
|
|
165
|
+
indegree = dict.fromkeys(graph, 0)
|
|
166
|
+
for targets in graph.values():
|
|
167
|
+
for target in targets:
|
|
168
|
+
indegree[target] += 1
|
|
169
|
+
ready = [key for key, degree in indegree.items() if degree == 0]
|
|
170
|
+
visited = 0
|
|
171
|
+
while ready:
|
|
172
|
+
visited += 1
|
|
173
|
+
for target in graph[ready.pop()]:
|
|
174
|
+
indegree[target] -= 1
|
|
175
|
+
if indegree[target] == 0:
|
|
176
|
+
ready.append(target)
|
|
177
|
+
require(visited == len(graph), "requested_before contains a cycle")
|
|
178
|
+
changes = data.get("changes")
|
|
179
|
+
require(isinstance(changes, list), "changes must be an array")
|
|
180
|
+
for change in changes if isinstance(changes, list) else []:
|
|
181
|
+
if not isinstance(change, dict):
|
|
182
|
+
errors.append("invalid change record")
|
|
183
|
+
continue
|
|
184
|
+
number = change.get("revision")
|
|
185
|
+
require(type(number) is int and type(revision) is int and 1 <= number <= revision, "invalid change revision")
|
|
186
|
+
require(change.get("actor") in {"user", "ai", "import"} and text(change.get("summary")), "change actor and summary required")
|
|
187
|
+
ids = change.get("feature_ids")
|
|
188
|
+
require(isinstance(ids, list) and all(text(v) and v in features for v in ids), "change references unknown features")
|
|
189
|
+
require(isinstance(data.get("open_questions"), list), "open_questions must be an array")
|
|
190
|
+
if previous is not None:
|
|
191
|
+
if not isinstance(previous, dict) or (previous.get("schema"), data.get("schema")) not in {("d2h-feature-plan/v1", "d2h-feature-plan/v1"), ("d2h-feature-plan/v1", "d2h-feature-plan/v2"), ("d2h-feature-plan/v2", "d2h-feature-plan/v2")}:
|
|
192
|
+
errors.append("unsupported feature schema transition")
|
|
193
|
+
else:
|
|
194
|
+
old_revision = previous.get("revision")
|
|
195
|
+
if data != previous:
|
|
196
|
+
require(type(revision) is int and type(old_revision) is int and revision == old_revision + 1, "changed plan must increment revision by one")
|
|
197
|
+
require(isinstance(changes, list) and any(isinstance(c, dict) and c.get("revision") == revision for c in changes), "changed plan needs a current revision event")
|
|
198
|
+
old_changes = previous.get("changes", [])
|
|
199
|
+
require(isinstance(changes, list) and changes[:len(old_changes)] == old_changes, "previous change history must be preserved")
|
|
200
|
+
for feature in features.values():
|
|
201
|
+
old = next((f for f in previous.get("features", []) if f.get("feature_id") == feature["feature_id"]), {})
|
|
202
|
+
old_scope = {v["scope_id"] for v in old.get("scope_items", [])}
|
|
203
|
+
new_scope = {v["scope_id"] for v in feature.get("scope_items", [])}
|
|
204
|
+
require(old_scope <= new_scope, "previous scope IDs must be retained; exclude replaced scope items")
|
|
205
|
+
old_ids = {f["feature_id"] for f in previous.get("features", []) if isinstance(f, dict) and text(f.get("feature_id"))}
|
|
206
|
+
require(old_ids <= features.keys(), "previous feature IDs must be retained; exclude replaced features")
|
|
207
|
+
if not errors and check_analysis:
|
|
208
|
+
loader = importlib.util.spec_from_file_location('analysis_handoff', Path(__file__).with_name('artifact_bindings.py'))
|
|
209
|
+
handoff = importlib.util.module_from_spec(loader)
|
|
210
|
+
loader.loader.exec_module(handoff)
|
|
211
|
+
errors.extend(handoff.validate_links(data))
|
|
212
|
+
return errors
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main():
|
|
216
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
217
|
+
parser.add_argument("plan", type=Path)
|
|
218
|
+
parser.add_argument("--previous", type=Path)
|
|
219
|
+
parser.add_argument("--require-delivery", action="store_true", help="Require explicit, nonempty MVP/core/full cumulative presets")
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
try:
|
|
222
|
+
data = json.loads(args.plan.read_text(encoding="utf-8"))
|
|
223
|
+
previous = json.loads(args.previous.read_text(encoding="utf-8")) if args.previous else None
|
|
224
|
+
errors = validate(data, previous, require_delivery=args.require_delivery)
|
|
225
|
+
except (OSError, ValueError) as error:
|
|
226
|
+
errors = [str(error)]
|
|
227
|
+
for error in errors:
|
|
228
|
+
print(f"ERROR: {error}")
|
|
229
|
+
print(json.dumps({"errors": len(errors)}, ensure_ascii=False))
|
|
230
|
+
return bool(errors)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Explicit page-round validation and bounded batch recommendations; no source inference."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def require(value, message):
|
|
7
|
+
if not value:
|
|
8
|
+
raise ValueError(message)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def text(value):
|
|
12
|
+
return isinstance(value, str) and bool(value.strip())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def records(items, key):
|
|
16
|
+
require(isinstance(items, list) and bool(items), f'{key}: nonempty records required')
|
|
17
|
+
result = {}
|
|
18
|
+
for item in items:
|
|
19
|
+
require(isinstance(item, dict) and text(item.get(key)) and item[key] not in result, f'{key}: missing or duplicate ID')
|
|
20
|
+
result[item[key]] = item
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def enabled(plan):
|
|
25
|
+
return plan.get('dispatch_mode', 'single-spec') == 'page-batches'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate(plan, features, specs, edges):
|
|
29
|
+
mode = plan.get('dispatch_mode', 'single-spec')
|
|
30
|
+
require(mode in {'single-spec', 'page-batches'}, 'unknown dispatch_mode')
|
|
31
|
+
if mode == 'single-spec':
|
|
32
|
+
require('rounds' not in plan and 'page_batches' not in plan, 'rounds require explicit page-batches dispatch_mode')
|
|
33
|
+
return
|
|
34
|
+
rounds = records(plan.get('rounds'), 'round_id')
|
|
35
|
+
batches = records(plan.get('page_batches'), 'batch_id')
|
|
36
|
+
milestones = [m['milestone_id'] for m in features['milestones']]
|
|
37
|
+
milestone_order = []
|
|
38
|
+
for index, item in enumerate(rounds.values(), 1):
|
|
39
|
+
require(type(item.get('order')) is int and item['order'] == index, 'round order must be consecutive and match array order')
|
|
40
|
+
require(item.get('kind') in {'baseline', 'incremental', 'completion'}, 'unknown round kind')
|
|
41
|
+
require(text(item.get('goal')) and text(item.get('rationale')), 'round goal and rationale required')
|
|
42
|
+
require(item.get('milestone') in milestones, 'round has unknown milestone')
|
|
43
|
+
milestone_order.append(milestones.index(item['milestone']))
|
|
44
|
+
checks = item.get('goal_checks')
|
|
45
|
+
require(isinstance(checks, list) and bool(checks) and all(text(c) for c in checks) and len(set(checks)) == len(checks), 'round goal_checks required and unique')
|
|
46
|
+
require(milestone_order == sorted(milestone_order), 'rounds cannot reverse milestone order')
|
|
47
|
+
for milestone in {r['milestone'] for r in rounds.values()}:
|
|
48
|
+
group = [r for r in rounds.values() if r['milestone'] == milestone]
|
|
49
|
+
require(group[0]['kind'] == 'baseline' and sum(r['kind'] == 'baseline' for r in group) == 1, 'each milestone starts with exactly one baseline round')
|
|
50
|
+
require(all(r['kind'] != 'completion' for r in group[:-1]), 'completion round must be last in its milestone')
|
|
51
|
+
pages = {p['page_id'] for p in plan['pages']}
|
|
52
|
+
owner = {}
|
|
53
|
+
for bid, batch in batches.items():
|
|
54
|
+
require(batch.get('round_id') in rounds and batch.get('page_id') in pages, f'{bid}: unknown round or page')
|
|
55
|
+
require(text(batch.get('goal')) and text(batch.get('rationale')), f'{bid}: batch goal and grouping rationale required')
|
|
56
|
+
members = batch.get('spec_ids')
|
|
57
|
+
require(isinstance(members, list) and bool(members) and all(text(s) for s in members) and len(set(members)) == len(members), f'{bid}: unique spec_ids required')
|
|
58
|
+
require(type(batch.get('max_specs')) is int and batch['max_specs'] > 0 and len(members) <= batch['max_specs'], f'{bid}: spec count exceeds batch budget')
|
|
59
|
+
for key in ('max_effort_hours', 'opportunistic_hours'):
|
|
60
|
+
value = batch.get(key)
|
|
61
|
+
require(type(value) in (int, float) and value >= 0 and value < float('inf'), f'{bid}: invalid {key}')
|
|
62
|
+
require(batch['max_effort_hours'] > 0 and batch['opportunistic_hours'] <= batch['max_effort_hours'], f'{bid}: invalid effort budget')
|
|
63
|
+
for sid in members:
|
|
64
|
+
require(sid in specs and sid not in owner, f'{bid}: unknown or multiply assigned Spec: {sid}')
|
|
65
|
+
require(specs[sid]['page_id'] == batch['page_id'], f'{bid}: cross-page grouping is not allowed')
|
|
66
|
+
require(specs[sid]['milestone'] == rounds[batch['round_id']]['milestone'], f'{bid}: round milestone mismatch')
|
|
67
|
+
owner[sid] = bid
|
|
68
|
+
require(sum(specs[s]['effort']['expected'] for s in members) <= batch['max_effort_hours'], f'{bid}: effort exceeds batch budget; split the page pass')
|
|
69
|
+
require(owner.keys() == specs.keys(), 'every Spec must belong to exactly one page batch')
|
|
70
|
+
for rid, item in rounds.items():
|
|
71
|
+
members = {s for s, bid in owner.items() if batches[bid]['round_id'] == rid}
|
|
72
|
+
require(bool(members), f'{rid}: empty round')
|
|
73
|
+
checks = {c for sid in members for c in specs[sid]['checks']}
|
|
74
|
+
require(set(item['goal_checks']) <= checks, f'{rid}: goal checks must map to member Specs')
|
|
75
|
+
batch_edges = {bid: set() for bid in batches}
|
|
76
|
+
for sid, parents in edges.items():
|
|
77
|
+
consumer = owner[sid]
|
|
78
|
+
consumer_round = rounds[batches[consumer]['round_id']]['order']
|
|
79
|
+
for parent in parents:
|
|
80
|
+
provider = owner[parent]
|
|
81
|
+
require(rounds[batches[provider]['round_id']]['order'] <= consumer_round, f'{sid}: dependency/order requires a later round')
|
|
82
|
+
if provider != consumer:
|
|
83
|
+
batch_edges[consumer].add(provider)
|
|
84
|
+
remaining = set(batches)
|
|
85
|
+
while remaining:
|
|
86
|
+
layer = {b for b in remaining if not batch_edges[b] & remaining}
|
|
87
|
+
require(layer, 'page batch contraction cycle; split interleaved page work')
|
|
88
|
+
remaining -= layer
|
|
89
|
+
for page in plan['pages']:
|
|
90
|
+
require(type(page.get('scope_complete')) is bool, 'page scope_complete coverage declaration required')
|
|
91
|
+
if page['scope_complete']:
|
|
92
|
+
refs = page.get('scope_evidence_refs')
|
|
93
|
+
require(isinstance(refs, list) and bool(refs) and all(text(ref) for ref in refs), 'complete page scope requires coverage evidence refs')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def round_signature(plan, features, round_id, edges):
|
|
97
|
+
specs = {s['spec_id']: s for s in plan['specs']}
|
|
98
|
+
item = next(r for r in plan['rounds'] if r['round_id'] == round_id)
|
|
99
|
+
members = {sid for b in plan['page_batches'] if b['round_id'] == round_id for sid in b['spec_ids']}
|
|
100
|
+
closure, pending = set(members), list(members)
|
|
101
|
+
while pending:
|
|
102
|
+
for parent in edges[pending.pop()]:
|
|
103
|
+
if parent not in closure:
|
|
104
|
+
closure.add(parent)
|
|
105
|
+
pending.append(parent)
|
|
106
|
+
semantic = ('page_id', 'feature_refs', 'scope', 'evidence_refs', 'requires', 'provides', 'checks')
|
|
107
|
+
needed_features = {f for sid in closure for f in specs[sid]['feature_refs']}
|
|
108
|
+
payload = {'round': {k: item[k] for k in ('round_id', 'kind', 'milestone', 'goal', 'goal_checks')}, 'members': sorted(members), 'specs': [{k: specs[sid][k] for k in ('spec_id', *semantic)} for sid in sorted(closure)], 'features': [{k: feature.get(k) for k in ('feature_id', 'required_scope', 'acceptance', 'source_refs', 'origin')} for feature in sorted(features['features'], key=lambda f: f['feature_id']) if feature['feature_id'] in needed_features]}
|
|
109
|
+
if features['schema'] == 'd2h-feature-plan/v2':
|
|
110
|
+
payload['scope_refs'] = {sid: specs[sid].get('scope_refs') for sid in sorted(closure)}
|
|
111
|
+
keys = {(ref['feature_id'], ref['scope_id']) for sid in closure for ref in specs[sid].get('scope_refs', [])}
|
|
112
|
+
payload['scope_items'] = [{'feature_id': f['feature_id'], **item} for f in features['features'] for item in f['scope_items'] if (f['feature_id'], item['scope_id']) in keys]
|
|
113
|
+
if plan.get('work_packet_policy') == 'required-v1':
|
|
114
|
+
payload['work_packets'] = {sid: {'brief': specs[sid].get('implementation_brief'), 'layers': specs[sid]['testing'].get('layers')} for sid in sorted(closure)}
|
|
115
|
+
payload['shared_contracts'] = [c for c in plan.get('shared_contracts', []) if set([c['producer_spec_id'], *c['consumer_spec_ids']]) & closure]
|
|
116
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def relevant_specs(plan, initial, edges):
|
|
120
|
+
"""A required round goal cannot be delivered with only half its membership."""
|
|
121
|
+
if not enabled(plan):
|
|
122
|
+
return initial
|
|
123
|
+
rounds = {r['round_id']: r for r in plan['rounds']}
|
|
124
|
+
result = set(initial)
|
|
125
|
+
while True:
|
|
126
|
+
before = set(result)
|
|
127
|
+
touched = {b['round_id'] for b in plan['page_batches'] if set(b['spec_ids']) & result}
|
|
128
|
+
required_rounds = {rid for rid, item in rounds.items() if any(item['milestone'] == rounds[target]['milestone'] and item['order'] <= rounds[target]['order'] for target in touched)}
|
|
129
|
+
result.update(s for b in plan['page_batches'] if b['round_id'] in required_rounds for s in b['spec_ids'])
|
|
130
|
+
result.update(parent for sid in list(result) for parent in edges[sid])
|
|
131
|
+
if result == before:
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def batch_sequence(plan, edges):
|
|
136
|
+
batches = {b['batch_id']: b for b in plan['page_batches']}
|
|
137
|
+
owner = {sid: bid for bid, b in batches.items() for sid in b['spec_ids']}
|
|
138
|
+
result = []
|
|
139
|
+
for round_plan in plan['rounds']:
|
|
140
|
+
remaining = {bid for bid, b in batches.items() if b['round_id'] == round_plan['round_id']}
|
|
141
|
+
while remaining:
|
|
142
|
+
layer = sorted(bid for bid in remaining if not any(owner[parent] in remaining and owner[parent] != bid for sid in batches[bid]['spec_ids'] for parent in edges[sid]))
|
|
143
|
+
require(layer, 'invalid page batch order')
|
|
144
|
+
result.extend(layer)
|
|
145
|
+
remaining.difference_update(layer)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def schedule(plan, features, state, view, edges, evidence_valid):
|
|
150
|
+
if not enabled(plan):
|
|
151
|
+
return view
|
|
152
|
+
specs = {s['spec_id']: s for s in plan['specs']}
|
|
153
|
+
rounds = {r['round_id']: r for r in plan['rounds']}
|
|
154
|
+
batches = {b['batch_id']: b for b in plan['page_batches']}
|
|
155
|
+
batch_order = {bid: index for index, bid in enumerate(batch_sequence(plan, edges))}
|
|
156
|
+
rows = view['tasks']
|
|
157
|
+
relevant = set(view['relevant_specs'])
|
|
158
|
+
round_rows = {}
|
|
159
|
+
for rid, item in rounds.items():
|
|
160
|
+
members = sorted(s for b in batches.values() if b['round_id'] == rid for s in b['spec_ids'])
|
|
161
|
+
signature = round_signature(plan, features, rid, edges)
|
|
162
|
+
proofs = [p for p in state.get('round_deliveries', []) if p['round_id'] == rid and p['signature'] == signature and evidence_valid(p['evidence'], p['candidate'])]
|
|
163
|
+
current = any(p['candidate'] == state['candidate'] for p in proofs) and all(rows[s]['accepted'] for s in members)
|
|
164
|
+
round_rows[rid] = {'order': item['order'], 'kind': item['kind'], 'goal': item['goal'], 'milestone': item['milestone'], 'spec_ids': members, 'delivered': bool(proofs), 'current_accepted': current, 'relevant': bool(set(members) & relevant)}
|
|
165
|
+
current_round = next((rid for rid, item in round_rows.items() if item['relevant'] and not item['delivered']), None)
|
|
166
|
+
active = state.get('active_batch')
|
|
167
|
+
if active:
|
|
168
|
+
require(active['batch_id'] in batches and set(active['spec_ids']) <= set(batches[active['batch_id']]['spec_ids']), 'active batch no longer matches plan; pause before revision')
|
|
169
|
+
old_ready = list(view['ready'])
|
|
170
|
+
stage_ready = [s for s in old_ready if any(b['round_id'] == current_round and s in b['spec_ids'] for b in batches.values())]
|
|
171
|
+
ranks = {'later': 0, 'normal': 1, 'must': 2}
|
|
172
|
+
last_page = state.get('batch_runs', [{}])[-1].get('page_id') if state.get('batch_runs') else None
|
|
173
|
+
batch_rows = {}
|
|
174
|
+
for bid, batch in batches.items():
|
|
175
|
+
members = batch['spec_ids']
|
|
176
|
+
potential = {s for s in members if state['tasks'][s]['implementation'] == 'planned' and not rows[s]['external_blockers'] and not rows[s].get('readiness_gaps') and s in relevant}
|
|
177
|
+
# Internal unverified prerequisites can be prepared in the same lease, but
|
|
178
|
+
# a blocked external dependency must never be hidden by grouping.
|
|
179
|
+
while True:
|
|
180
|
+
kept = {s for s in potential if set(rows[s]['blocked_by']) <= potential}
|
|
181
|
+
if kept == potential:
|
|
182
|
+
break
|
|
183
|
+
potential = kept
|
|
184
|
+
rank = max((ranks[rows[s]['effective_priority']] for s in potential), default=-1)
|
|
185
|
+
core = {s for s in potential if ranks[rows[s]['effective_priority']] == rank}
|
|
186
|
+
optional = potential - core
|
|
187
|
+
outside = [s for s in stage_ready if s not in members]
|
|
188
|
+
optional_hours = sum(specs[s]['effort']['expected'] for s in optional)
|
|
189
|
+
min_optional_rank = min((ranks[rows[s]['effective_priority']] for s in optional), default=rank)
|
|
190
|
+
higher_outside = any(ranks[rows[s]['effective_priority']] > min_optional_rank for s in outside)
|
|
191
|
+
selected = core | (optional if optional_hours <= batch['opportunistic_hours'] and not higher_outside else set())
|
|
192
|
+
# Keep deterministic dependency order within the fixed dispatch window.
|
|
193
|
+
selected_order, remaining = [], set(selected)
|
|
194
|
+
while remaining:
|
|
195
|
+
layer = sorted((s for s in remaining if not set(edges[s]) & remaining), key=lambda s: (-ranks[rows[s]['effective_priority']], s))
|
|
196
|
+
require(layer, 'invalid internal batch order')
|
|
197
|
+
selected_order.extend(layer)
|
|
198
|
+
remaining.difference_update(layer)
|
|
199
|
+
eligible = bool(set(selected_order) & set(stage_ready)) and batch['round_id'] == current_round and not active and not view['running'] and not view['validation_debt']['backpressure']
|
|
200
|
+
highest = [s for s in selected_order if ranks[rows[s]['effective_priority']] == rank]
|
|
201
|
+
critical = max((view['remaining_path_hours'][s] for s in highest), default=0)
|
|
202
|
+
shared = max((len(rows[s]['inherited_from']) for s in highest), default=0)
|
|
203
|
+
effort = sum(specs[s]['effort']['expected'] for s in selected_order)
|
|
204
|
+
score = sum(rows[s]['value_score'] for s in selected_order) / effort if effort else 0
|
|
205
|
+
batch_rows[bid] = {'page_id': batch['page_id'], 'round_id': batch['round_id'], 'goal': batch['goal'], 'spec_ids': list(members), 'dispatchable_spec_ids': selected_order, 'deferred_spec_ids': sorted(potential - set(selected_order)), 'eligible': eligible, 'accepted': all(rows[s]['accepted'] for s in members), 'implemented': all(state['tasks'][s]['implementation'] == 'implemented' for s in members), 'blocked_by': sorted({p for s in members for p in rows[s]['blocked_by'] if p not in members}), 'external_blockers': sorted({p for s in members for p in rows[s]['external_blockers']}), 'effective_priority_rank': rank, 'critical_hours': critical, 'shared_unlocks': shared, 'page_affinity': int(batch['page_id'] == last_page), 'priority_index': score, 'estimated_worker_hours': effort, 'planned_worker_hours': sum(specs[s]['effort']['expected'] for s in members)}
|
|
206
|
+
ordered_batches = [bid for bid, item in batch_rows.items() if item['eligible']]
|
|
207
|
+
ordered_batches.sort(key=lambda b: (-batch_rows[b]['effective_priority_rank'], -batch_rows[b]['critical_hours'], -batch_rows[b]['shared_unlocks'], -batch_rows[b]['page_affinity'], -batch_rows[b]['priority_index'], b))
|
|
208
|
+
page_rows = {}
|
|
209
|
+
for page in plan['pages']:
|
|
210
|
+
pid = page['page_id']
|
|
211
|
+
passes = [b for b in batches.values() if b['page_id'] == pid]
|
|
212
|
+
passes.sort(key=lambda b: batch_order[b['batch_id']])
|
|
213
|
+
first_order = rounds[passes[0]['round_id']]['order'] if passes else None
|
|
214
|
+
baseline_specs = {s for b in passes if rounds[b['round_id']]['order'] == first_order for s in b['spec_ids']}
|
|
215
|
+
all_specs = {s for b in passes for s in b['spec_ids']}
|
|
216
|
+
page_rows[pid] = {'scope_complete': page['scope_complete'], 'baseline_verified': bool(baseline_specs) and all(rows[s]['accepted'] for s in baseline_specs), 'complete': page['scope_complete'] and bool(all_specs) and all(rows[s]['accepted'] for s in all_specs), 'passes': [{'page_pass': i, 'round_id': b['round_id'], 'batch_id': b['batch_id'], 'spec_ids': list(b['spec_ids']), 'accepted': batch_rows[b['batch_id']]['accepted']} for i, b in enumerate(passes, 1)]}
|
|
217
|
+
view.update(rounds=round_rows, current_round=current_round, page_batches=batch_rows, ready_batches=ordered_batches, active_batch=active, pages=page_rows, ready_specs=stage_ready)
|
|
218
|
+
runs = state.get('batch_runs', [])
|
|
219
|
+
view['page_dispatch_metrics'] = {'finished_windows': sum(r['result'] == 'completed' for r in runs), 'paused_windows': sum(r['result'] == 'paused' for r in runs), 'page_switches': sum(a['page_id'] != b['page_id'] for a, b in zip(runs, runs[1:])), 'average_specs_per_window': sum(len(r['spec_ids']) for r in runs) / len(runs) if runs else None}
|
|
220
|
+
view['ready'] = [s for s in stage_ready if active and s in active['spec_ids']]
|
|
221
|
+
relevant_rounds = [r for r in round_rows.values() if r['relevant']]
|
|
222
|
+
view['milestone_accepted'] = view['milestone_accepted'] and all(r['current_accepted'] for r in relevant_rounds)
|
|
223
|
+
return view
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Bind selected, detailed Spec blocks to per-member planning inputs (not acceptance)."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
POLICY = 'required-v1'
|
|
8
|
+
SCHEMA = 'd2h-spec-generation/v1'
|
|
9
|
+
HEADINGS = ('页面范围', '开发顺序', '页面功能', 'UI 操作与验收', 'Android 证据', 'HarmonyOS 实现范围', '未决项与平台差异')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def digest(value):
|
|
13
|
+
return hashlib.sha256(value.encode('utf-8')).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical(value):
|
|
17
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def signature(plan, features, spec):
|
|
21
|
+
page = next(p for p in plan['pages'] if p['page_id'] == spec['page_id'])
|
|
22
|
+
feature_map = {f['feature_id']: f for f in features['features']}
|
|
23
|
+
scopes = []
|
|
24
|
+
for ref in spec.get('scope_refs', []):
|
|
25
|
+
feature = feature_map[ref['feature_id']]
|
|
26
|
+
scopes.append({'feature_id': feature['feature_id'],
|
|
27
|
+
'scope': next(s for s in feature['scope_items'] if s['scope_id'] == ref['scope_id']),
|
|
28
|
+
'source': feature.get('source'), 'origin': feature.get('origin'), 'source_refs': feature.get('source_refs'),
|
|
29
|
+
'acceptance': feature.get('acceptance'), 'scope_status': feature['scope_status'],
|
|
30
|
+
'needs_clarification': feature['needs_clarification']})
|
|
31
|
+
if not scopes:
|
|
32
|
+
scopes = [feature_map[f] for f in spec['feature_refs']]
|
|
33
|
+
contracts = [c for c in plan.get('shared_contracts', []) if spec['spec_id'] in [c['producer_spec_id'], *c['consumer_spec_ids']]]
|
|
34
|
+
# Global revisions and unrelated scopes are deliberately excluded; actual member changes are not.
|
|
35
|
+
semantic_spec = {k: spec.get(k) for k in ('spec_id', 'page_id', 'feature_refs', 'scope_refs', 'scope', 'evidence_refs', 'requires', 'provides', 'checks', 'implementation_brief', 'allocation')}
|
|
36
|
+
semantic_spec['testing'] = {k: v for k, v in spec['testing'].items() if k != 'estimated_hours'}
|
|
37
|
+
return digest(canonical({'spec': semantic_spec, 'package': page['package'], 'scopes': scopes,
|
|
38
|
+
'shared_contracts': contracts, 'work_packet_policy': plan.get('work_packet_policy')}))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def document(plan, root, spec):
|
|
42
|
+
if root is None:
|
|
43
|
+
raise ValueError('generation root required')
|
|
44
|
+
root = Path(root).resolve()
|
|
45
|
+
page = next(p for p in plan['pages'] if p['page_id'] == spec['page_id'])
|
|
46
|
+
directory = root / page['package']
|
|
47
|
+
file = directory / 'spec.md'
|
|
48
|
+
if directory.is_symlink() or file.is_symlink() or not file.is_file() or file.resolve().parent != directory:
|
|
49
|
+
raise ValueError('local detailed spec.md required')
|
|
50
|
+
text = file.read_text(encoding='utf-8')
|
|
51
|
+
sid = spec['spec_id']
|
|
52
|
+
start, end = f'<!-- d2h-spec:start {sid} -->', f'<!-- d2h-spec:end {sid} -->'
|
|
53
|
+
if text.count(start) != 1 or text.count(end) != 1 or text.index(start) >= text.index(end):
|
|
54
|
+
raise ValueError('unique ordered Spec block markers required')
|
|
55
|
+
body = text.split(start, 1)[1].split(end, 1)[0]
|
|
56
|
+
if '<!-- d2h-spec:' in body:
|
|
57
|
+
raise ValueError('Spec blocks cannot be nested')
|
|
58
|
+
if not all(check in body for check in spec['checks']):
|
|
59
|
+
raise ValueError('all planned check IDs must occur in detailed block')
|
|
60
|
+
pattern = re.compile(r'<!-- d2h-spec:start ([^\r\n]+?) -->.*?<!-- d2h-spec:end \1 -->', re.S)
|
|
61
|
+
members = pattern.findall(text)
|
|
62
|
+
context = pattern.sub('', text)
|
|
63
|
+
if len(set(members)) != len(members) or '<!-- d2h-spec:' in context:
|
|
64
|
+
raise ValueError('all member blocks must have unique, ordered matching boundaries')
|
|
65
|
+
# All seven shared sections remain in the original layout, including sections after UI checks.
|
|
66
|
+
# Adding another member may add empty separator lines, not change shared behavior.
|
|
67
|
+
context = '\n'.join(line.rstrip() for line in context.splitlines() if line.strip())
|
|
68
|
+
if not all('## ' + heading in context for heading in HEADINGS):
|
|
69
|
+
raise ValueError('seven shared Spec sections required outside member blocks')
|
|
70
|
+
return {'context_sha256': digest(context), 'body_sha256': digest(body)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def manifest(root):
|
|
74
|
+
path = Path(root) / 'generation.json'
|
|
75
|
+
if path.is_symlink():
|
|
76
|
+
raise ValueError('generation manifest cannot be linked')
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return {'schema': SCHEMA, 'specs': {}}
|
|
79
|
+
data = json.loads(path.read_text(encoding='utf-8'))
|
|
80
|
+
if not isinstance(data, dict) or data.get('schema') != SCHEMA or not isinstance(data.get('specs'), dict):
|
|
81
|
+
raise ValueError('generation manifest schema mismatch')
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def gaps(plan, features, spec, root):
|
|
86
|
+
if plan.get('spec_generation_policy') != POLICY:
|
|
87
|
+
return []
|
|
88
|
+
try:
|
|
89
|
+
if root is None:
|
|
90
|
+
raise ValueError('generation root required')
|
|
91
|
+
recorded = manifest(root)['specs'].get(spec['spec_id'], {})
|
|
92
|
+
if not isinstance(recorded, dict):
|
|
93
|
+
raise ValueError('generation entry must be an object')
|
|
94
|
+
current = {**document(plan, root, spec), 'input_sha256': signature(plan, features, spec)}
|
|
95
|
+
if any(recorded.get(key) != value for key, value in current.items()):
|
|
96
|
+
raise ValueError('detailed Spec absent, changed or bound to stale planning inputs')
|
|
97
|
+
return []
|
|
98
|
+
except (OSError, ValueError, KeyError, TypeError, StopIteration) as error:
|
|
99
|
+
return ['spec generation: ' + str(error)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def record(plan, features, root, spec_ids, expected_hash):
|
|
103
|
+
if plan.get('spec_generation_policy') != POLICY:
|
|
104
|
+
raise ValueError('generation requires explicit spec_generation_policy=required-v1')
|
|
105
|
+
if expected_hash != digest(canonical(plan)):
|
|
106
|
+
raise ValueError('generation plan hash mismatch; re-read selected revision')
|
|
107
|
+
specs = {s['spec_id']: s for s in plan['specs']}
|
|
108
|
+
if not spec_ids or len(set(spec_ids)) != len(spec_ids) or not set(spec_ids) <= specs.keys():
|
|
109
|
+
raise ValueError('explicit unique known spec IDs required')
|
|
110
|
+
result = manifest(root)
|
|
111
|
+
for sid in spec_ids:
|
|
112
|
+
result['specs'][sid] = {**document(plan, root, specs[sid]),
|
|
113
|
+
'input_sha256': signature(plan, features, specs[sid]),
|
|
114
|
+
'plan_revision': plan['revision'], 'plan_sha256': expected_hash}
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def proof(root, sid):
|
|
119
|
+
entry = manifest(root)['specs'][sid]
|
|
120
|
+
return {key: entry[key] for key in ('input_sha256', 'context_sha256', 'body_sha256')}
|