okstra 0.171.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 +29 -14
- package/docs/cli.md +40 -7
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +24 -14
- 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/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- 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 +42 -17
- package/runtime/prompts/lead/plan-body-verification.md +42 -14
- package/runtime/prompts/lead/report-writer.md +38 -15
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -2
- 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 +56 -37
- 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/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +306 -0
- 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/clarification_items.py +37 -20
- 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/lead_events.py +47 -4
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +12 -3
- 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_finalize.py +51 -14
- package/runtime/python/okstra_ctl/report_html/common.py +5 -3
- 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 +42 -11
- package/runtime/python/okstra_ctl/report_translation.py +14 -0
- 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_audit_ledger.py +150 -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 +1623 -143
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report-v2.template.md +12 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/assets/base.css +7 -0
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +27 -2
- package/runtime/templates/reports/html/i18n/ko.json +27 -2
- package/runtime/templates/reports/html/macros/forms.html +42 -4
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +61 -2
- package/runtime/templates/reports/i18n/en.json +17 -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 +137 -21
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +28 -5
- package/runtime/templates/worker-prompt-preamble.md +16 -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 +1124 -54
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/runtime/validators/validate_session_conformance.py +523 -35
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
- package/src/commands/report/agent-activity.mjs +21 -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
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
-
|
|
5
|
+
import re
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Mapping
|
|
9
|
+
from typing import Any, Iterator, Mapping
|
|
10
|
+
|
|
11
|
+
from okstra_ctl.run_context import dir_flock
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
REQUIRED_FIELDS = (
|
|
@@ -72,9 +76,48 @@ class LeadEvent:
|
|
|
72
76
|
|
|
73
77
|
def append_lead_event(path: Path, event: LeadEvent) -> None:
|
|
74
78
|
"""Append one lead event as compact JSONL, creating parent directories."""
|
|
79
|
+
with _event_log_lock(path):
|
|
80
|
+
_append_unlocked(path, event)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextmanager
|
|
84
|
+
def _event_log_lock(path: Path) -> Iterator[None]:
|
|
85
|
+
with dir_flock(path.parent, f".{path.name}.lock"):
|
|
86
|
+
yield
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _append_unlocked(path: Path, event: LeadEvent) -> None:
|
|
75
90
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
-
with path.open("a", encoding="utf-8") as
|
|
77
|
-
|
|
91
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
92
|
+
handle.write(_event_json(event) + "\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _next_activity_id(events: list[LeadEvent]) -> str:
|
|
96
|
+
numbers = [
|
|
97
|
+
int(match.group(1))
|
|
98
|
+
for event in events
|
|
99
|
+
if event.event_type == "activity"
|
|
100
|
+
for match in [
|
|
101
|
+
re.fullmatch(
|
|
102
|
+
r"A-(\d{3,})", str(event.details.get("activityId", ""))
|
|
103
|
+
)
|
|
104
|
+
]
|
|
105
|
+
if match is not None
|
|
106
|
+
]
|
|
107
|
+
return f"A-{max(numbers, default=0) + 1:03d}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def append_activity_event(path: Path, event: LeadEvent) -> LeadEvent:
|
|
111
|
+
"""Assign the next activity ID and append the event under one lock."""
|
|
112
|
+
if event.event_type != "activity":
|
|
113
|
+
raise ValueError("activity append requires eventType=activity")
|
|
114
|
+
with _event_log_lock(path):
|
|
115
|
+
details = dict(event.details)
|
|
116
|
+
details["activityVersion"] = 1
|
|
117
|
+
details["activityId"] = _next_activity_id(read_lead_events(path))
|
|
118
|
+
stored = replace(event, details=details)
|
|
119
|
+
_append_unlocked(path, stored)
|
|
120
|
+
return stored
|
|
78
121
|
|
|
79
122
|
|
|
80
123
|
def read_lead_events(path: Path) -> list[LeadEvent]:
|
|
@@ -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)
|
|
@@ -1501,7 +1501,7 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1501
1501
|
(forces `verificationMode` to "full-reanalysis"), False otherwise
|
|
1502
1502
|
- `planBodyVerification` is implementation-planning specific; the key is
|
|
1503
1503
|
always emitted (dead-letter on other phases) so the schema stays stable.
|
|
1504
|
-
Its `selfFixMaxRounds` default
|
|
1504
|
+
Its `selfFixMaxRounds` default 1 bounds the report-writer self-fix loop
|
|
1505
1505
|
that runs before a planner-fixable defect is promoted to the user.
|
|
1506
1506
|
|
|
1507
1507
|
ctx knobs honoured:
|
|
@@ -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",
|
|
@@ -1557,7 +1558,7 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1557
1558
|
"planBodyVerification": {
|
|
1558
1559
|
"enabled": plan_verify_enabled,
|
|
1559
1560
|
"maxRounds": 1,
|
|
1560
|
-
"selfFixMaxRounds":
|
|
1561
|
+
"selfFixMaxRounds": 1,
|
|
1561
1562
|
"gating": True,
|
|
1562
1563
|
},
|
|
1563
1564
|
}
|
|
@@ -1565,8 +1566,9 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1565
1566
|
|
|
1566
1567
|
def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
1567
1568
|
run_manifest_file = Path(run_manifest_path)
|
|
1569
|
+
run_manifest_exists = run_manifest_file.is_file()
|
|
1568
1570
|
existing_run_manifest = {}
|
|
1569
|
-
if
|
|
1571
|
+
if run_manifest_exists:
|
|
1570
1572
|
try:
|
|
1571
1573
|
loaded_run_manifest = json.loads(
|
|
1572
1574
|
run_manifest_file.read_text(encoding="utf-8")
|
|
@@ -1767,6 +1769,13 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1767
1769
|
payload["analysisScopeConfirmation"] = scope_confirmation
|
|
1768
1770
|
if ctx.get("FIX_CYCLE_ID"):
|
|
1769
1771
|
payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1772
|
+
if ctx.get("TASK_TYPE") == "implementation-planning":
|
|
1773
|
+
if not run_manifest_exists:
|
|
1774
|
+
payload["activityContractVersion"] = 1
|
|
1775
|
+
elif "activityContractVersion" in existing_run_manifest:
|
|
1776
|
+
payload["activityContractVersion"] = existing_run_manifest[
|
|
1777
|
+
"activityContractVersion"
|
|
1778
|
+
]
|
|
1770
1779
|
payload["reportContracts"] = (
|
|
1771
1780
|
["implementation-design-prep-v1"]
|
|
1772
1781
|
if ctx.get("TASK_TYPE") == "implementation-planning"
|
|
@@ -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",
|