okstra 0.185.0 → 0.186.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 (41) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/chat/chat.mjs +62 -15
  3. package/dist/commands/chat/chat.mjs.map +1 -1
  4. package/docs/architecture.md +1 -1
  5. package/docs/cli.md +2 -2
  6. package/docs/for-ai/skills/okstra-chat.md +7 -1
  7. package/docs/project-structure-overview.md +1 -1
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  11. package/runtime/prompts/lead/plan-body-verification.md +8 -7
  12. package/runtime/prompts/profiles/implementation-planning.md +3 -3
  13. package/runtime/python/okstra_ctl/cmux.py +63 -35
  14. package/runtime/python/okstra_ctl/incremental_carry.py +149 -5
  15. package/runtime/python/okstra_ctl/incremental_scope.py +25 -3
  16. package/runtime/python/okstra_ctl/plan_items.py +40 -4
  17. package/runtime/python/okstra_ctl/report_html/common.py +86 -11
  18. package/runtime/python/okstra_ctl/report_html/filters.py +27 -10
  19. package/runtime/python/okstra_ctl/report_html/render.py +1 -0
  20. package/runtime/python/okstra_ctl/report_html/report_index.py +5 -1
  21. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +1 -1
  22. package/runtime/skills/okstra-chat/SKILL.md +25 -13
  23. package/runtime/templates/reports/html/assets/base.css +48 -3
  24. package/runtime/templates/reports/html/base.template.html +7 -4
  25. package/runtime/templates/reports/html/i18n/en.json +86 -8
  26. package/runtime/templates/reports/html/i18n/ko.json +86 -8
  27. package/runtime/templates/reports/html/macros/forms.html +74 -55
  28. package/runtime/templates/reports/html/macros/layout.html +2 -2
  29. package/runtime/templates/reports/html/macros/visualizations.html +1 -1
  30. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +2 -2
  31. package/runtime/templates/reports/html/tasks/error-analysis.template.html +3 -3
  32. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +4 -4
  33. package/runtime/templates/reports/html/tasks/final-verification.template.html +3 -3
  34. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +9 -9
  35. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +20 -18
  36. package/runtime/templates/reports/html/tasks/implementation.template.html +3 -3
  37. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +3 -3
  38. package/runtime/templates/reports/html/tasks/project-analysis.template.html +10 -10
  39. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  40. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +4 -4
  41. package/runtime/validators/validate-run.py +19 -19
@@ -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
 
@@ -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
 
@@ -1,8 +1,6 @@
1
1
  """Common data transformations that carry no task-specific section order."""
2
2
  from __future__ import annotations
3
3
 
4
- import re
5
-
6
4
 
7
5
  # Every block whose rows prose cites but no section of its own renders, with
8
6
  # the keys each one names its content, its provenance, and its confidence by. A
