okstra 0.184.0 → 0.185.1

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 (40) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.d.mts +1 -0
  5. package/dist/commands/chat/chat.mjs +386 -0
  6. package/dist/commands/chat/chat.mjs.map +1 -0
  7. package/dist/lib/skill-catalog.mjs +1 -0
  8. package/dist/lib/skill-catalog.mjs.map +1 -1
  9. package/docs/architecture.md +9 -7
  10. package/docs/cli.md +3 -2
  11. package/docs/for-ai/README.md +4 -2
  12. package/docs/for-ai/skills/okstra-chat.md +34 -0
  13. package/docs/for-ai/skills/okstra-inspect.md +1 -1
  14. package/docs/for-ai/skills/okstra-run.md +2 -2
  15. package/docs/for-ai/skills/okstra-user-response.md +10 -8
  16. package/docs/project-structure-overview.md +6 -5
  17. package/docs/task-process/README.md +1 -1
  18. package/docs/task-process/implementation-planning.md +1 -1
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
  22. package/runtime/prompts/lead/plan-body-verification.md +8 -7
  23. package/runtime/prompts/lead/report-writer.md +1 -1
  24. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
  25. package/runtime/prompts/profiles/implementation-planning.md +4 -3
  26. package/runtime/prompts/wizard/prompts.ko.json +2 -0
  27. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
  28. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  29. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  30. package/runtime/python/okstra_ctl/incremental_carry.py +149 -5
  31. package/runtime/python/okstra_ctl/incremental_scope.py +25 -3
  32. package/runtime/python/okstra_ctl/next_phase.py +67 -4
  33. package/runtime/python/okstra_ctl/plan_items.py +40 -4
  34. package/runtime/python/okstra_ctl/user_response.py +147 -37
  35. package/runtime/python/okstra_ctl/wizard.py +13 -0
  36. package/runtime/skills/okstra-chat/SKILL.md +108 -0
  37. package/runtime/skills/okstra-inspect/facets/status.md +6 -5
  38. package/runtime/skills/okstra-run/SKILL.md +2 -2
  39. package/runtime/skills/okstra-user-response/SKILL.md +50 -16
  40. package/runtime/validators/validate-run.py +109 -34
@@ -13,10 +13,18 @@ from .convergence_store import write_json_atomic
13
13
  from .final_report_schema import load_schema_version
14
14
  from .report_narrative import parse_narrative, writer_owned_data
15
15
  from .json_boundary import load_owned_object
16
+ from .plan_items import (
17
+ PlanItemContractError,
18
+ content_hash,
19
+ extract_plan_items,
20
+ planning_stage_ledger,
21
+ reverify_item_ids,
22
+ )
16
23
 
17
24
 
18
25
  PREP_VERDICT_RE = re.compile(r"^P-Prep-S([1-9][0-9]*)-")
19
26
  STEP_VERDICT_RE = re.compile(r"^P-Step-([1-9][0-9]*)\.")
27
+ _CHECKLIST_ID_PREFIXES = ("P-Val-", "P-Req-", "P-Rb-")
20
28
 
21
29
 
22
30
  class CarryError(Exception):
@@ -306,6 +314,100 @@ def _plan_item_stage(item: Mapping[str, Any]) -> int | None:
306
314
  return None
307
315
 
308
316
 
