okstra 0.141.3 → 0.142.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/docs/architecture.md +11 -2
- package/docs/cli.md +15 -0
- package/docs/for-ai/skills/okstra-setup.md +8 -0
- package/docs/project-structure-overview.md +4 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +2 -1
- package/runtime/prompts/coding-preflight/architectures/hexagonal.md +3 -3
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/adapters/claude-code.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +20 -9
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -0
- package/runtime/prompts/profiles/_implementation-deliverable.md +3 -3
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -1
- package/runtime/prompts/profiles/forbidden-actions.json +0 -1
- package/runtime/prompts/profiles/implementation-planning.md +7 -2
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/python/okstra_ctl/clarification_items.py +99 -5
- package/runtime/python/okstra_ctl/paths.py +34 -7
- package/runtime/python/okstra_ctl/phase_cleanup.py +235 -0
- package/runtime/python/okstra_ctl/plan_items.py +38 -0
- package/runtime/python/okstra_ctl/wizard.py +16 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +15 -5
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_project/resolver.py +25 -0
- package/runtime/schemas/final-report-v1.0.schema.json +63 -4
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +3 -0
- package/runtime/skills/okstra-setup/references/project-config.md +47 -0
- package/runtime/templates/reports/final-report.template.md +25 -0
- package/runtime/templates/reports/i18n/en.json +12 -1
- package/runtime/templates/reports/i18n/ko.json +12 -1
- package/runtime/templates/reports/implementation-input.template.md +1 -2
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/validate-run.py +143 -12
- package/src/cli-registry.mjs +10 -0
- package/src/commands/execute/phase-cleanup.mjs +38 -0
|
@@ -27,7 +27,7 @@ from dataclasses import dataclass
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
from typing import Optional
|
|
29
29
|
|
|
30
|
-
from okstra_ctl.md_table import is_separator_row, split_pipe_row
|
|
30
|
+
from okstra_ctl.md_table import is_separator_row, split_pipe_row, to_cell_text
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
# The final-report renderer (render_final_report.py:_inject_anchors) appends a
|
|
@@ -372,6 +372,24 @@ def user_response_sidecars(source: Path) -> list[Path]:
|
|
|
372
372
|
)
|
|
373
373
|
|
|
374
374
|
|
|
375
|
+
def _sidecar_answers(source: Path) -> dict[str, str]:
|
|
376
|
+
"""`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
|
|
377
|
+
|
|
378
|
+
같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다. `value`
|
|
379
|
+
가 빈 항목은 담지 않는다. `user_response` 를 지연 import 해 순환 참조를 피한다.
|
|
380
|
+
"""
|
|
381
|
+
from okstra_ctl.user_response import parse_user_response_entries
|
|
382
|
+
|
|
383
|
+
answers: dict[str, str] = {}
|
|
384
|
+
for sidecar in user_response_sidecars(source):
|
|
385
|
+
for entry in parse_user_response_entries(
|
|
386
|
+
sidecar.read_text(encoding="utf-8")
|
|
387
|
+
):
|
|
388
|
+
if entry.value:
|
|
389
|
+
answers[entry.response_id] = entry.value
|
|
390
|
+
return answers
|
|
391
|
+
|
|
392
|
+
|
|
375
393
|
def attached_user_responses_section(source: Path) -> str:
|
|
376
394
|
"""`source` 형제 `user-responses/` 사이드카만 모은 `# Attached User Responses`
|
|
377
395
|
섹션 본문. 사이드카 부재 시 빈 문자열.
|
|
@@ -410,19 +428,29 @@ def clarification_response_with_sidecars(source: Path) -> str:
|
|
|
410
428
|
"""
|
|
411
429
|
text = source.read_text(encoding="utf-8")
|
|
412
430
|
section = attached_user_responses_section(source)
|
|
413
|
-
|
|
431
|
+
answers = _sidecar_answers(source)
|
|
432
|
+
body = _clarification_carry_body(source, text, answers)
|
|
414
433
|
if not section:
|
|
415
434
|
return body
|
|
416
435
|
return body.rstrip("\n") + "\n\n---\n\n" + section
|
|
417
436
|
|
|
418
437
|
|
|
419
|
-
def _clarification_carry_body(
|
|
420
|
-
|
|
438
|
+
def _clarification_carry_body(
|
|
439
|
+
source: Path, text: str, answers: dict[str, str]
|
|
440
|
+
) -> str:
|
|
441
|
+
"""final-report 소스는 §1 + 원문 포인터로 좁히고, 그 외는 원문 그대로.
|
|
442
|
+
|
|
443
|
+
§1 이 있으면 사이드카 답변을 그 표의 `User input` 열에 병합해, 답이 표 안에
|
|
444
|
+
자리하도록 한다(파일 헤더가 선언하는 "답은 User input 열에" 계약을 실제로
|
|
445
|
+
참으로 만든다)."""
|
|
421
446
|
slice_ = _section_1_slice(text)
|
|
422
447
|
if slice_ is None:
|
|
423
448
|
return text
|
|
424
449
|
heading = SECTION_HEADING_PATTERN.search(text)
|
|
425
450
|
assert heading is not None # _section_1_slice returned a slice
|
|
451
|
+
section_body = slice_.rstrip()
|
|
452
|
+
if answers:
|
|
453
|
+
section_body = _reconcile_user_input(section_body, answers)
|
|
426
454
|
return (
|
|
427
455
|
"# Clarification Response (carry-in)\n\n"
|
|
428
456
|
f"- Source report: `{source}`\n"
|
|
@@ -430,5 +458,71 @@ def _clarification_carry_body(source: Path, text: str) -> str:
|
|
|
430
458
|
"section; the report itself is read from the path above when a phase "
|
|
431
459
|
"needs it. Do not re-read the source report to find the answers — they "
|
|
432
460
|
"are in the `User input` column below.\n\n"
|
|
433
|
-
f"{heading.group(0)}\n{
|
|
461
|
+
f"{heading.group(0)}\n{section_body}\n"
|
|
434
462
|
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# The final-report renderer writes `Status: open` / `Status: answered` unquoted
|
|
466
|
+
# in the stacked meta cell; only those two are unresolved. Resolve in place so
|
|
467
|
+
# the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
|
|
468
|
+
_STATUS_RESOLVE_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
|
|
472
|
+
"""§1 데이터 표의 헤더 줄 인덱스와 `User input` 열 인덱스. 표가 없으면 (-1, -1)."""
|
|
473
|
+
for idx, line in enumerate(lines):
|
|
474
|
+
if not line.lstrip().startswith("|"):
|
|
475
|
+
continue
|
|
476
|
+
cells = [c.lower() for c in _split_pipe_row(line)]
|
|
477
|
+
if "user input" in cells and any(c.startswith("statement") for c in cells):
|
|
478
|
+
return idx, cells.index("user input")
|
|
479
|
+
return -1, -1
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
483
|
+
"""답이 있고 open/answered 이며 `User input` 칸이 빈 행이면 답을 채우고 Status 를
|
|
484
|
+
resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다. 이미 채워진 칸은
|
|
485
|
+
표에 든 값이 정본이므로 덮어쓰지 않는다.
|
|
486
|
+
|
|
487
|
+
판정은 앵커/백틱을 벗긴 셀(`_split_pipe_row`)로 — 그래야 `_meta_id` 가 스크롤
|
|
488
|
+
앵커의 소문자 slug 대신 진짜 대문자 ID 를 읽는다. 재조립은 원본 셀
|
|
489
|
+
(`split_pipe_row`)로 해서 앵커를 보존한다."""
|
|
490
|
+
norm = _split_pipe_row(line)
|
|
491
|
+
item = parse_meta_cell(norm[0]) if norm else None
|
|
492
|
+
if item is None or item.row_id not in answers:
|
|
493
|
+
return line
|
|
494
|
+
if item.status not in UNRESOLVED_STATUSES:
|
|
495
|
+
return line
|
|
496
|
+
raw = split_pipe_row(line)
|
|
497
|
+
if not (0 <= ui_col < len(raw) and raw[ui_col] == ""):
|
|
498
|
+
return line
|
|
499
|
+
raw[ui_col] = answers[item.row_id]
|
|
500
|
+
raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
|
|
501
|
+
return "| " + " | ".join(to_cell_text(c) for c in raw) + " |"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
|
|
505
|
+
"""§1 표에서 사이드카 답이 있는 미해결 행의 빈 `User input` 칸을 답으로 채우고
|
|
506
|
+
Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
|
|
507
|
+
|
|
508
|
+
답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
|
|
509
|
+
빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만
|
|
510
|
+
있어서 표만 신뢰하는 소비자는 그 답을 놓쳤다."""
|
|
511
|
+
lines = section.splitlines()
|
|
512
|
+
header_idx, ui_col = _locate_user_input_column(lines)
|
|
513
|
+
if header_idx < 0:
|
|
514
|
+
return section
|
|
515
|
+
out = list(lines)
|
|
516
|
+
body = False
|
|
517
|
+
for i in range(header_idx + 1, len(lines)):
|
|
518
|
+
line = lines[i]
|
|
519
|
+
if not line.lstrip().startswith("|"):
|
|
520
|
+
if body:
|
|
521
|
+
break
|
|
522
|
+
continue
|
|
523
|
+
if is_separator_row(line):
|
|
524
|
+
body = True
|
|
525
|
+
continue
|
|
526
|
+
if body:
|
|
527
|
+
out[i] = _reconcile_row(line, ui_col, answers)
|
|
528
|
+
return "\n".join(out)
|
|
@@ -66,6 +66,19 @@ def _newest_report(reports_dir: Path) -> Optional[Path]:
|
|
|
66
66
|
return max(found, key=lambda p: (p.stat().st_mtime, p.name))
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _report_dirs(type_dir: Path, include_stages: bool) -> list[Path]:
|
|
70
|
+
"""한 task-type 의 report 디렉터리들. flat 은 항상, stage-<N> 은 opt-in.
|
|
71
|
+
|
|
72
|
+
stage-isolated task-type(implementation / final-verification)만 stage 하위를
|
|
73
|
+
갖는다. `include_stages` 가 꺼져 있으면 flat `reports/` 만 돌려준다 —
|
|
74
|
+
`latest_under` 의 원래 범위이자 resume-clarification 이 의존하는 계약이다.
|
|
75
|
+
"""
|
|
76
|
+
dirs = [type_dir / "reports"]
|
|
77
|
+
if include_stages and type_dir.name in _STAGED_TASK_TYPES:
|
|
78
|
+
dirs += sorted(type_dir.glob("stage-*/reports"))
|
|
79
|
+
return dirs
|
|
80
|
+
|
|
81
|
+
|
|
69
82
|
def _project_root_of(task_root: Path) -> Optional[Path]:
|
|
70
83
|
"""canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
|
|
71
84
|
|
|
@@ -257,34 +270,48 @@ class RunRef:
|
|
|
257
270
|
task_group: str,
|
|
258
271
|
task_id: str,
|
|
259
272
|
task_types: Optional[tuple[str, ...]] = None,
|
|
273
|
+
*,
|
|
274
|
+
include_stages: bool = False,
|
|
260
275
|
) -> Optional["RunRef"]:
|
|
261
276
|
"""여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
|
|
262
277
|
|
|
263
278
|
`task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
|
|
264
279
|
훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
|
|
280
|
+
|
|
281
|
+
`include_stages` 를 켜면 stage-isolated task-type 의 `stage-<N>/reports/`
|
|
282
|
+
도 함께 훑는다 — phase-cleanup 이 staged run 을 자동발견할 때 쓴다.
|
|
265
283
|
"""
|
|
266
284
|
return cls.latest_under(
|
|
267
|
-
task_dir(project_root, task_group, task_id),
|
|
285
|
+
task_dir(project_root, task_group, task_id),
|
|
286
|
+
task_types,
|
|
287
|
+
include_stages=include_stages,
|
|
268
288
|
)
|
|
269
289
|
|
|
270
290
|
@classmethod
|
|
271
291
|
def latest_under(
|
|
272
|
-
cls,
|
|
292
|
+
cls,
|
|
293
|
+
task_root: Path,
|
|
294
|
+
task_types: Optional[tuple[str, ...]] = None,
|
|
295
|
+
*,
|
|
296
|
+
include_stages: bool = False,
|
|
273
297
|
) -> Optional["RunRef"]:
|
|
274
298
|
"""`latest_across` 의 task_root 진입점.
|
|
275
299
|
|
|
276
300
|
task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
|
|
277
|
-
(bash resume-clarification)가 쓴다.
|
|
301
|
+
(bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
|
|
302
|
+
넘기므로 기본값(`include_stages=False`)이 그 범위를 그대로 보존한다.
|
|
278
303
|
"""
|
|
279
304
|
runs_dir = runs_dir_of(task_root)
|
|
280
305
|
if task_types is None:
|
|
281
306
|
if not runs_dir.is_dir():
|
|
282
307
|
return None
|
|
283
308
|
task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
|
|
284
|
-
candidates = [
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
309
|
+
candidates: list[Path] = []
|
|
310
|
+
for task_type in task_types:
|
|
311
|
+
for reports_dir in _report_dirs(runs_dir / task_type, include_stages):
|
|
312
|
+
found = _newest_report(reports_dir)
|
|
313
|
+
if found is not None:
|
|
314
|
+
candidates.append(found)
|
|
288
315
|
if not candidates:
|
|
289
316
|
return None
|
|
290
317
|
return cls.from_report_path(
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Phase-transition resource cleanup orchestrator.
|
|
2
|
+
|
|
3
|
+
Reuses the existing pane-reclaim (okstra-trace-cleanup.sh) and teammate-reconcile
|
|
4
|
+
(okstra-team-reconcile.sh) primitives; this module only decides tmux-vs-in-process
|
|
5
|
+
mode, finds the prior run dir, and sequences the two scripts. It never
|
|
6
|
+
re-implements pane kill or completion detection.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
from okstra_project import StateError, parse_task_key
|
|
18
|
+
|
|
19
|
+
from .paths import RunRef, okstra_home
|
|
20
|
+
from .tmux import resolve_caller_pane, tmux_available
|
|
21
|
+
|
|
22
|
+
_TRACE_SCRIPT = "okstra-trace-cleanup.sh"
|
|
23
|
+
_RECONCILE_SCRIPT = "okstra-team-reconcile.sh"
|
|
24
|
+
_MODE_TMUX = "tmux"
|
|
25
|
+
_MODE_IN_PROCESS = "in-process"
|
|
26
|
+
_RUNNER_TIMEOUT_SECONDS = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _recorded_lead_pane(prev_run_dir: Optional[Path]) -> Optional[str]:
|
|
30
|
+
"""The pane the prior run's lead recorded, or None when unrecorded.
|
|
31
|
+
|
|
32
|
+
'<run_dir>/state/lead-pane.id' is written once at run start by the lead
|
|
33
|
+
adapter: a non-empty pane id means that run was inside a tmux pane, an empty
|
|
34
|
+
(0-byte) file means the run resolved to in-process. This on-disk fact
|
|
35
|
+
outlives the cleanup process's own context, which -- when okstra runs as a
|
|
36
|
+
daemon child -- can no longer walk its ancestry back to a pane.
|
|
37
|
+
"""
|
|
38
|
+
if prev_run_dir is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return (prev_run_dir / "state" / "lead-pane.id").read_text().strip()
|
|
42
|
+
except OSError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_mode(
|
|
47
|
+
*,
|
|
48
|
+
prev_run_dir: Optional[Path] = None,
|
|
49
|
+
pane_probe: Callable[[], str] = resolve_caller_pane,
|
|
50
|
+
tmux_probe: Callable[[], bool] = tmux_available,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""'tmux' when the prior run had a pane to reclaim, else 'in-process'.
|
|
53
|
+
|
|
54
|
+
The prior run's recorded lead pane is authoritative: it captured that run's
|
|
55
|
+
tmux-ness at run start and survives a cleanup process that can no longer walk
|
|
56
|
+
its ancestry to a pane (daemon child). Only when nothing was recorded does
|
|
57
|
+
this fall back to the live ancestor-walk probe.
|
|
58
|
+
"""
|
|
59
|
+
recorded = _recorded_lead_pane(prev_run_dir)
|
|
60
|
+
if recorded is not None:
|
|
61
|
+
return _MODE_TMUX if recorded else _MODE_IN_PROCESS
|
|
62
|
+
if pane_probe():
|
|
63
|
+
return _MODE_TMUX
|
|
64
|
+
if prev_run_dir is not None and tmux_probe():
|
|
65
|
+
# A prior run exists to reclaim from, but neither its recording nor the
|
|
66
|
+
# live probe could confirm tmux -- yet tmux is reachable. Surface the
|
|
67
|
+
# ambiguity instead of silently skipping pane reclaim.
|
|
68
|
+
sys.stderr.write(
|
|
69
|
+
"phase-cleanup: prior run has no recorded lead pane and no caller "
|
|
70
|
+
"pane could be resolved, but tmux is reachable; treating as "
|
|
71
|
+
"in-process and skipping pane reclaim\n"
|
|
72
|
+
)
|
|
73
|
+
return _MODE_IN_PROCESS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_prev_run_dir(
|
|
77
|
+
*,
|
|
78
|
+
run_dir: Optional[str],
|
|
79
|
+
project_root: Path,
|
|
80
|
+
task_group: str,
|
|
81
|
+
task_id: str,
|
|
82
|
+
) -> Optional[Path]:
|
|
83
|
+
"""Explicit --run-dir wins; otherwise the newest completed run across phases.
|
|
84
|
+
|
|
85
|
+
Auto-discovery walks both FLAT runs/<type>/reports/ and staged
|
|
86
|
+
runs/<type>/stage-N/reports/ (implementation / final-verification), so a
|
|
87
|
+
staged prior run is found without an explicit run_dir; --run-dir still
|
|
88
|
+
overrides when a specific prior run is wanted.
|
|
89
|
+
"""
|
|
90
|
+
if run_dir:
|
|
91
|
+
return Path(run_dir)
|
|
92
|
+
ref = RunRef.latest_across(
|
|
93
|
+
Path(project_root), task_group, task_id, include_stages=True
|
|
94
|
+
)
|
|
95
|
+
return ref.run_dir if ref is not None else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _default_runner(cmd: list[str]) -> str:
|
|
99
|
+
# Cleanup must never block the next phase, so a spawn failure or a missing
|
|
100
|
+
# script degrades to "nothing to report" instead of propagating.
|
|
101
|
+
try:
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
cmd,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
check=False,
|
|
107
|
+
timeout=_RUNNER_TIMEOUT_SECONDS,
|
|
108
|
+
)
|
|
109
|
+
return proc.stdout
|
|
110
|
+
except (OSError, subprocess.SubprocessError):
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _script_path(name: str) -> str:
|
|
115
|
+
# repo layout: scripts/<name>; installed layout: ~/.okstra/bin/<name>
|
|
116
|
+
candidates = [
|
|
117
|
+
Path(__file__).resolve().parent.parent / name,
|
|
118
|
+
okstra_home() / "bin" / name,
|
|
119
|
+
]
|
|
120
|
+
for candidate in candidates:
|
|
121
|
+
if candidate.is_file():
|
|
122
|
+
return str(candidate)
|
|
123
|
+
return str(candidates[-1]) # a missing script is tolerated by _default_runner
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _reclaim_panes(prev_run_dir: Path, runner: Callable[[list[str]], str]) -> int:
|
|
127
|
+
listing = runner(
|
|
128
|
+
[
|
|
129
|
+
_script_path(_TRACE_SCRIPT),
|
|
130
|
+
"--run-dir",
|
|
131
|
+
str(prev_run_dir),
|
|
132
|
+
"--reclaim-completed",
|
|
133
|
+
"--list",
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
count = len([ln for ln in listing.splitlines() if ln.strip()])
|
|
137
|
+
runner(
|
|
138
|
+
[_script_path(_TRACE_SCRIPT), "--run-dir", str(prev_run_dir), "--reclaim-completed"]
|
|
139
|
+
)
|
|
140
|
+
return count
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _dismissible_teammates(
|
|
144
|
+
project_root: Path,
|
|
145
|
+
runner: Callable[[list[str]], str],
|
|
146
|
+
fallback_team: str = "",
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
# The live team dir is keyed by the current session id, which Claude Code
|
|
149
|
+
# re-issues on resume/compaction; without the caller's label the resolver
|
|
150
|
+
# finds no live roster and reports nothing to dismiss.
|
|
151
|
+
cmd = [_script_path(_RECONCILE_SCRIPT), "--project-root", str(project_root)]
|
|
152
|
+
if fallback_team:
|
|
153
|
+
cmd += ["--fallback-team", fallback_team]
|
|
154
|
+
out = runner(cmd)
|
|
155
|
+
prefix = "dismissible-member:"
|
|
156
|
+
return [ln[len(prefix):].strip() for ln in out.splitlines() if ln.startswith(prefix)]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_cleanup(
|
|
160
|
+
*,
|
|
161
|
+
mode: str,
|
|
162
|
+
prev_run_dir: Optional[Path],
|
|
163
|
+
project_root: Path,
|
|
164
|
+
runner: Callable[[list[str]], str] = _default_runner,
|
|
165
|
+
fallback_team: str = "",
|
|
166
|
+
) -> dict:
|
|
167
|
+
# Cleanup is performed here (real pane kill + reconcile), not merely logged;
|
|
168
|
+
# the CLI's execution IS the enforcement (a caller cannot emit a "cleaned"
|
|
169
|
+
# line without this actually running).
|
|
170
|
+
panes = 0
|
|
171
|
+
if mode == _MODE_TMUX and prev_run_dir is not None:
|
|
172
|
+
panes = _reclaim_panes(prev_run_dir, runner)
|
|
173
|
+
teammates = _dismissible_teammates(project_root, runner, fallback_team)
|
|
174
|
+
return {"mode": mode, "panesReclaimed": panes, "dismissibleTeammates": teammates}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: list[str]) -> int:
|
|
178
|
+
ap = argparse.ArgumentParser(prog="okstra phase-cleanup")
|
|
179
|
+
ap.add_argument(
|
|
180
|
+
"--task-key",
|
|
181
|
+
help="finds the newest completed run across phases, including staged "
|
|
182
|
+
"(implementation / final-verification) stage-N runs",
|
|
183
|
+
)
|
|
184
|
+
ap.add_argument("--run-dir")
|
|
185
|
+
ap.add_argument("--project-root", required=True)
|
|
186
|
+
ap.add_argument(
|
|
187
|
+
"--fallback-team",
|
|
188
|
+
default="",
|
|
189
|
+
help="team label to resolve the roster from when the live session dir is "
|
|
190
|
+
"gone (session id re-issued by resume/compaction)",
|
|
191
|
+
)
|
|
192
|
+
ap.add_argument("--json", action="store_true")
|
|
193
|
+
args = ap.parse_args(argv)
|
|
194
|
+
|
|
195
|
+
task_group = task_id = ""
|
|
196
|
+
if args.task_key:
|
|
197
|
+
try:
|
|
198
|
+
_pid, task_group, task_id = parse_task_key(args.task_key)
|
|
199
|
+
except StateError as exc:
|
|
200
|
+
sys.stderr.write(f"phase-cleanup: {exc}\n")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
prev = resolve_prev_run_dir(
|
|
204
|
+
run_dir=args.run_dir,
|
|
205
|
+
project_root=Path(args.project_root),
|
|
206
|
+
task_group=task_group,
|
|
207
|
+
task_id=task_id,
|
|
208
|
+
)
|
|
209
|
+
if prev is None and not args.run_dir:
|
|
210
|
+
sys.stderr.write(
|
|
211
|
+
"phase-cleanup: no prior run found for auto-discovery "
|
|
212
|
+
"(normal on a task's first phase); pass --run-dir to point at a "
|
|
213
|
+
"specific prior run\n"
|
|
214
|
+
)
|
|
215
|
+
result = run_cleanup(
|
|
216
|
+
mode=resolve_mode(prev_run_dir=prev),
|
|
217
|
+
prev_run_dir=prev,
|
|
218
|
+
project_root=Path(args.project_root),
|
|
219
|
+
fallback_team=args.fallback_team,
|
|
220
|
+
)
|
|
221
|
+
if args.json:
|
|
222
|
+
sys.stdout.write(json.dumps(result) + "\n")
|
|
223
|
+
else:
|
|
224
|
+
sys.stdout.write(f"mode: {result['mode']}\n")
|
|
225
|
+
sys.stdout.write(f"panes-reclaimed: {result['panesReclaimed']}\n")
|
|
226
|
+
sys.stdout.write(
|
|
227
|
+
"dismissible-teammates: "
|
|
228
|
+
+ ", ".join(result["dismissibleTeammates"])
|
|
229
|
+
+ "\n"
|
|
230
|
+
)
|
|
231
|
+
return 0 # cleanup never blocks the next phase
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -189,12 +189,50 @@ def _extract_prep_items(
|
|
|
189
189
|
)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def _extract_variation_point_items(
|
|
193
|
+
planning: Mapping[str, Any], items: list[dict[str, Any]]
|
|
194
|
+
) -> None:
|
|
195
|
+
analysis = planning.get("variationPointAnalysis")
|
|
196
|
+
if not isinstance(analysis, Mapping):
|
|
197
|
+
raise PlanItemContractError("variationPointAnalysis must be an object")
|
|
198
|
+
points = analysis.get("points") or []
|
|
199
|
+
if not analysis.get("hasMultipleImplementations") or not points:
|
|
200
|
+
_add_item(
|
|
201
|
+
items,
|
|
202
|
+
{
|
|
203
|
+
"id": "P-Var-0",
|
|
204
|
+
"subject": "No variation point declared",
|
|
205
|
+
"sourceSection": "5.5.11",
|
|
206
|
+
"ticketId": "",
|
|
207
|
+
"payload": _row_payload(dict(analysis)),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
for index, point in enumerate(points, start=1):
|
|
212
|
+
if not isinstance(point, Mapping):
|
|
213
|
+
raise PlanItemContractError(
|
|
214
|
+
"variationPointAnalysis.points row must be an object"
|
|
215
|
+
)
|
|
216
|
+
_add_item(
|
|
217
|
+
items,
|
|
218
|
+
{
|
|
219
|
+
"id": f"P-Var-{index}",
|
|
220
|
+
"subject": _non_empty_string(point.get("behavior"))
|
|
221
|
+
or f"variation point {index}",
|
|
222
|
+
"sourceSection": "5.5.11",
|
|
223
|
+
"ticketId": "",
|
|
224
|
+
"payload": _row_payload(dict(point)),
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
192
229
|
def extract_plan_items(implementation_planning: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
193
230
|
"""Return deterministic, lossless P-* items in contract order."""
|
|
194
231
|
if not isinstance(implementation_planning, Mapping):
|
|
195
232
|
raise PlanItemContractError("implementationPlanning must be an object")
|
|
196
233
|
items = _extract_standard_items(implementation_planning)
|
|
197
234
|
_extract_prep_items(implementation_planning, items)
|
|
235
|
+
_extract_variation_point_items(implementation_planning, items)
|
|
198
236
|
return items
|
|
199
237
|
|
|
200
238
|
|
|
@@ -2402,6 +2402,15 @@ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
2402
2402
|
stages=state.handoff_stages)
|
|
2403
2403
|
|
|
2404
2404
|
|
|
2405
|
+
def _same_file(a: Path, b_str: str) -> bool:
|
|
2406
|
+
"""Whether two paths resolve to the same file. False when either path
|
|
2407
|
+
cannot be resolved (missing intermediate dir, permission, etc.)."""
|
|
2408
|
+
try:
|
|
2409
|
+
return a.resolve() == Path(b_str).resolve()
|
|
2410
|
+
except OSError:
|
|
2411
|
+
return False
|
|
2412
|
+
|
|
2413
|
+
|
|
2405
2414
|
def _suggest_latest_final_report(state: WizardState) -> str:
|
|
2406
2415
|
"""clarification carry-in 으로 추천할 직전 final-report 의 relpath.
|
|
2407
2416
|
|
|
@@ -2436,6 +2445,13 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
2436
2445
|
best = _newest("*/reports/final-report-*.md")
|
|
2437
2446
|
if best is None:
|
|
2438
2447
|
return ""
|
|
2448
|
+
# The approved plan is already wired via --approved-plan. On the first run
|
|
2449
|
+
# of an approved-plan consuming phase (final-verification / implementation)
|
|
2450
|
+
# the newest cross-phase report IS that plan, so the fallback would
|
|
2451
|
+
# re-recommend it as a clarification answer — injecting it twice, read as a
|
|
2452
|
+
# user clarification it is not. Never carry the approved plan back in here.
|
|
2453
|
+
if state.approved_plan_path and _same_file(best, state.approved_plan_path):
|
|
2454
|
+
return ""
|
|
2439
2455
|
try:
|
|
2440
2456
|
return str(best.relative_to(Path(state.project_root)))
|
|
2441
2457
|
except ValueError:
|
|
@@ -33,17 +33,27 @@ SINGLE_WRITE_STAGES = frozenset({
|
|
|
33
33
|
})
|
|
34
34
|
SINGLE_WRITE_MAX_GAP_SECONDS = 20 * 60 + 60
|
|
35
35
|
|
|
36
|
+
# report-writer 가 도구 호출 없이 모든 입력을 정독(required-reading-complete)하거나
|
|
37
|
+
# 종합(synthesis-start)하는 구간. 그 사이에는 `in-stage:` 라인조차 append 할 수 없어
|
|
38
|
+
# 5분 예산으로는 살아 있는 워커가 stalled 로 판정된다 (실측: synthesis-start gap 41건
|
|
39
|
+
# 중 24%가 5분+60s 초과, 최대 14.9분). 단일 Write 만큼 길지는 않으므로 15분 예산.
|
|
40
|
+
SYNTHESIS_STAGES = frozenset({
|
|
41
|
+
"required-reading-complete",
|
|
42
|
+
"synthesis-start",
|
|
43
|
+
})
|
|
44
|
+
SYNTHESIS_MAX_GAP_SECONDS = 15 * 60 + 60
|
|
45
|
+
|
|
36
46
|
|
|
37
47
|
def max_gap_seconds_after(stage: str) -> int:
|
|
38
48
|
"""*stage* 를 알린 뒤 다음 하트비트까지 허용되는 최대 공백(초).
|
|
39
49
|
|
|
40
50
|
간격이 재는 것은 직전에 선언된 단계의 작업 시간이므로, 예산은 언제나
|
|
41
51
|
구간을 *여는* 단계에서 고른다."""
|
|
42
|
-
|
|
43
|
-
SINGLE_WRITE_MAX_GAP_SECONDS
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
if stage in SINGLE_WRITE_STAGES:
|
|
53
|
+
return SINGLE_WRITE_MAX_GAP_SECONDS
|
|
54
|
+
if stage in SYNTHESIS_STAGES:
|
|
55
|
+
return SYNTHESIS_MAX_GAP_SECONDS
|
|
56
|
+
return HEARTBEAT_MAX_GAP_SECONDS
|
|
47
57
|
|
|
48
58
|
|
|
49
59
|
@dataclass(frozen=True)
|
|
@@ -87,7 +87,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
87
87
|
' - validation evidence: actual stdout/stderr and exit code for every pre / mid / post command from the plan (no paraphrased "tests pass")\n'
|
|
88
88
|
" - TDD evidence for TDD-applicable steps: failing-test output before implementation commit and passing-test output after, with framing SHAs\n"
|
|
89
89
|
" - per-verifier sections for every verifier in the resolved roster (`Claude verifier`, `Codex verifier`, plus `Antigravity verifier` when opted in) with independent verdict (PASS / CONCERNS / FAIL) and cited diff snippets; dissent is preserved by `Claude lead`\n"
|
|
90
|
-
" - rollback verification (
|
|
90
|
+
" - rollback verification (advisory, never blocks — record the revert path for a human; `result` is ok / not-applicable / advisory — human-run)\n"
|
|
91
91
|
" - routing recommendation for `final-verification` (ready / needs new error-analysis or planning loop)"
|
|
92
92
|
),
|
|
93
93
|
},
|
|
@@ -83,6 +83,31 @@ def resolve_project_root(*, explicit_root: str = "",
|
|
|
83
83
|
"프로젝트 루트 또는 그 하위에서 실행하거나, git 작업 트리 안에서 실행해 주십시오.)")
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
_ARCHITECTURE_STYLES = frozenset({"hexagonal", "layered", "none"})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_architecture(project_root: Path | str) -> str:
|
|
90
|
+
"""Return the declared architecture style, else ``"none"``.
|
|
91
|
+
|
|
92
|
+
Mirrors resolve_build_tool_tokens: any read/parse failure or an
|
|
93
|
+
unrecognised value falls back to the neutral ``"none"`` so an
|
|
94
|
+
unconfigured project keeps the layer-1 behaviour only.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
payload = json.loads(project_json_path(Path(project_root)).read_text(encoding="utf-8"))
|
|
98
|
+
# ValueError covers json.JSONDecodeError and UnicodeDecodeError alike — a
|
|
99
|
+
# project.json hand-saved in a non-UTF-8 encoding must fall back, not raise.
|
|
100
|
+
except (OSError, ValueError):
|
|
101
|
+
return "none"
|
|
102
|
+
if not isinstance(payload, dict):
|
|
103
|
+
return "none"
|
|
104
|
+
architecture = payload.get("architecture")
|
|
105
|
+
style = architecture.get("style") if isinstance(architecture, dict) else None
|
|
106
|
+
if not isinstance(style, str):
|
|
107
|
+
return "none"
|
|
108
|
+
return style if style in _ARCHITECTURE_STYLES else "none"
|
|
109
|
+
|
|
110
|
+
|
|
86
111
|
def upsert_project_json(project_root: Path, project_id: str, *,
|
|
87
112
|
now: Optional[str] = None) -> dict:
|
|
88
113
|
"""project.json 을 읽거나 새로 만든다.
|