okstra 0.186.1 → 0.186.3

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 (34) hide show
  1. package/docs/architecture.md +3 -3
  2. package/docs/for-ai/skills/okstra-run.md +1 -1
  3. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  4. package/docs/project-structure-overview.md +2 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/okstra-lead-contract.md +18 -5
  8. package/runtime/prompts/lead/plan-body-verification.md +5 -5
  9. package/runtime/prompts/lead/report-writer.md +1 -1
  10. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +4 -5
  12. package/runtime/python/okstra_ctl/approval_decisions.py +1 -1
  13. package/runtime/python/okstra_ctl/clarification_items.py +141 -28
  14. package/runtime/python/okstra_ctl/design_surfaces.py +103 -18
  15. package/runtime/python/okstra_ctl/implementation_direction.py +8 -9
  16. package/runtime/python/okstra_ctl/next_phase.py +24 -22
  17. package/runtime/python/okstra_ctl/render_final_report.py +4 -8
  18. package/runtime/python/okstra_ctl/report_assembly.py +5 -1
  19. package/runtime/python/okstra_ctl/run.py +34 -3
  20. package/runtime/python/okstra_ctl/user_response.py +2 -1
  21. package/runtime/python/okstra_ctl/worker_prompt_body.py +4 -15
  22. package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -5
  23. package/runtime/skills/okstra-run/SKILL.md +1 -1
  24. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  25. package/runtime/templates/reports/html/assets/base.css +19 -9
  26. package/runtime/templates/reports/html/assets/base.js +21 -0
  27. package/runtime/templates/reports/html/base.template.html +5 -2
  28. package/runtime/templates/reports/html/i18n/en.json +1 -0
  29. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  30. package/runtime/templates/reports/html/macros/forms.html +8 -3
  31. package/runtime/templates/reports/report.js +33 -0
  32. package/runtime/validators/validate-run.py +138 -47
  33. package/runtime/validators/validate_analysis_report.py +7 -6
  34. package/runtime/validators/validate_session_conformance.py +24 -3
@@ -274,6 +274,38 @@
274
274
  }
275
275
  }
276
276
 
277
+ // 선택 카드는 보여 주기만 하면 고른 것처럼 보이지만 값이 안 실린다.
278
+ // 카드를 누르면 같은 칸의 select 를 맞춘다.
279
+ function bindOptionCardPicks() {
280
+ var articles = document.querySelectorAll("article[data-response-id]");
281
+ for (var i = 0; i < articles.length; i++) {
282
+ (function (article) {
283
+ var sel = article.querySelector("select[data-response-id]");
284
+ if (!sel) return;
285
+ var cards = article.querySelectorAll(".clarification-option[data-option-value]");
286
+ var sync = function () {
287
+ for (var c = 0; c < cards.length; c++) {
288
+ cards[c].classList.toggle(
289
+ "is-selected",
290
+ cards[c].getAttribute("data-option-value") === sel.value
291
+ );
292
+ }
293
+ };
294
+ for (var c = 0; c < cards.length; c++) {
295
+ cards[c].addEventListener("click", function (event) {
296
+ if (sel.disabled) return;
297
+ if (event.target.closest && event.target.closest("a")) return;
298
+ sel.value = event.currentTarget.getAttribute("data-option-value") || "";
299
+ sel.dispatchEvent(new Event("change"));
300
+ sync();
301
+ });
302
+ }
303
+ sel.addEventListener("change", sync);
304
+ sync();
305
+ })(articles[i]);
306
+ }
307
+ }
308
+
277
309
  function quotedReviewField(label, value) {
278
310
  var cleaned = trimMultiline(value);
279
311
  if (!cleaned) return "- " + label + ":\n";
@@ -547,6 +579,7 @@
547
579
  setReaderMode("action");
548
580
  }
549
581
  bindOtherInputToggle();
582
+ bindOptionCardPicks();
550
583
  }
551
584
 
552
585
  if (typeof window !== "undefined") {
@@ -88,6 +88,13 @@ from okstra_ctl.incremental_scope import ( # noqa: E402
88
88
  stages_for_clarification,
89
89
  )
90
90
  from okstra_ctl import next_phase # noqa: E402
91
+ from okstra_ctl.clarification_items import ( # noqa: E402
92
+ APPROVAL_BLOCKS,
93
+ PROCEEDING_DISPOSITIONS,
94
+ clarification_disposition,
95
+ progress_blocking_ids,
96
+ row_blocks_progress,
97
+ )
91
98
  from okstra_ctl.workflow import ( # noqa: E402
92
99
  ERROR_ANALYSIS_ROUTING_DIRECTIONS,
93
100
  PHASE_SEQUENCE,
@@ -4117,34 +4124,102 @@ def _state_classification(item: dict, gate_class: str) -> str:
4117
4124
  return "dissent-isolated" if dissenting == 1 else "partial-consensus"
4118
4125
 
4119
4126
 
4120
- def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4121
- """Plan items whose remaining dissent the user explicitly accepted."""
4127
+ def _clarification_resolution(row: dict, context: dict) -> dict | None:
4128
+ """계약 3.0 은 행의 `resolution`, 2.0 은 `approvalContext.resolution`."""
4129
+ for candidate in (row.get("resolution"), context.get("resolution")):
4130
+ if isinstance(candidate, dict):
4131
+ return candidate
4132
+ return None
4133
+
4134
+
4135
+ def _clarification_ids_on_activity(activity: dict) -> set[str]:
4136
+ refs: set[str] = set()
4137
+ for key in ("clarificationRefs", "evidenceRefs"):
4138
+ for value in activity.get(key) or []:
4139
+ if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(value):
4140
+ refs.add(value)
4141
+ return refs
4142
+
4143
+
4144
+ def _plan_item_ids_for_clarification(
4145
+ row: dict, context: dict, data: dict,
4146
+ ) -> list[str]:
4147
+ """이 C 행이 가리키는 계획 항목.
4148
+
4149
+ 계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
4150
+ `evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
4151
+ 역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
4152
+ """
4153
+ linked = [
4154
+ item_id
4155
+ for item_id in (context.get("planItemIds") or [])
4156
+ if isinstance(item_id, str) and item_id
4157
+ ]
4158
+ if linked:
4159
+ return linked
4160
+ row_id = str(row.get("id") or "")
4161
+ if not row_id:
4162
+ return []
4163
+ singleton: list[str] = []
4164
+ bulk: list[str] = []
4165
+ for activity in data.get("agentActivity") or []:
4166
+ if not isinstance(activity, dict):
4167
+ continue
4168
+ refs = _clarification_ids_on_activity(activity)
4169
+ if row_id not in refs:
4170
+ continue
4171
+ ids = [
4172
+ item_id
4173
+ for item_id in (activity.get("planItemIds") or [])
4174
+ if isinstance(item_id, str) and item_id
4175
+ ]
4176
+ if refs == {row_id}:
4177
+ singleton.extend(ids)
4178
+ else:
4179
+ bulk.extend(ids)
4180
+ if singleton or bulk:
4181
+ return singleton or bulk
4182
+ items = (
4183
+ ((data.get("implementationPlanning") or {}).get("planBodyVerification")
4184
+ or {}).get("planItems") or []
4185
+ )
4186
+ return [
4187
+ str(item.get("id") or "")
4188
+ for item in items
4189
+ if isinstance(item, dict)
4190
+ and row_id in {
4191
+ ref for ref in (item.get("clarificationRefs") or [])
4192
+ if isinstance(ref, str)
4193
+ }
4194
+ and item.get("id")
4195
+ ]
4196
+
4197
+
4198
+ def _user_accepted_plan_item_ids(data: dict) -> set[str]:
4199
+ """사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.
4200
+
4201
+ DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
4202
+ """
4122
4203
  accepted: set[str] = set()
4123
4204
  for row in data.get("clarificationItems") or []:
4124
4205
  if not isinstance(row, dict) or row.get("blocks") != "approval":
4125
4206
  continue
4207
+ if row_blocks_progress(
4208
+ str(row.get("status") or ""), clarification_disposition(row)
4209
+ ):
4210
+ continue
4126
4211
  context = row.get("approvalContext")
4127
4212
  if not isinstance(context, dict):
4128
- continue
4129
- resolution = context.get("resolution")
4130
- if (
4131
- row.get("status") == "resolved"
4132
- and context.get("classification") == "noncritical-dissent"
4133
- and isinstance(resolution, dict)
4134
- and resolution.get("disposition") == "accept-risk"
4135
- and str(resolution.get("userText") or "").strip()
4136
- and _approval_context_activity_refs_exist(
4137
- data, str(row.get("id") or ""), context, resolution
4138
- )
4139
- ):
4140
- accepted.update(
4141
- item_id
4142
- for item_id in context.get("planItemIds") or []
4143
- if isinstance(item_id, str)
4144
- )
4213
+ context = {}
4214
+ accepted.update(_plan_item_ids_for_clarification(row, context, data))
4145
4215
  return accepted
4146
4216
 
4147
4217
 
4218
+ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4219
+ """호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
4220
+ return _user_accepted_plan_item_ids(data)
4221
+
4222
+
4148
4223
  def _is_dissent_downgraded(
4149
4224
  item: dict,
4150
4225
  pbv: dict,
@@ -4152,16 +4227,11 @@ def _is_dissent_downgraded(
4152
4227
  ) -> bool:
4153
4228
  """Whether a surviving `majority-disagree` item stops blocking approval.
4154
4229
 
4155
- Exhausting the automatic self-fix budget records the unresolved dissent but
4156
- does not accept it. Only an explicit, resolved noncritical risk-acceptance
4157
- row can lower the item to `has-dissent`. Correctness-critical defects remain
4158
- blocking regardless of the user's selected disposition.
4230
+ 사용자 진행 처분(`accept-risk` / `select` / `answer`)이 있으면 표는 남기고
4231
+ 게이트만 `has-dissent` 로 내린다. 분류와 자가수정 소진 여부는 보지 않는다.
4159
4232
  """
4160
4233
  return (
4161
4234
  _classify_plan_item_gate(item) == "majority-disagree"
4162
- and not _is_correctness_critical(item)
4163
- and _has_planner_fixable_majority(item)
4164
- and _self_fix_budget_exhausted(pbv)
4165
4235
  and str(item.get("id") or "") in accepted_item_ids
4166
4236
  )
4167
4237
 
@@ -4477,11 +4547,15 @@ def _gate_blocking_causes(
4477
4547
 
4478
4548
 
4479
4549
  _APPROVAL_DISPOSITIONS_BY_CLASSIFICATION = {
4480
- "user-decision": frozenset({"select", "request-revision", "reject"}),
4550
+ "user-decision": frozenset(
4551
+ {"select", "accept-risk", "request-revision", "reject"}
4552
+ ),
4481
4553
  "noncritical-dissent": frozenset(
4482
4554
  {"accept-risk", "request-revision", "reject"}
4483
4555
  ),
4484
- "correctness-critical": frozenset({"request-revision", "reject"}),
4556
+ "correctness-critical": frozenset(
4557
+ {"accept-risk", "request-revision", "reject"}
4558
+ ),
4485
4559
  }
4486
4560
 
4487
4561
 
@@ -4949,6 +5023,8 @@ def _validate_resolved_approval(
4949
5023
  f"final-report data.json: resolved approval clarification `{row_id}` "
4950
5024
  "requires non-empty resolution.userText."
4951
5025
  )
5026
+ if str(resolution.get("disposition") or "") in PROCEEDING_DISPOSITIONS:
5027
+ return
4952
5028
  check_refs = resolution.get("checkRefs")
4953
5029
  if not isinstance(check_refs, list) or not any(
4954
5030
  isinstance(value, str) and value for value in check_refs
@@ -5097,6 +5173,7 @@ def _validate_correctness_resolution(
5097
5173
  if (
5098
5174
  context.get("classification") != "correctness-critical"
5099
5175
  or row.get("status") != "resolved"
5176
+ or clarification_disposition(row) in PROCEEDING_DISPOSITIONS
5100
5177
  ):
5101
5178
  return
5102
5179
  row_id = str(row.get("id") or "<unknown>")
@@ -5349,11 +5426,13 @@ def _validate_approval_context(
5349
5426
  activity_timestamps,
5350
5427
  failures,
5351
5428
  )
5352
- if report_approved and row.get("status") in {"open", "answered"}:
5429
+ if report_approved and row_blocks_progress(
5430
+ str(row.get("status") or ""), clarification_disposition(row)
5431
+ ):
5353
5432
  failures.append(
5354
5433
  f"final-report data.json: approval is true while clarification `{row_id}` "
5355
- f"has status `{row.get('status')}`; open and answered approval "
5356
- "rows remain blocking."
5434
+ f"has status `{row.get('status')}`; open and return-disposition "
5435
+ "approval rows remain blocking."
5357
5436
  )
5358
5437
 
5359
5438
 
@@ -5425,7 +5504,9 @@ def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
5425
5504
  row, context, failures, schema_version="3.0"
5426
5505
  )
5427
5506
  _validate_v3_resolution_links(row, activities, failures)
5428
- if approved and row.get("status") in {"open", "answered"}:
5507
+ if approved and row_blocks_progress(
5508
+ str(row.get("status") or ""), clarification_disposition(row)
5509
+ ):
5429
5510
  failures.append(
5430
5511
  f"final-report data.json: approval is true while clarification "
5431
5512
  f"`{row.get('id')}` remains `{row.get('status')}`."
@@ -6165,6 +6246,7 @@ def _validate_approval_clarification_backtrace(
6165
6246
 
6166
6247
 
6167
6248
  _RERUN_FLAG = "--answered-clarifications"
6249
+ _USER_RESPONSE_HINT = re.compile(r"okstra-user-response", re.IGNORECASE)
6168
6250
  _APPROVE_HINT = re.compile(r"--approve|\bapprov", re.IGNORECASE)
6169
6251
 
6170
6252
 
@@ -6182,10 +6264,9 @@ def _next_step_texts(steps: object) -> list[str]:
6182
6264
  return texts
6183
6265
 
6184
6266
 
6185
- def _has_blocks_approval_row(data: dict) -> bool:
6186
- return any(
6187
- isinstance(row, dict) and row.get("blocks") == "approval"
6188
- for row in data.get("clarificationItems") or []
6267
+ def _has_unresolved_approval_blocker(data: dict) -> bool:
6268
+ return bool(
6269
+ progress_blocking_ids(data.get("clarificationItems"), APPROVAL_BLOCKS)
6189
6270
  )
6190
6271
 
6191
6272
 
@@ -6197,7 +6278,14 @@ def _planning_gate_blocks_approval(data: dict) -> bool:
6197
6278
  if not isinstance(verification, dict):
6198
6279
  return False
6199
6280
  gate = str(verification.get("gateResult") or "").strip().lower()
6200
- return gate in {"blocked-by-disagreement", "aborted-non-result"}
6281
+ if gate == "aborted-non-result":
6282
+ return True
6283
+ if gate != "blocked-by-disagreement":
6284
+ return False
6285
+ return _has_unresolved_approval_blocker(data) or not any(
6286
+ isinstance(row, dict) and row.get("blocks") == "approval"
6287
+ for row in data.get("clarificationItems") or []
6288
+ )
6201
6289
 
6202
6290
 
6203
6291
  def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
@@ -6212,19 +6300,20 @@ def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
6212
6300
  """
6213
6301
  if (data.get("header") or {}).get("taskType") != "implementation-planning":
6214
6302
  return
6215
- has_blocker = _has_blocks_approval_row(data)
6216
- if not has_blocker:
6303
+ if not _has_unresolved_approval_blocker(data):
6217
6304
  return
6218
- if any(_RERUN_FLAG in text for text in _next_step_texts(
6219
- data.get("recommendedNextSteps")
6220
- )):
6305
+ texts = _next_step_texts(data.get("recommendedNextSteps"))
6306
+ if any(_RERUN_FLAG in text for text in texts) and any(
6307
+ _USER_RESPONSE_HINT.search(text) for text in texts
6308
+ ):
6221
6309
  return
6222
6310
  failures.append(
6223
- "final-report data.json: this plan withholds approval on a "
6311
+ "final-report data.json: this plan withholds approval on an unresolved "
6224
6312
  "`blocks: approval` clarification, but no `recommendedNextSteps` entry "
6225
- f"tells the reader how to resume — name the `{_RERUN_FLAG}` re-run in a "
6226
- "step's `text` or one of its `commands`. `okstra recap assemble` "
6227
- "prints the exact ids and flag value once the answers are recorded."
6313
+ "tells the reader the command to run now — name `/okstra-user-response` "
6314
+ f"and the `{_RERUN_FLAG}` re-run in a step's `text` or one of its "
6315
+ "`commands`. `okstra recap assemble` prints the exact ids and flag "
6316
+ "value once the answers are recorded."
6228
6317
  )
6229
6318
 
6230
6319
 
@@ -6239,7 +6328,7 @@ def _validate_approval_guidance(data: dict, failures: list[str]) -> None:
6239
6328
  planning = data.get("implementationPlanning")
6240
6329
  if not isinstance(planning, dict) or planning.get("outcome") != "plan-ready":
6241
6330
  return
6242
- if _has_blocks_approval_row(data) or _planning_gate_blocks_approval(data):
6331
+ if _has_unresolved_approval_blocker(data) or _planning_gate_blocks_approval(data):
6243
6332
  return
6244
6333
  if _report_already_approved(data):
6245
6334
  return
@@ -7265,11 +7354,13 @@ def _validate_tie_received_extra_vote(
7265
7354
  pbv = ip.get("planBodyVerification")
7266
7355
  if not isinstance(pbv, dict):
7267
7356
  return
7357
+ accepted = _resolved_noncritical_dissent_ids(data)
7268
7358
  missing = sorted({
7269
7359
  str(item.get("id") or "").strip()
7270
7360
  for item in pbv.get("planItems") or []
7271
7361
  if isinstance(item, dict)
7272
7362
  and not item.get("carriedForwardFromSeq")
7363
+ and str(item.get("id") or "").strip() not in accepted
7273
7364
  and _stage_scope_bucket(item, pbv) == "in-scope"
7274
7365
  and _is_even_blocking_split(item)
7275
7366
  and _distinct_verdict_workers(item) < 3
@@ -54,11 +54,12 @@ _ANALYSIS_PARENT_KEYS = {
54
54
  "feature-analysis": "featureAnalysis",
55
55
  "change-impact-analysis": "changeImpactAnalysis",
56
56
  }
57
- _ANALYSIS_WORKER_ROLES = {
58
- "Claude worker",
59
- "Codex worker",
60
- "Antigravity worker",
61
- }
57
+
58
+
59
+ def _is_report_writer_role(role: str) -> bool:
60
+ return "reportwriter" in re.sub(r"[^a-z0-9]", "", role.lower())
61
+
62
+
62
63
  _FINAL_ANALYSIS_REPORT_RE = re.compile(
63
64
  r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
64
65
  r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
@@ -467,7 +468,7 @@ def validate_analysis_semantics(
467
468
  ) -> None:
468
469
  required_roles = _required_worker_roles(run_manifest, errors)
469
470
  analysis_worker_roles = tuple(
470
- role for role in required_roles if role in _ANALYSIS_WORKER_ROLES
471
+ role for role in required_roles if not _is_report_writer_role(role)
471
472
  )
472
473
  verdict_roles_unambiguous = _validate_execution_status_roles(
473
474
  data, analysis_worker_roles, errors
@@ -235,8 +235,24 @@ def _worker_role(value: str) -> str | None:
235
235
 
236
236
 
237
237
  def _worker_roles(worker: dict) -> set[str]:
238
- role = _worker_role(str(worker.get("role") or ""))
239
- return {role, role.replace(" ", "-")} if role else set()
238
+ """명단 `role` 과 `workerId` 를 활동 `agent` 가 쓸 수 있는 별칭으로 펼친다.
239
+
240
+ 활동은 `codex` / `codex-worker` 를 쓰고 명단은 `Codex worker` 를 쓴다.
241
+ 역할 문자열만 보면 둘은 다른 사람이 된다.
242
+ """
243
+ roles: set[str] = set()
244
+ for raw in (worker.get("role"), worker.get("workerId")):
245
+ role = _worker_role(str(raw or ""))
246
+ if not role:
247
+ continue
248
+ compact = role.replace(" ", "-")
249
+ roles.add(role)
250
+ roles.add(compact)
251
+ if role.endswith(" worker"):
252
+ roles.add(role[: -len(" worker")])
253
+ if compact.endswith("-worker"):
254
+ roles.add(compact[: -len("-worker")])
255
+ return roles
240
256
 
241
257
 
242
258
  def _analysis_workers(team_state: Mapping[str, Any]) -> list[dict]:
@@ -1036,12 +1052,17 @@ def _check_activity_contract(
1036
1052
  if not _is_activity_contract_v1_planning(run_manifest):
1037
1053
  return
1038
1054
  indexed = _activity_index(evidence.activities)
1055
+ workers = [
1056
+ worker
1057
+ for worker in (team_state.get("workers") or [])
1058
+ if isinstance(worker, dict)
1059
+ ]
1039
1060
  analysis_workers = _analysis_workers(team_state)
1040
1061
  report_data = _read_report_data(report_path)
1041
1062
  _check_activity_id_order(evidence.activities, errors)
1042
1063
  _check_projected_agent_activity(evidence.activities, report_data, errors)
1043
1064
  _check_activity_worker_pairs(evidence, indexed, analysis_workers, errors)
1044
- _check_activity_worker_agents(indexed, analysis_workers, errors)
1065
+ _check_activity_worker_agents(indexed, workers, errors)
1045
1066
  _check_activity_round_counts(indexed, report_data, run_dir, suffix, errors)
1046
1067
  _check_activity_user_decisions(indexed, report_data, errors)
1047
1068