317
+ def _checklist_id(item_id: str) -> bool:
318
+ return item_id.startswith(_CHECKLIST_ID_PREFIXES)
319
+
320
+
321
+ def _item_stages(item: Mapping[str, Any]) -> list[int]:
322
+ scope = item.get("stageScope")
323
+ if not isinstance(scope, list):
324
+ return []
325
+ return [
326
+ value for value in scope
327
+ if isinstance(value, int) and not isinstance(value, bool)
328
+ ]
329
+
330
+
331
+ def _stamp_carried(previous: dict, current: Mapping[str, Any], prev_seq: str) -> dict:
332
+ carried = copy.deepcopy(previous)
333
+ carried["carriedForwardFromSeq"] = str(prev_seq)
334
+ current_hash = current.get("contentHash")
335
+ if isinstance(current_hash, str) and current_hash:
336
+ carried["verifiedContentHash"] = current_hash
337
+ carried["contentHash"] = current_hash
338
+ return carried
339
+
340
+
341
+ def _carry_unchanged_checklists(
342
+ prev: Mapping[str, Any],
343
+ narrative: Mapping[str, Any],
344
+ state_items: dict[str, dict],
345
+ prev_items: dict[str, dict],
346
+ *,
347
+ prev_seq: str,
348
+ reverify_stages: set[int],
349
+ ) -> None:
350
+ """본문이 같은 P-Val / P-Req / P-Rb 판정을 이월한다.
351
+
352
+ 단계가 없으면 매 재실행 라운드 1 큐에 남아 이미 통과한 줄이 다시 반대되고
353
+ 새 C 가 열린다. 추출 해시가 같으면 이전 표를 가져오고, 재검증 스테이지에
354
+ 걸린 행과 본문이 바뀐 행은 그대로 둔다.
355
+ """
356
+ try:
357
+ prev_extracted = {
358
+ item["id"]: item
359
+ for item in extract_plan_items(_planning(dict(prev)))
360
+ if isinstance(item.get("id"), str)
361
+ }
362
+ cur_extracted = {
363
+ item["id"]: item
364
+ for item in extract_plan_items(_planning(dict(narrative)))
365
+ if isinstance(item.get("id"), str)
366
+ }
367
+ except (CarryError, PlanItemContractError, KeyError, TypeError):
368
+ return
369
+ for item_id, current in list(state_items.items()):
370
+ if not _checklist_id(item_id):
371
+ continue
372
+ stages = _item_stages(current)
373
+ if stages and set(stages) & reverify_stages:
374
+ continue
375
+ previous = prev_items.get(item_id)
376
+ if previous is None:
377
+ continue
378
+ prev_row = prev_extracted.get(item_id)
379
+ cur_row = cur_extracted.get(item_id)
380
+ if prev_row is None or cur_row is None:
381
+ continue
382
+ if content_hash(prev_row) != content_hash(cur_row):
383
+ continue
384
+ state_items[item_id] = _stamp_carried(previous, current, prev_seq)
385
+
386
+
387
+ def _recompute_dispatch_queue(
388
+ narrative: Mapping[str, Any],
389
+ state_items: dict[str, dict],
390
+ ) -> list[str]:
391
+ try:
392
+ extracted = extract_plan_items(_planning(dict(narrative)))
393
+ except (CarryError, PlanItemContractError, KeyError, TypeError):
394
+ return []
395
+ by_id = {
396
+ item["id"]: item
397
+ for item in extracted
398
+ if isinstance(item.get("id"), str)
399
+ }
400
+ previous_hashes = {
401
+ item_id: content_hash(by_id[item_id])
402
+ for item_id, row in state_items.items()
403
+ if row.get("carriedForwardFromSeq") and item_id in by_id
404
+ }
405
+ if not previous_hashes:
406
+ return []
407
+ ledger = planning_stage_ledger(_planning(dict(narrative)))
408
+ return reverify_item_ids(extracted, previous_hashes, ledger)
409
+
410
+
309
411
  def _writer_stage_rows(data: Mapping[str, Any], *, snapshot: str) -> dict[int, dict]:
310
412
  planning = _planning(dict(writer_owned_data(data)))
311
413
  return _stage_rows(planning, snapshot=snapshot)
@@ -320,7 +422,7 @@ def merge_v3_plan_state(
320
422
  carry_stages: set[int],
321
423
  reverify_stages: set[int],
322
424
  ) -> dict[str, Any]:
323
- """이전 판정 중 이월 스테이지 소유분만 v3 수렴 상태에 복사한다."""
425
+ """이전 판정 중 이월 스테이지 소유분과 본문이 같은 체크리스트를 복사한다."""
324
426
  prev_stages = _writer_stage_rows(prev, snapshot="prior")
325
427
  cur_stages = _stage_rows(_planning(narrative), snapshot="current")
326
428
  _validate_stage_sets(prev_stages, cur_stages, carry_stages, reverify_stages)
