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.
- package/README.md +2 -1
- package/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.d.mts +1 -0
- package/dist/commands/chat/chat.mjs +386 -0
- package/dist/commands/chat/chat.mjs.map +1 -0
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/docs/architecture.md +9 -7
- package/docs/cli.md +3 -2
- package/docs/for-ai/README.md +4 -2
- package/docs/for-ai/skills/okstra-chat.md +34 -0
- package/docs/for-ai/skills/okstra-inspect.md +1 -1
- package/docs/for-ai/skills/okstra-run.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +10 -8
- package/docs/project-structure-overview.md +6 -5
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/implementation-planning.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
- package/runtime/prompts/lead/plan-body-verification.md +8 -7
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
- package/runtime/prompts/profiles/implementation-planning.md +4 -3
- package/runtime/prompts/wizard/prompts.ko.json +2 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- 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/next_phase.py +67 -4
- package/runtime/python/okstra_ctl/plan_items.py +40 -4
- package/runtime/python/okstra_ctl/user_response.py +147 -37
- package/runtime/python/okstra_ctl/wizard.py +13 -0
- package/runtime/skills/okstra-chat/SKILL.md +108 -0
- package/runtime/skills/okstra-inspect/facets/status.md +6 -5
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/skills/okstra-user-response/SKILL.md +50 -16
- 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
|
-
"""이전 판정 중 이월 스테이지
|
|
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
|
|
|
@@ -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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
2029
|
-
row
|
|
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
|
|