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.
- package/README.md +1 -0
- package/dist/commands/chat/chat.mjs +62 -15
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/for-ai/skills/okstra-chat.md +7 -1
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +8 -7
- package/runtime/prompts/profiles/implementation-planning.md +3 -3
- package/runtime/python/okstra_ctl/cmux.py +63 -35
- package/runtime/python/okstra_ctl/incremental_carry.py +149 -5
- package/runtime/python/okstra_ctl/incremental_scope.py +25 -3
- package/runtime/python/okstra_ctl/plan_items.py +40 -4
- package/runtime/python/okstra_ctl/report_html/common.py +86 -11
- package/runtime/python/okstra_ctl/report_html/filters.py +27 -10
- package/runtime/python/okstra_ctl/report_html/render.py +1 -0
- package/runtime/python/okstra_ctl/report_html/report_index.py +5 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +1 -1
- package/runtime/skills/okstra-chat/SKILL.md +25 -13
- package/runtime/templates/reports/html/assets/base.css +48 -3
- package/runtime/templates/reports/html/base.template.html +7 -4
- package/runtime/templates/reports/html/i18n/en.json +86 -8
- package/runtime/templates/reports/html/i18n/ko.json +86 -8
- package/runtime/templates/reports/html/macros/forms.html +74 -55
- package/runtime/templates/reports/html/macros/layout.html +2 -2
- package/runtime/templates/reports/html/macros/visualizations.html +1 -1
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +2 -2
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +3 -3
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +4 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +3 -3
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +9 -9
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +20 -18
- package/runtime/templates/reports/html/tasks/implementation.template.html +3 -3
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +3 -3
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +10 -10
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +4 -4
- 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
|
-
"""이전 판정 중 이월 스테이지
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
#
|
|
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
|
|
562
|
-
if previous_hashes.get(item_id) != content_hash(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
152
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|