@@ -351,9 +453,7 @@ def merge_v3_plan_state(
351
453
  for field in ("subject", "sourceSection"):
352
454
  if current.get(field) != previous.get(field):
353
455
  raise CarryError(f"carry-owned plan item {item_id} changed {field}")
354
- carried = copy.deepcopy(previous)
355
- carried["carriedForwardFromSeq"] = str(prev_seq)
356
- state_items[item_id] = carried
456
+ state_items[item_id] = _stamp_carried(previous, current, prev_seq)
357
457
 
358
458
  prior_carried_ids = {
359
459
  item_id
@@ -363,12 +463,55 @@ def merge_v3_plan_state(
363
463
  missing = sorted(prior_carried_ids - set(state_items))
364
464
  if missing:
365
465
  raise CarryError(f"current state omitted carry-owned plan items: {missing}")
366
- state["planBodyVerification"]["planItems"] = [
466
+ _carry_unchanged_checklists(
467
+ prev,
468
+ narrative,
469
+ state_items,
470
+ prev_items,
471
+ prev_seq=prev_seq,
472
+ reverify_stages=reverify_stages,
473
+ )
474
+ pbv = state.setdefault("planBodyVerification", {})
475
+ pbv["planItems"] = [
367
476
  state_items[item_id] for item_id in sorted(state_items)
368
477
  ]
478
+ queue = _recompute_dispatch_queue(narrative, state_items)
479
+ if queue:
480
+ pbv["dispatchQueue"] = queue
369
481
  return state
370
482
 
371
483
 
484
+ def _sync_prepared_dispatch_queue(state_path: Path, merged: Mapping[str, Any]) -> None:
485
+ """이월 뒤 프롬프트 큐를 상태 파일과 맞춘다.
486
+
487
+ `plan-items prompt` 는 prepare 가 쓴 `plan-items-*.json` 을 읽는다. 시드가
488
+ 그 파일을 먼저 쓰므로, 이월이 큐를 줄인 뒤에는 형제 파일을 같이 고쳐야
489
+ 워커가 이미 통과한 P-Val 을 다시 보지 않는다.
490
+ """
491
+ name = state_path.name
492
+ prefix = "plan-body-verification-"
493
+ if not name.startswith(prefix):
494
+ return
495
+ prepared = state_path.with_name("plan-items-" + name.removeprefix(prefix))
496
+ if not prepared.is_file():
497
+ return
498
+ queue = (
499
+ merged.get("planBodyVerification", {}).get("dispatchQueue")
500
+ if isinstance(merged.get("planBodyVerification"), Mapping)
501
+ else None
502
+ )
503
+ if not isinstance(queue, list):
504
+ return
505
+ try:
506
+ envelope = load_owned_object(prepared, artifact="prepared plan items")
507
+ except (OSError, ValueError):
508
+ return
509
+ if not isinstance(envelope, dict):
510
+ return
511
+ envelope["dispatchQueue"] = queue
512
+ write_json_atomic(prepared, envelope)
513
+
514
+
372
515
  def _parse_stage_csv(value: str | None, *, option: str) -> set[int] | None:
373
516
  if value is None:
374
517
  return None
@@ -457,6 +600,7 @@ def main(argv: list[str]) -> int:
457
600
 
458
601
  try:
459
602
  write_json_atomic(output, merged)
603
+ _sync_prepared_dispatch_queue(output, merged)
460
604
  except OSError as exc:
461
605
  print(f"carry refused: --out could not be written: {exc}", file=sys.stderr)
462
606
  return 1
@@ -34,9 +34,8 @@ DECLARED_FULL_PREFIX = "declared structural change:"
34
34
  UNRESOLVED_MODE = "unresolved"
35
35
 
36
36
  # `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
37
- # id itself. Every other prefix (`P-Opt`, `P-Dep`, `P-Val`, `P-Rb`, `P-Req`) is
38
- # numbered by position in its own array, so its stage is only recoverable from
39
- # the prose the planner wrote.
37
+ # id itself. Every other prefix is numbered by position; its stage comes from
38
+ # `stageScope` / `stageRefs`, then from the prose the planner wrote.
40
39
  _STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")
41
40
 
42
41
 
@@ -131,10 +130,32 @@ def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
131
130
  return impacted
132
131
 
133
132
 
133
+ def _int_stages(value: object) -> set[int]:
134
+ if not isinstance(value, list):
135
+ return set()
136
+ stages: set[int] = set()
137
+ for entry in value:
138
+ if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 1:
139
+ stages.add(entry)
140
+ return stages
141
+
142
+
134
143
  def _plan_item_stages(item: dict) -> set[int]:
144
+ """항목이 걸린 스테이지. id 좌표, 그다음 기록된 범위, 그다음 산문.
145
+
146
+ `P-Val-*` / `P-Req-*` 는 위치 번호라 id 에서 스테이지가 안 나온다. 행이
147
+ `stageScope` / `stageRefs` 를 실어도 산문 `subject` 만 읽으면 C-039 같은
148
+ 답이 unlinked 가 되어 재실행이 full 로 떨어진다.
149
+ """
135
150
  structural = _STRUCTURAL_STAGE_IN_ID_RE.match(str(item.get("id") or ""))
136
151
  if structural:
137
152
  return {int(structural.group(1) or structural.group(2))}
153
+ stages = _int_stages(item.get("stageScope")) or _int_stages(item.get("stageRefs"))
154
+ payload = item.get("payload")
155
+ if not stages and isinstance(payload, dict):
156
+ stages = _int_stages(payload.get("stageRefs"))
157
+ if stages:
158
+ return stages
138
159
  return cited_stage_numbers(str(item.get("subject") or ""))
139
160
 
140
161
 
@@ -169,6 +190,7 @@ def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
169
190
  for row in coverage if isinstance(coverage, list) else []:
170
191
  if coverage_row_blocked_on(row, clarification_id):
171
192
  stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
193
+ stages |= _int_stages(row.get("stageRefs"))
172
194
  return stages
173
195
 
174
196
 
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any, Mapping
11
11
 
12
+ from okstra_ctl.clarification_items import APPROVAL_BLOCKS, UNRESOLVED_STATUSES
13
+
12
14
  STATUS_READY = "ready"
13
15
  STATUS_PENDING = "pending"
14
16
  STATUS_BLOCKED = "blocked"
@@ -69,6 +71,12 @@ _OPTION_SELECTION_NON_PHASE = {
69
71
  "blocked": STATUS_BLOCKED,
70
72
  }
71
73
 
74
+ # run.py BLOCKING_PLAN_BODY_GATES 와 같아야 한다. next_phase 는 run 을
75
+ # 가져오지 않는다 — wizard 가 둘 다 import 해서 순환이 생긴다.
76
+ _BLOCKING_PLAN_GATES = frozenset(
77
+ {"blocked-by-disagreement", "aborted-non-result"}
78
+ )
79
+
72
80
  # final-verification 의 routing enum 중 phase 이름이 아니라 phase 에 붙은 범위
73
81
  # 한정자인 값 → 실제로 실행할 phase. `release-handoff(stage-group)` 은 넘길 stage
74
82
  # 묶음을 좁힌다는 뜻이지 다른 phase 가 아니다. 범위는 위저드의 handoff_stage_pick
@@ -210,13 +218,68 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
210
218
  return make(phase=routing, status=STATUS_READY)
211
219
 
212
220
 
221
+ def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
222
+ rows = report_data.get("clarificationItems")
223
+ if not isinstance(rows, list):
224
+ return []
225
+ ids: list[str] = []
226
+ for row in rows:
227
+ if not isinstance(row, Mapping):
228
+ continue
229
+ blocks = str(row.get("blocks") or "").strip().lower()
230
+ status = str(row.get("status") or "").strip().lower()
231
+ row_id = row.get("id")
232
+ if (
233
+ blocks in APPROVAL_BLOCKS
234
+ and status in UNRESOLVED_STATUSES
235
+ and isinstance(row_id, str)
236
+ and row_id
237
+ ):
238
+ ids.append(row_id)
239
+ return ids
240
+
241
+
242
+ def _planning_approval_block_reason(
243
+ report_data: Mapping[str, Any], planning: Mapping[str, Any]
244
+ ) -> str:
245
+ """plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.
246
+
247
+ 자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
248
+ 들어온다. 차단은 `blocked-by-disagreement` / `aborted-non-result` 와
249
+ `Status` 가 open/answered 인 `Blocks=approval` 행뿐이다.
250
+ """
251
+ ids = _unresolved_approval_ids(report_data)
252
+ if ids:
253
+ listed = ", ".join(ids)
254
+ return (
255
+ f"{listed} 가 Blocks=approval 로 열려 승인할 수 없습니다. "
256
+ "okstra-user-response 로 답한 뒤 그 답을 가지고 계획 단계를 "
257
+ "재개하세요. 구현을 시작하거나, 답을 쓰기 전에 계획 단계를 "
258
+ "다시 돌리지 마세요."
259
+ )
260
+ verification = planning.get("planBodyVerification")
261
+ gate = ""
262
+ if isinstance(verification, Mapping):
263
+ gate = str(verification.get("gateResult") or "").strip().lower()
264
+ if gate in _BLOCKING_PLAN_GATES:
265
+ return (
266
+ f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
267
+ "구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
268
+ )
269
+ return ""
270
+
271
+
213
272
  def _from_planning(report_data: Mapping[str, Any]) -> dict[str, str]:
214
- outcome = str(_block(report_data, "implementationPlanning").get("outcome") or "")
215
- if outcome == "plan-ready":
216
- return make(phase="implementation", status=STATUS_READY)
273
+ planning = _block(report_data, "implementationPlanning")
274
+ outcome = str(planning.get("outcome") or "")
217
275
  if outcome == "direction-invalidated":
218
276
  return make(phase="implementation-option-selection", status=STATUS_READY)
219
- return make(status=STATUS_PENDING)
277
+ if outcome != "plan-ready":
278
+ return make(status=STATUS_PENDING)
279
+ blocked_reason = _planning_approval_block_reason(report_data, planning)
280
+ if blocked_reason:
281
+ return make(status=STATUS_BLOCKED, rationale=blocked_reason)
282
+ return make(phase="implementation", status=STATUS_READY)
220
283
 
221
284
 
222
285
  def _from_target(report_data: Mapping[str, Any], key: str) -> dict[str, str]:
@@ -407,6 +407,15 @@ def expected_plan_item_ids(implementation_planning: Mapping[str, Any]) -> list[s
407
407
 
408
408
  _STARTABLE_STATUSES = frozenset({"ready", "active"})
409
409
 
410
+ # 계획 전체를 판정하는 항목. 스테이지 하나를 고쳐도 요청하지 않은 작업이
411
+ # 들어왔는지 다시 봐야 한다. P-Val / P-Req / P-Rb 는 여기 넣지 않는다 —
412
+ # stageRefs 가 비어 있어도 이웃 수정의 일소 대상이 되면 C 행이 매 라운드 늘어난다.
413
+ _PLAN_WIDE_ID_PREFIXES = ("P-Opt-", "P-Var-", "P-Dir-", "P-Dep-")
414
+
415
+
416
+ def _is_plan_wide(item_id: str) -> bool:
417
+ return item_id.startswith(_PLAN_WIDE_ID_PREFIXES)
418
+
410
419
 
411
420
  def content_hash(item: Mapping[str, Any]) -> str:
412
421
  """이 항목 본문의 지문. 문구가 같으면 라운드가 달라도 같은 판정이다.
@@ -430,9 +439,13 @@ def stage_scope_bucket(
430
439
  ) -> str:
431
440
  """게이트·디스패치가 공유하는 범위. ``in-scope`` / ``observed`` / ``deferred``.
432
441
 
433
- 원장이 없거나 항목에 스테이지가 없으면 계획 전체로 읽는다. 검증기
442
+ 원장이 없거나 계획 전체 항목이면 in-scope 이다. 검증기
434
443
  ``_stage_scope_bucket`` 과 같은 판정이어야 한다 — 디스패치와 게이트가
435
444
  다른 통을 쓰면 워커가 본 항목과 승인을 막는 항목이 갈라진다.
445
+
446
+ 단계가 없는 ``P-Val`` / ``P-Req`` / ``P-Rb`` 는 원장에 ``done`` 이 생긴
447
+ 뒤부터는 다음 착수를 막지 않는다. 생략된 ``stageRefs`` 를 모든 스테이지로
448
+ 읽으면 이미 끝난 계획의 재실행마다 체크리스트 전부가 다시 채점된다.
436
449
  """
437
450
  if not isinstance(ledger, Mapping) or not ledger:
438
451
  return "in-scope"
@@ -442,6 +455,11 @@ def stage_scope_bucket(
442
455
  if isinstance(value, int) and not isinstance(value, bool)
443
456
  ] if isinstance(scope, list) else []
444
457
  if not stages:
458
+ item_id = str(item.get("id") or "")
459
+ if _is_plan_wide(item_id):
460
+ return "in-scope"
461
+ if "done" in {str(value) for value in ledger.values()}:
462
+ return "deferred"
445
463
  return "in-scope"
446
464
  statuses = {str(ledger.get(str(stage)) or "") for stage in stages}
447
465
  if statuses & _STARTABLE_STATUSES:
@@ -548,6 +566,10 @@ def reverify_item_ids(
548
566
 
549
567
  이전이 없으면 첫 라운드라 디스패치 큐 전부다. 해시가 같은 다른 스테이지
550
568
  항목은 보내지 않는다 — 그게 96개 일소 배치를 만들던 규칙이다.
569
+
570
+ 단계가 없는 항목 전부가 계획 전체는 아니다. `P-Opt` / `P-Var` / `P-Dir` /
571
+ `P-Dep` 만 이웃 수정에 다시 묶인다. `P-Val` / `P-Req` / `P-Rb` 는 자기
572
+ 해시가 바뀔 때만 다시 본다.
551
573
  """
552
574
  dispatched = dispatch_item_ids(items, ledger)
553
575
  by_id = {
@@ -558,8 +580,8 @@ def reverify_item_ids(
558
580
  if not previous_hashes:
559
581
  return dispatched
560
582
  changed = [
561
- item_id for item_id in dispatched
562
- if previous_hashes.get(item_id) != content_hash(by_id[item_id])
583
+ item_id for item_id, item in by_id.items()
584
+ if previous_hashes.get(item_id) != content_hash(item)
563
585
  ]
564
586
  changed_stages: set[int] = set()
565
587
  for item_id in changed:
@@ -570,6 +592,7 @@ def reverify_item_ids(
570
592
  if isinstance(value, int) and not isinstance(value, bool)
571
593
  )
572
594
  queue: list[str] = []
595
+ queued: set[str] = set()
573
596
  for item_id in dispatched:
574
597
  item = by_id[item_id]
575
598
  scope = item.get("stageScope")
@@ -578,11 +601,24 @@ def reverify_item_ids(
578
601
  if isinstance(value, int) and not isinstance(value, bool)
579
602
  ] if isinstance(scope, list) else []
580
603
  if not stages:
581
- if changed:
604
+ if item_id in changed or (changed and _is_plan_wide(item_id)):
582
605
  queue.append(item_id)
606
+ queued.add(item_id)
583
607
  continue
584
608
  if item_id in changed or changed_stages.intersection(stages):
585
609
  queue.append(item_id)
610
+ queued.add(item_id)
611
+ # done 이후 디스패치에서 빠진 체크리스트라도 본문이 바뀌면 다시 본다.
612
+ for item_id in changed:
613
+ if item_id in queued or item_id not in by_id:
614
+ continue
615
+ scope = by_id[item_id].get("stageScope")
616
+ stages = [
617
+ value for value in scope
618
+ if isinstance(value, int) and not isinstance(value, bool)
619
+ ] if isinstance(scope, list) else []
620
+ if not stages:
621
+ queue.append(item_id)
586
622
  return queue
587
623
 
588
624
 
@@ -954,12 +954,15 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
954
954
  # `§x.y` is a full-reading-copy heading and is not a record coordinate.
955
955
  # `path.ext:line` is a source pointer the record does not define.
956
956
  _SECTION_REF_RE = re.compile(r"§[\d.]+|[A-Z]{1,4}-\d+|[\w./-]+\.\w+:\d+")
957
+ _PATH_LINE_RE = re.compile(r"[\w./-]+\.\w+:\d+")
957
958
  _ID_TOKEN_RE = re.compile(r"^[A-Z]{1,4}-\d+$")
959
+ _PLAN_ITEM_ID_RE = re.compile(r"^P-")
958
960
  _ROW_DEFINITION_KEYS = (
959
961
  "statement",
960
962
  "summary",
961
963
  "item",
962
964
  "title",
965
+ "subject",
963
966
  "check",
964
967
  "action",
965
968
  "evidence",
@@ -1992,6 +1995,138 @@ def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
1992
1995
  ]
1993
1996
 
1994
1997
 
1998
+ def _option_probe_texts(options: list[Any]) -> list[str]:
1999
+ texts: list[str] = []
2000
+ for option in options:
2001
+ if not isinstance(option, Mapping):
2002
+ continue
2003
+ texts.extend(
2004
+ str(option.get(key) or "")
2005
+ for key in ("answer", "rationale", "addedWork", "directionChange")
2006
+ )
2007
+ return texts
2008
+
2009
+
2010
+ def _row_probe_texts(row: Mapping[str, Any]) -> list[str]:
2011
+ return [
2012
+ str(row.get("statement") or ""),
2013
+ str(row.get("expected_form") or ""),
2014
+ *_option_probe_texts(list(row.get("options") or [])),
2015
+ ]
2016
+
2017
+
2018
+ def _path_line_refs(*texts: str) -> list[str]:
2019
+ found: list[str] = []
2020
+ seen: set[str] = set()
2021
+ for text in texts:
2022
+ for match in _PATH_LINE_RE.findall(text or ""):
2023
+ if match not in seen:
2024
+ seen.add(match)
2025
+ found.append(match)
2026
+ return found
2027
+
2028
+
2029
+ def _why_asked(row: Mapping[str, Any]) -> str:
2030
+ approval = row.get("approval_context") or {}
2031
+ if not isinstance(approval, Mapping):
2032
+ return "not stated in the report"
2033
+ unblock = str(approval.get("unblockCondition") or "").strip()
2034
+ if unblock:
2035
+ return unblock
2036
+ classification = str(approval.get("classification") or "").strip()
2037
+ return classification or "not stated in the report"
2038
+
2039
+
2040
+ def _linked_plan_items(
2041
+ record: dict[str, Any] | None, clarification_id: str
2042
+ ) -> list[dict[str, str]]:
2043
+ if record is None:
2044
+ return []
2045
+ linked: list[dict[str, str]] = []
2046
+ seen: set[str] = set()
2047
+
2048
+ def walk(node: object) -> None:
2049
+ if isinstance(node, dict):
2050
+ row_id = node.get("id")
2051
+ refs = node.get("clarificationRefs") or []
2052
+ if (
2053
+ isinstance(row_id, str)
2054
+ and _PLAN_ITEM_ID_RE.match(row_id)
2055
+ and isinstance(refs, list)
2056
+ and clarification_id in refs
2057
+ and row_id not in seen
2058
+ ):
2059
+ seen.add(row_id)
2060
+ linked.append({
2061
+ "id": row_id,
2062
+ "definition": _row_definition(node) or "not stated in the report",
2063
+ })
2064
+ for value in node.values():
2065
+ walk(value)
2066
+ elif isinstance(node, list):
2067
+ for item in node:
2068
+ walk(item)
2069
+
2070
+ walk(record)
2071
+ return linked
2072
+
2073
+
2074
+ def _format_ref_list(label: str, items: list[str]) -> list[str]:
2075
+ if not items:
2076
+ return [f"{label}: none"]
2077
+ return [f"{label}:", *(f"- {item}" for item in items)]
2078
+
2079
+
2080
+ def _format_open_row_view(
2081
+ row: Mapping[str, Any],
2082
+ record: dict[str, Any] | None,
2083
+ markdown_text: str,
2084
+ response: UserResponseEntry | None,
2085
+ ) -> list[str]:
2086
+ item = row["item"]
2087
+ probe = _row_probe_texts(row)
2088
+ refs = sorted(set(_SECTION_REF_RE.findall(" ".join(probe))))
2089
+ resolved = (
2090
+ resolve_refs_from_record(record, refs)
2091
+ if record is not None
2092
+ else resolve_refs(markdown_text, refs)
2093
+ )
2094
+ lines = [
2095
+ "",
2096
+ f"[{item.row_id}]",
2097
+ f"Kind: {item.kind}",
2098
+ f"Blocks: {item.blocks}",
2099
+ f"Report status: {item.status}",
2100
+ f"Question: {row['statement']}",
2101
+ f"Expected form: {row['expected_form']}",
2102
+ f"Current response: {response.value if response else 'none'}",
2103
+ f"Current disposition: {response.disposition if response else 'none'}",
2104
+ f"Why asked: {_why_asked(row)}",
2105
+ "Options:",
2106
+ ]
2107
+ approval = row.get("approval_context") or {}
2108
+ if isinstance(approval, Mapping) and approval:
2109
+ lines.extend([
2110
+ f"Approval classification: {approval.get('classification', '')}",
2111
+ f"Approval unblock condition: {approval.get('unblockCondition', '')}",
2112
+ f"Approval recommended disposition: {approval.get('recommendedDisposition', '')}",
2113
+ ])
2114
+ for index, option in enumerate(row["options"], start=1):
2115
+ lines.extend(_option_view(option, index))
2116
+ linked = _linked_plan_items(record, item.row_id)
2117
+ lines.extend(_format_ref_list(
2118
+ "Linked plan items",
2119
+ [f"{plan['id']}: {plan['definition']}" for plan in linked],
2120
+ ))
2121
+ lines.extend(_format_ref_list("Cited artifacts", _path_line_refs(*probe)))
2122
+ lines.append("Context:")
2123
+ lines.extend(
2124
+ f"- {ref['ref']}: {ref['definition'] or 'not stated in the report'}"
2125
+ for ref in resolved
2126
+ )
2127
+ return lines
2128
+
2129
+
1995
2130
  def format_show_view(report_path: Path, project_root: Path) -> str:
1996
2131
  context = _validate_owned_report_context(
1997
2132
  report_path, expected_project_root=project_root
@@ -1999,6 +2134,10 @@ def format_show_view(report_path: Path, project_root: Path) -> str:
1999
2134
  rows, record = _all_report_rows(context.report_path)
2000
2135
  state = _existing_sidecar_state(context.sidecar_path)
2001
2136
  current = {entry.response_id: entry for entry in state.entries}
2137
+ markdown_text = (
2138
+ "" if record is not None
2139
+ else context.markdown_path.read_text(encoding="utf-8")
2140
+ )
2002
2141
  lines = [
2003
2142
  "USER RESPONSE REPORT",
2004
2143
  f"Report: {context.report_path}",
@@ -2015,51 +2154,22 @@ def format_show_view(report_path: Path, project_root: Path) -> str:
2015
2154
  if candidates:
2016
2155
  lines.append("Plan option candidates:")
2017
2156
  for index, candidate in enumerate(candidates, start=1):
2157
+ current_pick = (
2158
+ state.plan_decision is not None
2159
+ and state.plan_decision.implementation_option == candidate
2160
+ )
2018
2161
  lines.extend([
2019
2162
  f"Plan option {index}: {candidate}",
2020
2163
  f" Recommended: {'yes' if candidate == recommended_name else 'no'}",
2021
- " Current decision: "
2022
- f"{'yes' if state.plan_decision and state.plan_decision.implementation_option == candidate else 'no'}",
2164
+ f" Current decision: {'yes' if current_pick else 'no'}",
2023
2165
  ])
2024
2166
  for row in rows:
2025
2167
  item = row["item"]
2026
2168
  if item.status not in {"open", "answered"} or item.row_id in current:
2027
2169
  continue
2028
- refs = sorted(set(_SECTION_REF_RE.findall(
2029
- row["statement"] + " " + row["expected_form"]
2030
- )))
2031
- resolved = (
2032
- resolve_refs_from_record(record, refs)
2033
- if record is not None
2034
- else resolve_refs(context.markdown_path.read_text(encoding="utf-8"), refs)
2035
- )
2036
- response = current.get(item.row_id)
2037
- lines.extend([
2038
- "",
2039
- f"[{item.row_id}]",
2040
- f"Kind: {item.kind}",
2041
- f"Blocks: {item.blocks}",
2042
- f"Report status: {item.status}",
2043
- f"Question: {row['statement']}",
2044
- f"Expected form: {row['expected_form']}",
2045
- f"Current response: {response.value if response else 'none'}",
2046
- f"Current disposition: {response.disposition if response else 'none'}",
2047
- "Options:",
2048
- ])
2049
- approval = row.get("approval_context") or {}
2050
- if approval:
2051
- lines.extend([
2052
- f"Approval classification: {approval.get('classification', '')}",
2053
- f"Approval unblock condition: {approval.get('unblockCondition', '')}",
2054
- f"Approval recommended disposition: {approval.get('recommendedDisposition', '')}",
2055
- ])
2056
- for index, option in enumerate(row["options"], start=1):
2057
- lines.extend(_option_view(option, index))
2058
- lines.append("Context:")
2059
- lines.extend(
2060
- f"- {ref['ref']}: {ref['definition'] or 'not stated in the report'}"
2061
- for ref in resolved
2062
- )
2170
+ lines.extend(_format_open_row_view(
2171
+ row, record, markdown_text, current.get(item.row_id),
2172
+ ))
2063
2173
  return "\n".join(lines) + "\n"
2064
2174
 
2065
2175