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,306 @@
1
+ """Record lead activity and project it into final-report data."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Mapping, Sequence
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from okstra_ctl.lead_events import (
16
+ LeadEvent,
17
+ append_activity_event,
18
+ read_lead_events,
19
+ )
20
+ from okstra_ctl.worker_audit_ledger import (
21
+ EvidenceCommand,
22
+ parse_evidence_commands,
23
+ read_evidence_commands,
24
+ )
25
+
26
+
27
+ ACTIVITY_KINDS = frozenset({
28
+ "worker-dispatched",
29
+ "worker-completed",
30
+ "verification-round-completed",
31
+ "self-fix-applied",
32
+ "user-decision-required",
33
+ "user-decision-evaluated",
34
+ })
35
+ ACTIVITY_OUTCOMES = frozenset({
36
+ "pending",
37
+ "completed",
38
+ "failed",
39
+ "blocked",
40
+ "resolved",
41
+ })
42
+ ACTIVITY_FIELDS = (
43
+ "activityId",
44
+ "kind",
45
+ "agent",
46
+ "summary",
47
+ "planItemIds",
48
+ "resultPath",
49
+ "commands",
50
+ "evidenceRefs",
51
+ "outcome",
52
+ )
53
+ _ACTIVITY_ID_RE = re.compile(r"^A-(\d{3,})$")
54
+
55
+
56
+ class ActivityProjectionError(ValueError):
57
+ """Raised when activity cannot be recorded or projected safely."""
58
+
59
+
60
+ def _read_json_object(path: Path) -> dict[str, Any]:
61
+ try:
62
+ payload = json.loads(path.read_text(encoding="utf-8"))
63
+ except (OSError, json.JSONDecodeError) as exc:
64
+ raise ActivityProjectionError(f"cannot read JSON object {path}: {exc}") from exc
65
+ if not isinstance(payload, dict):
66
+ raise ActivityProjectionError(f"JSON value must be an object: {path}")
67
+ return payload
68
+
69
+
70
+ def _resolve_project_path(project_root: Path, value: str | Path) -> Path:
71
+ path = Path(value)
72
+ return path if path.is_absolute() else project_root / path
73
+
74
+
75
+ def _require_string(payload: Mapping[str, Any], key: str) -> str:
76
+ value = payload.get(key)
77
+ if not isinstance(value, str) or not value.strip():
78
+ raise ActivityProjectionError(f"required string missing: {key}")
79
+ return value.strip()
80
+
81
+
82
+ def _manifest_run_seq(manifest: Mapping[str, Any]) -> str:
83
+ sequences = manifest.get("runSequencesByCategory")
84
+ if not isinstance(sequences, Mapping):
85
+ raise ActivityProjectionError(
86
+ "run manifest has no runSequencesByCategory object"
87
+ )
88
+ return _require_string(sequences, "manifests")
89
+
90
+
91
+ def _utc_now() -> str:
92
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
93
+
94
+
95
+ def _activity_row(event: LeadEvent) -> dict[str, Any]:
96
+ details = dict(event.details)
97
+ missing = [key for key in ACTIVITY_FIELDS if key not in details]
98
+ if missing:
99
+ raise ActivityProjectionError(
100
+ "activity event is missing " + ", ".join(missing)
101
+ )
102
+ if details["kind"] not in ACTIVITY_KINDS:
103
+ raise ActivityProjectionError(
104
+ f"unsupported activity kind: {details['kind']}"
105
+ )
106
+ if details["outcome"] not in ACTIVITY_OUTCOMES:
107
+ raise ActivityProjectionError(
108
+ f"unsupported activity outcome: {details['outcome']}"
109
+ )
110
+ activity_id = details["activityId"]
111
+ if (
112
+ not isinstance(activity_id, str)
113
+ or _ACTIVITY_ID_RE.fullmatch(activity_id) is None
114
+ ):
115
+ raise ActivityProjectionError(f"invalid activityId: {activity_id}")
116
+ return {key: details[key] for key in ACTIVITY_FIELDS}
117
+
118
+
119
+ def _validate_activity_order(rows: Sequence[Mapping[str, Any]]) -> None:
120
+ ids = [str(row["activityId"]) for row in rows]
121
+ if len(ids) != len(set(ids)):
122
+ raise ActivityProjectionError("activityId values must be unique")
123
+ numbers = [int(_ACTIVITY_ID_RE.fullmatch(value).group(1)) for value in ids]
124
+ if any(current <= previous for previous, current in zip(numbers, numbers[1:])):
125
+ raise ActivityProjectionError(
126
+ "activity events must be in strict activityId order"
127
+ )
128
+
129
+
130
+ def _atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None:
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ temporary_path: Path | None = None
133
+ try:
134
+ with tempfile.NamedTemporaryFile(
135
+ mode="w",
136
+ encoding="utf-8",
137
+ dir=path.parent,
138
+ prefix=f".{path.name}.",
139
+ suffix=".tmp",
140
+ delete=False,
141
+ ) as handle:
142
+ temporary_path = Path(handle.name)
143
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
144
+ handle.write("\n")
145
+ os.replace(temporary_path, path)
146
+ finally:
147
+ if temporary_path is not None and temporary_path.exists():
148
+ temporary_path.unlink()
149
+
150
+
151
+ def record_activity(
152
+ project_root: Path,
153
+ run_manifest_path: Path,
154
+ details: Mapping[str, Any],
155
+ timestamp: str | None = None,
156
+ ) -> LeadEvent:
157
+ """Append one activity event using identity from its run manifest."""
158
+ manifest = _read_json_object(run_manifest_path)
159
+ if manifest.get("activityContractVersion") != 1:
160
+ raise ActivityProjectionError(
161
+ "run manifest does not declare activityContractVersion 1"
162
+ )
163
+ event = LeadEvent(
164
+ event_type="activity",
165
+ lead_runtime=_require_string(manifest, "leadRuntime"),
166
+ task_key=_require_string(manifest, "taskKey"),
167
+ task_type=_require_string(manifest, "taskType"),
168
+ run_seq=_manifest_run_seq(manifest),
169
+ timestamp=timestamp or _utc_now(),
170
+ details=dict(details),
171
+ )
172
+ events_path = _resolve_project_path(
173
+ project_root, _require_string(manifest, "leadEventsPath")
174
+ )
175
+ return append_activity_event(events_path, event)
176
+
177
+
178
+ def project_agent_activity(
179
+ project_root: Path,
180
+ run_manifest_path: Path,
181
+ data_path: Path,
182
+ ) -> tuple[dict[str, Any], ...]:
183
+ """Replace only ``agentActivity`` with this run's canonical events."""
184
+ manifest = _read_json_object(run_manifest_path)
185
+ if manifest.get("activityContractVersion") != 1:
186
+ return ()
187
+ events_path = _resolve_project_path(
188
+ project_root, _require_string(manifest, "leadEventsPath")
189
+ )
190
+ run_seq = _manifest_run_seq(manifest)
191
+ task_key = _require_string(manifest, "taskKey")
192
+ task_type = _require_string(manifest, "taskType")
193
+ events = (
194
+ event
195
+ for event in read_lead_events(events_path)
196
+ if event.event_type == "activity"
197
+ and event.task_key == task_key
198
+ and event.task_type == task_type
199
+ and event.run_seq == run_seq
200
+ )
201
+ rows = tuple(_activity_row(event) for event in events)
202
+ _validate_activity_order(rows)
203
+ data = _read_json_object(data_path)
204
+ data["agentActivity"] = list(rows)
205
+ _atomic_write_json(data_path, data)
206
+ return rows
207
+
208
+
209
+ def _parse_command_records(raw_records: Sequence[str]) -> tuple[EvidenceCommand, ...]:
210
+ content = "\n".join(
211
+ f"- Evidence command: {record}" for record in raw_records
212
+ )
213
+ commands, failures = parse_evidence_commands(content)
214
+ if failures:
215
+ raise ActivityProjectionError("; ".join(failures))
216
+ return commands
217
+
218
+
219
+ def _activity_commands(args: argparse.Namespace) -> tuple[EvidenceCommand, ...]:
220
+ commands = list(_parse_command_records(args.command_record))
221
+ if args.audit_sidecar is not None:
222
+ audit_commands, failures = read_evidence_commands(args.audit_sidecar)
223
+ if failures:
224
+ raise ActivityProjectionError("; ".join(failures))
225
+ commands.extend(audit_commands)
226
+ return tuple(commands)
227
+
228
+
229
+ def _conversation_activity_line(details: Mapping[str, Any]) -> str:
230
+ plan_items = ",".join(details["planItemIds"]) or "<none>"
231
+ result_path = str(details["resultPath"] or "<none>")
232
+ summary = json.dumps(details["summary"], ensure_ascii=False)
233
+ return (
234
+ f"ACTIVITY: id={details['activityId']} agent={details['agent']} "
235
+ f"summary={summary} items={plan_items} result={result_path} "
236
+ f"outcome={details['outcome']}"
237
+ )
238
+
239
+
240
+ def _append(args: argparse.Namespace) -> int:
241
+ commands = _activity_commands(args)
242
+ details = {
243
+ "kind": args.kind,
244
+ "agent": args.agent,
245
+ "summary": args.summary,
246
+ "planItemIds": args.plan_item_id,
247
+ "resultPath": args.result_path,
248
+ "commands": [command.to_record() for command in commands],
249
+ "evidenceRefs": args.evidence_ref,
250
+ "outcome": args.outcome,
251
+ }
252
+ event = record_activity(args.project_root, args.run_manifest, details)
253
+ payload = dict(event.details)
254
+ payload["ok"] = True
255
+ payload["activityLine"] = _conversation_activity_line(payload)
256
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
257
+ return 0
258
+
259
+
260
+ def _project(args: argparse.Namespace) -> int:
261
+ rows = project_agent_activity(args.project_root, args.run_manifest, args.data)
262
+ print(json.dumps(
263
+ {"ok": True, "count": len(rows), "agentActivity": rows},
264
+ ensure_ascii=False,
265
+ indent=2,
266
+ ))
267
+ return 0
268
+
269
+
270
+ def _parser() -> argparse.ArgumentParser:
271
+ parser = argparse.ArgumentParser(prog="okstra agent-activity")
272
+ subparsers = parser.add_subparsers(dest="command", required=True)
273
+ append_parser = subparsers.add_parser("append")
274
+ append_parser.add_argument("--project-root", type=Path, required=True)
275
+ append_parser.add_argument("--run-manifest", type=Path, required=True)
276
+ append_parser.add_argument(
277
+ "--kind", choices=sorted(ACTIVITY_KINDS), required=True
278
+ )
279
+ append_parser.add_argument("--agent", required=True)
280
+ append_parser.add_argument("--summary", required=True)
281
+ append_parser.add_argument(
282
+ "--outcome", choices=sorted(ACTIVITY_OUTCOMES), required=True
283
+ )
284
+ append_parser.add_argument("--plan-item-id", action="append", default=[])
285
+ append_parser.add_argument("--evidence-ref", action="append", default=[])
286
+ append_parser.add_argument("--command-record", action="append", default=[])
287
+ append_parser.add_argument("--result-path", default="")
288
+ append_parser.add_argument("--audit-sidecar", type=Path)
289
+ project_parser = subparsers.add_parser("project")
290
+ project_parser.add_argument("--project-root", type=Path, required=True)
291
+ project_parser.add_argument("--run-manifest", type=Path, required=True)
292
+ project_parser.add_argument("--data", type=Path, required=True)
293
+ return parser
294
+
295
+
296
+ def main(argv: list[str] | None = None) -> int:
297
+ args = _parser().parse_args(argv)
298
+ try:
299
+ return _append(args) if args.command == "append" else _project(args)
300
+ except ActivityProjectionError as exc:
301
+ print(f"okstra agent-activity: {exc}", file=sys.stderr)
302
+ return 1
303
+
304
+
305
+ if __name__ == "__main__":
306
+ raise SystemExit(main(sys.argv[1:]))
@@ -21,6 +21,7 @@ AgentAudience = Literal[
21
21
  "discovery-worker",
22
22
  "diagnosis-worker",
23
23
  "planning-worker",
24
+ "direction-selection-worker",
24
25
  "implementation-executor",
25
26
  "implementation-verifier",
26
27
  "acceptance-verifier",
@@ -57,6 +57,12 @@ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
57
57
  "Brief consumption",
58
58
  "Worker diagnosis procedure",
59
59
  ),