@@ -97,14 +95,69 @@ def _own_section_ids(data: dict, omitted_fields: tuple[str, ...] = ()) -> set[st
97
95
  return found
98
96
 
99
97
 
100
- def evidence_index(data: dict) -> dict[str, object]:
98
+ _OMITTED_TEXT_KEYS = (
99
+ "subject",
100
+ "check",
101
+ "item",
102
+ "action",
103
+ "requiredWork",
104
+ "statement",
105
+ "summary",
106
+ "need",
107
+ "title",
108
+ )
109
+
110
+
111
+ def _omitted_row_text(row: dict) -> str:
112
+ """생략된 행에서 독자가 읽을 한 줄을 고른다."""
113
+ for key in _OMITTED_TEXT_KEYS:
114
+ value = row.get(key)
115
+ if isinstance(value, str) and value.strip():
116
+ return value.strip()
117
+ return ""
118
+
119
+
120
+ def _collect_row_dicts(value: object, rows: dict[str, dict]) -> None:
121
+ if isinstance(value, dict):
122
+ row_id = value.get("id") or value.get("activityId") or value.get("clarificationId")
123
+ if isinstance(row_id, str) and row_id:
124
+ rows.setdefault(row_id, value)
125
+ for nested in value.values():
126
+ _collect_row_dicts(nested, rows)
127
+ elif isinstance(value, list):
128
+ for nested in value:
129
+ _collect_row_dicts(nested, rows)
130
+
131
+
132
+ def _omitted_rows(data: dict, omitted_fields: tuple[str, ...]) -> dict[str, dict]:
133
+ from ..report_contract import TASK_TYPE_DATA_PROPERTY
134
+
135
+ property_name = TASK_TYPE_DATA_PROPERTY.get(
136
+ (data.get("header") or {}).get("taskType", "")
137
+ )
138
+ block = data.get(property_name) if property_name else None
139
+ if not isinstance(block, dict):
140
+ return {}
141
+ rows: dict[str, dict] = {}
142
+ for field in omitted_fields:
143
+ _collect_row_dicts(block.get(field), rows)
144
+ return rows
145
+
146
+
147
+ def evidence_index(
148
+ data: dict, omitted_fields: tuple[str, ...] = ()
149
+ ) -> dict[str, object]:
101
150
  """The rows the ledger carries, keyed by id.
102
151
 
103
152
  A row whose statement came out empty is dropped rather than listed: the
104
153
  ledger exists so a cited id resolves to something the reader can read, and
105
154
  an id above a blank line resolves to nothing.
155
+
156
+ `omitted_fields` are task-block arrays the HTML template does not render
157
+ as their own section. Their rows still have to land somewhere, because
158
+ prose cites them — without a ledger entry the id in the body is dead text.
106
159
  """
107
- owned = _own_section_ids(data)
160
+ owned = _own_section_ids(data, omitted_fields)
108
161
  rows: dict[str, object] = {}
109
162
  for path, text_key, source_key, confidence_key, kind in _LEDGER_BLOCKS:
110
163
  for row in _dig(data, path):
@@ -116,12 +169,26 @@ def evidence_index(data: dict) -> dict[str, object]:
116
169
  entry = _ledger_row(row, text_key, source_key, confidence_key, kind)
117
170
  if entry["text"]:
118
171
  rows[row_id] = entry
172
+ for row_id, row in _omitted_rows(data, omitted_fields).items():
173
+ if row_id in owned or row_id in rows:
174
+ continue
175
+ text = _omitted_row_text(row)
176
+ if not text:
177
+ continue
178
+ rows[row_id] = {
179
+ "id": row_id,
180
+ "kind": "plan-item",
181
+ "text": text,
182
+ "codeEvidence": [],
183
+ "source": "",
184
+ "confidence": "",
185
+ }
119
186
  return rows
120
187
 
121
188
 
122
189
  def _collect_ids(value: object, found: set[str]) -> None:
123
190
  if isinstance(value, dict):
124
- for key in ("id", "activityId"):
191
+ for key in ("id", "activityId", "clarificationId"):
125
192
  row_id = value.get(key)
126
193
  if isinstance(row_id, str) and row_id:
127
194
  found.add(row_id)
@@ -132,7 +199,9 @@ def _collect_ids(value: object, found: set[str]) -> None:
132
199
  _collect_ids(nested, found)
133
200
 
134
201
 
135
- _ROW_ID = re.compile(r"[A-Z]{1,3}-\d+")
202
+ def _anchorable(row_id: str) -> bool:
203
+ """공백이나 경로 구분자가 있는 값은 HTML id 로 쓰지 않는다."""
204
+ return bool(row_id) and " " not in row_id and "/" not in row_id
136
205
 
137
206
 
138
207
  def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str, str]:
@@ -141,15 +210,21 @@ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str,
141
210
  Prose cites ids across section boundaries — a hotspot names a
142
211
  cross-verification finding, a quality row names a difference — so the
143
212
  target set spans the whole reader-facing report: the task's own sections,
144
- the clarifications, and every block the ledger takes in.
213
+ the clarifications, and every block the ledger takes in, including rows
214
+ whose section the template left out.
145
215
 
146
216
  It stops there. `summary` is the AI-facing digest and
147
217
  `analysisCommon.scope` describes the analysis target rather than listing
