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.
Files changed (39) hide show
  1. package/docs/architecture.md +11 -2
  2. package/docs/cli.md +15 -0
  3. package/docs/for-ai/skills/okstra-setup.md +8 -0
  4. package/docs/project-structure-overview.md +4 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +2 -1
  8. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +3 -3
  9. package/runtime/prompts/coding-preflight/overview.md +1 -1
  10. package/runtime/prompts/lead/adapters/claude-code.md +2 -2
  11. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  12. package/runtime/prompts/lead/plan-body-verification.md +20 -9
  13. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -0
  14. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -3
  15. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  16. package/runtime/prompts/profiles/_implementation-verifier.md +2 -1
  17. package/runtime/prompts/profiles/forbidden-actions.json +0 -1
  18. package/runtime/prompts/profiles/implementation-planning.md +7 -2
  19. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  20. package/runtime/python/okstra_ctl/clarification_items.py +99 -5
  21. package/runtime/python/okstra_ctl/paths.py +34 -7
  22. package/runtime/python/okstra_ctl/phase_cleanup.py +235 -0
  23. package/runtime/python/okstra_ctl/plan_items.py +38 -0
  24. package/runtime/python/okstra_ctl/wizard.py +16 -0
  25. package/runtime/python/okstra_ctl/worker_heartbeat.py +15 -5
  26. package/runtime/python/okstra_ctl/workflow.py +1 -1
  27. package/runtime/python/okstra_project/resolver.py +25 -0
  28. package/runtime/schemas/final-report-v1.0.schema.json +63 -4
  29. package/runtime/skills/okstra-run/SKILL.md +3 -1
  30. package/runtime/skills/okstra-setup/SKILL.md +3 -0
  31. package/runtime/skills/okstra-setup/references/project-config.md +47 -0
  32. package/runtime/templates/reports/final-report.template.md +25 -0
  33. package/runtime/templates/reports/i18n/en.json +12 -1
  34. package/runtime/templates/reports/i18n/ko.json +12 -1
  35. package/runtime/templates/reports/implementation-input.template.md +1 -2
  36. package/runtime/templates/reports/task-brief.template.md +1 -1
  37. package/runtime/validators/validate-run.py +143 -12
  38. package/src/cli-registry.mjs +10 -0
  39. 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
- body = _clarification_carry_body(source, text)
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(source: Path, text: str) -> str:
420
- """final-report 소스는 §1 + 원문 포인터로 좁히고, 그 외는 원문 그대로."""
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{slice_.rstrip()}\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), task_types
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, task_root: Path, task_types: Optional[tuple[str, ...]] = None
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
- found for task_type in task_types
286
- if (found := _newest_report(runs_dir / task_type / "reports")) is not None
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
- return (
43
- SINGLE_WRITE_MAX_GAP_SECONDS
44
- if stage in SINGLE_WRITE_STAGES
45
- else HEARTBEAT_MAX_GAP_SECONDS
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 (revert SHA reachable, feature flag toggle works, migration down step valid; dry-run preferred)\n"
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 을 읽거나 새로 만든다.