60
+ "implementation-option-selection": (
61
+ "Brief consumption",
62
+ "Worker direction-selection procedure",
63
+ "Pre-selection context exploration",
64
+ "Option evaluation rules",
65
+ ),
60
66
  "implementation-planning": (
61
67
  "Brief consumption",
62
68
  "Worker planning procedure",
@@ -372,6 +372,13 @@ APPROVAL_BLOCKS = frozenset({"approval"})
372
372
  # user-response skill must list them — counting only `approval` made a report
373
373
  # whose open items were all `next-phase` look like it had none.
374
374
  USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
375
+ ANSWER_DISPOSITIONS = frozenset({
376
+ "answer",
377
+ "select",
378
+ "accept-risk",
379
+ "request-revision",
380
+ "reject",
381
+ })
375
382
 
376
383
 
377
384
  @dataclass(frozen=True)
@@ -391,28 +398,39 @@ class ClarificationScan:
391
398
  def scan_approval_gate(report_path: Path) -> ClarificationScan:
392
399
  """Scan for unresolved ``Blocks=approval`` rows (``Status`` in
393
400
  ``{open, answered}``), refusing to guess whenever the schema drifted."""
394
- return scan_clarification_blockers(report_path, APPROVAL_BLOCKS)
401
+ return scan_clarification_blockers(
402
+ report_path, APPROVAL_BLOCKS, honor_sidecar_answers=False
403
+ )
395
404
 
396
405
 
397
406
  def scan_open_user_input(report_path: Path) -> ClarificationScan:
398
407
  """Scan for every unresolved row that still owes the user an answer
399
408
  (``Blocks`` in ``{approval, next-phase}``)."""
400
- return scan_clarification_blockers(report_path, USER_INPUT_BLOCKS)
409
+ return scan_clarification_blockers(
410
+ report_path, USER_INPUT_BLOCKS, honor_sidecar_answers=True
411
+ )
401
412
 
402
413
 
403
414
  def scan_clarification_blockers(
404
- report_path: Path, blocking_values: frozenset[str]
415
+ report_path: Path,
416
+ blocking_values: frozenset[str],
417
+ *,
418
+ honor_sidecar_answers: bool,
405
419
  ) -> ClarificationScan:
406
420
  """Shared fail-closed clarification walk for both gates above — schema-v2
407
- reads its rows from the data sibling, schema-v1 from the §1 table, and the
408
- user's `user-responses/` sidecar outranks whatever the report says about
409
- those rows."""
421
+ reads its rows from the data sibling and schema-v1 from the §1 table.
422
+ ``honor_sidecar_answers`` controls whether a user's sidecar hides answered
423
+ rows from this scan; approval deliberately passes ``False``."""
410
424
  v2_scan = _scan_v2_blockers(report_path, blocking_values)
411
425
  scan = (
412
426
  v2_scan if v2_scan is not None
413
427
  else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
414
428
  )
415
- return _resolve_blockers_answered_by_user(report_path, scan)
429
+ return (
430
+ _resolve_blockers_answered_by_user(report_path, scan)
431
+ if honor_sidecar_answers
432
+ else scan
433
+ )
416
434
 
417
435
 
418
436
  def _resolve_blockers_answered_by_user(
@@ -422,9 +440,8 @@ def _resolve_blockers_answered_by_user(
422
440
 
423
441
  답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
424
442
  run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
425
- `Export user response`, `okstra user-response write`)에서는 갱신되지
426
- 않는다 — 게이트가 리포트만 보면 사용자가 답을 다 채운 뒤에도 같은 항목이
427
- 영원히 미해결로 남아 다음 phase 를 막는다.
443
+ `Export user response`, `okstra user-response write`)에서는 갱신되지 않는다.
444
+ 사용자 입력 목록은 사이드카 답변이 있는 항목을 다시 묻지 않는다.
428
445
 
429
446
  fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
430
447
  어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
@@ -630,10 +647,10 @@ def sidecar_answers(source: Path) -> dict[str, str]:
630
647
  사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
631
648
  게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
632
649
 
633
- `disposition` 이 `answer` 인 항목만 답으로 센다. `reframe` 은 "다음 run 에서
634
- 다시 물어달라" 이지 답이 아니므로(`skills/okstra-user-response/SKILL.md` 의
635
- "A reframe is not an answer") 답 집합에서 빠져야 게이트가 그 항목을 계속
636
- 미해결로 잡는다.
650
+ `disposition` 이 `ANSWER_DISPOSITIONS` 에 속하는 항목만 답으로 센다.
651
+ `reframe` 은 "다음 run 에서 다시 물어달라" 이지 답이 아니므로
652
+ (`skills/okstra-user-response/SKILL.md` 의 "A reframe is not an answer")
653
+ 답 집합에서 빠져야 사용자 입력 목록에 그 항목이 계속 남는다.
637
654
 
638
655
  같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
639
656
  reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
@@ -646,7 +663,7 @@ def sidecar_answers(source: Path) -> dict[str, str]:
646
663
  for entry in parse_user_response_entries(
647
664
  sidecar.read_text(encoding="utf-8")
648
665
  ):
649
- if entry.value and entry.disposition == "answer":
666
+ if entry.value and entry.disposition in ANSWER_DISPOSITIONS:
650
667
  answers[entry.response_id] = entry.value
651
668
  else:
652
669
  answers.pop(entry.response_id, None)
@@ -759,7 +776,7 @@ def _v2_section_1_body(entries: list) -> str:
759
776
  """schema-v2 `clarificationItems[]` 를 §1 표 본문으로.
760
777
 
761
778
  메타 셀은 렌더러가 쓰는 모양 그대로다 — `Status:` 는 따옴표 없이 써야
762
- `_reconcile_user_input` 이 답을 병합하면서 상태를 resolved 로 넘길 수 있다."""
779
+ `_reconcile_user_input` 이 답을 병합하면서 상태를 answered 로 넘길 수 있다."""
763
780
  rows = [e for e in entries if isinstance(e, dict) and e.get("id")]
764
781
  if not rows:
765
782
  return f"\n{_SECTION_1_EMPTY_STATE}"
@@ -783,7 +800,7 @@ def _v2_section_1_body(entries: list) -> str:
783
800
  # The final-report renderer writes `Status: open` / `Status: answered` unquoted
784
801
  # in the stacked meta cell; only those two are unresolved. Resolve in place so
785
802
  # the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
786
- _STATUS_RESOLVE_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
803
+ _STATUS_ANSWER_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
787
804
 
788
805
 
789
806
  def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
@@ -799,7 +816,7 @@ def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
799
816
 
800
817
  def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
801
818
  """답이 있고 open/answered 인 행이면 `User input` 칸을 그 답으로 채우고 Status 를
802
- resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
819
+ answered 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
803
820
 
804
821
  칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
805
822
  run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
@@ -819,13 +836,13 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
819
836
  if not 0 <= ui_col < len(raw):
820
837
  return line
821
838
  raw[ui_col] = answers[item.row_id]
822
- raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
839
+ raw[0] = _STATUS_ANSWER_RE.sub(r"\1answered", raw[0])
823
840
  return "| " + " | ".join(to_cell_text(c) for c in raw) + " |"
824
841
 
825
842
 
826
843
  def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
827
844
  """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
828
- Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
845
+ Status 를 answered 로 바꾼 §1 본문을 돌려준다.
829
846
 
830
847
  답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
831
848
  빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만
@@ -0,0 +1,128 @@
1
+ """Pure exact requirement coverage calculation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+
10
+ class ExactCoverageError(ValueError):
11
+ """Raised when coverage input cannot define a valid comparison."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ExactCoverageResult:
16
+ coverage_percent: float
17
+ scope_precision_percent: float
18
+ covered_count: int
19
+ total_count: int
20
+ unmapped_commitments: tuple[str, ...]
21
+ contradicted_requirements: tuple[str, ...]
22
+ verdict: Literal["exact", "under", "over", "contradicted"]
23
+
24
+ def as_report_summary(self) -> dict[str, object]:
25
+ return {
26
+ "coveragePercent": self.coverage_percent,
27
+ "scopePrecisionPercent": self.scope_precision_percent,
28
+ "coveredCount": self.covered_count,
29
+ "totalCount": self.total_count,
30
+ "unmappedCommitments": list(self.unmapped_commitments),
31
+ "contradictedRequirements": list(self.contradicted_requirements),
32
+ "coverageVerdict": self.verdict,
33
+ }
34
+
35
+
36
+ def calculate_exact_coverage(
37
+ original_requirement_ids: Sequence[str],
38
+ coverage_status_by_id: Mapping[str, str],
39
+ commitment_requirement_ids: Mapping[str, Sequence[str]],
40
+ ) -> ExactCoverageResult:
41
+ original_ids = tuple(original_requirement_ids)
42
+ if not original_ids:
43
+ raise ExactCoverageError("original requirement denominator is empty")
44
+ if len(set(original_ids)) != len(original_ids):
45
+ raise ExactCoverageError("original requirement ids contain duplicates")
46
+
47
+ original_id_set = set(original_ids)
48
+ if set(coverage_status_by_id) != original_id_set:
49
+ raise ExactCoverageError("coverage rows must match original requirement ids")
50
+ allowed_statuses = {"covered", "not-covered", "contradicted"}
51
+ if any(status not in allowed_statuses for status in coverage_status_by_id.values()):
52
+ raise ExactCoverageError("coverage status is invalid")
53
+ if not commitment_requirement_ids:
54
+ raise ExactCoverageError("implementation commitment denominator is empty")
55
+
56
+ unmapped = tuple(
57
+ commitment_id
58
+ for commitment_id, requirement_ids in commitment_requirement_ids.items()
59
+ if not requirement_ids
60
+ or any(requirement_id not in original_id_set for requirement_id in requirement_ids)
61
+ )
62
+ contradicted = tuple(
63
+ requirement_id
64
+ for requirement_id in original_ids
65
+ if coverage_status_by_id[requirement_id] == "contradicted"
66
+ )
67
+ covered_count = sum(
68
+ coverage_status_by_id[requirement_id] == "covered"
69
+ for requirement_id in original_ids
70
+ )
71
+ coverage_percent = round(covered_count / len(original_ids) * 100, 2)
72
+ traceable_count = len(commitment_requirement_ids) - len(unmapped)
73
+ scope_precision_percent = round(
74
+ traceable_count / len(commitment_requirement_ids) * 100,
75
+ 2,
76
+ )
77
+ verdict: Literal["exact", "under", "over", "contradicted"]
78
+ if contradicted:
79
+ verdict = "contradicted"
80
+ elif covered_count != len(original_ids):
81
+ verdict = "under"
82
+ elif unmapped:
83
+ verdict = "over"
84
+ else:
85
+ verdict = "exact"
86
+
87
+ return ExactCoverageResult(
88
+ coverage_percent=coverage_percent,
89
+ scope_precision_percent=scope_precision_percent,
90
+ covered_count=covered_count,
91
+ total_count=len(original_ids),
92
+ unmapped_commitments=unmapped,
93
+ contradicted_requirements=contradicted,
94
+ verdict=verdict,
95
+ )
96
+
97
+
98
+ def calculate_plan_exact_coverage(
99
+ original_requirement_ids: Sequence[str],
100
+ coverage_status_by_id: Mapping[str, str],
101
+ stage_requirement_ids: Mapping[int, Sequence[str]],
102
+ file_requirement_ids: Mapping[str, Sequence[str]],
103
+ ) -> tuple[ExactCoverageResult, tuple[int, ...], tuple[str, ...]]:
104
+ """Apply exact coverage to the executable stage and file-change scope."""
105
+ commitments = {
106
+ **{
107
+ f"stage:{stage}": requirement_ids
108
+ for stage, requirement_ids in stage_requirement_ids.items()
109
+ },
110
+ **{
111
+ f"file:{path}": requirement_ids
112
+ for path, requirement_ids in file_requirement_ids.items()
113
+ },
114
+ }
115
+ result = calculate_exact_coverage(
116
+ original_requirement_ids,
117
+ coverage_status_by_id,
118
+ commitments,
119
+ )
120
+ unmapped_stages = tuple(
121
+ stage for stage, requirement_ids in stage_requirement_ids.items()
122
+ if not requirement_ids
123
+ )
124
+ unmapped_files = tuple(
125
+ path for path, requirement_ids in file_requirement_ids.items()
126
+ if not requirement_ids
127
+ )
128
+ return result, unmapped_stages, unmapped_files
@@ -27,7 +27,9 @@ _LOCK_FILENAME = ".fix-cycles.lock"
27
27
  # 완료(release-handoff) task 에 fix-cycle 로 재진입할 수 있는 entry phase 들.
28
28
  # prepare(run.py) 의 게이트와 wizard 의 감지 술어가 공유하는 SSOT.
29
29
  FIX_CYCLE_ENTRY_PHASES = (
30
- "requirements-discovery", "error-analysis", "implementation-planning")
30
+ "requirements-discovery", "error-analysis", "implementation-option-selection",
31
+ "implementation-planning",
32
+ )
31
33
 
32
34
 
33
35
  def fix_cycles_path(task_root: Path) -> Path: