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.
Files changed (106) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +11 -0
  3. package/docs/architecture.md +29 -14
  4. package/docs/cli.md +40 -7
  5. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  6. package/docs/performance-improvement-plan-v2.md +6 -5
  7. package/docs/project-structure-overview.md +24 -14
  8. package/docs/task-process/README.md +5 -3
  9. package/docs/task-process/error-analysis.md +2 -2
  10. package/docs/task-process/final-verification.md +2 -2
  11. package/docs/task-process/implementation-option-selection.md +70 -0
  12. package/docs/task-process/implementation-planning.md +23 -15
  13. package/docs/task-process/requirements-discovery.md +2 -2
  14. package/package.json +1 -1
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/agents/workers/report-writer-worker.md +30 -6
  17. package/runtime/bin/lib/okstra/cli.sh +5 -1
  18. package/runtime/bin/lib/okstra/globals.sh +1 -0
  19. package/runtime/bin/lib/okstra/usage.sh +3 -0
  20. package/runtime/bin/okstra.sh +2 -0
  21. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  22. package/runtime/prompts/duties/planning-worker.md +12 -4
  23. package/runtime/prompts/launch.template.md +4 -0
  24. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  25. package/runtime/prompts/lead/context-loader.md +1 -1
  26. package/runtime/prompts/lead/convergence.md +5 -5
  27. package/runtime/prompts/lead/okstra-lead-contract.md +42 -17
  28. package/runtime/prompts/lead/plan-body-verification.md +42 -14
  29. package/runtime/prompts/lead/report-writer.md +38 -15
  30. package/runtime/prompts/lead/team-contract.md +2 -0
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
  32. package/runtime/prompts/profiles/_common-contract.md +3 -2
  33. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  34. package/runtime/prompts/profiles/error-analysis.md +3 -3
  35. package/runtime/prompts/profiles/final-verification.md +3 -3
  36. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  37. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  38. package/runtime/prompts/profiles/implementation-planning.md +56 -37
  39. package/runtime/prompts/profiles/implementation.md +2 -1
  40. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  41. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  42. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  43. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  44. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  45. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  46. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  47. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  48. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  49. package/runtime/python/okstra_ctl/agent_activity.py +306 -0
  50. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  51. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  52. package/runtime/python/okstra_ctl/clarification_items.py +37 -20
  53. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  54. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  55. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  56. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  57. package/runtime/python/okstra_ctl/lead_events.py +47 -4
  58. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  59. package/runtime/python/okstra_ctl/render.py +12 -3
  60. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  61. package/runtime/python/okstra_ctl/report_contract.py +45 -13
  62. package/runtime/python/okstra_ctl/report_finalize.py +51 -14
  63. package/runtime/python/okstra_ctl/report_html/common.py +5 -3
  64. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  65. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  66. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  67. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +42 -11
  68. package/runtime/python/okstra_ctl/report_translation.py +14 -0
  69. package/runtime/python/okstra_ctl/report_views.py +148 -12
  70. package/runtime/python/okstra_ctl/run.py +350 -2
  71. package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
  72. package/runtime/python/okstra_ctl/user_response.py +75 -0
  73. package/runtime/python/okstra_ctl/wizard.py +144 -0
  74. package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
  75. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  76. package/runtime/python/okstra_ctl/workflow.py +29 -7
  77. package/runtime/schemas/final-report-v2.0.schema.json +1623 -143
  78. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  79. package/runtime/templates/reports/final-report-v2.template.md +12 -0
  80. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  81. package/runtime/templates/reports/html/assets/base.css +7 -0
  82. package/runtime/templates/reports/html/base.template.html +3 -2
  83. package/runtime/templates/reports/html/i18n/en.json +27 -2
  84. package/runtime/templates/reports/html/i18n/ko.json +27 -2
  85. package/runtime/templates/reports/html/macros/forms.html +42 -4
  86. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  87. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +61 -2
  88. package/runtime/templates/reports/i18n/en.json +17 -0
  89. package/runtime/templates/reports/implementation-input.template.md +4 -2
  90. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  91. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  92. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  93. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  94. package/runtime/templates/reports/report.js +137 -21
  95. package/runtime/templates/reports/task-brief.template.md +9 -3
  96. package/runtime/templates/reports/user-response.template.md +28 -5
  97. package/runtime/templates/worker-prompt-preamble.md +16 -0
  98. package/runtime/validators/validate-implementation-plan-stages.py +106 -1
  99. package/runtime/validators/validate-report-views.py +2 -2
  100. package/runtime/validators/validate-run.py +1124 -54
  101. package/runtime/validators/validate_improvement_report.py +5 -1
  102. package/runtime/validators/validate_session_conformance.py +523 -35
  103. package/src/cli-registry.mjs +7 -0
  104. package/src/commands/execute/codex-run.mjs +1 -0
  105. package/src/commands/execute/render-bundle.mjs +1 -0
  106. 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
- from dataclasses import dataclass, field
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 f:
77
- f.write(_event_json(event) + "\n")
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": ("subject", "requirement", "title", "name", "action"),
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
- for source, prefix, section in PLAN_ITEM_SOURCES:
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(planning)
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 3 bounds the report-writer self-fix loop
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": 3,
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 run_manifest_file.is_file():
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",