okstra 0.172.0 → 0.174.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 (123) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +24 -3
  3. package/docs/architecture.md +21 -35
  4. package/docs/cli.md +39 -7
  5. package/docs/container.md +1 -1
  6. package/docs/contributor-change-matrix.md +1 -1
  7. package/docs/performance-improvement-plan-v2.md +6 -5
  8. package/docs/project-structure-overview.md +33 -25
  9. package/docs/task-process/README.md +6 -4
  10. package/docs/task-process/error-analysis.md +2 -2
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation-option-selection.md +70 -0
  13. package/docs/task-process/implementation-planning.md +24 -16
  14. package/docs/task-process/requirements-discovery.md +2 -2
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/report-writer-worker.md +30 -6
  19. package/runtime/bin/lib/okstra/cli.sh +5 -1
  20. package/runtime/bin/lib/okstra/globals.sh +2 -1
  21. package/runtime/bin/lib/okstra/usage.sh +3 -0
  22. package/runtime/bin/okstra-provider-exec.py +29 -12
  23. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  24. package/runtime/bin/okstra.sh +2 -0
  25. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  26. package/runtime/prompts/duties/planning-worker.md +12 -4
  27. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  28. package/runtime/prompts/lead/context-loader.md +1 -1
  29. package/runtime/prompts/lead/convergence.md +5 -5
  30. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  31. package/runtime/prompts/lead/plan-body-verification.md +23 -6
  32. package/runtime/prompts/lead/report-writer.md +33 -11
  33. package/runtime/prompts/profiles/_common-contract.md +3 -3
  34. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  35. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  36. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  37. package/runtime/prompts/profiles/error-analysis.md +4 -4
  38. package/runtime/prompts/profiles/final-verification.md +3 -3
  39. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  40. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  41. package/runtime/prompts/profiles/implementation-planning.md +61 -46
  42. package/runtime/prompts/profiles/implementation.md +4 -2
  43. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  44. package/runtime/prompts/profiles/release-handoff.md +1 -1
  45. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  46. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  47. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  48. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  49. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -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/conformance.py +68 -0
  53. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  54. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  55. package/runtime/python/okstra_ctl/doctor.py +2 -2
  56. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  57. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  58. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  59. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  60. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  61. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  62. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  63. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  64. package/runtime/python/okstra_ctl/render.py +1 -0
  65. package/runtime/python/okstra_ctl/render_final_report.py +16 -19
  66. package/runtime/python/okstra_ctl/report_contract.py +45 -14
  67. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  68. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  69. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  70. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  71. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  72. package/runtime/python/okstra_ctl/report_views.py +148 -12
  73. package/runtime/python/okstra_ctl/run.py +393 -4
  74. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  75. package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
  76. package/runtime/python/okstra_ctl/session.py +69 -12
  77. package/runtime/python/okstra_ctl/team.py +51 -25
  78. package/runtime/python/okstra_ctl/tmux.py +19 -149
  79. package/runtime/python/okstra_ctl/user_response.py +75 -0
  80. package/runtime/python/okstra_ctl/wizard.py +144 -0
  81. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  82. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  83. package/runtime/python/okstra_ctl/workflow.py +29 -7
  84. package/runtime/python/okstra_ctl/worktree.py +69 -3
  85. package/runtime/python/okstra_token_usage/cli.py +1 -1
  86. package/runtime/python/okstra_token_usage/collect.py +66 -6
  87. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  88. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  89. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  90. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  91. package/runtime/templates/reports/html/base.template.html +3 -2
  92. package/runtime/templates/reports/html/i18n/en.json +21 -1
  93. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  94. package/runtime/templates/reports/html/macros/forms.html +21 -2
  95. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  97. package/runtime/templates/reports/i18n/en.json +13 -0
  98. package/runtime/templates/reports/implementation-input.template.md +4 -2
  99. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  100. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  101. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  102. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  103. package/runtime/templates/reports/report.js +111 -4
  104. package/runtime/templates/reports/settings.template.json +0 -24
  105. package/runtime/templates/reports/task-brief.template.md +9 -3
  106. package/runtime/templates/reports/user-response.template.md +25 -4
  107. package/runtime/templates/worker-prompt-preamble.md +8 -0
  108. package/runtime/validators/lib/fixtures.sh +49 -17
  109. package/runtime/validators/validate-implementation-plan-stages.py +169 -4
  110. package/runtime/validators/validate-report-views.py +2 -2
  111. package/runtime/validators/validate-run.py +149 -498
  112. package/runtime/validators/validate_improvement_report.py +5 -1
  113. package/runtime/validators/validate_session_conformance.py +1 -1
  114. package/src/cli-registry.mjs +8 -1
  115. package/src/commands/execute/codex-run.mjs +1 -0
  116. package/src/commands/execute/render-bundle.mjs +1 -0
  117. package/src/commands/execute/team.mjs +3 -3
  118. package/src/commands/execute/worktree-status.mjs +109 -0
  119. package/src/commands/lifecycle/install.mjs +0 -2
  120. package/src/commands/report/finalize.mjs +13 -6
  121. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  122. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  123. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -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
@@ -1,5 +1,13 @@
1
- """완료(terminal) worker 판정 — pane 회수용. 판정 SSOT 는
2
- wrapper_status.read_wrapper_status 이며 여기서 재사용한다."""
1
+ """활성 run 스코프 조회 — pane 정리 의무를 어느 run 에 걸지 고르는 데 쓴다.
2
+
3
+ 호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
4
+ 직후 리드에게 "이 run 의 완료 teammate pane 을 라운드 경계마다 회수하라"는 의무를
5
+ 재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
6
+
7
+ 완료 판정(`is_completed_status`)과 프로젝트 무관 전체 조회(`active_run_dirs`)도
8
+ 여기 있었으나, 그것을 쓰던 `okstra-trace-cleanup.sh --reclaim-completed` 와
9
+ `okstra-subagent-reclaim.sh` 가 제거되면서 함께 사라졌다.
10
+ """
3
11
  from __future__ import annotations
4
12
 
5
13
  import json
@@ -8,12 +16,6 @@ from pathlib import Path
8
16
 
9
17
  from .paths import resolve_under_root
10
18
  from .reconcile import NON_TERMINAL_RECENT_STATUSES
11
- from .wrapper_status import read_wrapper_status
12
-
13
-
14
- def is_completed_status(status_path: str) -> bool:
15
- status = read_wrapper_status(Path(status_path))
16
- return status is not None and status.is_terminal
17
19
 
18
20
 
19
21
  def _iter_active_run_dirs(home: Path):
@@ -30,9 +32,9 @@ def _iter_active_run_dirs(home: Path):
30
32
  row = json.loads(line)
31
33
  except json.JSONDecodeError:
32
34
  continue
33
- # 회수 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
34
- # SSOT. reserving 은 아직 run-dir 산출물이 없어(= 회수할 pane 이 없어) 이
35
- # 집합에 들어있지 않으므로 자연히 제외된다(allowlist).
35
+ # 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
36
+ # SSOT. reserving 은 아직 run-dir 산출물이 없어 이 집합에 들어있지
37
+ # 않으므로 자연히 제외된다(allowlist).
36
38
  if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
37
39
  continue
38
40
  run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
@@ -40,11 +42,6 @@ def _iter_active_run_dirs(home: Path):
40
42
  yield row.get("projectRoot"), run_dir
41
43
 
42
44
 
43
- def active_run_dirs(home: Path) -> list[Path]:
44
- """active.jsonl 의 진행 중 run 들의 절대 run-dir 목록."""
45
- return [run_dir for _project_root, run_dir in _iter_active_run_dirs(home)]
46
-
47
-
48
45
  def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
49
46
  """진행 중 run 중 projectRoot 이 cwd(또는 그 상위)인 것의 절대 run-dir 목록.
50
47
  SessionStart(compact) 훅이 이 프로젝트의 run 에만 리마인더를 걸도록 스코프한다."""
@@ -63,16 +60,10 @@ def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
63
60
 
64
61
 
65
62
  def main(argv: list[str]) -> int:
66
- if len(argv) == 2 and argv[0] == "--active-dirs":
67
- for run_dir in active_run_dirs(Path(argv[1])):
68
- print(run_dir)
69
- return 0
70
63
  if len(argv) == 3 and argv[0] == "--active-dirs-for":
71
64
  for run_dir in active_run_dirs_for_project(Path(argv[1]), Path(argv[2])):
72
65
  print(run_dir)
73
66
  return 0
74
- if len(argv) == 1:
75
- return 0 if is_completed_status(argv[0]) else 1
76
67
  return 1
77
68
 
78
69
 
@@ -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)
@@ -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",