148
- rows; neither renders, so a link to one would land nowhere. Same for the
149
- blocks a template declares in `omitted_fields`.
218
+ rows; neither renders, so a link to one would land nowhere.
150
219
  """
151
- found = _own_section_ids(data, omitted_fields) | set(evidence_index(data))
152
- return {row_id: f"id-{row_id}" for row_id in sorted(found) if _ROW_ID.fullmatch(row_id)}
220
+ found = _own_section_ids(data, omitted_fields) | set(
221
+ evidence_index(data, omitted_fields)
222
+ )
223
+ return {
224
+ row_id: f"id-{row_id}"
225
+ for row_id in sorted(found)
226
+ if isinstance(row_id, str) and _anchorable(row_id)
227
+ }
153
228
 
154
229
 
155
230
  def analysis_review_ids(data: dict) -> tuple[str, ...]:
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import re
5
+ from functools import lru_cache
5
6
 
6
7
  import okstra_vendor # noqa: F401 # registers vendored dependency aliases
7
8
  from markupsafe import Markup, escape
@@ -9,12 +10,24 @@ from markupsafe import Markup, escape
9
10
  _INLINE_CODE = re.compile(r"`([^`]+)`")
10
11
  _SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
11
12
  _SENTENCES_PER_PARAGRAPH = 2
12
- # `\b` after the digits would end the token only where the next character is
13
- # non-word, and a Korean particle is a word character — `EA-001에` matched
14
- # nothing, so every id a translated report cites mid-sentence lost its link
15
- # while the English source kept it. The boundary a row id actually needs is
16
- # "not part of a longer alphanumeric run", which is what these assertions say.
17
- _ID_TOKEN = re.compile(r"(?<![A-Za-z0-9])[A-Z]{1,3}-\d+(?!\d)")
13
+
14
+
15
+ @lru_cache(maxsize=16)
16
+ def _anchors_pattern(keys: tuple[str, ...]) -> re.Pattern[str] | None:
17
+ """이 문서가 실제로 가진 아이디만, 것부터 맞춘다.
18
+
19
+ 고정 `[A-Z]{1,3}-\\d+` 토큰은 `PREP-001` 과 `P-Step-5.1` 을 놓쳐
20
+ 행이 페이지에 있어도 본문이 그냥 글자가 됐다. 인덱스에 있는 키로
21
+ 패턴을 만들면 `DEV-10339` 같은 티켓은 그 id 의 행이 없을 때 링크되지 않는다.
22
+ """
23
+ if not keys:
24
+ return None
25
+ ordered = tuple(sorted(keys, key=len, reverse=True))
26
+ return re.compile(
27
+ r"(?<![A-Za-z0-9])(?:"
28
+ + "|".join(re.escape(key) for key in ordered)
29
+ + r")(?![A-Za-z0-9])"
30
+ )
18
31
 
19
32
 
20
33
  def _link_ids(escaped: str, anchors: dict) -> str:
@@ -27,12 +40,16 @@ def _link_ids(escaped: str, anchors: dict) -> str:
27
40
  """
28
41
  if not anchors:
29
42
  return escaped
43
+ pattern = _anchors_pattern(tuple(sorted(anchors)))
44
+ if pattern is None:
45
+ return escaped
30
46
 
31
- def swap(match: re.Match) -> str:
32
- name = anchors.get(match.group(0))
33
- return f'<a href="#{name}">{match.group(0)}</a>' if name else match.group(0)
47
+ def swap(match: re.Match[str]) -> str:
48
+ token = match.group(0)
49
+ name = anchors.get(token)
50
+ return f'<a href="#{name}">{token}</a>' if name else token
34
51
 
35
- return _ID_TOKEN.sub(swap, escaped)
52
+ return pattern.sub(swap, escaped)
36
53
 
37
54
 
38
55
  def inline_code(value: object, anchors: dict | None = None) -> Markup:
@@ -153,6 +153,7 @@ def render_v2_html_view(
153
153
  "runMeta": run_meta,
154
154
  "reportMeta": _report_meta(data, run_meta),
155
155
  "taskType": view.task_type,
156
+ "lang": lang,
156
157
  "sourceData": source_data,
157
158
  "dataSha256": _sha256(data_path),
158
159
  "clarificationItems": data.get("clarificationItems", []),
@@ -24,6 +24,8 @@ _SLUG_RE = re.compile(r'\bdata-report-section="([^"]+)"')
24
24
  _TAG_RE = re.compile(r"<[^>]+>")
25
25
 
26
26
  INDEX_TITLE_ID = "report-index-title"
27
+ # 맨 위로 버튼 패널에 같은 목차 항목을 채우는 자리. 본문 목차와 한 함수에서 만든다.
28
+ _INDEX_ITEMS_SLOT = "<!--report-index-items-->"
27
29
 
28
30
 
29
31
  def _heading_text(title_markup: str) -> str:
@@ -34,6 +36,7 @@ def _heading_text(title_markup: str) -> str:
34
36
  def inject_report_index(document: str, *, label: str) -> str:
35
37
  """Return ``document`` with a section index at the top of ``<main>``.
36
38
 
39
+ The same list fills the back-to-top hover panel, so the two cannot drift.
37
40
  Sections that lack both an id and a slug are skipped rather than given a
38
41
  generated anchor: a link whose target moves between renders is worse than
39
42
  an entry the reader never had.
@@ -72,4 +75,5 @@ def inject_report_index(document: str, *, label: str) -> str:
72
75
  f"<ol>{items}</ol>"
73
76
  "</nav>"
74
77
  )
75
- return f"{head}\n{index}{body}"
78
+ filled = f"{head}\n{index}{body}"
79
+ return filled.replace(_INDEX_ITEMS_SLOT, f"<ol>{items}</ol>", 1)
@@ -134,7 +134,7 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
134
134
  row["activityId"]: row for row in activities if row.get("activityId")
135
135
  },
136
136
  "decisionCards": decision_cards,
137
- "evidenceIndex": evidence_index(data),
137
+ "evidenceIndex": evidence_index(data, _OMITTED_FIELDS),
138
138
  }
139
139
  return HumanReportView(
140
140
  "implementation-planning",
@@ -59,26 +59,46 @@ If the host has a native picker, use it. Otherwise print a numbered list.
59
59
  okstra chat unread --room <room> --as <display>
60
60
  ```
61
61
 
62
- Show the rows. Each row is `id:time:recipient:body`.
62
+ Show the rows. Each row is `id @from YYYY-MM-DD HH:MM body`. Sender is always `@name`. The id is the first token (before the first space). A reply inserts `↑<parentId>` after the time. Recipient is not on the line.
63
+
64
+ If the output is `no unread`, do not ack.
65
+
66
+ If there are unread rows, always run:
67
+
68
+ ```bash
69
+ okstra chat ack --room <room> --as <display> --through <id>
70
+ ```
71
+
72
+ Use the last unread row's id. CLI unread does not move the cursor; this ack does.
63
73
 
64
74
  ## Step 3: Next action
65
75
 
66
- Ask: send, inbox, log, ack, or done.
76
+ Ask: send, unread, inbox, log, reply, or done.
77
+
78
+ There is no ack menu item. Choosing unread again shows the rows then acks, same as Step 2.
67
79
 
68
80
  ### Send
69
81
 
70
82
  1. Run `okstra chat members --room <room>`.
71
- 2. Pick the recipient from `all` plus those names. Recipient is required.
83
+ 2. Pick the recipient from `all` plus those names except the current display name (`--as`). Filter after `okstra chat members`. Do not pass `--as` to `members`. Recipient is required.
72
84
  3. Ask for the body as free input.
73
85
  4. Run `okstra chat send --room <room> --as <display> --to <all|name> --body <text>`.
74
86
 
87
+ ### Reply
88
+
89
+ 1. Ask for the parent message id as free input.
90
+ 2. Ask for the body as free input.
91
+ 3. Run `okstra chat send --room <room> --as <display> --reply-to <id> --body <text>`.
92
+
93
+ Do not pick a recipient. There is no reply subcommand.
94
+
75
95
  ### Inbox
76
96
 
77
97
  ```bash
78
98
  okstra chat inbox --room <room> --as <display>
79
99
  ```
80
100
 
81
- This is every arrival to `@you` or `all`, including messages already acked.
101
+ This is every arrival to `@you` or `all`, including messages already acked and messages you sent to `all` or to yourself. Inbox does not move the cursor.
82
102
 
83
103
  ### Log
84
104
 
@@ -86,15 +106,7 @@ This is every arrival to `@you` or `all`, including messages already acked.
86
106
  okstra chat log --room <room> --as <display>
87
107
  ```
88
108
 
89
- The whole room, including messages not addressed to you.
90
-
91
- ### Ack
92
-
93
- After unread, mark the last id the user has read:
94
-
95
- ```bash
96
- okstra chat ack --room <room> --as <display> --through <id>
97
- ```
109
+ The whole room, including messages not addressed to you. Log does not move the cursor.
98
110
 
99
111
  ## Rules
100
112