@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,890 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate AI-authored incremental plans and enforce explicit execution contracts."""
|
|
3
|
+
import argparse
|
|
4
|
+
import copy
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import tempfile
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
sys.dont_write_bytecode = True
|
|
16
|
+
|
|
17
|
+
SCHEMA = 'd2h-page-plan/v2'
|
|
18
|
+
PRIORITY = {'later': 0, 'normal': 1, 'must': 2}
|
|
19
|
+
WEIGHTS = {'milestone': 6, 'unlock': 5, 'user_value': 4, 'risk_reduction': 3, 'feedback': 2}
|
|
20
|
+
|
|
21
|
+
_batch_spec = importlib.util.spec_from_file_location('page_batches', Path(__file__).with_name('page_batches.py'))
|
|
22
|
+
page_batches = importlib.util.module_from_spec(_batch_spec)
|
|
23
|
+
_batch_spec.loader.exec_module(page_batches)
|
|
24
|
+
|
|
25
|
+
_packet_spec = importlib.util.spec_from_file_location('work_packets', Path(__file__).with_name('work_packets.py'))
|
|
26
|
+
work_packets = importlib.util.module_from_spec(_packet_spec)
|
|
27
|
+
_packet_spec.loader.exec_module(work_packets)
|
|
28
|
+
|
|
29
|
+
_generation_spec = importlib.util.spec_from_file_location('spec_generation', Path(__file__).with_name('spec_generation.py'))
|
|
30
|
+
spec_generation = importlib.util.module_from_spec(_generation_spec)
|
|
31
|
+
_generation_spec.loader.exec_module(spec_generation)
|
|
32
|
+
|
|
33
|
+
_delivery_spec = importlib.util.spec_from_file_location('delivery_view', Path(__file__).with_name('delivery_view.py'))
|
|
34
|
+
delivery_view = importlib.util.module_from_spec(_delivery_spec)
|
|
35
|
+
_delivery_spec.loader.exec_module(delivery_view)
|
|
36
|
+
|
|
37
|
+
_overview_spec = importlib.util.spec_from_file_location('dashboard_overview', Path(__file__).with_name('dashboard_overview.py'))
|
|
38
|
+
dashboard_overview = importlib.util.module_from_spec(_overview_spec)
|
|
39
|
+
_overview_spec.loader.exec_module(dashboard_overview)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def demand(value, message):
|
|
43
|
+
if not value:
|
|
44
|
+
raise ValueError(message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def nonempty(value):
|
|
48
|
+
return isinstance(value, str) and bool(value.strip())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def strings(value, label, required=False):
|
|
52
|
+
demand(isinstance(value, list) and (not required or value) and all(nonempty(v) for v in value), f'{label}: expected string array')
|
|
53
|
+
demand(len(set(value)) == len(value), f'{label}: duplicate values')
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def number(value, label, positive=False):
|
|
58
|
+
demand(type(value) in (int, float) and math.isfinite(value) and (value > 0 if positive else value >= 0), f'{label}: invalid number')
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fingerprint(value):
|
|
63
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def file_hash(path):
|
|
67
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def stamp():
|
|
71
|
+
return datetime.now(timezone.utc).isoformat()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_time(value):
|
|
75
|
+
parsed = datetime.fromisoformat(value)
|
|
76
|
+
demand(parsed.tzinfo is not None, 'timestamps require timezone')
|
|
77
|
+
return parsed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def indexed(items, key):
|
|
81
|
+
demand(isinstance(items, list), f'{key}: expected records')
|
|
82
|
+
result = {}
|
|
83
|
+
for item in items:
|
|
84
|
+
demand(isinstance(item, dict) and nonempty(item.get(key)), f'{key}: missing ID')
|
|
85
|
+
demand(item[key] not in result, f'{key}: duplicate {item[key]}')
|
|
86
|
+
result[item[key]] = item
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def feature_validator():
|
|
91
|
+
path = Path(__file__).with_name('feature_plan.py')
|
|
92
|
+
spec = importlib.util.spec_from_file_location('feature_validation', path)
|
|
93
|
+
module = importlib.util.module_from_spec(spec)
|
|
94
|
+
spec.loader.exec_module(module)
|
|
95
|
+
return module.validate
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def scope_owners(plan, features):
|
|
99
|
+
"""Every authorized atomic requirement has one implementation owner and check mapping."""
|
|
100
|
+
if features['schema'] != 'd2h-feature-plan/v2':
|
|
101
|
+
return None
|
|
102
|
+
items = {(f['feature_id'], i['scope_id']): (f, i) for f in features['features'] for i in f['scope_items']}
|
|
103
|
+
owners = {}
|
|
104
|
+
for spec in plan['specs']:
|
|
105
|
+
refs = spec.get('scope_refs')
|
|
106
|
+
demand(isinstance(refs, list) and refs, f"{spec['spec_id']}: scope_refs required for v2 features")
|
|
107
|
+
referenced = set()
|
|
108
|
+
mapped_checks = set()
|
|
109
|
+
for ref in refs:
|
|
110
|
+
demand(isinstance(ref, dict), 'invalid scope reference')
|
|
111
|
+
key = (ref.get('feature_id'), ref.get('scope_id'))
|
|
112
|
+
demand(key in items, 'unknown scope reference')
|
|
113
|
+
demand(key not in owners, 'scope item has duplicate Spec owners; split the requirement upstream')
|
|
114
|
+
feature, item = items[key]
|
|
115
|
+
demand(key[0] in spec['feature_refs'], 'scope reference outside feature_refs')
|
|
116
|
+
demand(feature['scope_status'] == 'included' and not feature['needs_clarification'] and item['status'] == 'included' and item['milestone'] == spec['milestone'], 'scope item not authorized for this milestone')
|
|
117
|
+
checks = strings(ref.get('checks'), 'scope checks', True)
|
|
118
|
+
demand(set(checks) <= set(spec['checks']), 'scope checks outside Spec checks')
|
|
119
|
+
mapped_checks.update(checks)
|
|
120
|
+
referenced.add(key[0])
|
|
121
|
+
owners[key] = spec['spec_id']
|
|
122
|
+
demand(referenced == set(spec['feature_refs']), 'every feature_ref needs a scope reference')
|
|
123
|
+
demand(mapped_checks == set(spec['checks']), 'every Spec check needs a scope mapping')
|
|
124
|
+
for key, (feature, item) in items.items():
|
|
125
|
+
authorized = feature['scope_status'] == 'included' and not feature['needs_clarification'] and item['status'] == 'included' and item['milestone'] != 'unassigned'
|
|
126
|
+
demand(not authorized or key in owners, f'{key}: authorized scope item needs a Spec')
|
|
127
|
+
return owners
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def scope_progress(plan, features, view):
|
|
131
|
+
owners = scope_owners(plan, features)
|
|
132
|
+
if owners is None:
|
|
133
|
+
view.update(scope_tracking=False, feature_progress={}, scope_coverage_accepted=False)
|
|
134
|
+
view['delivery_progress'] = delivery_view.progress(features, view)
|
|
135
|
+
return view
|
|
136
|
+
progress = {}
|
|
137
|
+
for feature in features['features']:
|
|
138
|
+
items = []
|
|
139
|
+
for item in feature['scope_items']:
|
|
140
|
+
owner = owners.get((feature['feature_id'], item['scope_id']))
|
|
141
|
+
excluded = feature['scope_status'] == 'excluded' or item['status'] == 'excluded'
|
|
142
|
+
accepted = bool(owner and view['tasks'][owner]['accepted'])
|
|
143
|
+
items.append(dict(item, spec_id=owner, accepted=accepted, resolved=excluded or accepted))
|
|
144
|
+
remaining = [i['scope_id'] for i in items if not i['resolved']]
|
|
145
|
+
progress[feature['feature_id']] = {'complete': not remaining, 'remaining_scope_ids': remaining, 'items': items}
|
|
146
|
+
view.update(scope_tracking=True, feature_progress=progress, scope_coverage_accepted=all(f['complete'] for f in progress.values()))
|
|
147
|
+
if 'pages' in view:
|
|
148
|
+
for pid, row in view['pages'].items():
|
|
149
|
+
related = {fid for spec in plan['specs'] if spec['page_id'] == pid for fid in spec['feature_refs']}
|
|
150
|
+
row['complete'] = row['complete'] and all(progress[f]['complete'] for f in related)
|
|
151
|
+
view['delivery_progress'] = delivery_view.progress(features, view)
|
|
152
|
+
return view
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate(plan, features, root=None):
|
|
156
|
+
demand(isinstance(plan, dict) and plan.get('schema_version') == SCHEMA, 'v2 plan required; v1 must be explicitly reconciled by AI, not reinterpreted')
|
|
157
|
+
errors = feature_validator()(features)
|
|
158
|
+
demand(not errors, '; '.join(errors))
|
|
159
|
+
demand(type(plan.get('revision')) is int and plan['revision'] > 0, 'positive plan revision required')
|
|
160
|
+
source = plan.get('feature_source', {})
|
|
161
|
+
demand(source.get('revision') == features['revision'] and source.get('sha256') == fingerprint(features), 'feature source revision/hash mismatch')
|
|
162
|
+
demand(nonempty(source.get('path')) and Path(source['path']).is_absolute(), 'feature source path must be absolute')
|
|
163
|
+
milestone_ids = [m['milestone_id'] for m in features['milestones']]
|
|
164
|
+
demand(plan.get('milestone') in milestone_ids, 'active milestone not declared')
|
|
165
|
+
feature_map = {f['feature_id']: f for f in features['features']}
|
|
166
|
+
pages = indexed(plan.get('pages'), 'page_id')
|
|
167
|
+
specs = indexed(plan.get('specs'), 'spec_id')
|
|
168
|
+
demand(bool(specs), 'at least one spec required')
|
|
169
|
+
retired = indexed(plan.get('retired_specs', []), 'spec_id')
|
|
170
|
+
demand(not specs.keys() & retired.keys(), 'retired spec cannot also be active')
|
|
171
|
+
for retired_spec in retired.values():
|
|
172
|
+
demand(nonempty(retired_spec.get('reason')), 'retired spec needs explicit reason')
|
|
173
|
+
demand(plan.get('spec_generation_policy') in (None, spec_generation.POLICY), 'unsupported spec_generation_policy')
|
|
174
|
+
for page in pages.values():
|
|
175
|
+
package = page.get('package')
|
|
176
|
+
demand(nonempty(package) and package not in {'.', '..'} and '/' not in package and '\\' not in package, 'page package must be one stable directory')
|
|
177
|
+
if root:
|
|
178
|
+
directory = (Path(root) / package).resolve()
|
|
179
|
+
demand(directory.parent == Path(root).resolve(), 'page package escapes output root')
|
|
180
|
+
spec_file = directory / 'spec.md'
|
|
181
|
+
if plan.get('spec_generation_policy') == spec_generation.POLICY and not directory.exists():
|
|
182
|
+
continue
|
|
183
|
+
demand(directory.is_dir() and spec_file.is_file() and spec_file.resolve().parent == directory and {p.name for p in directory.iterdir()} == {'spec.md'}, 'page package must contain only a local spec.md file')
|
|
184
|
+
demand(len({p['package'] for p in pages.values()}) == len(pages), 'duplicate page package')
|
|
185
|
+
providers, edges, coverage = {}, {}, set()
|
|
186
|
+
all_checks = set()
|
|
187
|
+
for sid, item in specs.items():
|
|
188
|
+
demand(item.get('page_id') in pages, f'{sid}: unknown page')
|
|
189
|
+
refs = strings(item.get('feature_refs'), f'{sid}.feature_refs', True)
|
|
190
|
+
demand(set(refs) <= feature_map.keys(), f'{sid}: unknown feature')
|
|
191
|
+
demand(item.get('milestone') in milestone_ids, f'{sid}: unknown milestone')
|
|
192
|
+
demand(all((features['schema'] == 'd2h-feature-plan/v2' or feature_map[f]['milestone'] == item['milestone']) and feature_map[f]['scope_status'] == 'included' and not feature_map[f]['needs_clarification'] for f in refs), f'{sid}: scope not authorized or unresolved; return to feature planning')
|
|
193
|
+
coverage.update(refs)
|
|
194
|
+
strings(item.get('scope'), f'{sid}.scope', True)
|
|
195
|
+
strings(item.get('evidence_refs'), f'{sid}.evidence_refs', True)
|
|
196
|
+
checks = strings(item.get('checks'), f'{sid}.checks', True)
|
|
197
|
+
demand(not all_checks & set(checks), f'{sid}: checks must be globally unique')
|
|
198
|
+
all_checks.update(checks)
|
|
199
|
+
testing = item.get('testing', {})
|
|
200
|
+
immediate = strings(testing.get('immediate'), f'{sid}.immediate')
|
|
201
|
+
batch = strings(testing.get('batch'), f'{sid}.batch')
|
|
202
|
+
demand(not set(immediate) & set(batch) and set(immediate + batch) == set(checks), f'{sid}: checks must partition into immediate and batch')
|
|
203
|
+
risk = testing.get('risk_dimensions', {})
|
|
204
|
+
demand(set(risk) == {'impact', 'shared', 'uncertainty', 'dependency'}, f'{sid}: four risk dimensions required')
|
|
205
|
+
demand(all(type(v) is int and 0 <= v <= 3 for v in risk.values()), f'{sid}: risk ratings must be 0..3')
|
|
206
|
+
triggers = strings(testing.get('triggers'), f'{sid}.triggers')
|
|
207
|
+
demand(set(triggers) <= {'data-loss', 'authentication', 'startup', 'shared-contract'}, f'{sid}: unknown risk trigger')
|
|
208
|
+
if triggers or sum(risk.values()) >= 8:
|
|
209
|
+
demand(set(immediate) == set(checks), f'{sid}: high risk requires immediate checks')
|
|
210
|
+
demand(testing.get('latest_validation_point') == item['milestone'], f'{sid}: validation deadline must be its milestone')
|
|
211
|
+
number(testing.get('estimated_hours'), f'{sid}.test hours', True)
|
|
212
|
+
score = item.get('ratings', {})
|
|
213
|
+
demand(set(score) == set(WEIGHTS) and all(type(v) is int and 0 <= v <= 5 for v in score.values()), f'{sid}: ratings must be five 0..5 integers')
|
|
214
|
+
effort = item.get('effort', {})
|
|
215
|
+
for name in ('low', 'expected', 'high', 'remaining_elapsed_hours'):
|
|
216
|
+
number(effort.get(name), f'{sid}.effort.{name}', True)
|
|
217
|
+
demand(effort['low'] <= effort['expected'] <= effort['high'], f'{sid}: invalid effort interval')
|
|
218
|
+
demand(effort.get('confidence') in {'low', 'medium', 'high'}, f'{sid}: confidence required')
|
|
219
|
+
allocation = item.get('allocation', {})
|
|
220
|
+
demand(type(allocation.get('recommended_workers')) is int and allocation['recommended_workers'] >= 1, f'{sid}: worker recommendation required')
|
|
221
|
+
demand(nonempty(allocation.get('rationale')), f'{sid}: allocation rationale required')
|
|
222
|
+
strings(allocation.get('owned_paths'), f'{sid}.owned_paths', True)
|
|
223
|
+
requires = strings(item.get('requires'), f'{sid}.requires')
|
|
224
|
+
provides = item.get('provides')
|
|
225
|
+
demand(isinstance(provides, list), f'{sid}: provides must be array')
|
|
226
|
+
for cap in provides:
|
|
227
|
+
demand(isinstance(cap, dict) and nonempty(cap.get('capability')), f'{sid}: invalid capability')
|
|
228
|
+
name = cap['capability']
|
|
229
|
+
demand(name not in providers, f'duplicate capability provider: {name}')
|
|
230
|
+
needed = strings(cap.get('checks'), f'{sid}.{name}.checks', True)
|
|
231
|
+
demand(set(needed) <= set(immediate), f'{sid}: provided capabilities require immediate contract checks')
|
|
232
|
+
providers[name] = sid
|
|
233
|
+
edges[sid] = []
|
|
234
|
+
dispositions = indexed(plan.get('dispositions'), 'feature_id')
|
|
235
|
+
for fid, disposition in dispositions.items():
|
|
236
|
+
demand(fid in feature_map and fid not in coverage, 'disposition must refer to uncovered known feature')
|
|
237
|
+
demand(nonempty(disposition.get('reason')), 'disposition reason required')
|
|
238
|
+
demand((features['schema'] == 'd2h-feature-plan/v2' and not any(i['status'] == 'included' and i['milestone'] != 'unassigned' for i in feature_map[fid]['scope_items'])) or feature_map[fid]['scope_status'] != 'included' or feature_map[fid]['milestone'] == 'unassigned' or feature_map[fid]['needs_clarification'], 'authorized included feature cannot be silently omitted')
|
|
239
|
+
scope_owners(plan, features)
|
|
240
|
+
demand(coverage | dispositions.keys() == feature_map.keys(), 'all features need specs or explicit upstream disposition')
|
|
241
|
+
for sid, item in specs.items():
|
|
242
|
+
for cap in item['requires']:
|
|
243
|
+
demand(cap in providers, f'{sid}: missing capability provider: {cap}')
|
|
244
|
+
provider = providers[cap]
|
|
245
|
+
demand(provider != sid, f'{sid}: self dependency')
|
|
246
|
+
demand(milestone_ids.index(specs[provider]['milestone']) <= milestone_ids.index(item['milestone']), f'{sid}: dependency requires later milestone; return scope conflict upstream')
|
|
247
|
+
edges[sid].append(provider)
|
|
248
|
+
for fid in item['feature_refs']:
|
|
249
|
+
for preceding in features['features']:
|
|
250
|
+
if fid in preceding['requested_before']:
|
|
251
|
+
edges[sid].extend(other for other, value in specs.items() if preceding['feature_id'] in value['feature_refs'] and other != sid and (features['schema'] != 'd2h-feature-plan/v2' or value['milestone'] == item['milestone']))
|
|
252
|
+
# Deduplicate shared edges; reject combined explicit order + capability cycles.
|
|
253
|
+
edges = {key: sorted(set(value)) for key, value in edges.items()}
|
|
254
|
+
ordered, remaining = [], set(specs)
|
|
255
|
+
while remaining:
|
|
256
|
+
layer = sorted(s for s in remaining if not set(edges[s]) & remaining)
|
|
257
|
+
demand(layer, 'dependency/order cycle: ' + ', '.join(sorted(remaining)))
|
|
258
|
+
ordered.extend(layer)
|
|
259
|
+
remaining.difference_update(layer)
|
|
260
|
+
policy = plan.get('validation_policy', {})
|
|
261
|
+
for key in ('max_pending_specs', 'max_pending_hours', 'max_age_hours'):
|
|
262
|
+
number(policy.get(key), key, True)
|
|
263
|
+
demand(type(policy['max_pending_specs']) is int, 'max_pending_specs must be integer')
|
|
264
|
+
demand(plan.get('implementation_workers') == 1, 'only one implementation worker supported in this version')
|
|
265
|
+
work_packets.validate(plan, specs, demand, strings)
|
|
266
|
+
page_batches.validate(plan, features, specs, edges)
|
|
267
|
+
return specs, providers, edges, ordered
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def init_state(plan, features):
|
|
271
|
+
specs, _, _, _ = validate(plan, features)
|
|
272
|
+
return {'schema': 'd2h-spec-execution/v1', 'plan_hash': fingerprint(plan), 'plan_revision': plan['revision'], 'feature_hash': fingerprint(features), 'candidate': None, 'tasks': {sid: {'implementation': 'planned', 'validation': 'not_ready', 'acceptance': 'pending', 'evidence': [], 'external_blockers': [], 'metrics': {}} for sid in specs}, 'events': [], 'history': [], 'milestone_evidence': [], 'active_batch': None, 'batch_runs': [], 'round_deliveries': []}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def current_evidence(task, candidate):
|
|
276
|
+
def intact(evidence):
|
|
277
|
+
try:
|
|
278
|
+
return all(Path(evidence[key]['path']).is_file() and file_hash(evidence[key]['path']) == evidence[key]['sha256'] for key in ('artifact', 'hap'))
|
|
279
|
+
except (KeyError, OSError, TypeError):
|
|
280
|
+
return False
|
|
281
|
+
return [e for e in task['evidence'] if e['candidate'] == candidate and intact(e)]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def passed(task, candidate):
|
|
285
|
+
return {check for e in current_evidence(task, candidate) if e['kind'] == 'test' for check in e['checks']}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def has_build(task, candidate):
|
|
289
|
+
return any(e['kind'] == 'build' for e in current_evidence(task, candidate))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def build_hash(task, candidate):
|
|
293
|
+
return next((e['hap']['sha256'] for e in reversed(current_evidence(task, candidate)) if e['kind'] == 'build'), None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def assessment(item, task):
|
|
297
|
+
metrics = task['metrics']
|
|
298
|
+
categories = ('implementation_hours', 'test_hours', 'coordination_hours', 'rework_hours')
|
|
299
|
+
actual = sum(metrics[k] for k in categories) if all(type(metrics.get(k)) in (int, float) for k in categories) else None
|
|
300
|
+
result = {'expected_worker_hours': item['effort']['expected'], 'actual_worker_hours': actual, 'effort_ratio': actual / item['effort']['expected'] if actual is not None else None}
|
|
301
|
+
for key in ('coordination_hours', 'rework_hours', 'context_hours', 'repeated_analysis_hours'):
|
|
302
|
+
result[key.replace('_hours', '_share')] = metrics[key] / actual if actual and type(metrics.get(key)) in (int, float) else None
|
|
303
|
+
elapsed, waiting = metrics.get('elapsed_hours'), metrics.get('waiting_hours')
|
|
304
|
+
result['waiting_share'] = waiting / elapsed if type(elapsed) in (int, float) and elapsed > 0 and type(waiting) in (int, float) else None
|
|
305
|
+
result['first_pass'] = metrics.get('first_pass')
|
|
306
|
+
result['conflicts'] = metrics.get('conflicts')
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def derive(plan, features, state, now=None, root=None):
|
|
311
|
+
specs, providers, edges, order = validate(plan, features)
|
|
312
|
+
demand(state.get('schema') == 'd2h-spec-execution/v1' and state.get('plan_hash') == fingerprint(plan) and state.get('feature_hash') == fingerprint(features), 'execution baseline mismatch; reconcile revision explicitly')
|
|
313
|
+
demand(set(state['tasks']) == set(specs), 'execution task set mismatch')
|
|
314
|
+
now = now or datetime.now(timezone.utc)
|
|
315
|
+
feature_map = {f['feature_id']: f for f in features['features']}
|
|
316
|
+
active = {s for s, item in specs.items() if item['milestone'] == plan['milestone']}
|
|
317
|
+
closure = set(active)
|
|
318
|
+
for sid in reversed(order):
|
|
319
|
+
if sid in closure:
|
|
320
|
+
closure.update(edges[sid])
|
|
321
|
+
closure = page_batches.relevant_specs(plan, closure, edges)
|
|
322
|
+
ranks = {s: max(PRIORITY[feature_map[f]['user_priority']] for f in specs[s]['feature_refs']) for s in specs}
|
|
323
|
+
inherited = {s: set() for s in specs}
|
|
324
|
+
cap_ready = dict.fromkeys(providers, False)
|
|
325
|
+
ready_specs = set()
|
|
326
|
+
debt, debt_hours, ages = [], 0, []
|
|
327
|
+
rows = {}
|
|
328
|
+
accepted = {}
|
|
329
|
+
for sid in order:
|
|
330
|
+
item = specs[sid]
|
|
331
|
+
task = state['tasks'][sid]
|
|
332
|
+
checks = passed(task, state['candidate'])
|
|
333
|
+
consumer_hap = build_hash(task, state['candidate'])
|
|
334
|
+
blocked = [providers[c] for c in item['requires'] if not cap_ready[c] or (consumer_hap is not None and build_hash(state['tasks'][providers[c]], state['candidate']) != consumer_hap)]
|
|
335
|
+
ordered_features = {f['feature_id'] for f in features['features'] if set(f['requested_before']) & set(item['feature_refs'])}
|
|
336
|
+
ordered_blockers = [p for p in edges[sid] if set(specs[p]['feature_refs']) & ordered_features and not accepted.get(p, False)]
|
|
337
|
+
blocked = sorted(set(blocked + ordered_blockers))
|
|
338
|
+
gaps = work_packets.readiness_gaps(plan, item) + spec_generation.gaps(plan, features, item, root)
|
|
339
|
+
if plan.get('spec_generation_policy') == spec_generation.POLICY and task['implementation'] != 'planned' and not gaps:
|
|
340
|
+
if task.get('spec_generation') != spec_generation.proof(root, sid):
|
|
341
|
+
gaps.append('spec generation: running/implemented work uses different detailed content; invalidate and repair')
|
|
342
|
+
missing_interfaces = [cap['capability'] for cap in item['provides'] if not work_packets.interface_ready(plan, task, cap['capability'], state['candidate'], file_hash)]
|
|
343
|
+
interfaces_ready = not missing_interfaces
|
|
344
|
+
if task['implementation'] == 'implemented' and not gaps and interfaces_ready and not blocked and not task['external_blockers'] and has_build(task, state['candidate']) and set(item['testing']['immediate']) <= checks:
|
|
345
|
+
ready_specs.add(sid)
|
|
346
|
+
for cap in item['provides']:
|
|
347
|
+
cap_ready[cap['capability']] = sid in ready_specs and set(cap['checks']) <= checks
|
|
348
|
+
complete = sid in ready_specs and not blocked and set(item['checks']) <= checks and task.get('reviewed_candidate') == state['candidate']
|
|
349
|
+
accepted[sid] = complete
|
|
350
|
+
if task['implementation'] == 'implemented' and not complete:
|
|
351
|
+
debt.append(sid)
|
|
352
|
+
debt_hours += item['testing']['estimated_hours']
|
|
353
|
+
ages.append((now - parse_time(task.get('pending_since', task['implemented_at']))).total_seconds() / 3600)
|
|
354
|
+
value = sum(item['ratings'][k] * w for k, w in WEIGHTS.items())
|
|
355
|
+
rows[sid] = {'effective_priority': next(k for k, v in PRIORITY.items() if v == ranks[sid]), 'inherited_from': sorted(inherited[sid]), 'blocked_by': blocked, 'external_blockers': task['external_blockers'], 'capabilities_ready': [c['capability'] for c in item['provides'] if cap_ready[c['capability']]], 'value_score': value, 'priority_index': value / item['effort']['expected'], 'assessment': assessment(item, task), 'accepted': complete, 'immediate_passed': sid in ready_specs, 'missing_checks': sorted(set(item['checks']) - checks), 'readiness_gaps': gaps, 'missing_interfaces': missing_interfaces, 'work_packet_ready': not gaps, 'eligible': sid in closure and not gaps and not blocked and not task['external_blockers'] and task['implementation'] == 'planned'}
|
|
356
|
+
for target in sorted(active):
|
|
357
|
+
if rows[target]['accepted']:
|
|
358
|
+
continue
|
|
359
|
+
pending, seen = list(edges[target]), set()
|
|
360
|
+
while pending:
|
|
361
|
+
sid = pending.pop()
|
|
362
|
+
if sid in seen:
|
|
363
|
+
continue
|
|
364
|
+
seen.add(sid)
|
|
365
|
+
inherited[sid].add(target)
|
|
366
|
+
ranks[sid] = max(ranks[sid], max(PRIORITY[feature_map[f]['user_priority']] for f in specs[target]['feature_refs']))
|
|
367
|
+
pending.extend(edges[sid])
|
|
368
|
+
for sid in rows:
|
|
369
|
+
rows[sid]['effective_priority'] = next(k for k, v in PRIORITY.items() if v == ranks[sid])
|
|
370
|
+
rows[sid]['inherited_from'] = sorted(inherited[sid])
|
|
371
|
+
# Longest remaining successor path (calendar time, distinct from worker effort).
|
|
372
|
+
lengths = {s: 0 if rows[s]['accepted'] else specs[s]['effort']['remaining_elapsed_hours'] for s in specs}
|
|
373
|
+
for sid in reversed(order):
|
|
374
|
+
for parent in edges[sid]:
|
|
375
|
+
if parent in closure and sid in closure:
|
|
376
|
+
lengths[parent] = max(lengths[parent], (0 if rows[parent]['accepted'] else specs[parent]['effort']['remaining_elapsed_hours']) + lengths[sid])
|
|
377
|
+
policy = plan['validation_policy']
|
|
378
|
+
pressure = len(debt) >= policy['max_pending_specs'] or debt_hours >= policy['max_pending_hours'] or any(age >= policy['max_age_hours'] for age in ages)
|
|
379
|
+
running = [s for s, t in state['tasks'].items() if t['implementation'] == 'in_progress']
|
|
380
|
+
candidates = [s for s, row in rows.items() if row['eligible']]
|
|
381
|
+
candidates.sort(key=lambda s: (-ranks[s], -lengths[s], -len(inherited[s]), -rows[s]['priority_index'], s))
|
|
382
|
+
validation_due = sorted(s for s in debt if not rows[s]['immediate_passed'])
|
|
383
|
+
regression = any(e['candidate'] == state['candidate'] and e['milestone'] == plan['milestone'] and current_evidence({'evidence': [e['evidence']]}, state['candidate']) for e in state['milestone_evidence'])
|
|
384
|
+
view = {'tasks': rows, 'relevant_specs': sorted(closure), 'ready': [] if pressure or running else candidates, 'ranked_candidates': candidates, 'validation_due': validation_due, 'validation_debt': {'specs': sorted(debt), 'estimated_hours': debt_hours, 'oldest_hours': max(ages, default=0), 'backpressure': pressure}, 'running': running, 'remaining_path_hours': lengths, 'milestone_regression_passed': regression, 'milestone_accepted': all(rows[s]['accepted'] for s in closure) and bool(active) and regression}
|
|
385
|
+
return scope_progress(plan, features, page_batches.schedule(plan, features, state, view, edges, lambda evidence, candidate: bool(current_evidence({'evidence': [evidence]}, candidate))))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def dispatch_context(plan, features, state, batch_id, root, now=None):
|
|
389
|
+
"""Read-only dispatch snapshot, trimmed to the claimed or eligible window."""
|
|
390
|
+
view = derive(plan, features, state, now, root)
|
|
391
|
+
demand(page_batches.enabled(plan), 'context requires page-batches mode')
|
|
392
|
+
demand(batch_id in view['page_batches'], 'unknown context batch')
|
|
393
|
+
active = state.get('active_batch')
|
|
394
|
+
if active:
|
|
395
|
+
demand(active['batch_id'] == batch_id, 'another batch owns the dispatch window')
|
|
396
|
+
selected = active['spec_ids']
|
|
397
|
+
else:
|
|
398
|
+
demand(batch_id in view['ready_batches'], 'context batch is not eligible')
|
|
399
|
+
selected = view['page_batches'][batch_id]['dispatchable_spec_ids']
|
|
400
|
+
batch = next(b for b in plan['page_batches'] if b['batch_id'] == batch_id)
|
|
401
|
+
page = next(p for p in plan['pages'] if p['page_id'] == batch['page_id'])
|
|
402
|
+
round_item = next(r for r in plan['rounds'] if r['round_id'] == batch['round_id'])
|
|
403
|
+
specs = {s['spec_id']: s for s in plan['specs']}
|
|
404
|
+
providers = {c['capability']: s['spec_id'] for s in plan['specs'] for c in s['provides']}
|
|
405
|
+
required = sorted({cap for sid in selected for cap in specs[sid]['requires']})
|
|
406
|
+
result = {'schema': 'd2h-dispatch-context/v1', 'plan_revision': plan['revision'], 'feature_revision': features['revision'], 'plan_hash': fingerprint(plan), 'feature_hash': fingerprint(features), 'candidate': state['candidate'], 'event_sequence': len(state['events']), 'batch_id': batch_id, 'page_id': page['page_id'], 'round_id': round_item['round_id'], 'goal': round_item['goal'], 'page_spec_path': str((Path(root) / page['package'] / 'spec.md').resolve()), 'spec_ids': list(selected), 'members': [{'work_packet': work_packets.packet(plan, specs[sid], state['tasks'][sid], state['candidate'], file_hash), 'readiness_gaps': view['tasks'][sid]['readiness_gaps'], 'spec': copy.deepcopy(specs[sid]), 'implementation': state['tasks'][sid]['implementation'], 'accepted': view['tasks'][sid]['accepted'], 'missing_checks': view['tasks'][sid]['missing_checks']} for sid in selected], 'required_capabilities': [{'capability': cap, 'provider_spec_id': providers[cap], 'current_ready': cap in view['tasks'][providers[cap]]['capabilities_ready'], 'interface': state['tasks'][providers[cap]].get('interfaces', {}).get(cap) if work_packets.interface_ready(plan, state['tasks'][providers[cap]], cap, state['candidate'], file_hash) else None} for cap in required]}
|
|
407
|
+
result['context_hash'] = fingerprint(result)
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def validate_evidence(evidence, candidate, spec, root):
|
|
412
|
+
evidence = copy.deepcopy(evidence)
|
|
413
|
+
demand(evidence.get('kind') in {'build', 'test'}, 'evidence kind must be build/test')
|
|
414
|
+
demand(nonempty(candidate) and evidence.get('candidate') == candidate, 'evidence candidate mismatch')
|
|
415
|
+
demand(evidence.get('exit_code') == 0 and type(evidence.get('exit_code')) is int, 'evidence must have successful exit code')
|
|
416
|
+
command = evidence.get('command')
|
|
417
|
+
demand(isinstance(command, list) and len(command) >= 3 and command[:3] == ['droid2hmos', evidence['kind'], 'harmony'], 'managed command required')
|
|
418
|
+
parse_time(evidence.get('timestamp', ''))
|
|
419
|
+
for name in ('artifact', 'hap'):
|
|
420
|
+
ref = evidence.get(name, {})
|
|
421
|
+
path = Path(ref.get('path', ''))
|
|
422
|
+
if not path.is_absolute():
|
|
423
|
+
path = root / path
|
|
424
|
+
demand(path.is_file() and ref.get('sha256') == file_hash(path), f'{name} evidence file/hash mismatch')
|
|
425
|
+
ref['path'] = str(path.resolve())
|
|
426
|
+
if evidence['kind'] == 'test':
|
|
427
|
+
demand(evidence.get('layer', 'device') == 'device', 'only managed device test evidence is supported; local/static evidence needs a CLI adapter')
|
|
428
|
+
demand(nonempty(evidence.get('device')), 'test device required')
|
|
429
|
+
demand('--runner' in command and command[command.index('--runner') + 1:command.index('--runner') + 2] == ['python'] and '--test-list' in command, 'managed Python runner/test-list required')
|
|
430
|
+
checks = strings(evidence.get('checks'), 'evidence checks', True)
|
|
431
|
+
demand(set(checks) <= set(spec['checks']), 'unknown test checks')
|
|
432
|
+
counts = evidence.get('counts', {})
|
|
433
|
+
demand(type(counts.get('executed')) is int and counts['executed'] > 0 and type(counts.get('passed')) is int and counts.get('passed') == counts['executed'] and all(type(counts.get(k)) is int and counts[k] == 0 for k in ('failed', 'errors', 'skipped')), 'nonzero execution and no failed/error/skipped required')
|
|
434
|
+
strings(evidence.get('managed_evidence'), 'managed evidence refs', True)
|
|
435
|
+
return evidence
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def apply_event(plan, features, state, event, root, now=None):
|
|
439
|
+
state = copy.deepcopy(state)
|
|
440
|
+
snapshot = derive(plan, features, state, now, root)
|
|
441
|
+
kind = event.get('type')
|
|
442
|
+
sid = event.get('spec_id')
|
|
443
|
+
task = state['tasks'].get(sid)
|
|
444
|
+
specs = {s['spec_id']: s for s in plan['specs']}
|
|
445
|
+
if kind == 'start_batch':
|
|
446
|
+
demand(page_batches.enabled(plan), 'start_batch requires page-batches mode')
|
|
447
|
+
bid = event.get('batch_id')
|
|
448
|
+
demand(bid in snapshot['ready_batches'], 'page batch not ready or round gate/backpressure/worker active')
|
|
449
|
+
highest_rank = max(snapshot['page_batches'][key]['effective_priority_rank'] for key in snapshot['ready_batches'])
|
|
450
|
+
demand(snapshot['page_batches'][bid]['effective_priority_rank'] == highest_rank, 'a higher-priority page batch is ready')
|
|
451
|
+
selected = snapshot['page_batches'][bid]['dispatchable_spec_ids']
|
|
452
|
+
demand(event.get('spec_ids') == selected, 'dispatch members must match current bounded batch recommendation')
|
|
453
|
+
demand(all(nonempty(event.get(k)) for k in ('baseline', 'reason', 'worker')), 'batch baseline, AI reason and worker required')
|
|
454
|
+
batch = next(b for b in plan['page_batches'] if b['batch_id'] == bid)
|
|
455
|
+
optional = [member for member in selected if PRIORITY[snapshot['tasks'][member]['effective_priority']] < highest_rank]
|
|
456
|
+
state['active_batch'] = {'batch_id': bid, 'page_id': batch['page_id'], 'round_id': batch['round_id'], 'spec_ids': selected, 'opportunistic_spec_ids': optional, 'plan_revision': plan['revision'], 'feature_revision': features['revision'], 'baseline': event['baseline'], 'reason': event['reason'], 'worker': event['worker'], 'started_at': now.isoformat() if now else stamp()}
|
|
457
|
+
elif kind in {'finish_batch', 'pause_batch'}:
|
|
458
|
+
active_batch = state.get('active_batch')
|
|
459
|
+
demand(active_batch and event.get('batch_id') == active_batch['batch_id'] and not snapshot['running'], 'finish/pause matching batch only after member work stops')
|
|
460
|
+
demand(nonempty(event.get('reason')), 'batch completion/pause reason required')
|
|
461
|
+
if kind == 'finish_batch':
|
|
462
|
+
demand(all(snapshot['tasks'][member]['immediate_passed'] and not snapshot['tasks'][member]['blocked_by'] for member in active_batch['spec_ids']), 'all dispatched members need implementation and immediate gates before release')
|
|
463
|
+
state.setdefault('batch_runs', []).append({**active_batch, 'result': 'completed' if kind == 'finish_batch' else 'paused', 'ended_at': now.isoformat() if now else stamp(), 'completion_reason': event['reason']})
|
|
464
|
+
state['active_batch'] = None
|
|
465
|
+
elif kind == 'round_accept':
|
|
466
|
+
demand(page_batches.enabled(plan) and not state.get('active_batch') and not snapshot['running'], 'release page batch before round acceptance')
|
|
467
|
+
rid = event.get('round_id')
|
|
468
|
+
demand(rid in snapshot['rounds'] and snapshot['rounds'][rid]['relevant'], 'known relevant round required')
|
|
469
|
+
round_row = snapshot['rounds'][rid]
|
|
470
|
+
demand(all(r['delivered'] for r in snapshot['rounds'].values() if r['relevant'] and r['order'] < round_row['order']), 'earlier round goal must be delivered first')
|
|
471
|
+
demand(all(snapshot['tasks'][member]['accepted'] for member in round_row['spec_ids']), 'all round Specs must be individually accepted')
|
|
472
|
+
demand(nonempty(event.get('reviewer')) and nonempty(event.get('review')), 'round goal behavior review required')
|
|
473
|
+
round_plan = next(r for r in plan['rounds'] if r['round_id'] == rid)
|
|
474
|
+
evidence = validate_evidence(event.get('evidence', {}), state['candidate'], {'checks': round_plan['goal_checks']}, root)
|
|
475
|
+
demand(evidence['kind'] == 'test' and set(evidence['checks']) == set(round_plan['goal_checks']), 'round goal checks must be executed')
|
|
476
|
+
demand(all(build_hash(state['tasks'][member], state['candidate']) == evidence['hap']['sha256'] for member in round_row['spec_ids']), 'round proof must match current member HAP')
|
|
477
|
+
_, _, edges, _ = validate(plan, features)
|
|
478
|
+
state.setdefault('round_deliveries', []).append({'round_id': rid, 'signature': page_batches.round_signature(plan, features, rid, edges), 'candidate': state['candidate'], 'evidence': evidence, 'reviewer': event['reviewer'], 'review': event['review']})
|
|
479
|
+
elif kind == 'candidate':
|
|
480
|
+
demand(not snapshot['running'], 'finish or pause running work before candidate change')
|
|
481
|
+
demand(nonempty(event.get('candidate')) and event['candidate'] != state['candidate'], 'new candidate required')
|
|
482
|
+
state['candidate'] = event['candidate']
|
|
483
|
+
for value in state['tasks'].values():
|
|
484
|
+
value['validation'] = 'stale' if value['evidence'] else value['validation']
|
|
485
|
+
value['acceptance'] = 'pending'
|
|
486
|
+
value.pop('reviewed_candidate', None)
|
|
487
|
+
if value['implementation'] == 'implemented':
|
|
488
|
+
value.setdefault('pending_since', now.isoformat() if now else stamp())
|
|
489
|
+
elif kind == 'regression':
|
|
490
|
+
active = [s for s in specs.values() if s['milestone'] == plan['milestone']]
|
|
491
|
+
demand(all(snapshot['tasks'][sid]['accepted'] for sid in snapshot['relevant_specs']), 'accept milestone and prerequisite specs before final regression')
|
|
492
|
+
if page_batches.enabled(plan):
|
|
493
|
+
demand(not state.get('active_batch') and all(r['current_accepted'] for r in snapshot['rounds'].values() if r['relevant']), 'current round goal proofs required before milestone regression')
|
|
494
|
+
combined = {'checks': sorted({check for s in active for check in s['checks']})}
|
|
495
|
+
evidence = validate_evidence(event.get('evidence', {}), state['candidate'], combined, root)
|
|
496
|
+
demand(evidence['kind'] == 'test' and set(evidence['checks']) == set(combined['checks']), 'regression must cover all milestone checks')
|
|
497
|
+
demand(all(any(e['kind'] == 'build' and e['hap'] == evidence['hap'] for e in current_evidence(state['tasks'][s['spec_id']], state['candidate'])) for s in active), 'regression requires matching current HAP builds')
|
|
498
|
+
state['milestone_evidence'].append({'candidate': state['candidate'], 'milestone': plan['milestone'], 'evidence': evidence})
|
|
499
|
+
else:
|
|
500
|
+
demand(task is not None, 'known spec_id required')
|
|
501
|
+
if kind == 'start':
|
|
502
|
+
demand(sid in snapshot['ready'], 'spec not ready or validation backpressure/another worker active')
|
|
503
|
+
active_batch = state.get('active_batch')
|
|
504
|
+
if active_batch and sid in active_batch.get('opportunistic_spec_ids', []):
|
|
505
|
+
demand(not any(other not in active_batch['spec_ids'] and PRIORITY[snapshot['tasks'][other]['effective_priority']] > PRIORITY[snapshot['tasks'][sid]['effective_priority']] for other in snapshot['ready_specs']), 'higher-priority work became ready; pause optional page work')
|
|
506
|
+
demand(nonempty(event.get('baseline')) and nonempty(event.get('reason')), 'dispatch baseline and AI selection reason required')
|
|
507
|
+
task.update(implementation='in_progress', dispatch={'plan_revision': plan['revision'], 'feature_revision': features['revision'], 'baseline': event['baseline'], 'reason': event['reason']})
|
|
508
|
+
if page_batches.enabled(plan):
|
|
509
|
+
task['dispatch']['batch'] = copy.deepcopy(state['active_batch'])
|
|
510
|
+
elif kind == 'implemented':
|
|
511
|
+
demand(task['implementation'] == 'in_progress', 'only running work can be implemented')
|
|
512
|
+
demand(nonempty(event.get('commit')), 'implementation commit required')
|
|
513
|
+
task.update(implementation='implemented', validation='pending', commit=event['commit'], implemented_at=(now.isoformat() if now else stamp()))
|
|
514
|
+
task.setdefault('pending_since', task['implemented_at'])
|
|
515
|
+
elif kind == 'evidence':
|
|
516
|
+
demand(task['implementation'] == 'implemented', 'implement before attaching evidence')
|
|
517
|
+
evidence = validate_evidence(event.get('evidence', {}), state['candidate'], specs[sid], root)
|
|
518
|
+
if evidence['kind'] == 'build' and any(e['kind'] == 'build' and e['candidate'] == state['candidate'] and e['hap']['sha256'] != evidence['hap']['sha256'] for e in task['evidence']):
|
|
519
|
+
task['evidence'] = []
|
|
520
|
+
task['acceptance'] = 'pending'
|
|
521
|
+
task.pop('reviewed_candidate', None)
|
|
522
|
+
if evidence['kind'] == 'test':
|
|
523
|
+
demand(any(e['kind'] == 'build' and e['candidate'] == state['candidate'] and e['hap'] == evidence['hap'] for e in task['evidence']), 'test requires matching current build/HAP')
|
|
524
|
+
task['evidence'].append(evidence)
|
|
525
|
+
task['validation'] = 'passed' if set(specs[sid]['checks']) <= passed(task, state['candidate']) and has_build(task, state['candidate']) else 'pending'
|
|
526
|
+
elif kind == 'interface':
|
|
527
|
+
demand(work_packets.enabled(plan) and task['implementation'] == 'implemented', 'interface confirmation requires implemented provider and work-packet policy')
|
|
528
|
+
capability = event.get('capability')
|
|
529
|
+
demand(any(c['capability'] == capability for c in specs[sid]['provides']), 'interface must belong to provider')
|
|
530
|
+
demand(nonempty(state['candidate']) and event.get('candidate') == state['candidate'], 'interface candidate mismatch')
|
|
531
|
+
demand(nonempty(event.get('reviewer')) and nonempty(event.get('summary')), 'interface behavior review required')
|
|
532
|
+
strings(event.get('symbols'), 'actual interface symbols', True)
|
|
533
|
+
code_files = copy.deepcopy(event.get('code_files'))
|
|
534
|
+
demand(isinstance(code_files, list) and bool(code_files), 'actual interface code files required')
|
|
535
|
+
for ref in code_files:
|
|
536
|
+
demand(isinstance(ref, dict) and nonempty(ref.get('path')), 'interface code path required')
|
|
537
|
+
path = Path(ref['path'])
|
|
538
|
+
path = path if path.is_absolute() else root / path
|
|
539
|
+
demand(path.is_file() and ref.get('sha256') == file_hash(path), 'interface code file/hash mismatch')
|
|
540
|
+
ref['path'] = str(path.resolve())
|
|
541
|
+
task.setdefault('interfaces', {})[capability] = {'candidate': state['candidate'], 'symbols': event['symbols'], 'summary': event['summary'], 'reviewer': event['reviewer'], 'code_files': code_files}
|
|
542
|
+
elif kind == 'accept':
|
|
543
|
+
demand(snapshot['tasks'][sid]['immediate_passed'] and not snapshot['tasks'][sid]['blocked_by'] and not snapshot['tasks'][sid]['missing_checks'], 'all checks, dependencies and build must pass')
|
|
544
|
+
demand(nonempty(event.get('review')) and nonempty(event.get('reviewer')), 'independent behavior review and reviewer required')
|
|
545
|
+
task.update(acceptance='accepted', reviewed_candidate=state['candidate'], review=event['review'], reviewer=event['reviewer'])
|
|
546
|
+
task.pop('pending_since', None)
|
|
547
|
+
elif kind == 'block':
|
|
548
|
+
strings(event.get('reasons'), 'external blockers', True)
|
|
549
|
+
task['external_blockers'] = event['reasons']
|
|
550
|
+
if task['implementation'] == 'in_progress':
|
|
551
|
+
task['implementation'] = 'planned'
|
|
552
|
+
elif kind == 'unblock':
|
|
553
|
+
demand(nonempty(event.get('reason')), 'recovery reason required')
|
|
554
|
+
task['external_blockers'] = []
|
|
555
|
+
elif kind in {'invalidate', 'failed'}:
|
|
556
|
+
demand(nonempty(event.get('reason')), 'invalidation/failure reason required')
|
|
557
|
+
task['evidence'] = []
|
|
558
|
+
task.update(validation='failed' if kind == 'failed' else 'stale', acceptance='pending')
|
|
559
|
+
task.pop('reviewed_candidate', None)
|
|
560
|
+
task.pop('interfaces', None)
|
|
561
|
+
state['milestone_evidence'] = []
|
|
562
|
+
if page_batches.enabled(plan):
|
|
563
|
+
_, _, dependency_edges, _ = validate(plan, features)
|
|
564
|
+
affected = {sid}
|
|
565
|
+
while True:
|
|
566
|
+
expanded = affected | {child for child, parents in dependency_edges.items() if set(parents) & affected}
|
|
567
|
+
if expanded == affected:
|
|
568
|
+
break
|
|
569
|
+
affected = expanded
|
|
570
|
+
affected_rounds = {b['round_id'] for b in plan['page_batches'] if set(b['spec_ids']) & affected}
|
|
571
|
+
state['round_deliveries'] = [proof for proof in state.get('round_deliveries', []) if proof['round_id'] not in affected_rounds]
|
|
572
|
+
if task['implementation'] == 'implemented':
|
|
573
|
+
task.setdefault('pending_since', now.isoformat() if now else stamp())
|
|
574
|
+
elif kind == 'repair':
|
|
575
|
+
demand(not snapshot['running'] and task['implementation'] == 'implemented' and task['validation'] in {'failed', 'stale'}, 'repair requires failed/stale implementation and idle worker')
|
|
576
|
+
demand(not state.get('active_batch') or sid in state['active_batch']['spec_ids'], 'pause active batch before repairing unrelated Spec')
|
|
577
|
+
task.update(implementation='in_progress', acceptance='pending')
|
|
578
|
+
task['evidence'] = []
|
|
579
|
+
task.pop('reviewed_candidate', None)
|
|
580
|
+
task.pop('interfaces', None)
|
|
581
|
+
elif kind == 'pause':
|
|
582
|
+
demand(task['implementation'] == 'in_progress' and nonempty(event.get('reason')), 'pause running work with reason')
|
|
583
|
+
task['implementation'] = 'planned'
|
|
584
|
+
elif kind == 'metrics':
|
|
585
|
+
metrics = event.get('metrics')
|
|
586
|
+
demand(isinstance(metrics, dict), 'metrics object required')
|
|
587
|
+
demand(set(metrics) <= {'implementation_hours', 'test_hours', 'coordination_hours', 'waiting_hours', 'rework_hours', 'elapsed_hours', 'first_pass', 'conflicts', 'context_hours', 'repeated_analysis_hours'}, 'unknown metric')
|
|
588
|
+
for key, value in metrics.items():
|
|
589
|
+
if value is not None:
|
|
590
|
+
demand(type(value) is bool, 'first_pass must be boolean') if key == 'first_pass' else number(value, key)
|
|
591
|
+
if key == 'conflicts':
|
|
592
|
+
demand(type(value) is int, 'conflicts must be integer')
|
|
593
|
+
combined = {**task['metrics'], **metrics}
|
|
594
|
+
for child, parent in [('context_hours', 'implementation_hours'), ('repeated_analysis_hours', 'context_hours')]:
|
|
595
|
+
a, b = combined.get(child), combined.get(parent)
|
|
596
|
+
if type(a) in (int, float) and type(b) in (int, float):
|
|
597
|
+
demand(a <= b, f'{child} must not exceed {parent}')
|
|
598
|
+
task['metrics'].update(metrics)
|
|
599
|
+
else:
|
|
600
|
+
raise ValueError('unknown event type')
|
|
601
|
+
if plan.get('spec_generation_policy') == spec_generation.POLICY and kind in {'start', 'repair'}:
|
|
602
|
+
demand(not spec_generation.gaps(plan, features, specs[sid], root), 'valid generated Spec required before implementation')
|
|
603
|
+
task['spec_generation'] = spec_generation.proof(root, sid)
|
|
604
|
+
state['events'].append({'sequence': len(state['events']) + 1, 'timestamp': now.isoformat() if now else stamp(), 'plan_revision': plan['revision'], 'event': copy.deepcopy(event)})
|
|
605
|
+
final_view = derive(plan, features, state, now, root)
|
|
606
|
+
for key, row in final_view['tasks'].items():
|
|
607
|
+
if not row['accepted']:
|
|
608
|
+
state['tasks'][key]['acceptance'] = 'pending'
|
|
609
|
+
return state
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def reconcile(plan, features, state, previous_plan, previous_features, reason, root=None):
|
|
613
|
+
derive(previous_plan, previous_features, state, root=root)
|
|
614
|
+
validate(plan, features)
|
|
615
|
+
demand(nonempty(reason), 'revision reason required')
|
|
616
|
+
demand(not state.get('active_batch') and not any(t['implementation'] == 'in_progress' for t in state['tasks'].values()), 'explicitly pause running work and active batch before replanning')
|
|
617
|
+
demand(plan['revision'] == previous_plan['revision'] + 1, 'plan revision must increment by one')
|
|
618
|
+
new_ids = {s['spec_id'] for s in plan['specs']}
|
|
619
|
+
retired_ids = {s['spec_id'] for s in plan.get('retired_specs', [])}
|
|
620
|
+
demand(set(state['tasks']) <= new_ids | retired_ids, 'retain historical spec IDs or explicitly retire them')
|
|
621
|
+
if features != previous_features:
|
|
622
|
+
errors = feature_validator()(features, previous_features)
|
|
623
|
+
demand(not errors, '; '.join(errors))
|
|
624
|
+
new = init_state(plan, features)
|
|
625
|
+
new['events'] = copy.deepcopy(state['events'])
|
|
626
|
+
new['history'] = copy.deepcopy(state['history']) + [{'plan': previous_plan, 'features': previous_features, 'execution': {k: copy.deepcopy(v) for k, v in state.items() if k not in {'history', 'events'}}, 'reason': reason}]
|
|
627
|
+
new['batch_runs'] = copy.deepcopy(state.get('batch_runs', []))
|
|
628
|
+
new['round_deliveries'] = copy.deepcopy(state.get('round_deliveries', []))
|
|
629
|
+
old_specs = {s['spec_id']: s for s in previous_plan['specs']}
|
|
630
|
+
old_features = {f['feature_id']: f for f in previous_features['features']}
|
|
631
|
+
new_features = {f['feature_id']: f for f in features['features']}
|
|
632
|
+
for item in plan['specs']:
|
|
633
|
+
sid = item['spec_id']
|
|
634
|
+
if sid in state['tasks']:
|
|
635
|
+
new['tasks'][sid] = copy.deepcopy(state['tasks'][sid])
|
|
636
|
+
semantic_keys = ('required_scope', 'acceptance', 'source_refs', 'origin')
|
|
637
|
+
feature_changed = any(any(old_features.get(fid, {}).get(k) != new_features[fid].get(k) for k in semantic_keys) for fid in item['feature_refs'])
|
|
638
|
+
semantic_spec_keys = ('implementation_brief', 'scope_refs', 'page_id', 'feature_refs', 'scope', 'evidence_refs', 'requires', 'provides', 'checks')
|
|
639
|
+
if features['schema'] == 'd2h-feature-plan/v2':
|
|
640
|
+
for ref in item.get('scope_refs', []):
|
|
641
|
+
fid, rid = ref['feature_id'], ref['scope_id']
|
|
642
|
+
old_item = next((i for i in old_features.get(fid, {}).get('scope_items', []) if i['scope_id'] == rid), None)
|
|
643
|
+
new_item = next(i for i in new_features[fid]['scope_items'] if i['scope_id'] == rid)
|
|
644
|
+
feature_changed = feature_changed or old_item != new_item
|
|
645
|
+
scope_changed = any(old_specs[sid].get(k) != item.get(k) for k in semantic_spec_keys)
|
|
646
|
+
checks_changed = any(old_specs[sid]['testing'].get(k) != item['testing'].get(k) for k in ('immediate', 'batch', 'triggers', 'risk_dimensions', 'layers'))
|
|
647
|
+
related = lambda p: [c for c in p.get('shared_contracts', []) if sid in [c['producer_spec_id'], *c['consumer_spec_ids']]]
|
|
648
|
+
contracts_changed = related(previous_plan) != related(plan) or previous_plan.get('work_packet_policy') != plan.get('work_packet_policy') or previous_plan.get('spec_generation_policy') != plan.get('spec_generation_policy')
|
|
649
|
+
if scope_changed or checks_changed or feature_changed or contracts_changed:
|
|
650
|
+
new['tasks'][sid].update(implementation='planned', validation='stale', acceptance='pending', evidence=[])
|
|
651
|
+
new['tasks'][sid].pop('reviewed_candidate', None)
|
|
652
|
+
new['tasks'][sid].pop('interfaces', None)
|
|
653
|
+
new['candidate'] = state['candidate']
|
|
654
|
+
new['events'].append({'sequence': len(new['events']) + 1, 'timestamp': stamp(), 'plan_revision': plan['revision'], 'event': {'type': 'reconcile', 'reason': reason}})
|
|
655
|
+
final_view = derive(plan, features, new, root=root)
|
|
656
|
+
for key, row in final_view['tasks'].items():
|
|
657
|
+
if not row['accepted']:
|
|
658
|
+
new['tasks'][key]['acceptance'] = 'pending'
|
|
659
|
+
return new
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def render(plan, features, state, root=None):
|
|
663
|
+
view = derive(plan, features, state, root=root)
|
|
664
|
+
report = {
|
|
665
|
+
'schema_version': 'd2h-spec-batch-report/v1',
|
|
666
|
+
'source': {
|
|
667
|
+
'plan_revision': plan['revision'], 'plan_sha256': fingerprint(plan),
|
|
668
|
+
'features_path': plan['feature_source']['path'],
|
|
669
|
+
'features_revision': features['revision'], 'features_sha256': fingerprint(features),
|
|
670
|
+
'execution': 'execution.json', 'event_count': len(state['events']),
|
|
671
|
+
},
|
|
672
|
+
'candidate': state['candidate'],
|
|
673
|
+
'milestone': plan['milestone'],
|
|
674
|
+
'state': view,
|
|
675
|
+
'tasks': {},
|
|
676
|
+
'dispositions': plan.get('dispositions', []),
|
|
677
|
+
'retired_specs': plan.get('retired_specs', []),
|
|
678
|
+
'milestone_evidence': state['milestone_evidence'],
|
|
679
|
+
'batch_runs': state.get('batch_runs', []),
|
|
680
|
+
'round_deliveries': state.get('round_deliveries', []),
|
|
681
|
+
'revision_history': state['history'],
|
|
682
|
+
'history': state['events'],
|
|
683
|
+
}
|
|
684
|
+
feature_map = {feature['feature_id']: feature for feature in features['features']}
|
|
685
|
+
for item in plan['specs']:
|
|
686
|
+
sid = item['spec_id']
|
|
687
|
+
report['tasks'][sid] = {
|
|
688
|
+
'plan': item,
|
|
689
|
+
'execution': state['tasks'][sid],
|
|
690
|
+
'user_priorities': {fid: feature_map[fid]['user_priority'] for fid in item['feature_refs']},
|
|
691
|
+
'current_evidence': current_evidence(state['tasks'][sid], state['candidate']),
|
|
692
|
+
}
|
|
693
|
+
return json.dumps(report, ensure_ascii=False, indent=2) + '\n'
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
REPORT_MARKER = '> Generated by d2h workflow; execution.json is authoritative. Do not edit status here.'
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def report_paths(root):
|
|
700
|
+
return root / 'batch_report.json', root / 'batch_report.md'
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def check_report_targets(root, initializing=False):
|
|
704
|
+
report, markdown = report_paths(root)
|
|
705
|
+
for target in (report, markdown):
|
|
706
|
+
demand(not target.is_symlink(), 'refusing linked report target: ' + str(target))
|
|
707
|
+
if target.exists():
|
|
708
|
+
demand(not initializing, 'existing report requires history-preserving recovery, not init')
|
|
709
|
+
if report.exists():
|
|
710
|
+
payload = json.loads(report.read_text(encoding='utf-8'))
|
|
711
|
+
demand(isinstance(payload, dict) and payload.get('schema_version') == 'd2h-spec-batch-report/v1',
|
|
712
|
+
'report schema mismatch; preserve the legacy page ledger before migration')
|
|
713
|
+
if markdown.exists():
|
|
714
|
+
demand(REPORT_MARKER in markdown.read_text(encoding='utf-8').splitlines()[:5],
|
|
715
|
+
'existing Markdown history requires explicit migration before projection')
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def render_markdown(report):
|
|
719
|
+
def cell(value):
|
|
720
|
+
return str(value if value is not None else 'unknown').replace('|', '\\|').replace('\n', ' ')
|
|
721
|
+
source = report['source']
|
|
722
|
+
lines = ['# Spec 执行报告', '', REPORT_MARKER, '',
|
|
723
|
+
f"- 计划版本:{source['plan_revision']};范围版本:{source['features_revision']}",
|
|
724
|
+
f"- 当前候选:{cell(report['candidate'])};里程碑:{cell(report['milestone'])}",
|
|
725
|
+
f"- 执行事件:{source['event_count']};计划身份:{source['plan_sha256']}", '',
|
|
726
|
+
'| Spec | 当前范围 | 实现 | 验收 | 待验证 | 阻塞 |',
|
|
727
|
+
'|---|---|---|---|---|---|']
|
|
728
|
+
for sid, task in report['tasks'].items():
|
|
729
|
+
status = report['state']['tasks'][sid]
|
|
730
|
+
blockers = status.get('blocked_by', []) + status.get('external_blockers', [])
|
|
731
|
+
lines.append('| ' + ' | '.join(cell(x) for x in (
|
|
732
|
+
sid, '; '.join(task['plan'].get('scope', [])), task['execution']['implementation'],
|
|
733
|
+
'accepted' if status.get('accepted') else 'pending',
|
|
734
|
+
'; '.join(status.get('missing_checks', [])), '; '.join(str(b) for b in blockers))) + ' |')
|
|
735
|
+
lines.extend(['', f"## {cell(sid)}", '',
|
|
736
|
+
'- 实现提交:' + cell(task['execution'].get('commit')),
|
|
737
|
+
'- 当前有效证据数:' + str(len(task['current_evidence']))])
|
|
738
|
+
for evidence in task['current_evidence']:
|
|
739
|
+
lines.append('- 证据:' + cell(json.dumps(evidence, ensure_ascii=False)))
|
|
740
|
+
lines.append('')
|
|
741
|
+
# Keep the summary table contiguous; details follow it.
|
|
742
|
+
rows = [line for line in lines if line.startswith('|')]
|
|
743
|
+
details = [line for line in lines[9:] if not line.startswith('|')]
|
|
744
|
+
return '\n'.join(lines[:8] + [''] + rows + [''] + details).rstrip() + '\n'
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def write_reports(root, plan, features, state):
|
|
748
|
+
report_path, markdown_path = report_paths(root)
|
|
749
|
+
serialized = render(plan, features, state, root)
|
|
750
|
+
write_atomic(report_path, serialized)
|
|
751
|
+
write_atomic(markdown_path, render_markdown(json.loads(serialized)))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def write_atomic(path, value, exclusive=False):
|
|
755
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
756
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix='.spec-runtime-')
|
|
757
|
+
try:
|
|
758
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
|
759
|
+
stream.write(value)
|
|
760
|
+
if exclusive:
|
|
761
|
+
os.link(temporary, path)
|
|
762
|
+
else:
|
|
763
|
+
os.replace(temporary, path)
|
|
764
|
+
finally:
|
|
765
|
+
if os.path.exists(temporary):
|
|
766
|
+
os.unlink(temporary)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def main():
|
|
770
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
771
|
+
parser.add_argument('action', choices=['validate', 'init', 'derive', 'event', 'render', 'reconcile', 'context', 'dashboard', 'generation'])
|
|
772
|
+
parser.add_argument('--plan', type=Path, required=True)
|
|
773
|
+
parser.add_argument('--features', type=Path, required=True)
|
|
774
|
+
parser.add_argument('--planning-only', action='store_true', help='validate structure without requiring generated page documents; validate only')
|
|
775
|
+
parser.add_argument('--spec-id', action='append')
|
|
776
|
+
parser.add_argument('--expected-plan-hash')
|
|
777
|
+
parser.add_argument('--execution', type=Path)
|
|
778
|
+
parser.add_argument('--event', type=Path)
|
|
779
|
+
parser.add_argument('--previous-plan', type=Path)
|
|
780
|
+
parser.add_argument('--previous-features', type=Path)
|
|
781
|
+
parser.add_argument('--reason')
|
|
782
|
+
parser.add_argument('--batch-id')
|
|
783
|
+
parser.add_argument('--output', type=Path)
|
|
784
|
+
parser.add_argument('--baseline-features', type=Path)
|
|
785
|
+
parser.add_argument('--overview', type=Path)
|
|
786
|
+
parser.add_argument('--feature-analysis', type=Path)
|
|
787
|
+
parser.add_argument('--analysis-progress', type=Path)
|
|
788
|
+
parser.add_argument('--expected-sequence', type=int)
|
|
789
|
+
args = parser.parse_args()
|
|
790
|
+
def load(path):
|
|
791
|
+
demand(path is not None, 'missing required file argument')
|
|
792
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
793
|
+
lock = None
|
|
794
|
+
try:
|
|
795
|
+
plan, features = load(args.plan), load(args.features)
|
|
796
|
+
demand(Path(plan.get('feature_source', {}).get('path', '')).resolve() == args.features.resolve(), 'feature source path mismatch')
|
|
797
|
+
demand(not args.planning_only or args.action == 'validate', '--planning-only is restricted to validate')
|
|
798
|
+
validate(plan, features, None if args.planning_only else args.plan.parent)
|
|
799
|
+
if args.action == 'generation':
|
|
800
|
+
receipt = spec_generation.record(plan, features, args.plan.parent, args.spec_id, args.expected_plan_hash)
|
|
801
|
+
write_atomic(args.plan.parent / 'generation.json', json.dumps(receipt, ensure_ascii=False, indent=2) + '\n')
|
|
802
|
+
print(json.dumps({'ok': True, 'spec_ids': args.spec_id, 'note': 'content binding only; not semantic review or acceptance'}))
|
|
803
|
+
return 0
|
|
804
|
+
if args.action == 'validate':
|
|
805
|
+
print(json.dumps({'ok': True, 'schema': SCHEMA, 'plan_sha256': fingerprint(plan), 'planning_only': args.planning_only}))
|
|
806
|
+
return 0
|
|
807
|
+
demand(args.execution is not None, '--execution required')
|
|
808
|
+
if args.action in {'init', 'event', 'reconcile', 'render'}:
|
|
809
|
+
check_report_targets(args.plan.parent, initializing=args.action == 'init')
|
|
810
|
+
if args.action in {'init', 'event', 'reconcile'}:
|
|
811
|
+
lock = args.execution.with_suffix(args.execution.suffix + '.lock')
|
|
812
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
# Lock file is held through read/validate/write. Never remove another process lock.
|
|
814
|
+
try:
|
|
815
|
+
lock.open('x').close()
|
|
816
|
+
except FileExistsError:
|
|
817
|
+
lock = None
|
|
818
|
+
raise ValueError('execution writer already active; inspect stale lock before retry')
|
|
819
|
+
state = init_state(plan, features) if args.action == 'init' else load(args.execution)
|
|
820
|
+
if args.action in {'event', 'reconcile'}:
|
|
821
|
+
demand(args.expected_sequence == len(state['events']), 'expected-sequence mismatch; reread ledger before writing')
|
|
822
|
+
if args.action == 'event':
|
|
823
|
+
payload = load(args.event)
|
|
824
|
+
for event in payload if isinstance(payload, list) else [payload]:
|
|
825
|
+
state = apply_event(plan, features, state, event, args.plan.parent)
|
|
826
|
+
if args.action == 'reconcile':
|
|
827
|
+
state = reconcile(plan, features, state, load(args.previous_plan), load(args.previous_features), args.reason, args.plan.parent)
|
|
828
|
+
if args.action in {'init', 'event', 'reconcile'}:
|
|
829
|
+
write_atomic(args.execution, json.dumps(state, ensure_ascii=False, indent=2) + '\n', args.action == 'init')
|
|
830
|
+
view = derive(plan, features, state, root=args.plan.parent)
|
|
831
|
+
if args.action in {'init', 'event', 'reconcile'}:
|
|
832
|
+
write_reports(args.plan.parent, plan, features, state)
|
|
833
|
+
if args.action == 'render':
|
|
834
|
+
demand(args.output is not None and args.output.resolve() == (args.plan.parent / 'batch_report.json').resolve(), '--output must be plan directory/batch_report.json')
|
|
835
|
+
write_reports(args.plan.parent, plan, features, state)
|
|
836
|
+
if args.action == 'context':
|
|
837
|
+
view = dispatch_context(plan, features, state, args.batch_id, args.plan.parent)
|
|
838
|
+
if args.action == 'dashboard':
|
|
839
|
+
baseline = load(args.baseline_features) if args.baseline_features else None
|
|
840
|
+
if baseline is not None:
|
|
841
|
+
demand(not feature_validator()(baseline, check_analysis=False) and baseline['schema'] == 'd2h-feature-plan/v2', 'valid v2 review baseline required')
|
|
842
|
+
demand(baseline['revision'] <= features['revision'], 'review baseline cannot be newer than current features')
|
|
843
|
+
view = delivery_view.snapshot(plan, features, state, view, stamp(), baseline)
|
|
844
|
+
view['overview'] = dashboard_overview.prepare(load(args.overview) if args.overview else None, state['candidate'])
|
|
845
|
+
if args.feature_analysis:
|
|
846
|
+
handoff_path = Path(__file__).with_name('artifact_bindings.py')
|
|
847
|
+
loader = importlib.util.spec_from_file_location('analysis_handoff', handoff_path)
|
|
848
|
+
handoff = importlib.util.module_from_spec(loader)
|
|
849
|
+
loader.loader.exec_module(handoff)
|
|
850
|
+
report, analysis_overview = handoff.dashboard_report(args.feature_analysis, features)
|
|
851
|
+
# Analysis owns stack/dependency inventory; optional overview supplies measured code/tools and reviewed preparation.
|
|
852
|
+
supplemental = copy.deepcopy(view['overview'])
|
|
853
|
+
for source in supplemental.get('sources', []):
|
|
854
|
+
source['source_id'] = 'supplemental:' + source['source_id']
|
|
855
|
+
for source in supplemental.get('source_checks', []):
|
|
856
|
+
source['source_id'] = 'supplemental:' + source['source_id']
|
|
857
|
+
for row in ([supplemental['code']] if supplemental.get('code') else []) + supplemental.get('tools', []) + supplemental.get('dependencies', []) + supplemental.get('blockers', []):
|
|
858
|
+
row['source_refs'] = ['supplemental:' + ref for ref in row.get('source_refs', [])]
|
|
859
|
+
merged = dashboard_overview.prepare(analysis_overview, state['candidate'])
|
|
860
|
+
merged['code'] = supplemental.get('code') or merged.get('code')
|
|
861
|
+
merged['tools'] = supplemental.get('tools', [])
|
|
862
|
+
merged['sources'] += supplemental.get('sources', [])
|
|
863
|
+
merged['source_checks'] += supplemental.get('source_checks', [])
|
|
864
|
+
merged['stale'] = merged['stale'] or supplemental.get('stale', False)
|
|
865
|
+
# Explicit supplemental preparation records supersede report todos only with a current report binding.
|
|
866
|
+
if args.overview:
|
|
867
|
+
bound = any(s.get('path') == report['path'] and s.get('sha256') == report['sha256'] for s in supplemental.get('sources', []))
|
|
868
|
+
if bound and not supplemental.get('stale', False):
|
|
869
|
+
if 'blockers' in supplemental:
|
|
870
|
+
merged['blockers'] = supplemental['blockers']
|
|
871
|
+
merged['dependencies'] += supplemental.get('dependencies', [])
|
|
872
|
+
view['overview'] = merged
|
|
873
|
+
view['feature_analysis'] = report
|
|
874
|
+
view['analysis'] = dashboard_overview.read_analysis(args.analysis_progress)
|
|
875
|
+
if args.output is not None:
|
|
876
|
+
demand(args.output.resolve() == (args.plan.parent / 'dashboard.html').resolve(), '--output must be plan directory/dashboard.html')
|
|
877
|
+
template = Path(__file__).with_name('assets') / 'dashboard.html'
|
|
878
|
+
write_atomic(args.output, delivery_view.html(view, template.read_text(encoding='utf-8')))
|
|
879
|
+
print(json.dumps(view, ensure_ascii=False, indent=2))
|
|
880
|
+
return 0
|
|
881
|
+
except (ValueError, OSError, KeyError, TypeError, IndexError, AttributeError) as error:
|
|
882
|
+
print(json.dumps({'error': str(error)}, ensure_ascii=False))
|
|
883
|
+
return 2
|
|
884
|
+
finally:
|
|
885
|
+
if lock is not None:
|
|
886
|
+
lock.unlink(missing_ok=True)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
if __name__ == '__main__':
|
|
890
|
+
raise SystemExit(main())
|