@harmonyos-arkts/d2h 0.1.20 → 0.1.22
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-spec-develop/SKILL.md +41 -0
- 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/SKILL.md +0 -126
- 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,109 @@
|
|
|
1
|
+
"""Validate optional report inputs and project AppGraph notifications without changing execution."""
|
|
2
|
+
import copy
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require(value, message):
|
|
11
|
+
if not value:
|
|
12
|
+
raise ValueError(message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def prepare(data, candidate):
|
|
16
|
+
if data is None:
|
|
17
|
+
return {'available': False, 'stale': False, 'code': None, 'tech_stack': [], 'dependencies': [], 'tools': []}
|
|
18
|
+
require(isinstance(data, dict) and data.get('schema') == 'd2h-overview/v1', 'd2h-overview/v1 required')
|
|
19
|
+
timestamp = datetime.fromisoformat(data['generated_at'].replace('Z', '+00:00'))
|
|
20
|
+
require(timestamp.tzinfo is not None, 'overview timestamp needs timezone')
|
|
21
|
+
sources = data.get('sources')
|
|
22
|
+
require(isinstance(sources, list), 'overview sources required')
|
|
23
|
+
checks, identities = [], set()
|
|
24
|
+
for source in sources:
|
|
25
|
+
require(isinstance(source, dict) and isinstance(source.get('source_id'), str) and bool(source['source_id']) and source['source_id'] not in identities, 'unique overview source_id required')
|
|
26
|
+
identities.add(source['source_id'])
|
|
27
|
+
path = Path(source['path'])
|
|
28
|
+
require(path.is_absolute() and isinstance(source.get('sha256'), str) and len(source['sha256']) == 64 and all(c in '0123456789abcdef' for c in source['sha256']), 'overview absolute source path and sha256 required')
|
|
29
|
+
try:
|
|
30
|
+
status = 'current' if hashlib.sha256(path.read_bytes()).hexdigest() == source['sha256'] else 'changed'
|
|
31
|
+
except OSError:
|
|
32
|
+
status = 'missing'
|
|
33
|
+
checks.append({'source_id': source['source_id'], 'status': status})
|
|
34
|
+
|
|
35
|
+
def refs(row):
|
|
36
|
+
values = row.get('source_refs')
|
|
37
|
+
require(isinstance(values, list) and bool(values) and all(isinstance(v, str) and v in identities for v in values), 'overview item needs known source_refs')
|
|
38
|
+
|
|
39
|
+
code = data.get('code')
|
|
40
|
+
if code is not None:
|
|
41
|
+
require(isinstance(code, dict), 'overview code must be an object')
|
|
42
|
+
refs(code)
|
|
43
|
+
for key in ('files', 'lines'):
|
|
44
|
+
require(code.get(key) is None or type(code[key]) is int and code[key] >= 0, 'nonnegative code counts or null required')
|
|
45
|
+
require(isinstance(code.get('method'), str) and bool(code['method'].strip()), 'code counting method required')
|
|
46
|
+
require(isinstance(code.get('languages', []), list), 'code languages must be an array')
|
|
47
|
+
for row in code.get('languages', []):
|
|
48
|
+
require(isinstance(row, dict) and isinstance(row.get('name'), str), 'language name required')
|
|
49
|
+
require(all(type(row.get(k)) is int and row[k] >= 0 for k in ('files', 'lines')), 'language file/line counts required')
|
|
50
|
+
for group in ('tech_stack', 'dependencies', 'tools', 'blockers'):
|
|
51
|
+
rows = data.get(group, [])
|
|
52
|
+
require(isinstance(rows, list), f'overview {group} must be an array')
|
|
53
|
+
for row in rows:
|
|
54
|
+
require(isinstance(row, dict) and isinstance(row.get('name'), str) and bool(row['name'].strip()), f'{group} item name required')
|
|
55
|
+
refs(row)
|
|
56
|
+
if group == 'blockers':
|
|
57
|
+
require(row.get('status') in ('open', 'resolved'), 'blocker status must be open/resolved')
|
|
58
|
+
require(isinstance(row.get('reason'), str) and bool(row['reason'].strip()), 'blocker reason required')
|
|
59
|
+
if group == 'dependencies':
|
|
60
|
+
require('blocking' not in row or type(row['blocking']) is bool, 'dependency blocking must be explicit boolean')
|
|
61
|
+
require(row.get('kind') in ('sdk', 'so', 'library'), 'dependency kind must be sdk/so/library')
|
|
62
|
+
require(row.get('provision') in ('self-build', 'provide-binary', 'platform-sdk', 'replace', 'unknown'), 'dependency provision decision required')
|
|
63
|
+
require(isinstance(row.get('reason'), str) and bool(row['reason'].strip()), 'dependency assessment reason required')
|
|
64
|
+
if group == 'tools':
|
|
65
|
+
require(row.get('status') in ('passed', 'failed', 'missing', 'unknown'), 'tool status must reflect actual check results')
|
|
66
|
+
result = copy.deepcopy(data)
|
|
67
|
+
result.update(available=True, source_checks=checks,
|
|
68
|
+
stale=any(c['status'] != 'current' for c in checks) or 'candidate' in data and data['candidate'] != candidate)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_analysis(file, now=None):
|
|
73
|
+
if file is None:
|
|
74
|
+
return None
|
|
75
|
+
content = Path(file).read_text(encoding='utf-8')
|
|
76
|
+
# A concurrently appended last line is not an event until its newline is present.
|
|
77
|
+
lines = content.split('\n')[:-1]
|
|
78
|
+
run_id, sequence, latest, terminal = None, 0, None, None
|
|
79
|
+
warnings, summary_counts = [], None
|
|
80
|
+
for line in lines:
|
|
81
|
+
if not line.strip():
|
|
82
|
+
continue
|
|
83
|
+
event = json.loads(line)
|
|
84
|
+
require(event.get('schema') == 'appgraph-progress/v1', 'unsupported analysis progress schema')
|
|
85
|
+
require(isinstance(event.get('run_id'), str) and bool(event['run_id']), 'analysis run_id required')
|
|
86
|
+
if run_id is None:
|
|
87
|
+
run_id = event['run_id']
|
|
88
|
+
require(event['run_id'] == run_id, 'one AppGraph run per progress file required')
|
|
89
|
+
require(type(event.get('sequence')) is int and event['sequence'] == sequence + 1, 'analysis progress sequence gap')
|
|
90
|
+
require(terminal is None, 'analysis event after terminal event')
|
|
91
|
+
require(event.get('status') in ('started', 'progress', 'completed', 'warning', 'failed'), 'unknown analysis event status')
|
|
92
|
+
require(isinstance(event.get('phase'), str), 'analysis phase required')
|
|
93
|
+
for key in ('current', 'total'):
|
|
94
|
+
require(event.get(key) is None or type(event[key]) in (int, float) and math.isfinite(event[key]) and event[key] >= 0, 'invalid analysis count')
|
|
95
|
+
sequence, latest = event['sequence'], event
|
|
96
|
+
if event['status'] == 'warning':
|
|
97
|
+
warnings.append({k: event[k] for k in ('phase', 'message', 'counts') if k in event})
|
|
98
|
+
if event['status'] == 'completed' and isinstance(event.get('counts'), dict):
|
|
99
|
+
summary_counts = event['counts']
|
|
100
|
+
if event['phase'] == 'analysis' and event['status'] in ('completed', 'failed'):
|
|
101
|
+
terminal = event['status']
|
|
102
|
+
if latest is None:
|
|
103
|
+
return {'state': 'unknown', 'message': '暂无完整进度事件', 'latest': None, 'stale': True}
|
|
104
|
+
timestamp = datetime.fromisoformat(latest['timestamp'].replace('Z', '+00:00'))
|
|
105
|
+
require(timestamp.tzinfo is not None, 'analysis timestamp needs timezone')
|
|
106
|
+
age = max(0, ((now or datetime.now(timezone.utc)) - timestamp).total_seconds())
|
|
107
|
+
return {'state': terminal or ('failed' if latest['status'] == 'failed' else 'running'), 'latest': latest, 'run_id': run_id, 'sequence': sequence,
|
|
108
|
+
'warnings': warnings, 'summary_counts': summary_counts,
|
|
109
|
+
'age_seconds': round(age, 1), 'stale': terminal is None and age > 30}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Read-only delivery projection. Acceptance always comes from spec_runtime.derive."""
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
LAYERS = ('mvp', 'core', 'full')
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ratio(items):
|
|
9
|
+
total = len(items)
|
|
10
|
+
accepted = sum(bool(i['accepted']) for i in items)
|
|
11
|
+
percent = (100.0 if accepted == total else min(99.9, round(100 * accepted / total, 1))) if total else None
|
|
12
|
+
return {'total': total, 'accepted': accepted, 'percent': percent,
|
|
13
|
+
'unplanned': sum(i.get('spec_id') is None for i in items)}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def progress(features, view):
|
|
17
|
+
if not view['scope_tracking']:
|
|
18
|
+
return {'configured': False, 'scope_tracking': False, 'selected': None, 'full': None, 'layers': {}}
|
|
19
|
+
rows = []
|
|
20
|
+
for feature in features['features']:
|
|
21
|
+
for item in view['feature_progress'][feature['feature_id']]['items']:
|
|
22
|
+
if feature['scope_status'] == 'excluded' or item['status'] == 'excluded':
|
|
23
|
+
continue
|
|
24
|
+
selected = feature['scope_status'] == 'included' and not feature['needs_clarification'] and item['status'] == 'included' and item['milestone'] != 'unassigned'
|
|
25
|
+
rows.append(dict(item, feature_id=feature['feature_id'], selected=selected))
|
|
26
|
+
config = features.get('delivery')
|
|
27
|
+
result = {'configured': config is not None, 'scope_tracking': True, 'target': config['target'] if config else None,
|
|
28
|
+
'customized': config['customized'] if config else None, 'selected': ratio([i for i in rows if i['selected']]),
|
|
29
|
+
'full': ratio(rows), 'layers': {}}
|
|
30
|
+
if config:
|
|
31
|
+
mapping = {(r['feature_id'], r['scope_id']): r['layer'] for r in config['scope_layers']}
|
|
32
|
+
for index, layer in enumerate(LAYERS):
|
|
33
|
+
result['layers'][layer] = {
|
|
34
|
+
'increment': ratio([i for i in rows if mapping[(i['feature_id'], i['scope_id'])] == layer]),
|
|
35
|
+
'cumulative': ratio([i for i in rows if mapping[(i['feature_id'], i['scope_id'])] in LAYERS[:index + 1]])}
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def snapshot(plan, features, state, view, captured_at, baseline=None):
|
|
40
|
+
"""Capture raw status and current proof together; baseline never substitutes for evidence."""
|
|
41
|
+
if view['running']:
|
|
42
|
+
activity = {'phase': 'implementation', 'label': '正在实施'}
|
|
43
|
+
elif view['validation_debt']['specs']:
|
|
44
|
+
activity = {'phase': 'verification', 'label': '有待验证或修复范围'}
|
|
45
|
+
elif state['candidate'] is None:
|
|
46
|
+
activity = {'phase': 'planning', 'label': '已有规划,尚未建立实现候选'}
|
|
47
|
+
else:
|
|
48
|
+
activity = {'phase': 'waiting', 'label': '当前无在途实现,等待下一步调度或 Review'}
|
|
49
|
+
before = {(f['feature_id'], i['scope_id']): i for f in (baseline or {}).get('features', []) for i in f.get('scope_items', [])}
|
|
50
|
+
current = {(f['feature_id'], i['scope_id']): (f, i) for f in features['features'] for i in f.get('scope_items', [])}
|
|
51
|
+
review = []
|
|
52
|
+
for key, (feature, item) in current.items():
|
|
53
|
+
derived = next((i for i in view['feature_progress'].get(key[0], {}).get('items', []) if i['scope_id'] == key[1]), {})
|
|
54
|
+
old = before.get(key)
|
|
55
|
+
difference = 'baseline-unavailable' if baseline is None else 'added' if old is None else 'changed' if any(old.get(k) != item.get(k) for k in ('description', 'acceptance')) else 'unchanged'
|
|
56
|
+
review.append({'feature_id': key[0], 'scope_id': key[1], 'description': item['description'], 'difference': difference,
|
|
57
|
+
'status': item['status'], 'spec_id': derived.get('spec_id'), 'accepted': derived.get('accepted', False),
|
|
58
|
+
'baseline': old, 'current': item, 'manual_review': 'pending'})
|
|
59
|
+
for key, item in before.items():
|
|
60
|
+
if key not in current:
|
|
61
|
+
review.append({'feature_id': key[0], 'scope_id': key[1], 'description': item['description'], 'difference': 'missing-from-current',
|
|
62
|
+
'status': 'unknown', 'spec_id': None, 'accepted': False, 'baseline': item, 'current': None, 'manual_review': 'pending'})
|
|
63
|
+
starts = {}
|
|
64
|
+
for entry in state['events']:
|
|
65
|
+
event = entry['event']
|
|
66
|
+
if event['type'] == 'start':
|
|
67
|
+
starts[event['spec_id']] = entry['timestamp']
|
|
68
|
+
# A task clock is the elapsed dispatch interval, not accumulated worker effort.
|
|
69
|
+
timing = {'task_started_at': {sid: starts[sid] for sid in view['running'] if sid in starts}}
|
|
70
|
+
return {'schema': 'd2h-dashboard/v1', 'captured_at': captured_at, 'activity': activity, 'timing': timing, 'feature_plan': features, 'plan': plan,
|
|
71
|
+
'execution': {k: state[k] for k in ('schema', 'candidate', 'plan_hash', 'feature_hash', 'tasks')},
|
|
72
|
+
'event_sequence': len(state['events']), 'derived': view, 'review': {
|
|
73
|
+
'baseline_available': baseline is not None,
|
|
74
|
+
'baseline_revision': baseline['revision'] if baseline else None,
|
|
75
|
+
'baseline_hash': hashlib.sha256(json.dumps(baseline, sort_keys=True, ensure_ascii=False).encode()).hexdigest() if baseline else None,
|
|
76
|
+
'items': review}}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def html(data, template):
|
|
80
|
+
# Payload may contain source/user text, including closing script tags.
|
|
81
|
+
payload = json.dumps(data, ensure_ascii=False).replace('<', '\\u003c').replace('\u2028', '\\u2028').replace('\u2029', '\\u2029')
|
|
82
|
+
return template.replace('/*__D2H_SNAPSHOT__*/null', payload)
|
|
@@ -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())
|