okstra 0.172.0 → 0.173.0
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/README.md +8 -6
- package/docs/architecture/storage-model.md +11 -0
- package/docs/architecture.md +16 -14
- package/docs/cli.md +36 -5
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +21 -13
- package/docs/task-process/README.md +5 -3
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +23 -15
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
- package/runtime/prompts/lead/plan-body-verification.md +20 -3
- package/runtime/prompts/lead/report-writer.md +27 -5
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +3 -3
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +50 -38
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +1 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_contract.py +45 -13
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +350 -2
- package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
- package/runtime/templates/reports/final-report-v2.template.md +4 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +21 -1
- package/runtime/templates/reports/html/i18n/ko.json +21 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
- package/runtime/templates/reports/i18n/en.json +13 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +111 -4
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +25 -4
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/validate-implementation-plan-stages.py +106 -1
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +135 -25
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Implementation-option deduplication, ranking, and report semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .exact_coverage import ExactCoverageError, calculate_exact_coverage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
EVALUATION_CRITERIA = (
|
|
16
|
+
"requirement-fit",
|
|
17
|
+
"architecture-fit",
|
|
18
|
+
"change-locality",
|
|
19
|
+
"implementation-complexity",
|
|
20
|
+
"correctness-risk",
|
|
21
|
+
"reversibility",
|
|
22
|
+
"verification-cost",
|
|
23
|
+
"rollout-cost",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_FINGERPRINT_FIELDS = (
|
|
27
|
+
"goal",
|
|
28
|
+
"coreMechanism",
|
|
29
|
+
"architectureBoundaries",
|
|
30
|
+
"expectedChangeAreas",
|
|
31
|
+
)
|
|
32
|
+
_CITATION_RE = re.compile(r"(?:[\w./-]+\.\w+:\d+|§\s*\d+)")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalized_text(value: object) -> str:
|
|
36
|
+
return " ".join(str(value).split()).casefold()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalized_fingerprint_value(value: object) -> object:
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
return _normalized_text(value)
|
|
42
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
43
|
+
normalized = [_normalized_fingerprint_value(item) for item in value]
|
|
44
|
+
return sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True))
|
|
45
|
+
if isinstance(value, Mapping):
|
|
46
|
+
return {
|
|
47
|
+
str(key): _normalized_fingerprint_value(item)
|
|
48
|
+
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
|
49
|
+
}
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def candidate_fingerprint(candidate: Mapping[str, object]) -> str:
|
|
54
|
+
"""Hash the direction-level fields that distinguish a candidate."""
|
|
55
|
+
payload = {
|
|
56
|
+
field: _normalized_fingerprint_value(candidate.get(field))
|
|
57
|
+
for field in _FINGERPRINT_FIELDS
|
|
58
|
+
}
|
|
59
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
60
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def weighted_score(
|
|
64
|
+
scores: Mapping[str, int | float],
|
|
65
|
+
weights: Mapping[str, int | float],
|
|
66
|
+
) -> float:
|
|
67
|
+
"""Return the four-decimal weighted mean for one candidate."""
|
|
68
|
+
if not scores or set(scores) != set(weights):
|
|
69
|
+
raise ValueError("scores and weights must name the same non-empty criteria")
|
|
70
|
+
total_weight = sum(weights.values())
|
|
71
|
+
if total_weight <= 0:
|
|
72
|
+
raise ValueError("criterion weights must have a positive sum")
|
|
73
|
+
total = sum(scores[criterion] * weights[criterion] for criterion in scores)
|
|
74
|
+
return round(total / total_weight, 4)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _criterion_values(rows: object, value_field: str) -> dict[str, Any]:
|
|
78
|
+
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)):
|
|
79
|
+
return {}
|
|
80
|
+
return {
|
|
81
|
+
str(row.get("criterion")): row.get(value_field)
|
|
82
|
+
for row in rows
|
|
83
|
+
if isinstance(row, Mapping)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def rank_valid_options(
|
|
88
|
+
options: Sequence[Mapping[str, object]],
|
|
89
|
+
criteria: Sequence[Mapping[str, object]],
|
|
90
|
+
) -> tuple[str, ...]:
|
|
91
|
+
"""Rank candidates by the fixed descending tie-break contract."""
|
|
92
|
+
weights = _criterion_values(criteria, "weight")
|
|
93
|
+
|
|
94
|
+
def sort_key(option: Mapping[str, object]) -> tuple[object, ...]:
|
|
95
|
+
scores = _criterion_values(option.get("criterionScores"), "score")
|
|
96
|
+
total = weighted_score(scores, weights)
|
|
97
|
+
return (
|
|
98
|
+
-scores["requirement-fit"],
|
|
99
|
+
-scores["correctness-risk"],
|
|
100
|
+
-scores["architecture-fit"],
|
|
101
|
+
-total,
|
|
102
|
+
str(option.get("id") or ""),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return tuple(str(option.get("id")) for option in sorted(options, key=sort_key))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_criteria(data: Mapping[str, object], errors: list[str]) -> dict[str, Any]:
|
|
109
|
+
criteria = data.get("evaluationCriteria")
|
|
110
|
+
rows = criteria if isinstance(criteria, Sequence) else ()
|
|
111
|
+
names = tuple(
|
|
112
|
+
str(row.get("criterion")) for row in rows if isinstance(row, Mapping)
|
|
113
|
+
)
|
|
114
|
+
if names != EVALUATION_CRITERIA:
|
|
115
|
+
errors.append("evaluationCriteria must contain the fixed eight criteria in order")
|
|
116
|
+
weights = _criterion_values(rows, "weight")
|
|
117
|
+
if any(
|
|
118
|
+
not isinstance(weight, int) or isinstance(weight, bool) or not 1 <= weight <= 5
|
|
119
|
+
for weight in weights.values()
|
|
120
|
+
):
|
|
121
|
+
errors.append("evaluationCriteria weights must be integers in 1..5")
|
|
122
|
+
return weights
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _coverage_input(option: Mapping[str, object]) -> tuple[dict[str, str], dict[str, tuple[str, ...]]]:
|
|
126
|
+
coverage_rows = option.get("requirementCoverage")
|
|
127
|
+
commitment_rows = option.get("scopeCommitments")
|
|
128
|
+
statuses = {
|
|
129
|
+
str(row.get("requirementId")): str(row.get("status"))
|
|
130
|
+
for row in coverage_rows or ()
|
|
131
|
+
if isinstance(row, Mapping)
|
|
132
|
+
}
|
|
133
|
+
commitments = {
|
|
134
|
+
str(row.get("id")): tuple(str(item) for item in row.get("requirementIds") or ())
|
|
135
|
+
for row in commitment_rows or ()
|
|
136
|
+
if isinstance(row, Mapping)
|
|
137
|
+
}
|
|
138
|
+
return statuses, commitments
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_option_coverage(
|
|
142
|
+
option: Mapping[str, object],
|
|
143
|
+
original_ids: Sequence[str],
|
|
144
|
+
errors: list[str],
|
|
145
|
+
*,
|
|
146
|
+
require_exact: bool,
|
|
147
|
+
) -> bool:
|
|
148
|
+
option_id = str(option.get("id") or "?")
|
|
149
|
+
statuses, commitments = _coverage_input(option)
|
|
150
|
+
try:
|
|
151
|
+
result = calculate_exact_coverage(original_ids, statuses, commitments)
|
|
152
|
+
except ExactCoverageError as exc:
|
|
153
|
+
errors.append(f"{option_id} exact coverage cannot be calculated: {exc}")
|
|
154
|
+
return False
|
|
155
|
+
if require_exact and result.verdict != "exact":
|
|
156
|
+
errors.append(
|
|
157
|
+
f"{option_id} recalculated coverage verdict is {result.verdict}, not exact"
|
|
158
|
+
)
|
|
159
|
+
if option.get("coverageSummary") != result.as_report_summary():
|
|
160
|
+
errors.append(f"{option_id} coverageSummary does not match recalculated coverage")
|
|
161
|
+
return False
|
|
162
|
+
return result.verdict == "exact"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_option_scores(
|
|
166
|
+
option: Mapping[str, object],
|
|
167
|
+
weights: Mapping[str, Any],
|
|
168
|
+
errors: list[str],
|
|
169
|
+
) -> bool:
|
|
170
|
+
option_id = str(option.get("id") or "?")
|
|
171
|
+
rows = option.get("criterionScores")
|
|
172
|
+
score_rows = rows if isinstance(rows, Sequence) else ()
|
|
173
|
+
names = tuple(
|
|
174
|
+
str(row.get("criterion")) for row in score_rows if isinstance(row, Mapping)
|
|
175
|
+
)
|
|
176
|
+
if names != EVALUATION_CRITERIA:
|
|
177
|
+
errors.append(f"{option_id} criterionScores must contain the fixed eight criteria")
|
|
178
|
+
scores = _criterion_values(score_rows, "score")
|
|
179
|
+
if any(
|
|
180
|
+
not isinstance(score, int) or isinstance(score, bool) or not 1 <= score <= 5
|
|
181
|
+
for score in scores.values()
|
|
182
|
+
):
|
|
183
|
+
errors.append(f"{option_id} criterion score values must be integers in 1..5")
|
|
184
|
+
values_valid = all(
|
|
185
|
+
isinstance(score, int)
|
|
186
|
+
and not isinstance(score, bool)
|
|
187
|
+
and 1 <= score <= 5
|
|
188
|
+
for score in scores.values()
|
|
189
|
+
)
|
|
190
|
+
if (
|
|
191
|
+
set(scores) != set(EVALUATION_CRITERIA)
|
|
192
|
+
or set(weights) != set(EVALUATION_CRITERIA)
|
|
193
|
+
):
|
|
194
|
+
return False
|
|
195
|
+
recalculated = weighted_score(scores, weights)
|
|
196
|
+
weighted_score_valid = option.get("weightedScore") == recalculated
|
|
197
|
+
if not weighted_score_valid:
|
|
198
|
+
errors.append(
|
|
199
|
+
f"{option_id} weightedScore must equal recalculated value {recalculated}"
|
|
200
|
+
)
|
|
201
|
+
return values_valid and weighted_score_valid
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _validate_option_feasibility(
|
|
205
|
+
option: Mapping[str, object],
|
|
206
|
+
participating_analysers: Sequence[str],
|
|
207
|
+
errors: list[str],
|
|
208
|
+
*,
|
|
209
|
+
require_valid: bool,
|
|
210
|
+
) -> bool:
|
|
211
|
+
option_id = str(option.get("id") or "?")
|
|
212
|
+
votes = option.get("feasibilityVotes") or ()
|
|
213
|
+
workers = [
|
|
214
|
+
str(vote.get("worker")) for vote in votes if isinstance(vote, Mapping)
|
|
215
|
+
]
|
|
216
|
+
all_participated = (
|
|
217
|
+
len(workers) == len(set(workers))
|
|
218
|
+
and set(workers) == set(participating_analysers)
|
|
219
|
+
)
|
|
220
|
+
if require_valid and not all_participated:
|
|
221
|
+
errors.append(f"{option_id} must be evaluated by every participating analyser")
|
|
222
|
+
feasible = sum(
|
|
223
|
+
isinstance(vote, Mapping) and vote.get("verdict") == "feasible"
|
|
224
|
+
for vote in votes
|
|
225
|
+
)
|
|
226
|
+
if require_valid and feasible < 2:
|
|
227
|
+
errors.append(f"{option_id} must have at least two feasible votes")
|
|
228
|
+
if require_valid and option.get("safetyBlockers"):
|
|
229
|
+
errors.append(f"{option_id} safetyBlockers must be empty")
|
|
230
|
+
if require_valid and option.get("unresolvedFeasibilityFacts"):
|
|
231
|
+
errors.append(f"{option_id} unresolvedFeasibilityFacts must be empty")
|
|
232
|
+
return (
|
|
233
|
+
all_participated
|
|
234
|
+
and feasible >= 2
|
|
235
|
+
and not option.get("safetyBlockers")
|
|
236
|
+
and not option.get("unresolvedFeasibilityFacts")
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_candidate(
|
|
241
|
+
option: Mapping[str, object],
|
|
242
|
+
original_ids: Sequence[str],
|
|
243
|
+
participating_analysers: Sequence[str],
|
|
244
|
+
weights: Mapping[str, Any],
|
|
245
|
+
errors: list[str],
|
|
246
|
+
*,
|
|
247
|
+
require_valid: bool,
|
|
248
|
+
) -> bool:
|
|
249
|
+
coverage_valid = _validate_option_coverage(
|
|
250
|
+
option, original_ids, errors, require_exact=require_valid
|
|
251
|
+
)
|
|
252
|
+
score_valid = _validate_option_scores(option, weights, errors)
|
|
253
|
+
feasibility_valid = _validate_option_feasibility(
|
|
254
|
+
option,
|
|
255
|
+
participating_analysers,
|
|
256
|
+
errors,
|
|
257
|
+
require_valid=require_valid,
|
|
258
|
+
)
|
|
259
|
+
return coverage_valid and score_valid and feasibility_valid
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_displayed_options(
|
|
263
|
+
data: Mapping[str, object],
|
|
264
|
+
original_ids: Sequence[str],
|
|
265
|
+
participating_analysers: Sequence[str],
|
|
266
|
+
weights: Mapping[str, Any],
|
|
267
|
+
errors: list[str],
|
|
268
|
+
) -> tuple[list[Mapping[str, object]], dict[str, bool]]:
|
|
269
|
+
raw_options = data.get("rankedOptions")
|
|
270
|
+
options = [
|
|
271
|
+
option for option in (raw_options or ()) if isinstance(option, Mapping)
|
|
272
|
+
]
|
|
273
|
+
if len(options) > 3:
|
|
274
|
+
errors.append("rankedOptions must display at most three valid options")
|
|
275
|
+
validity = {
|
|
276
|
+
str(option.get("id")): _validate_candidate(
|
|
277
|
+
option,
|
|
278
|
+
original_ids,
|
|
279
|
+
participating_analysers,
|
|
280
|
+
weights,
|
|
281
|
+
errors,
|
|
282
|
+
require_valid=True,
|
|
283
|
+
)
|
|
284
|
+
for option in options
|
|
285
|
+
}
|
|
286
|
+
return options, validity
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _validate_option_count_and_routing(
|
|
290
|
+
data: Mapping[str, object],
|
|
291
|
+
options: Sequence[Mapping[str, object]],
|
|
292
|
+
valid_candidates: Sequence[Mapping[str, object]],
|
|
293
|
+
errors: list[str],
|
|
294
|
+
) -> None:
|
|
295
|
+
recommended = data.get("recommendedOptionId")
|
|
296
|
+
routing = data.get("routing")
|
|
297
|
+
if not valid_candidates:
|
|
298
|
+
if recommended is not None:
|
|
299
|
+
errors.append("recommendedOptionId must be null when no valid options exist")
|
|
300
|
+
if routing != "blocked":
|
|
301
|
+
errors.append("routing must be blocked only when no valid options exist")
|
|
302
|
+
return
|
|
303
|
+
if routing == "blocked":
|
|
304
|
+
errors.append("routing may be blocked only when no valid options exist")
|
|
305
|
+
if not options:
|
|
306
|
+
errors.append("recommendedOptionId must name the first ranked option")
|
|
307
|
+
return
|
|
308
|
+
if recommended != options[0].get("id"):
|
|
309
|
+
errors.append("recommendedOptionId must name the first ranked option")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _validate_candidate_audit(
|
|
313
|
+
data: Mapping[str, object],
|
|
314
|
+
options: Sequence[Mapping[str, object]],
|
|
315
|
+
original_ids: Sequence[str],
|
|
316
|
+
participating_analysers: Sequence[str],
|
|
317
|
+
weights: Mapping[str, Any],
|
|
318
|
+
errors: list[str],
|
|
319
|
+
) -> tuple[list[Mapping[str, object]], dict[str, bool]]:
|
|
320
|
+
audit = [
|
|
321
|
+
row for row in (data.get("candidateAudit") or ()) if isinstance(row, Mapping)
|
|
322
|
+
]
|
|
323
|
+
validity = {
|
|
324
|
+
str(row.get("id")): _validate_candidate(
|
|
325
|
+
row,
|
|
326
|
+
original_ids,
|
|
327
|
+
participating_analysers,
|
|
328
|
+
weights,
|
|
329
|
+
errors,
|
|
330
|
+
require_valid=False,
|
|
331
|
+
)
|
|
332
|
+
for row in audit
|
|
333
|
+
}
|
|
334
|
+
_validate_candidate_ids_and_caps(options, audit, participating_analysers, errors)
|
|
335
|
+
_validate_candidate_fingerprints(options, audit, errors)
|
|
336
|
+
return audit, validity
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _validate_candidate_ids_and_caps(
|
|
340
|
+
options: Sequence[Mapping[str, object]],
|
|
341
|
+
audit: Sequence[Mapping[str, object]],
|
|
342
|
+
participating_analysers: Sequence[str],
|
|
343
|
+
errors: list[str],
|
|
344
|
+
) -> None:
|
|
345
|
+
candidates = [*options, *audit]
|
|
346
|
+
candidate_ids = [str(candidate.get("id")) for candidate in candidates]
|
|
347
|
+
if len(candidate_ids) != len(set(candidate_ids)):
|
|
348
|
+
errors.append("all IO-NNN candidate ids must be unique")
|
|
349
|
+
counts = Counter(str(candidate.get("proposedBy")) for candidate in candidates)
|
|
350
|
+
analyser_set = set(participating_analysers)
|
|
351
|
+
if any(worker not in analyser_set for worker in counts):
|
|
352
|
+
errors.append("every candidate proposedBy must name a participating analyser")
|
|
353
|
+
if any(count > 3 for count in counts.values()):
|
|
354
|
+
errors.append("each analyser may submit at most three raw candidates")
|
|
355
|
+
if len(candidate_ids) > len(analyser_set) * 3:
|
|
356
|
+
errors.append("total raw candidates exceed analyser count times three")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _validate_candidate_fingerprints(
|
|
360
|
+
options: Sequence[Mapping[str, object]],
|
|
361
|
+
audit: Sequence[Mapping[str, object]],
|
|
362
|
+
errors: list[str],
|
|
363
|
+
) -> None:
|
|
364
|
+
displayed = {str(option.get("id")): option for option in options}
|
|
365
|
+
groups: dict[str, list[Mapping[str, object]]] = {}
|
|
366
|
+
for candidate in [*options, *audit]:
|
|
367
|
+
groups.setdefault(candidate_fingerprint(candidate), []).append(candidate)
|
|
368
|
+
for row in audit:
|
|
369
|
+
target_id = row.get("mergedInto")
|
|
370
|
+
target = displayed.get(str(target_id))
|
|
371
|
+
if row.get("disposition") == "merged":
|
|
372
|
+
if target is None:
|
|
373
|
+
errors.append("candidateAudit mergedInto must target one displayed option")
|
|
374
|
+
elif candidate_fingerprint(row) != candidate_fingerprint(target):
|
|
375
|
+
errors.append("candidateAudit mergedInto target must share its fingerprint")
|
|
376
|
+
elif target_id is not None:
|
|
377
|
+
errors.append("rejected candidateAudit rows must not set mergedInto")
|
|
378
|
+
for group in groups.values():
|
|
379
|
+
if len(group) < 2:
|
|
380
|
+
continue
|
|
381
|
+
targets = [row for row in group if str(row.get("id")) in displayed]
|
|
382
|
+
if len(targets) != 1:
|
|
383
|
+
errors.append("duplicate candidate fingerprint must converge to one displayed option")
|
|
384
|
+
continue
|
|
385
|
+
target_id = targets[0].get("id")
|
|
386
|
+
for row in group:
|
|
387
|
+
if row is targets[0]:
|
|
388
|
+
continue
|
|
389
|
+
if row.get("disposition") != "merged" or row.get("mergedInto") != target_id:
|
|
390
|
+
errors.append("duplicate candidate fingerprint must be merged into its displayed option")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _validate_complete_ranking(
|
|
394
|
+
data: Mapping[str, object],
|
|
395
|
+
options: Sequence[Mapping[str, object]],
|
|
396
|
+
displayed_validity: Mapping[str, bool],
|
|
397
|
+
audit: Sequence[Mapping[str, object]],
|
|
398
|
+
audit_validity: Mapping[str, bool],
|
|
399
|
+
errors: list[str],
|
|
400
|
+
) -> list[Mapping[str, object]]:
|
|
401
|
+
valid = [
|
|
402
|
+
option for option in options if displayed_validity.get(str(option.get("id")))
|
|
403
|
+
]
|
|
404
|
+
displayed_fingerprints = {candidate_fingerprint(option) for option in options}
|
|
405
|
+
valid.extend(
|
|
406
|
+
row
|
|
407
|
+
for row in audit
|
|
408
|
+
if row.get("disposition") == "rejected"
|
|
409
|
+
and audit_validity.get(str(row.get("id")))
|
|
410
|
+
and candidate_fingerprint(row) not in displayed_fingerprints
|
|
411
|
+
)
|
|
412
|
+
expected = rank_valid_options(valid, data.get("evaluationCriteria") or ())[:3]
|
|
413
|
+
reported = tuple(str(option.get("id")) for option in options)
|
|
414
|
+
if reported != expected:
|
|
415
|
+
errors.append("rankedOptions must contain the top valid candidates in order")
|
|
416
|
+
return valid
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _validate_mode(
|
|
420
|
+
data: Mapping[str, object],
|
|
421
|
+
options: Sequence[Mapping[str, object]],
|
|
422
|
+
audit: Sequence[Mapping[str, object]],
|
|
423
|
+
errors: list[str],
|
|
424
|
+
) -> None:
|
|
425
|
+
mode = data.get("mode")
|
|
426
|
+
direction = data.get("preselectedDirection")
|
|
427
|
+
if mode == "candidate-comparison":
|
|
428
|
+
if direction is not None:
|
|
429
|
+
errors.append("candidate-comparison must not set preselectedDirection")
|
|
430
|
+
if options and data.get("routing") != "pending-direction-selection":
|
|
431
|
+
errors.append("candidate-comparison with options must await direction selection")
|
|
432
|
+
return
|
|
433
|
+
if len(options) != 1:
|
|
434
|
+
errors.append("preselected-validation must contain exactly one validated option")
|
|
435
|
+
if audit:
|
|
436
|
+
errors.append("preselected-validation candidateAudit must be empty")
|
|
437
|
+
if not isinstance(direction, Mapping):
|
|
438
|
+
errors.append("preselected-validation requires preselectedDirection")
|
|
439
|
+
return
|
|
440
|
+
if options and direction.get("optionId") != options[0].get("id"):
|
|
441
|
+
errors.append("preselectedDirection optionId must name the validated option")
|
|
442
|
+
if not _CITATION_RE.search(str(direction.get("citation") or "")):
|
|
443
|
+
errors.append("preselectedDirection citation must be citable")
|
|
444
|
+
if data.get("routing") != "implementation-planning":
|
|
445
|
+
errors.append("validated preselected direction must route to implementation-planning")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def validate_implementation_option_selection(
|
|
449
|
+
data: Mapping[str, object],
|
|
450
|
+
original_ids: Sequence[str],
|
|
451
|
+
participating_analysers: Sequence[str],
|
|
452
|
+
) -> list[str]:
|
|
453
|
+
"""Recompute all selection semantics instead of trusting report summaries."""
|
|
454
|
+
errors: list[str] = []
|
|
455
|
+
declared_ids = tuple(
|
|
456
|
+
(data.get("decisionContext") or {}).get("originalRequirementIds") or ()
|
|
457
|
+
)
|
|
458
|
+
if declared_ids != tuple(original_ids):
|
|
459
|
+
errors.append("decisionContext originalRequirementIds do not match the source ids")
|
|
460
|
+
if len(participating_analysers) != len(set(participating_analysers)):
|
|
461
|
+
errors.append("participating analyser ids must be unique")
|
|
462
|
+
weights = _validate_criteria(data, errors)
|
|
463
|
+
options, displayed_validity = _validate_displayed_options(
|
|
464
|
+
data, original_ids, participating_analysers, weights, errors
|
|
465
|
+
)
|
|
466
|
+
audit, audit_validity = _validate_candidate_audit(
|
|
467
|
+
data,
|
|
468
|
+
options,
|
|
469
|
+
original_ids,
|
|
470
|
+
participating_analysers,
|
|
471
|
+
weights,
|
|
472
|
+
errors,
|
|
473
|
+
)
|
|
474
|
+
valid_candidates = _validate_complete_ranking(
|
|
475
|
+
data, options, displayed_validity, audit, audit_validity, errors
|
|
476
|
+
)
|
|
477
|
+
_validate_option_count_and_routing(data, options, valid_candidates, errors)
|
|
478
|
+
_validate_mode(data, options, audit, errors)
|
|
479
|
+
return errors
|
|
@@ -31,9 +31,18 @@ _SUBJECT_FIELDS = {
|
|
|
31
31
|
"dependencyMigrationRisk": ("subject", "item", "title", "name", "action"),
|
|
32
32
|
"validationChecklist": ("subject", "check", "title", "name", "action"),
|
|
33
33
|
"rollbackStrategy": ("subject", "action", "title", "name"),
|
|
34
|
-
"requirementCoverage": (
|
|
34
|
+
"requirementCoverage": (
|
|
35
|
+
"subject",
|
|
36
|
+
"requirement",
|
|
37
|
+
"originalRequirementId",
|
|
38
|
+
"title",
|
|
39
|
+
"name",
|
|
40
|
+
"action",
|
|
41
|
+
),
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
_SELECTED_DIRECTION_CONTRACT = "selected-direction"
|
|
45
|
+
|
|
37
46
|
|
|
38
47
|
def _non_empty_string(value: object) -> str | None:
|
|
39
48
|
if isinstance(value, str) and value.strip():
|
|
@@ -93,7 +102,27 @@ def _coordinate(value: object, field: str) -> int:
|
|
|
93
102
|
|
|
94
103
|
def _extract_standard_items(planning: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
95
104
|
items: list[dict[str, Any]] = []
|
|
96
|
-
|
|
105
|
+
sources = (
|
|
106
|
+
PLAN_ITEM_SOURCES[1:]
|
|
107
|
+
if planning.get("planningContract") == _SELECTED_DIRECTION_CONTRACT
|
|
108
|
+
else PLAN_ITEM_SOURCES
|
|
109
|
+
)
|
|
110
|
+
if planning.get("planningContract") == _SELECTED_DIRECTION_CONTRACT:
|
|
111
|
+
realization = planning.get("directionRealization")
|
|
112
|
+
if not isinstance(realization, Mapping):
|
|
113
|
+
raise PlanItemContractError("directionRealization must be an object")
|
|
114
|
+
_add_item(
|
|
115
|
+
items,
|
|
116
|
+
{
|
|
117
|
+
"id": "P-Dir-1",
|
|
118
|
+
"subject": _non_empty_string(realization.get("goal"))
|
|
119
|
+
or "Selected direction realization",
|
|
120
|
+
"sourceSection": "Selected Direction",
|
|
121
|
+
"ticketId": "",
|
|
122
|
+
"payload": deepcopy(dict(realization)),
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
for source, prefix, section in sources:
|
|
97
126
|
if source == "stepwiseExecution":
|
|
98
127
|
_extract_step_items(planning, items, prefix, section)
|
|
99
128
|
continue
|
|
@@ -160,8 +189,21 @@ def _extract_prep_items(
|
|
|
160
189
|
) -> None:
|
|
161
190
|
if not planning.get("stages"):
|
|
162
191
|
return
|
|
192
|
+
detection_input = planning
|
|
193
|
+
if planning.get("planningContract") == _SELECTED_DIRECTION_CONTRACT:
|
|
194
|
+
realization = planning.get("directionRealization")
|
|
195
|
+
if not isinstance(realization, Mapping):
|
|
196
|
+
raise PlanItemContractError("directionRealization must be an object")
|
|
197
|
+
detection_input = dict(planning)
|
|
198
|
+
detection_input["optionCandidates"] = [
|
|
199
|
+
{
|
|
200
|
+
"name": "Selected Direction",
|
|
201
|
+
"fileStructure": realization.get("fileStructure") or [],
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
detection_input["recommendedOption"] = {"name": "Selected Direction"}
|
|
163
205
|
try:
|
|
164
|
-
triggers = detect_design_surfaces(
|
|
206
|
+
triggers = detect_design_surfaces(detection_input)
|
|
165
207
|
except (DesignSurfaceError, TypeError, AttributeError) as exc:
|
|
166
208
|
raise PlanItemContractError(f"design-surface detection failed: {exc}") from exc
|
|
167
209
|
for trigger in triggers:
|
|
@@ -230,6 +272,12 @@ def extract_plan_items(implementation_planning: Mapping[str, Any]) -> list[dict[
|
|
|
230
272
|
"""Return deterministic, lossless P-* items in contract order."""
|
|
231
273
|
if not isinstance(implementation_planning, Mapping):
|
|
232
274
|
raise PlanItemContractError("implementationPlanning must be an object")
|
|
275
|
+
if (
|
|
276
|
+
implementation_planning.get("planningContract")
|
|
277
|
+
== _SELECTED_DIRECTION_CONTRACT
|
|
278
|
+
and implementation_planning.get("outcome") == "direction-invalidated"
|
|
279
|
+
):
|
|
280
|
+
return []
|
|
233
281
|
items = _extract_standard_items(implementation_planning)
|
|
234
282
|
_extract_prep_items(implementation_planning, items)
|
|
235
283
|
_extract_variation_point_items(implementation_planning, items)
|
|
@@ -1517,6 +1517,7 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1517
1517
|
adversarial_phases = {
|
|
1518
1518
|
"requirements-discovery",
|
|
1519
1519
|
"error-analysis",
|
|
1520
|
+
"implementation-option-selection",
|
|
1520
1521
|
"implementation-planning",
|
|
1521
1522
|
"project-analysis",
|
|
1522
1523
|
"feature-analysis",
|
|
@@ -70,6 +70,7 @@ TASK_DELIVERABLE_TITLES = {
|
|
|
70
70
|
"requirements-discovery": "Requirements Discovery",
|
|
71
71
|
"improvement-discovery": "Improvement Discovery",
|
|
72
72
|
"error-analysis": "Error Analysis",
|
|
73
|
+
"implementation-option-selection": "Implementation Option Selection",
|
|
73
74
|
"project-analysis": "Project Analysis",
|
|
74
75
|
"feature-analysis": "Feature Analysis",
|
|
75
76
|
"change-impact-analysis": "Change Impact Analysis",
|
|
@@ -9,6 +9,7 @@ PUBLIC_REPORT_TASK_TYPES = (
|
|
|
9
9
|
"requirements-discovery",
|
|
10
10
|
"improvement-discovery",
|
|
11
11
|
"error-analysis",
|
|
12
|
+
"implementation-option-selection",
|
|
12
13
|
"project-analysis",
|
|
13
14
|
"feature-analysis",
|
|
14
15
|
"change-impact-analysis",
|
|
@@ -22,6 +23,7 @@ TASK_TYPE_DATA_PROPERTY = {
|
|
|
22
23
|
"requirements-discovery": "requirementsDiscovery",
|
|
23
24
|
"improvement-discovery": "improvementDiscovery",
|
|
24
25
|
"error-analysis": "errorAnalysis",
|
|
26
|
+
"implementation-option-selection": "implementationOptionSelection",
|
|
25
27
|
"project-analysis": "projectAnalysis",
|
|
26
28
|
"feature-analysis": "featureAnalysis",
|
|
27
29
|
"change-impact-analysis": "changeImpactAnalysis",
|
|
@@ -63,6 +65,12 @@ TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
|
|
|
63
65
|
"errorAnalysis.causeCandidates.falsifyingEvidenceChecked",
|
|
64
66
|
"errorAnalysis.causeCandidates.disproveWith",
|
|
65
67
|
),
|
|
68
|
+
"implementation-option-selection": (
|
|
69
|
+
"implementationOptionSelection.decisionContext.originalRequirementIds",
|
|
70
|
+
"implementationOptionSelection.rankedOptions.requirementCoverage",
|
|
71
|
+
"implementationOptionSelection.rankedOptions.coverageSummary",
|
|
72
|
+
"implementationOptionSelection.recommendedOptionId",
|
|
73
|
+
),
|
|
66
74
|
"project-analysis": (
|
|
67
75
|
"projectAnalysis.components",
|
|
68
76
|
"projectAnalysis.entryPoints",
|
|
@@ -89,20 +97,12 @@ TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
|
|
|
89
97
|
"changeImpactAnalysis.securityAndPerformanceImpact",
|
|
90
98
|
"changeImpactAnalysis.planningInputs",
|
|
91
99
|
),
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# requirement-coverage section, and both still render in the markdown report
|
|
97
|
-
# (`final-report.template.md` §"Validation Checklist" / §"Rollback
|
|
98
|
-
# Strategy"). When that split landed the template dropped the sections and
|
|
99
|
-
# this list kept demanding them, so `validate-report-views.py` failed every
|
|
100
|
-
# implementation-planning report with nothing a report could do about it.
|
|
100
|
+
# Legacy plans render option comparison fields; selected-direction plans
|
|
101
|
+
# render the selected reference, its realization, and exact coverage. The
|
|
102
|
+
# task-level marker is the only field common to both HTML branches. Each
|
|
103
|
+
# branch's required details are covered by its schema and renderer tests.
|
|
101
104
|
"implementation-planning": (
|
|
102
|
-
"implementationPlanning
|
|
103
|
-
"implementationPlanning.tradeoffMatrix",
|
|
104
|
-
"implementationPlanning.recommendedOption",
|
|
105
|
-
"implementationPlanning.stageMap",
|
|
105
|
+
"implementationPlanning",
|
|
106
106
|
),
|
|
107
107
|
"implementation": (
|
|
108
108
|
"implementation.diffSummary",
|
|
@@ -124,6 +124,38 @@ TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
|
|
|
124
124
|
),
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
IMPLEMENTATION_PLANNING_LEGACY_REQUIRED_HUMAN_FIELDS = (
|
|
128
|
+
"implementationPlanning.optionCandidates",
|
|
129
|
+
"implementationPlanning.tradeoffMatrix",
|
|
130
|
+
"implementationPlanning.recommendedOption",
|
|
131
|
+
"implementationPlanning.stageMap",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
IMPLEMENTATION_PLANNING_PLAN_READY_REQUIRED_HUMAN_FIELDS = (
|
|
135
|
+
"implementationPlanning.selectedDirectionRef",
|
|
136
|
+
"implementationPlanning.directionRealization",
|
|
137
|
+
"implementationPlanning.coverageSummary",
|
|
138
|
+
"implementationPlanning.stageMap",
|
|
139
|
+
"implementationPlanning.requirementCoverage",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
IMPLEMENTATION_PLANNING_INVALIDATED_REQUIRED_HUMAN_FIELDS = (
|
|
143
|
+
"implementationPlanning.selectedDirectionRef",
|
|
144
|
+
"implementationPlanning.directionInvalidation",
|
|
145
|
+
"implementationPlanning.routing",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def required_human_fields_for_report(task_type: str, data: dict) -> tuple[str, ...]:
|
|
150
|
+
if task_type != "implementation-planning":
|
|
151
|
+
return TASK_TYPE_REQUIRED_HUMAN_FIELDS.get(task_type, ())
|
|
152
|
+
planning = data.get("implementationPlanning", {})
|
|
153
|
+
if planning.get("planningContract") != "selected-direction":
|
|
154
|
+
return IMPLEMENTATION_PLANNING_LEGACY_REQUIRED_HUMAN_FIELDS
|
|
155
|
+
if planning.get("outcome") == "direction-invalidated":
|
|
156
|
+
return IMPLEMENTATION_PLANNING_INVALIDATED_REQUIRED_HUMAN_FIELDS
|
|
157
|
+
return IMPLEMENTATION_PLANNING_PLAN_READY_REQUIRED_HUMAN_FIELDS
|
|
158
|
+
|
|
127
159
|
|
|
128
160
|
def report_property_for(task_type: str) -> str:
|
|
129
161
|
try:
|